Procházet zdrojové kódy

master: Added 添加喊话、修复问题

gitboyzcf před 3 měsíci
rodič
revize
056e373ab4

+ 2 - 2
.env.development

@@ -4,8 +4,8 @@
 VITE_APP_TITLE = 铁塔大视野
 
 # 网络请求
-VITE_APP_API_BASEURL = https://192.168.10.140:8081
-# VITE_APP_API_BASEURL = https://192.168.211.89
+# VITE_APP_API_BASEURL = https://192.168.10.140:8081
+VITE_APP_API_BASEURL = https://192.168.211.58:8082
 
 # 项目localStorage存储前缀
 VITE_APP_PREFIX = monitoring

+ 1 - 1
.gitignore

@@ -11,7 +11,7 @@ node_modules
 .DS_Store
 dist
 dist-ly
-dist-tt
+dist-tt*
 dist-ssr
 coverage
 *.local

+ 8 - 5
package.json

@@ -18,6 +18,7 @@
     "@kjgl77/datav-vue3": "^1.6.1",
     "@popperjs/core": "^2.11.8",
     "@vueuse/core": "^10.3.0",
+    "@fingerprintjs/fingerprintjs": "^4.5.1",
     "animate.css": "^4.1.1",
     "animejs": "^3.2.1",
     "axios": "^1.4.0",
@@ -31,7 +32,8 @@
     "lodash-es": "^4.17.21",
     "mitt": "^3.0.1",
     "moment": "^2.29.4",
-    "mp4box": "^0.5.2",
+    "mpegts.js": "^1.8.0",
+    "mp4box": "0.5.2",
     "native-file-system-adapter": "^3.0.1",
     "node-forge": "^1.3.1",
     "pinia": "^2.1.4",
@@ -44,9 +46,9 @@
     "vis-timeline": "^7.7.2",
     "vue": "^3.3.4",
     "vue-router": "^4.2.4",
-    "vue3-mini-weather": "^0.1.2",
     "vuedraggable": "^4.1.0",
-    "zx-calendar": "^0.7.3"
+    "zx-calendar": "^0.7.3",
+    "livekit-client": "^2.7.5"
   },
   "devDependencies": {
     "@iconify/iconify": "^3.1.1",
@@ -70,5 +72,6 @@
     "unplugin-vue-components": "^0.25.1",
     "vite": "^4.5.0",
     "vite-plugin-compression": "^0.5.1"
-  }
-}
+  },
+  "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3011 - 230
pnpm-lock.yaml


+ 1 - 0
src/App.vue

@@ -19,6 +19,7 @@ const themeOverrides = {
     colorHover: 'rgba(0, 123, 255,0.2)',
     borderHover: 'rgba(0, 123, 255,1)'
   },
+  
   Select: {
     optiosColor: 'red',
     peers: {

+ 26 - 1
src/api/modules/system.js

@@ -99,6 +99,14 @@ export default {
       data
     })
   },
+  // ai告警类型
+  API_AI_TYPE_GET(data = {}) {
+    return request({
+      url: `/KunPengYun/AlgorithmList`,
+      method: 'get',
+      data
+    })
+  },
   // 获取裁剪信息
   API_CLIP_INFO_GET(data = {}) {
     return request({
@@ -118,10 +126,19 @@ export default {
   // 获取报警图片信息
   API_IMG_FILES_GET(path = '') {
     return request({
-      url: `/KunPengYun/Files${path}`,
+      url: `/KunPengYun/Files/${path}`,
       method: 'get'
     })
   },
+  // 投递信箱
+  API_MAILBOX_PUT(data = {}, headers = {}) {
+    return request({
+      url: `/Host/Mailbox`,
+      method: 'put',
+      data,
+      headers
+    })
+  },
   // 获取配置文件
   API_GET_CONFIG() {
     return request({
@@ -129,5 +146,13 @@ export default {
       url: '/foreground/config.json',
       method: 'get'
     })
+  },
+    // 获取视频播放地址列表
+  API_VIDEO_LIST_GET(params = {}) {
+    return request({
+      url: `/VideoShow/list`,
+      method: 'get',
+      params
+    })
   }
 }

+ 364 - 0
src/assets/js/video-lib/new-lib/FlvStreamRecorder.js

@@ -0,0 +1,364 @@
+import { DownloadStreamSaver, formatDateTime } from './utils'
+
+// 添加日志
+function addLog(message) {
+  const now = new Date()
+  const timestamp = `[${now.getHours().toString().padStart(2, '0')}:${now
+    .getMinutes()
+    .toString()
+    .padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}]`
+
+  let logcatbox = document.getElementsByName('logcatbox')[0]
+  logcatbox.value = logcatbox.value + timestamp + message + '\n'
+  console.info(logcatbox.value)
+
+  // logcatbox.scrollTop = logcatbox.scrollHeight;
+}
+
+// ======================
+// FLV录像功能封装类
+// ======================
+export class FlvStreamRecorder {
+  constructor(videoElement, options = {}) {
+    // 配置参数
+    this.options = Object.assign(
+      {
+        streamName: '未命名流',
+        fps: 25,
+        scale: 0.8,
+        bitrate: 2000000,
+        containerFormat: 'mp4'
+      },
+      options
+    )
+
+    // 元素和状态
+    this.videoElement = videoElement
+    this.isRecording = false
+    this.recordingStartTime = 0
+    this.lastFrameTime = 0
+    this.frameCount = 0
+    this.droppedFrameCount = 0
+    this.currentFps = this.options.fps
+    this.mediaRecorder = null
+    this.recordedChunks = []
+    this.canvas = null
+    this.ctx = null
+    this.animationFrameId = null
+    this.dataBuffer = []
+    this.forceKeyFrame = true // 强制下一个帧是关键帧
+    this.minRecordingTime = 2000 // 最小录制时间(毫秒)
+
+    // 日志函数
+    this.log = (message) => {
+      const timestamp = new Date().toLocaleTimeString()
+      addLog(`[${this.options.streamName}] ${message}`)
+    }
+
+    // 创建Canvas
+    this._createCanvas()
+    this.log(`录像器初始化完成 (${this.options.fps}FPS, 缩放${this.options.scale})`)
+  }
+
+  _createCanvas() {
+    if (this.canvas) {
+      document.body.removeChild(this.canvas)
+    }
+
+    this.canvas = document.createElement('canvas')
+    this.canvas.style.display = 'none'
+    document.body.appendChild(this.canvas)
+
+    // 设置Canvas尺寸
+    this.canvas.width = this.videoElement.videoWidth * this.options.scale
+    this.canvas.height = this.videoElement.videoHeight * this.options.scale
+
+    this.ctx = this.canvas.getContext('2d')
+    this.log(`创建录制画布: ${this.canvas.width}x${this.canvas.height}`)
+  }
+
+  _getSupportedMimeType() {
+    const codecs = [
+      'video/mp4;codecs=avc1.640028',
+      'video/webm;codecs=vp9',
+      'video/webm;codecs=vp8',
+      'video/webm'
+    ]
+
+    if (this.options.containerFormat === 'mp4') {
+      codecs.unshift('video/mp4;codecs=avc1.42E01E')
+    }
+
+    for (let mime of codecs) {
+      if (MediaRecorder.isTypeSupported(mime)) {
+        this.log(`使用编码格式: ${mime}`)
+        return mime
+      }
+    }
+
+    this.log('错误: 没有找到支持的编码格式', 'error')
+    return null
+  }
+
+  /**
+   * 确保视频包含完整的关键帧
+   */
+  _ensureKeyFrame() {
+    if (!this.mediaRecorder) return
+
+    // 强制插入关键帧
+    try {
+      // 重新绘制Canvas强制新帧
+      this.ctx.drawImage(this.videoElement, 0, 0, this.canvas.width, this.canvas.height)
+
+      // 对于MediaRecorder,我们无法直接插入关键帧
+      // 但通过重新配置可以间接实现
+      this.mediaRecorder.pause()
+      this.mediaRecorder.resume()
+
+      this.forceKeyFrame = false
+      this.log('已插入关键帧')
+    } catch (e) {
+      this.log(`插入关键帧失败: ${e.message}`)
+    }
+  }
+
+  startRecording() {
+    if (this.isRecording) {
+      this.log('录像已经在进行中')
+      return false
+    }
+
+    if (!this.videoElement.videoWidth) {
+      this.log('错误: 视频未准备好')
+      return false
+    }
+
+    if (
+      this.canvas.width !== this.videoElement.videoWidth * this.options.scale ||
+      this.canvas.height !== this.videoElement.videoHeight * this.options.scale
+    ) {
+      this._createCanvas()
+    }
+
+    // 重置状态
+    this.isRecording = true
+    this.recordedChunks = []
+    this.dataBuffer = []
+    this.recordingStartTime = Date.now()
+    this.lastFrameTime = 0
+    this.frameCount = 0
+    this.droppedFrameCount = 0
+    this.currentFps = this.options.fps
+    this.forceKeyFrame = true
+
+    // 从Canvas获取媒体流
+    const stream = this.canvas.captureStream(this.options.fps)
+
+    // 获取支持的MIME类型
+    const mimeType = this._getSupportedMimeType()
+    if (!mimeType) return false
+
+    // 设置录制选项
+    const options = {
+      mimeType: mimeType,
+      videoBitsPerSecond: this.options.bitrate
+    }
+
+    try {
+      this.mediaRecorder = new MediaRecorder(stream, options)
+
+      this.mediaRecorder.ondataavailable = (event) => {
+        if (event.data && event.data.size > 0) {
+          this.dataBuffer.push(event.data)
+          this.log(`收到数据块: ${(event.data.size / 1024).toFixed(1)}KB`)
+          this._processDataBuffer()
+        }
+      }
+
+      this.mediaRecorder.onstop = () => {
+        const duration = Date.now() - this.recordingStartTime
+
+        // 延长短录制处理时间
+        const delay = duration < 3000 ? 1000 : 500
+
+        setTimeout(() => {
+          this._processDataBuffer(true)
+          this.isRecording = false
+          this.log('录制已停止,准备下载')
+          this.downloadRecording()
+
+          // 对于短录制,添加完整性检查
+          if (duration < 3000) {
+            this._validateRecording()
+          }
+        }, delay)
+      }
+
+      this.mediaRecorder.onerror = (event) => {
+        this.log(`录制错误: ${event}`, 'error')
+      }
+
+      // 开始录制
+      this.mediaRecorder.start(2000)
+
+      // 启动帧捕获循环
+      this._captureFrame()
+
+      this.log(`开始录制,目标帧率: ${this.options.fps}FPS`)
+      return true
+    } catch (e) {
+      this.log(`创建MediaRecorder失败: ${e.message}`, 'error')
+      return false
+    }
+  }
+
+  /**
+   * 验证录制完整性
+   */
+  _validateRecording() {
+    if (this.recordedChunks.length === 0) {
+      this.log('警告: 录制数据为空')
+      return
+    }
+
+    const totalSize = this.recordedChunks.reduce((sum, chunk) => sum + chunk.size, 0)
+    const duration = Date.now() - this.recordingStartTime
+
+    // 检查文件大小是否合理
+    if (totalSize < 1024) {
+      this.log('警告: 录制文件过小,可能不完整')
+    }
+
+    // 检查录制时长
+    if (duration < 1000) {
+      this.log('警告: 录制时间过短,视频可能无法播放')
+    }
+  }
+
+  _processDataBuffer(final = false) {
+    if (this.dataBuffer.length > 0) {
+      this.recordedChunks = this.recordedChunks.concat(this.dataBuffer)
+      this.dataBuffer = []
+      this.log(`已处理缓冲数据,总数据块: ${this.recordedChunks.length}`)
+    }
+
+    if (final && this.dataBuffer.length === 0) {
+      this.log('所有缓冲数据已处理完成')
+    }
+  }
+
+  _captureFrame() {
+    if (!this.isRecording) return
+
+    const timestamp = performance.now()
+
+    if (!this.lastFrameTime) this.lastFrameTime = timestamp
+
+    const elapsed = timestamp - this.lastFrameTime
+    const targetInterval = 1000 / this.currentFps
+
+    if (elapsed > targetInterval) {
+      try {
+        this.ctx.drawImage(this.videoElement, 0, 0, this.canvas.width, this.canvas.height)
+
+        // 强制插入关键帧(录制开始时)
+        if (this.forceKeyFrame) {
+          this._ensureKeyFrame()
+        }
+
+        if (elapsed > targetInterval * 1.5 && this.currentFps > 15) {
+          this.currentFps = Math.max(15, this.currentFps - 2)
+          this.log(`系统负载高,降低帧率至 ${this.currentFps} FPS`, 'warning')
+        } else if (this.currentFps < this.options.fps && elapsed < targetInterval * 0.8) {
+          this.currentFps = Math.min(this.options.fps, this.currentFps + 1)
+          this.log(`系统性能良好,提高帧率至 ${this.currentFps} FPS`)
+        }
+
+        this.lastFrameTime = timestamp
+        this.frameCount++
+      } catch (e) {
+        this.log(`帧处理错误: ${e.message}`)
+      }
+    } else {
+      this.droppedFrameCount++
+    }
+
+    this.animationFrameId = requestAnimationFrame(() => this._captureFrame())
+  }
+
+  stopRecording() {
+    if (this.mediaRecorder && this.isRecording) {
+      const duration = Date.now() - this.recordingStartTime
+
+      // 确保最短录制时间
+      if (duration < this.minRecordingTime) {
+        this.log(`录制时间不足${this.minRecordingTime}ms,延长至最短时间`)
+        setTimeout(() => {
+          this.mediaRecorder.stop()
+          cancelAnimationFrame(this.animationFrameId)
+          this.log('正在停止录制...')
+        }, this.minRecordingTime - duration)
+      } else {
+        this.mediaRecorder.stop()
+        cancelAnimationFrame(this.animationFrameId)
+        this.log('正在停止录制...')
+      }
+
+      return true
+    }
+    return false
+  }
+
+  /**
+   * 增强短录制视频的播放兼容性
+   */
+  _enhanceShortRecording(blob) {
+    // 在实际应用中,这里可以使用Mux.js等库处理
+    // 为简化演示,我们直接返回原始blob
+    // 但在实际项目中,这里应该:
+    // 1. 解析视频数据
+    // 2. 确保包含关键帧
+    // 3. 添加必要的元数据
+    // 4. 重新封装视频
+    return blob
+  }
+
+  /**
+   * 获取录像
+   */
+  downloadRecording() {
+    if (this.recordedChunks.length === 0) {
+      this.log('错误: 没有录制内容')
+      return false
+    }
+
+    const fileExtension = this.options.containerFormat === 'mp4' ? 'mp4' : 'webm'
+
+    try {
+      const blob = new Blob(this.recordedChunks, {
+        type: this.options.containerFormat === 'mp4' ? 'video/mp4' : 'video/webm'
+      })
+
+      DownloadStreamSaver(blob, `${formatDateTime(new Date())}.${fileExtension}`)
+      return { blob, type: fileExtension }
+    } catch (e) {
+      this.log(`获取失败: ${e.message}`, 'error')
+      return false
+    }
+  }
+
+  /**
+   * 获取录制状态
+   */
+  getStatus() {
+    return {
+      isRecording: this.isRecording,
+      streamName: this.options.streamName,
+      fps: this.currentFps,
+      frameCount: this.frameCount,
+      droppedFrames: this.droppedFrameCount,
+      duration: this.isRecording ? (Date.now() - this.recordingStartTime) / 1000 : 0
+    }
+  }
+}

+ 141 - 0
src/assets/js/video-lib/new-lib/omatVideoPlayer.js

@@ -0,0 +1,141 @@
+import { enableImageManipulation, DownloadStreamSaver, formatDateTime } from './utils'
+import { FlvStreamRecorder } from './FlvStreamRecorder'
+import mpegts from 'mpegts.js'
+
+class VideoPlayer {
+  #player = null
+  #canvasDom = null
+  #animationFrame = null
+  #recorder = null
+  /**
+   * @param {HTMLVideoElement} videoElement - The video element to attach the player to.
+   * @param {string} url - The URL of the video stream.
+   * @param {Object} config - Additional configuration options for the player.
+   */
+  constructor(videoElement, url, callback, config) {
+    this.videoElement = videoElement
+    this.url = url
+    this.config = config
+    this.callback = callback
+    this.init()
+  }
+
+  init() {
+    if (!mpegts.isSupported()) {
+      throw new Error('MPEG-TS is not supported')
+    }
+    const mediaDataSource = {
+      type: 'flv',
+      url: this.url,
+      isLive: true,
+      hasAudio: false,
+      withCredentials: true,
+      liveBufferLatencyChasing: true
+    }
+    const playerConfig = {
+      enableWorker: true, // 启用分离线程(DedicatedWorker)进行转换
+      enableWorkerForMSE: true, // 为MediaSource启用分隔线程(DedicatedWorker)
+      // 追踪由HTMLMediaElement中的内部缓冲区引起的实时流延迟。
+      liveBufferLatencyChasing: true,
+      lazyLoad: true,
+      lazyLoadMaxDuration: 3 * 60,
+      seekType: 'range'
+    }
+    this.#player = mpegts.createPlayer(mediaDataSource, playerConfig)
+    this.#player.on(mpegts.Events.SCRIPTDATA_ARRIVED, () => {
+      console.error('LOADING_COMPLETE  加载完成')
+      this.callback()
+    })
+    this.#player.on(mpegts.Events.DESTROYING, () => {
+      console.error('DESTROYING  销毁完成')
+    })
+
+    this.#player.on(mpegts.ErrorTypes.NETWORK_ERROR, (e) => {
+      console.error('NETWORK_ERROR 网络错误', e)
+    })
+    this.#player.on(mpegts.ErrorTypes.MEDIA_ERROR, (e) => {
+      console.error('MEDIA_ERROR 媒体错误', e)
+    })
+    this.#player.on(mpegts.ErrorTypes.OTHER_ERROR, (e) => {
+      console.error('OTHER_ERROR 其他错误', e)
+    })
+    this.#player.on(mpegts.Events.ERROR, (e) => {
+      console.error('ERROR 播放器错误', e)
+    })
+    this.#player.attachMediaElement(this.videoElement)
+    this.#player.load()
+    enableImageManipulation(this.videoElement.parentElement, this.videoElement, {
+      enableZoom: this.config.enableZoom === undefined ? true : this.config.enableZoom,
+      enableDrag: this.config.enableDrag === undefined ? true : this.config.enableDrag,
+      minScale: 1, // 最小缩放比例
+      maxScale: 6, // 最大缩放比例
+      dragSpeed: 1.9 // 拖动速度
+    })
+  }
+  play() {
+    this.#player.play()
+  }
+  pause() {
+    this.#player.pause()
+  }
+
+  destroyed() {
+    this.#player.unload()
+    this.#player.detachMediaElement()
+    this.#player.destroy()
+    this.#player = null
+  }
+  // 截图
+  screenshot() {
+    const canvas = document.createElement('canvas')
+    canvas.width = this.videoElement.videoWidth
+    canvas.height = this.videoElement.videoHeight
+    canvas.getContext('2d').drawImage(this.videoElement, 0, 0, canvas.width, canvas.height)
+    canvas.toBlob(async (blob) => {
+      if (blob) {
+        await DownloadStreamSaver(blob, `${formatDateTime(new Date())}.png`)
+      } else {
+        console.error('截图失败')
+      }
+    }, 'image/png')
+  }
+
+  // 录像
+  recording(isLx) {
+    if (!isLx) {
+      this.#recorder = new FlvStreamRecorder(this.videoElement, {
+        streamName: 'streamName',
+        fps: 30,
+        scale: 1,
+        containerFormat: 'mp4' // 优先使用MP4格式确保兼容性
+      })
+      if (this.#recorder.startRecording()) {
+        return true
+      }
+    } else {
+      if (this.#recorder && this.#recorder.stopRecording()) {
+        return false
+      }
+    }
+  }
+}
+
+/**
+ *
+ * @param {String} selector 选择器
+ * @param {String} url 视频流地址
+ * @param {Object} config?: {enableZoom: true, enableDrag: true} 配置选项
+ * @param {Function} callback 回调函数,视频加载完成后调用
+ * @returns VideoPlayer
+ */
+export function omatVideoPlayer(selector, url, callback = () => {}, config = {}) {
+  const videoElement = document.querySelector(selector)
+  const player = new VideoPlayer(videoElement, url, callback, config)
+  if (!videoElement) {
+    throw new Error(`Video element not found for selector: ${selector}`)
+  }
+  if (!url) {
+    throw new Error('Video URL is required')
+  }
+  return player
+}

+ 158 - 0
src/assets/js/video-lib/new-lib/utils.js

@@ -0,0 +1,158 @@
+import { showSaveFilePicker } from 'native-file-system-adapter'
+
+export function formatDateTime(date) {
+  function padZero(num) {
+    return num < 10 ? '0' + num : num
+  }
+
+  var year = date.getFullYear()
+  var month = padZero(date.getMonth() + 1)
+  var day = padZero(date.getDate())
+  var hours = padZero(date.getHours())
+  var minutes = padZero(date.getMinutes())
+  var seconds = padZero(date.getSeconds())
+  var Milliseconds = date.getMilliseconds()
+
+  return `${year}${month}${day}${hours}${minutes}${seconds}${Milliseconds}`
+}
+
+// 下载资源
+export async function DownloadStreamSaver(blob, fileName) {
+  const opts = {
+    suggestedName: fileName,
+    types: [{ 'image/png': ['png'] }]
+  }
+  const handle = await showSaveFilePicker(opts)
+  const ws = await handle.createWritable()
+  ws.write(blob)
+  ws.close()
+}
+
+// 滚轮缩放、放大逻辑
+export function enableImageManipulation(container, image, options = {}) {
+  // const container = document.querySelector(containerId);
+  // const image = document.querySelector(imageId);
+  let isDragging = false
+  let startX,
+    startY,
+    initialX = 0,
+    initialY = 0,
+    scale = 1
+  let currentX = 0,
+    currentY = 0,
+    targetX = 0,
+    targetY = 0
+
+  // 配置选项: 最小和最大缩放比例,是否启用缩放和拖动功能
+  const minScale = options.minScale || container.offsetWidth / image.offsetWidth
+  const maxScale = options.maxScale || 3
+  const dragSpeed = options.dragSpeed || 0.2
+  const enableZoom = options.enableZoom !== false // 默认启用缩放
+  const enableDrag = options.enableDrag !== false // 默认启用拖动
+  function reset() {
+    isDragging = false
+  }
+
+  // 处理缩放功能
+  if (enableZoom) {
+    container.addEventListener('wheel', function (event) {
+      event.preventDefault()
+
+      const { offsetX, offsetY } = event
+      const delta = Math.sign(event.deltaY) * -0.1
+
+      const newScale = Math.min(Math.max(scale + delta, minScale), maxScale)
+
+      const dx = (offsetX - currentX) * (newScale / scale - 1)
+      const dy = (offsetY - currentY) * (newScale / scale - 1)
+
+      scale = newScale
+      currentX -= dx
+      currentY -= dy
+
+      updateImageTransform()
+      adjustPosition()
+    })
+  }
+  // 处理拖动功能
+  if (enableDrag) {
+    image.addEventListener('mousedown', function (event) {
+      isDragging = true
+      startX = event.clientX
+      startY = event.clientY
+
+      const transform = image.style.transform.match(/translate\(([^)]+)\)/)
+      ;[initialX, initialY] = transform ? transform[1].split(',').map(parseFloat) : [0, 0]
+    })
+
+    document.addEventListener('mousemove', function (event) {
+      if (isDragging) {
+        const dx = (event.clientX - startX) * dragSpeed
+        const dy = (event.clientY - startY) * dragSpeed
+
+        targetX = initialX + dx
+        targetY = initialY + dy
+        updateDrag()
+      }
+    })
+
+    document.addEventListener('mouseleave', reset)
+    document.addEventListener('mouseup', reset)
+  }
+
+  // 更新拖动的图像位置
+  function updateDrag() {
+    if (!isDragging) return
+
+    currentX = targetX
+    currentY = targetY
+
+    updateImageTransform()
+    adjustPosition()
+  }
+
+  // 更新图像的变换样式
+  function updateImageTransform() {
+    image.style.transform = `translate(${currentX}px, ${currentY}px) scale(${scale})`
+  }
+
+  // 调整图像位置以限制在容器内
+  function adjustPosition() {
+    const rect = image.getBoundingClientRect()
+    const containerRect = container.getBoundingClientRect()
+
+    let newX = rect.left - containerRect.left
+    let newY = rect.top - containerRect.top
+
+    if (rect.width < containerRect.width) {
+      newX = (containerRect.width - rect.width) / 2
+    } else {
+      if (newX > 0) newX = 0
+      if (newX + rect.width < containerRect.width) newX = containerRect.width - rect.width
+    }
+
+    if (rect.height < containerRect.height) {
+      newY = (containerRect.height - rect.height) / 2
+    } else {
+      if (newY > 0) newY = 0
+      if (newY + rect.height < containerRect.height) newY = containerRect.height - rect.height
+    }
+
+    currentX = newX
+    currentY = newY
+
+    updateImageTransform()
+  }
+  return () => {
+    isDragging = false
+    startX = 0
+    startY = 0
+    initialX = 0
+    initialY = 0
+    scale = 1
+    currentX = 0
+    currentY = 0
+    targetX = 0
+    targetY = 0
+  }
+}

+ 15 - 14
src/assets/js/video-lib/omnimatrix-video-player.js

@@ -187,21 +187,22 @@ function useWorker(url, className, device, callback = () => {}) {
       })
     }
     if (message.data.DataType === 'lx') {
-      ffmpeg.on('log', ({ message: msg }) => {
-        console.log(msg)
-      })
-      await ffmpeg.writeFile('test.mp4', await fetchFile(message.data.Data))
+      ffmpeg.on("log", ({ message: msg }) => {
+        console.log(msg);
+      });
+
+      await ffmpeg.writeFile("test.mp4", await fetchFile(message.data.Data));
       ffmpeg.exec([
-        '-i',
-        'test.mp4',
-        '-c',
-        'copy',
-        '-metadata:s:v',
-        'rotate=' + JSON.parse(cropFullInfo).Rotate,
-        'out.mp4'
-      ])
-      const data = await ffmpeg.readFile('out.mp4')
-      ffmpeg.deleteFile('test.mp4')
+        "-i",
+        "test.mp4",
+        "-c",
+        "copy",
+        "-metadata:s:v",
+        "rotate=" + JSON.parse(cropFullInfo).Rotate,
+        "out.mp4",
+      ]);
+      const data = await ffmpeg.readFile("out.mp4");
+      ffmpeg.deleteFile("test.mp4");
       DownloadStreamSaver(
         new Blob([data.buffer], { type: 'video/mp4' }),
         `${formatDateTime(new Date())}.mp4`,

+ 28 - 19
src/assets/js/video-lib/renderer_2d.js

@@ -50,42 +50,51 @@ export class Canvas2DRenderer {
   temp = null
   draw(frame) {
     if (this.cropFullInfo.UseCrop) {
-      this.canvas.width = this.cropFullInfo.W
-      this.canvas.height = this.cropFullInfo.H
+      this.canvas.width = this.cropFullInfo.W;
+      this.canvas.height = this.cropFullInfo.H;
     } else {
-      this.canvas.width = frame.displayWidth
-      this.canvas.height = frame.displayHeight
+      this.canvas.width = frame.displayWidth;
+      this.canvas.height = frame.displayHeight;
     }
-    this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2)
+    this.ctx.save();
+    this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
     if (!this.cropFullInfo.UseRotate) {
-      this.ctx.rotate(0)
-      this.cropFullInfo.Rotate = 0
+      this.ctx.rotate(0);
+      this.cropFullInfo.Rotate = 0;
     } else {
-      this.ctx.rotate((this.cropFullInfo.Rotate / 90) * 1.57)
+      this.ctx.rotate((this.cropFullInfo.Rotate / 90) * 1.57);
     }
-
-    switch (this.cropFullInfo.Rotate) {      
+    switch (this.cropFullInfo.Rotate) {
       case 0:
       case 180:
         this.ctx.drawImage(
-          frame,          
-          this.cropFullInfo.UseCrop?-this.canvas.width / 2 - this.cropFullInfo.X:-this.canvas.width / 2,
-          this.cropFullInfo.UseCrop?-this.canvas.height / 2 - this.cropFullInfo.Y:-this.canvas.height / 2,
+          frame,
+          this.cropFullInfo.UseCrop
+            ? -this.canvas.width / 2 - this.cropFullInfo.X
+            : -this.canvas.width / 2,
+          this.cropFullInfo.UseCrop
+            ? -this.canvas.height / 2 - this.cropFullInfo.Y
+            : -this.canvas.height / 2,
           frame.displayWidth,
           frame.displayHeight
-        )
-        break
+        );
+        break;
       case 90:
       case 270:
         this.ctx.drawImage(
           frame,
-          this.cropFullInfo.UseCrop?-this.canvas.height / 2 - this.cropFullInfo.Y:-this.canvas.height / 2,
-          this.cropFullInfo.UseCrop?-this.canvas.width / 2 - this.cropFullInfo.X:-this.canvas.width / 2,
+          this.cropFullInfo.UseCrop
+            ? -this.canvas.height / 2 - this.cropFullInfo.Y
+            : -this.canvas.height / 2,
+          this.cropFullInfo.UseCrop
+            ? -this.canvas.width / 2 - this.cropFullInfo.X
+            : -this.canvas.width / 2,
           frame.displayWidth,
           frame.displayHeight
-        )
-        break
+        );
+        break;
     }
+    this.ctx.restore();
     if (self.GetImg) {
       this.ctx.canvas.convertToBlob().then((blob) => {
         self.postMessage({ img: blob, type: 'img' })

+ 8 - 0
src/components/Icon.vue

@@ -53,6 +53,8 @@ import TablerDeviceCctv from '~icons/tabler/device-cctv'
 import GisCompassRoseN from '~icons/gis/compass-rose-n'
 import LineMdCompassLoop from '~icons/line-md/compass-loop'
 import LucideHardHat from '~icons/lucide/hard-hat';
+import SvgSpinnersBarsScaleMiddle from '~icons/svg-spinners/bars-scale-middle';
+import TablerMicrophone from '~icons/tabler/microphone';
 
 const props = defineProps({
   icon: {
@@ -216,6 +218,12 @@ const setIcon = () => {
     case 'lucide:hard-hat':
       iconCom.value = LucideHardHat
       break
+    case 'svg-spinners:bars-scale-middle':
+      iconCom.value = SvgSpinnersBarsScaleMiddle
+      break
+    case 'tabler:microphone':
+      iconCom.value = TablerMicrophone
+      break
     default:
       console.error(`${props.icon}需要到 src/components/Icon.vue 中配置改图标`)
       break

+ 1 - 1
src/components/PubVideo.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="pub-video-box" :style="{ height }" relative overflow-hidden flex>
+  <div class="pub-video-box bg-#5e8dff33" :style="{ height }" relative overflow-hidden flex>
     <canvas
       :class="newClass"
       :style="{

+ 49 - 0
src/components/PubVideoNew.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="pub-video-box" :style="{ width }" relative overflow-hidden flex>
+    <video
+      name="videoElement"
+      style="width: 100%; transform-origin: left top; object-fit: fill"
+      :class="newClass"
+      :id="newClass"
+      muted
+      autoplay
+      playsinline
+      v-double-click
+    >
+      Your browser is too old which doesn't support HTML5 video.
+    </video>
+  </div>
+</template>
+<script setup>
+// import videojs from 'video.js'
+// import 'video.js/dist/video-js.css'
+
+const props = defineProps({
+  width: {
+    type: String,
+    default: '100%'
+  },
+  height: {
+    type: String,
+    default: '100%'
+  },
+  src: {
+    type: String
+  },
+  // 画布类名 区分画布
+  newClass: {
+    type: [String, Object],
+    default: 'pub-video'
+  }
+})
+
+onMounted(() => {
+  // videojs('#' + props.newClass, {
+  //   autoplay: true,
+  //   controls: false,
+  //   muted: true,
+  //   fluid: true,
+  //   liveui: true
+  // })
+})
+</script>

+ 64 - 44
src/stores/modules/system.js

@@ -1,14 +1,13 @@
 import { piniaStore } from '@/stores'
 import { isWap } from '@/utils'
 import storage from '@/utils/storage'
-import dayjs from 'dayjs'
 const {
-  API_VIEW_PREVIEW_POST,
   API_LY_NVR_LIST_POST,
   API_VIEW_BC_GET,
   API_INFER_MODE_GET,
   API_LY_TRANSFER_GET,
-  API_CLIP_INFO_GET
+  API_CLIP_INFO_GET,
+  API_VIDEO_LIST_GET
 } = useRequest()
 
 export const useSystemStore = defineStore('systemStore', {
@@ -38,7 +37,9 @@ export const useSystemStore = defineStore('systemStore', {
     cilpInfo: {}, // 裁剪信息
     rtspVideo: '', // rtsp视频
     nvrId: '', // 硬盘录列id
-    videoLoading: false // 全景loading
+    videoLoading: false, // 全景loading
+    netType: 1, // 视频模式
+    videoList: {} // 视频列表
   }),
   getters: {
     ipF() {
@@ -49,10 +50,14 @@ export const useSystemStore = defineStore('systemStore', {
     }
   },
   actions: {
-    async getVideoUrl(coordinate, type = 'Full') {
-      this.videoUrl = `wss://${this.ip.split('//')[1]}/VideoShow/Preview/${type}/Main?X=${
-        coordinate.X
-      }&Y=${coordinate.Y}`
+    // Internal 内网/ External 外网
+    async getVideoList(type = 'Internal') {
+      const res = await API_VIDEO_LIST_GET({ ServerType: type })
+      this.videoList = res
+    },
+    async getVideoUrl({ X, Y }, type = 'Full') {
+      this.videoUrl = `wss://${this.ipF}/VideoShow/Preview/${type}/${this.netType === 1 ? 'Main' : 'Sub'
+        }?X=${X}&Y=${Y}`
       return this.videoUrl
     },
     async getVideoArrange() {
@@ -97,6 +102,7 @@ export const useSystemStore = defineStore('systemStore', {
         }, 100)
       })
     },
+
     // 连接报警信息
     async connectAlarmWS() {
       // setTimeout(() => {
@@ -116,23 +122,37 @@ export const useSystemStore = defineStore('systemStore', {
       //   //   }
       //   // }
       //   const data = {
-      //     AlgorithmName: '人员计数D',
-      //     CameraDeviceId: 12,
-      //     src: '/20240618/20240618174105_510903_30_12_src.jpg?serverUUID=3f16a3b113234807b81f6f8d0f268232',
-      //     time: 1718703665,
-      //     h: 1146,
-      //     w: 1271,
-      //     x: 167,
-      //     y: 1204
-      //   }
-      //   const { x, y, w, h, src } = data
+      //           "AlertType": "28",
+      //           "Archived": 0,
+      //           "ArchivedTime": "0000-00-00 00:00:00",
+      //           "ArchivedUserPhone": "",
+      //           "AssociateResult": null,
+      //           "AssociateType": null,
+      //           "CameraId": 24,
+      //           "CreatedAt": "2025-08-06 18:01:19",
+      //           "DataID": "4dd2a7d672ac11f091380242ac120007",
+      //           "GUID": "003a3374-7b1f-4d2f-a4ec-2c94602d5603",
+      //           "HandleStatus": 0,
+      //           "HandleStatusTime": "0000-00-00 00:00:00",
+      //           "HandleStatusUserPhone": "",
+      //           "Id": 94276,
+      //           "JsonFile": "resultJson/20250806/20250806180119_433968_28_24_result.json?serverUUID=3f16a3b113234807b81f6f8d0f268232",
+      //           "OriginPicture": "originPicture/20250806/20250806180119_433968_28_24_src.jpg?serverUUID=3f16a3b113234807b81f6f8d0f268232",
+      //           "Other": "",
+      //           "Picture": "picture/20250806/20250806180119_433968_28_24_alarm.jpg",
+      //           "Remark": "",
+      //           "RemarkTime": "0000-00-00 00:00:00",
+      //           "RemarkUserPhone": "",
+      //           "StandardPicture": "",
+      //           "TaskGroupId": null,
+      //           "TaskId": 9,
+      //           "Thumbnail": "picthumbnail/20250806/20250806180119_433968_28_24_src.jpg?serverUUID=3f16a3b113234807b81f6f8d0f268232",
+      //           "UserId": 1,
+      //           "Video": null
+      //       }
       //   this.aiList.unshift({
       //     ...data,
       //     state: 0,
-      //     Res: {
-      //       [src]: [{ x, y, w, h }]
-      //     },
-      //     timeFormat: dayjs(parseInt(`${data.time}`.padEnd(13, '0'))).format('YYYY-MM-DD HH:mm:ss')
       //   })
       //   if (this.aiList.length >= 30) {
       //     this.aiList.splice(20, 10)
@@ -141,29 +161,29 @@ export const useSystemStore = defineStore('systemStore', {
 
       let websocket
       let that = this
-      ;(() => {
-        websocket = new WebSocket(`wss://${this.ipF}/Ai/Alarm`)
-        websocket.onopen = function (e) {
-          console.log('/Ai/Alarm 连接已经建立')
-        }
-        websocket.onmessage = function (e) {
-          const data = JSON.parse(e.data)
-
-          that.aiList.unshift({
-            ...data,
-            state: 0
-          })
-          if (that.aiList.length >= 100) {
-            that.aiList.splice(50, 10)
+        ; (() => {
+          websocket = new WebSocket(`wss://${this.ipF}/Ai/Alarm`)
+          websocket.onopen = function (e) {
+            console.log('/Ai/Alarm 连接已经建立')
           }
-        }
-        websocket.onerror = function (e) {
-          console.log('Ai/Alarm 发生错误')
-        }
-        websocket.onclose = function (e) {
-          console.log('Ai/Alarm 连接断开')
-        }
-      })()
+          websocket.onmessage = function (e) {
+            const data = JSON.parse(e.data)
+            that.aiList.unshift({
+              ...data,
+              state: 0
+            })
+            console.log(that.aiList)
+            if (that.aiList.length >= 100) {
+              that.aiList.splice(50, 10)
+            }
+          }
+          websocket.onerror = function (e) {
+            console.log('Ai/Alarm 发生错误')
+          }
+          websocket.onclose = function (e) {
+            console.log('Ai/Alarm 连接断开')
+          }
+        })()
     },
     async getInferMode() {
       const res = await API_INFER_MODE_GET()

+ 639 - 0
src/utils/Intercom.js

@@ -0,0 +1,639 @@
+// import { Message as $msg } from '@arco-design/web-vue';
+import { getToken } from '@/api/system';
+import E2EEWorker from 'livekit-client/e2ee-worker?worker';
+import {
+    ConnectionQuality,
+    ConnectionState,
+    DisconnectReason,
+    ExternalE2EEKeyProvider,
+    LocalAudioTrack,
+    LocalParticipant,
+    LogLevel,
+    MediaDeviceFailure,
+    Participant,
+    ParticipantEvent,
+    RemoteParticipant,
+    RemoteTrackPublication,
+    RemoteVideoTrack,
+    Room,
+    RoomEvent,
+    ScreenSharePresets,
+    Track,
+    TrackPublication,
+    VideoPresets,
+    // VideoQuality,
+    createAudioAnalyser,
+    setLogLevel,
+    supportsAV1,
+    supportsVP9,
+} from 'livekit-client';
+import FingerprintJS from '@fingerprintjs/fingerprintjs';
+
+// 指纹
+const getFingerprint = async () => {
+    const fpPromise = FingerprintJS.load();
+    const fp = await fpPromise;
+    const result = await fp.get();
+    // const components = {
+    //   languages: result.components.languages,
+    //   colorDepth: result.components.colorDepth,
+    //   deviceMemory: result.components.deviceMemory,
+    //   hardwareConcurrency: result.components.hardwareConcurrency,
+    //   timezone: result.components.timezone,
+    //   platform: result.components.platform,
+    //   vendor: result.components.vendor,
+    //   vendorFlavors: result.components.vendorFlavors,
+    //   cookiesEnabled: result.components.cookiesEnabled,
+    //   colorGamut: result.components.colorGamut,
+    //   hdr: result.components.hdr,
+    //   videoCard: result.components.videoCard,
+    // };
+    return result.visitorId;
+};
+
+const state = {
+    isFrontFacing: false,
+    encoder: new TextEncoder(),
+    decoder: new TextDecoder(),
+    defaultDevices: new Map(),
+    bitrateInterval,
+    e2eeKeyProvider: new ExternalE2EEKeyProvider(),
+};
+
+let currentRoom;
+
+let startTime;
+let timer;
+
+function appendLog(...args) {
+    let logger = '';
+    for (let i = 0; i < arguments.length; i += 1) {
+        if (typeof args[i] === 'object') {
+            logger += `${JSON && JSON.stringify ? JSON.stringify(args[i], undefined, 2) : args[i]
+                } `;
+        } else {
+            logger += `${args[i]} `;
+        }
+    }
+    // eslint-disable-next-line no-console
+    console.log(logger);
+}
+
+function getParticipantsAreaElement() {
+    return (
+        window.documentPictureInPicture?.window?.document.querySelector(
+            '#participants-area'
+        ) || document.querySelector('#participants-area')
+    );
+}
+// updates participant UI
+function renderParticipant(participant, remove = false) {
+    const container = getParticipantsAreaElement();
+    if (!container) return;
+    const { identity } = participant;
+    let div = container.querySelector(`#participant-${identity}`);
+    if (!div && !remove) {
+        div = document.createElement('div');
+        div.id = `participant-${identity}`;
+        div.className = 'participant';
+        div.innerHTML = `
+      <video id="video-${identity}"></video>
+      <audio id="audio-${identity}"></audio>
+      <div class="info-bar">
+        <div id="name-${identity}" class="name">
+        </div>
+        <div style="text-align: center;">
+          <span id="codec-${identity}" class="codec">
+          </span>
+          <span id="size-${identity}" class="size">
+          </span>
+          <span id="bitrate-${identity}" class="bitrate">
+          </span>
+        </div>
+        <div class="right">
+          <span id="signal-${identity}"></span>
+          <span id="mic-${identity}" class="mic-on"></span>
+          <span id="e2ee-${identity}" class="e2ee-on"></span>
+        </div>
+      </div>
+      ${participant instanceof RemoteParticipant
+                ? `<div class="volume-control">
+        <input id="volume-${identity}" type="range" min="0" max="1" step="0.1" value="1" orient="vertical" />
+      </div>`
+                : `<progress id="local-volume" max="1" value="0" />`
+            }
+
+    `;
+        container.appendChild(div);
+
+        const sizeElm = container.querySelector(`#size-${identity}`);
+        const videoElm = (
+            container.querySelector(`#video-${identity}`)
+        );
+        // videoElm.onresize = () => {
+        //   updateVideoSize(videoElm!, sizeElm!);
+        // };
+    }
+    const videoElm = (
+        container.querySelector(`#video-${identity}`)
+    );
+    const audioELm = (
+        container.querySelector(`#audio-${identity}`)
+    );
+    if (remove) {
+        div?.remove();
+        if (videoElm) {
+            videoElm.srcObject = null;
+            videoElm.src = '';
+        }
+        if (audioELm) {
+            audioELm.srcObject = null;
+            audioELm.src = '';
+        }
+        return;
+    }
+
+    // update properties
+    const nameElement = container.querySelector(`#name-${identity}`);
+    if (nameElement) {
+        nameElement.innerHTML = participant.identity;
+        if (participant instanceof LocalParticipant) {
+            nameElement.innerHTML += ' (you)';
+        }
+    }
+    const micElm = container.querySelector(`#mic-${identity}`);
+    const signalElm = container.querySelector(`#signal-${identity}`);
+    const cameraPub = participant.getTrackPublication(Track.Source.Camera);
+    const micPub = participant.getTrackPublication(Track.Source.Microphone);
+    if (participant.isSpeaking) {
+        div.classList.add('speaking');
+    } else {
+        div.classList.remove('speaking');
+    }
+
+    if (participant instanceof RemoteParticipant) {
+        const volumeSlider = (
+            container.querySelector(`#volume-${identity}`)
+        );
+        volumeSlider.addEventListener('input', (ev) => {
+            participant.setVolume(
+                Number.parseFloat(ev.target.value)
+            );
+        });
+    }
+
+    const cameraEnabled =
+        cameraPub && cameraPub.isSubscribed && !cameraPub.isMuted;
+    if (cameraEnabled) {
+        if (participant instanceof LocalParticipant) {
+            // flip
+            videoElm.style.transform = 'scale(-1, 1)';
+        } else if (!cameraPub?.videoTrack?.attachedElements.includes(videoElm)) {
+            const renderStartTime = Date.now();
+            // measure time to render
+            videoElm.onloadeddata = () => {
+                const elapsed = Date.now() - renderStartTime;
+                let fromJoin = 0;
+                if (
+                    participant.joinedAt &&
+                    participant.joinedAt.getTime() < startTime
+                ) {
+                    fromJoin = Date.now() - startTime;
+                }
+                appendLog(
+                    `RemoteVideoTrack ${cameraPub?.trackSid} (${videoElm.videoWidth}x${videoElm.videoHeight}) rendered in ${elapsed}ms`,
+                    fromJoin > 0 ? `, ${fromJoin}ms from start` : ''
+                );
+            };
+        }
+        cameraPub?.videoTrack?.attach(videoElm);
+    } else {
+        // clear information display
+        const sizeElement = container.querySelector(`#size-${identity}`);
+        if (sizeElement) {
+            sizeElement.innerHTML = '';
+        }
+        if (cameraPub?.videoTrack) {
+            // detach manually whenever possible
+            cameraPub.videoTrack?.detach(videoElm);
+        } else {
+            videoElm.src = '';
+            videoElm.srcObject = null;
+        }
+    }
+
+    const micEnabled = micPub && micPub.isSubscribed && !micPub.isMuted;
+    if (micEnabled) {
+        if (!(participant instanceof LocalParticipant)) {
+            // don't attach local audio
+            audioELm.onloadeddata = () => {
+                if (
+                    participant.joinedAt &&
+                    participant.joinedAt.getTime() < startTime
+                ) {
+                    const fromJoin = Date.now() - startTime;
+                    appendLog(
+                        `RemoteAudioTrack ${micPub?.trackSid} played ${fromJoin}ms from start`
+                    );
+                }
+            };
+            micPub?.audioTrack?.attach(audioELm);
+        }
+        micElm.className = 'mic-on';
+        micElm.innerHTML = '<i class="fas fa-microphone"></i>';
+    } else {
+        micElm.className = 'mic-off';
+        micElm.innerHTML = '<i class="fas fa-microphone-slash"></i>';
+    }
+
+    const e2eeElm = container.querySelector(`#e2ee-${identity}`);
+    if (participant.isEncrypted) {
+        e2eeElm.className = 'e2ee-on';
+        e2eeElm.innerHTML = '<i class="fas fa-lock"></i>';
+    } else {
+        e2eeElm.className = 'e2ee-off';
+        e2eeElm.innerHTML = '<i class="fas fa-unlock"></i>';
+    }
+
+    switch (participant.connectionQuality) {
+        case ConnectionQuality.Excellent:
+        case ConnectionQuality.Good:
+        case ConnectionQuality.Poor:
+            signalElm.className = `connection-${participant.connectionQuality}`;
+            signalElm.innerHTML = '<i class="fas fa-circle"></i>';
+            break;
+        default:
+            signalElm.innerHTML = '';
+        // do nothing
+    }
+}
+
+function participantConnected(participant) {
+    appendLog(
+        'participant',
+        participant.identity,
+        'connected',
+        participant.metadata
+    );
+    // eslint-disable-next-line no-console
+    console.log('tracks', participant.trackPublications);
+    participant
+        .on(ParticipantEvent.TrackMuted, (pub) => {
+            appendLog('track was muted', pub.trackSid, participant.identity);
+            renderParticipant(participant);
+        })
+        .on(ParticipantEvent.TrackUnmuted, (pub) => {
+            appendLog('track was unmuted', pub.trackSid, participant.identity);
+            renderParticipant(participant);
+        })
+        .on(ParticipantEvent.IsSpeakingChanged, () => {
+            // eslint-disable-next-line no-console
+            console.log(participant);
+            renderParticipant(participant);
+        })
+        .on(ParticipantEvent.ConnectionQualityChanged, () => {
+            // eslint-disable-next-line no-console
+            console.log(participant);
+            renderParticipant(participant);
+        });
+}
+
+function participantDisconnected(participant) {
+    appendLog('participant', participant.sid, 'disconnected');
+
+    // eslint-disable-next-line no-console
+    console.log(participant);
+}
+
+function handleRoomDisconnect(reason) {
+    if (!currentRoom) return;
+    appendLog('disconnected from room', { reason });
+    currentRoom = undefined;
+    window.currentRoom = undefined;
+}
+
+const elementMapping = {
+    // 'video-input': 'videoinput',
+    'audio-input': 'audioinput',
+    'audio-output': 'audiooutput',
+};
+
+async function handleDevicesChanged() {
+    Promise.all(
+        Object.keys(elementMapping).map(async (id) => {
+            const kind = elementMapping[id];
+            if (!kind) {
+                return;
+            }
+            const devices = await Room.getLocalDevices(kind);
+            // eslint-disable-next-line no-restricted-syntax
+            for (const device of devices) {
+                if (device.deviceId === state.defaultDevices.get(kind)) {
+                    // eslint-disable-next-line no-console
+                    console.log(device.label);
+                }
+            }
+        })
+    );
+}
+
+function renderScreenShare(room) {
+    // const div = $('screenshare-area')!;
+    if (room.state !== ConnectionState.Connected) {
+        // div.style.display = 'none';
+        return;
+    }
+    let participant;
+    let screenSharePub =
+        room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
+    let screenShareAudioPub;
+    if (!screenSharePub) {
+        room.remoteParticipants.forEach((p) => {
+            if (screenSharePub) {
+                return;
+            }
+            participant = p;
+            const pub = p.getTrackPublication(Track.Source.ScreenShare);
+            if (pub?.isSubscribed) {
+                screenSharePub = pub;
+            }
+            const audioPub = p.getTrackPublication(Track.Source.ScreenShareAudio);
+            if (audioPub?.isSubscribed) {
+                screenShareAudioPub = audioPub;
+            }
+        });
+    } else {
+        participant = room.localParticipant;
+    }
+
+    // if (screenSharePub && participant) {
+    //   // div.style.display = 'block';
+    //   // const videoElm = $('screenshare-video');
+    //   screenSharePub.videoTrack?.attach(videoElm);
+    //   if (screenShareAudioPub) {
+    //     screenShareAudioPub.audioTrack?.attach(videoElm);
+    //   }
+    //   videoElm.onresize = () => {
+    //     updateVideoSize(videoElm, <HTMLSpanElement>$('screenshare-resolution'));
+    //   };
+    //   const infoElm = $('screenshare-info')!;
+    //   infoElm.innerHTML = `Screenshare from ${participant.identity}`;
+    // } else {
+    //   div.style.display = 'none';
+    // }
+}
+
+const connectToRoom = async (
+    url,
+    token,
+    roomOptions,
+    connectOptions,
+    shouldPublish
+) => {
+    const room = new Room(roomOptions);
+
+    startTime = Date.now();
+    await room.prepareConnection(url, token);
+    const prewarmTime = Date.now() - startTime;
+    appendLog(`prewarmed connection in ${prewarmTime}ms`);
+
+    room
+        .on(RoomEvent.ParticipantConnected, participantConnected)
+        .on(RoomEvent.ParticipantDisconnected, participantDisconnected)
+        .on(RoomEvent.Disconnected, handleRoomDisconnect)
+        .on(RoomEvent.Reconnecting, () => appendLog('Reconnecting to room'))
+        .on(RoomEvent.Reconnected, async () => {
+            appendLog(
+                'Successfully reconnected. server',
+                await room.engine.getConnectedServerAddress()
+            );
+        })
+        .on(RoomEvent.LocalTrackPublished, (pub) => {
+            const track = pub.track;
+
+            if (track instanceof LocalAudioTrack) {
+                const { calculateVolume } = createAudioAnalyser(track);
+                // setInterval(() => {
+                //   $('local-volume')?.setAttribute(
+                //     'value',
+                //     calculateVolume().toFixed(4)
+                //   );
+                // }, 200);
+            }
+            renderScreenShare(room);
+        })
+        .on(RoomEvent.LocalTrackUnpublished, () => {
+            renderScreenShare(room);
+        })
+        .on(RoomEvent.RoomMetadataChanged, (metadata) => {
+            appendLog('new metadata for room', metadata);
+        })
+        .on(RoomEvent.MediaDevicesChanged, handleDevicesChanged)
+        .on(RoomEvent.MediaDevicesError, (e) => {
+            const failure = MediaDeviceFailure.getFailure(e);
+            appendLog('media device failure', failure);
+        })
+        .on(
+            RoomEvent.ConnectionQualityChanged,
+            (quality, participant) => {
+                appendLog('connection quality changed', participant?.identity, quality);
+            }
+        )
+        .on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
+            appendLog('subscribed to track', pub.trackSid, participant.identity);
+            renderParticipant(participant);
+            renderScreenShare(room);
+        })
+        .on(RoomEvent.TrackUnsubscribed, (_, pub, participant) => {
+            appendLog('unsubscribed from track', pub.trackSid);
+            renderParticipant(participant);
+            renderScreenShare(room);
+        })
+        .on(RoomEvent.SignalConnected, async () => {
+            const signalConnectionTime = Date.now() - startTime;
+            appendLog(`signal connection established in ${signalConnectionTime}ms`);
+            // speed up publishing by starting to publish before it's fully connected
+            // publishing is accepted as soon as signal connection has established
+            if (shouldPublish) {
+                await room.localParticipant.enableCameraAndMicrophone();
+                appendLog(`tracks published in ${Date.now() - startTime}ms`);
+            }
+        })
+        .on(RoomEvent.TrackStreamStateChanged, (pub, streamState, participant) => {
+            appendLog(
+                `stream state changed for ${pub.trackSid} (${participant.identity
+                }) to ${streamState.toString()}`
+            );
+        });
+
+    try {
+        // read and set current key from input
+        const cryptoKey = '';
+        const e2eChack = false;
+        state.e2eeKeyProvider.setKey(cryptoKey);
+        if (e2eChack) {
+            await room.setE2EEEnabled(true);
+        }
+
+        await room.connect(url, token, connectOptions);
+        const elapsed = Date.now() - startTime;
+        appendLog(
+            `successfully connected to ${room.name} in ${Math.round(elapsed)}ms`,
+            await room.engine.getConnectedServerAddress()
+        );
+    } catch (error) {
+        let message = error;
+        if (error.message) {
+            message = error.message;
+        }
+        appendLog('could not connect:', message);
+        return undefined;
+    }
+    currentRoom = room;
+    window.currentRoom = room;
+
+    room.remoteParticipants.forEach((participant) => {
+        participantConnected(participant);
+    });
+    participantConnected(room.localParticipant);
+    // 禁用视频
+    await currentRoom.localParticipant.setCameraEnabled(false);
+    currentRoom?.startAudio();
+
+    return room;
+};
+
+function renderBitrate() {
+    if (!currentRoom || currentRoom.state !== ConnectionState.Connected) {
+        return;
+    }
+    const participants = [
+        ...currentRoom.remoteParticipants.values(),
+    ];
+    participants.push(currentRoom.localParticipant);
+
+    // eslint-disable-next-line no-restricted-syntax
+    for (const p of participants) {
+        let totalBitrate = 0;
+        // eslint-disable-next-line no-restricted-syntax
+        for (const t of p.trackPublications.values()) {
+            if (t.track) {
+                totalBitrate += t.track.currentBitrate;
+            }
+
+            if (t.source === Track.Source.Camera) {
+                if (t.videoTrack instanceof RemoteVideoTrack) {
+                    // eslint-disable-next-line no-console
+                    console.log(t.videoTrack.getDecoderImplementation() ?? '');
+                }
+            }
+        }
+        let displayText = '';
+        if (totalBitrate > 0) {
+            displayText = `${Math.round(totalBitrate / 1024).toLocaleString()} kbps`;
+        }
+        // eslint-disable-next-line no-console
+        console.log(displayText);
+    }
+}
+
+const connectWithFormInput = async (
+    url,
+    token,
+    disabled
+) => {
+    const simulcast = true; // 同步播出
+    const dynacast = true; // Dynacast
+    const forceTURN = false; // 强制转向
+    const adaptiveStream = true; // 自适应流
+    const shouldPublish = true; // 发布
+    const preferredCodec = '';
+    const scalabilityMode = '';
+    const cryptoKey = '';
+    const autoSubscribe = true; // 自动订阅
+    const e2eeEnabled = false;
+    const audioOutputId = ''; // E2EE密钥
+    setLogLevel(LogLevel.debug);
+
+    const roomOpts = {
+        adaptiveStream,
+        dynacast,
+        audioOutput: {
+            deviceId: audioOutputId,
+        },
+        publishDefaults: {
+            simulcast,
+            videoSimulcastLayers: [VideoPresets.h90, VideoPresets.h216],
+            videoCodec: preferredCodec || 'vp8',
+            dtx: true,
+            red: true,
+            forceStereo: false,
+            screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
+            scalabilityMode: 'L3T3_KEY',
+        },
+        videoCaptureDefaults: {
+            resolution: VideoPresets.h720.resolution,
+        },
+        e2ee: e2eeEnabled
+            ? { keyProvider: state.e2eeKeyProvider, worker: new E2EEWorker() }
+            : undefined,
+    };
+    if (
+        roomOpts.publishDefaults?.videoCodec === 'av1' ||
+        roomOpts.publishDefaults?.videoCodec === 'vp9'
+    ) {
+        roomOpts.publishDefaults.backupCodec = true;
+        if (scalabilityMode !== '') {
+            roomOpts.publishDefaults.scalabilityMode =
+                scalabilityMode;
+        }
+    }
+
+    const connectOpts = {
+        autoSubscribe,
+    };
+    if (forceTURN) {
+        connectOpts.rtcConfig = {
+            iceTransportPolicy: 'relay',
+        };
+    }
+    await connectToRoom(url, token, roomOpts, connectOpts, shouldPublish);
+    disabled.value = 'open';
+    timer = setTimeout(handleDevicesChanged, 100);
+
+    state.bitrateInterval = setInterval(renderBitrate, 1000);
+};
+
+const disconnectRoom = (disabled) => {
+    if (currentRoom) {
+        currentRoom.disconnect();
+    }
+    if (state.bitrateInterval) {
+        clearInterval(state.bitrateInterval);
+    }
+    disabled.value = 'close';
+};
+
+async function acquireDeviceList() {
+    handleDevicesChanged();
+}
+
+acquireDeviceList();
+
+export const IntercomFn = async (disabled, guid) => {
+    if (disabled.value === 'open') {
+        disconnectRoom(disabled);
+        clearInterval(timer);
+        return;
+    }
+    disabled.value = 'loading';
+    const fingerprint = await getFingerprint();
+    const token = await getToken({ Room: guid, Identity: fingerprint });
+    connectWithFormInput(
+        `wss://${import.meta.env.VITE_API_INTERCOM_IP}`,
+        token,
+        disabled
+    );
+};
+
+export default null;

+ 4 - 1
src/views/VideoMonitoring/components/Header.vue

@@ -31,6 +31,8 @@
       flex-items-center
       color="#8ac5ff"
     >
+      <Speech class="mr-2"/>
+
       <div class="layout-header-box-right-btn" flex flex-items-center cursor-pointer gap-3>
         <div
           class="flex justify-center items-center"
@@ -90,7 +92,7 @@
           <Compass />
         </n-popover>
       </div> -->
-      <div class="layout-header-box-right-tq" mx-3>
+      <div class="layout-header-box-right-tq ml-2">
         <Weather />
       </div>
       <div class="layout-header-box-right-icon screen-full-trigger" flex flex-items-center>
@@ -123,6 +125,7 @@
 import storage from '@/utils/storage'
 import useScreenfull from '@/hooks/useScreenfull'
 import SafetyHat from './SafetyHat.vue'
+import Speech from './Speech.vue'
 import { Button as DvButton } from '@kjgl77/datav-vue3'
 import { useOutsideScreenOperation } from '@/stores/modules/screenOperation.js'
 import { useOutsideSystemStore } from '@/stores/modules/system.js'

+ 36 - 0
src/views/VideoMonitoring/components/Intercom.vue

@@ -0,0 +1,36 @@
+<template>
+  <n-button text :disabled="intercomDisabled === 'loading'" @click="intercomClick">
+    <template #icon>
+      <Icon
+        v-if="intercomDisabled === 'close'"
+        icon="material-symbols:record-voice-over-outline"
+        width="22"
+        height="22"
+      />
+      <Icon
+        v-else-if="intercomDisabled === 'loading'"
+        icon="eos-icons:three-dots-loading"
+        width="22"
+        height="22"
+      />
+      <Icon
+        v-else
+        class="fade-open"
+        icon="icon-park-outline:voice"
+        width="22"
+        height="22"
+        color="red"
+      />
+    </template>
+  </n-button>
+</template>
+
+<script setup>
+  import { IntercomFn } from '@/utils/Intercom';
+  
+  const intercomDisabled = ref('close');
+
+  const intercomClick = () => {
+    // IntercomFn(intercomDisabled.value, props.data.id);
+  };
+</script>

+ 9 - 2
src/views/VideoMonitoring/components/RealTime.vue

@@ -34,10 +34,10 @@
                   v-else
                 />
               </div> -->
-              <span>{{ item.Algorithms[0].algorithm_name }}</span>
+              <span>{{ typeObj[item.AlertType] }}</span>
             </div>
             <div>
-              <span mr-1vw>{{ item.Create_at }}</span>
+              <span mr-1vw>{{ item.CreatedAt }}</span>
               <span>{{ item.state ? '已处理' : '未处理' }}</span>
             </div>
           </div>
@@ -78,8 +78,11 @@
 import { useOutsideSystemStore } from '@/stores/modules/system.js'
 import RealTimeAll from './RealTimeAll.vue'
 import RealTimeImg from './RealTimeImg.vue'
+
 const useSystem = useOutsideSystemStore()
+const { API_AI_TYPE_GET } = useRequest()
 
+const typeObj = ref({})
 const alarmItem = ref({})
 const clickAlarmItem = async (item) => {
   alarmItem.value = item
@@ -92,6 +95,10 @@ const showModal2 = ref(false)
 const openAll = () => (showModal.value = true)
 const clearAlarm = () => useSystem.aiList.forEach((item) => (item.state = 1))
 
+API_AI_TYPE_GET().then(res => {
+  console.log(res)
+  typeObj.value = res
+})
 </script>
 <style scoped lang="scss">
 .real-time {

+ 71 - 9
src/views/VideoMonitoring/components/RealTimeImg.vue

@@ -60,7 +60,7 @@ const draw = () => {
           // 在矩形框内绘制矩形文字
           ctx.fillStyle = 'white'
           ctx.font = '26px Arial'
-          ctx.fillText(props.alarm.Algorithms[0].algorithm_name, rects.rect.x + 6, rects.rect.y - 4)
+          ctx.fillText(props.alarm.AlertType, rects.rect.x + 6, rects.rect.y - 4)
         })
         canvas.toBlob((blob) => {
           // 创建一个指向该 Blob 的 URL
@@ -106,16 +106,78 @@ const draw = () => {
 const debounceDraw = useDebounceFn(draw, 400)
 const init = async () => {
   const data = []
-  const fileRes = await API_IMG_FILES_GET(props.alarm.Result_data_path)
-  console.log(fileRes)
+  const fileRes = await API_IMG_FILES_GET(props.alarm.JsonFile)
+  
+  /*
 
-  fileRes.forEach((item, i) => {
-    data.push({
-      id: i,
-      src: `${useSystem.ip}/KunPengYun/Files${props.alarm.Src_pic_url}`,
-      rects: item.result_data.task_result.object_list
+  {
+    "taskId": 28,
+    "cameraId": "24",
+    "timestamp": 1754479748,
+    "imageWidth": 1520,
+    "imageHeight": 2688,
+    "parameter": {
+        "roiList": []
+    },
+    "srcPicName": "20250806192908_143604_28_24_src.jpg",
+    "alarmPicName": "20250806192908_143604_28_24_alarm.jpg",
+    "resultData": {
+        "classId": 1,
+        "score": 0,
+        "objectList": [
+            {
+                "classId": 1,
+                "score": 0.843615,
+                "scoreList": [
+                    0.888926,
+                    0.999998,
+                    1.046612,
+                    0.636956,
+                    0.008129,
+                    0.005303,
+                    0.008364,
+                    0.019103,
+                    0.843615,
+                    0.026886,
+                    0.004599,
+                    0.084,
+                    0.015082,
+                    0.009639,
+                    0.449705,
+                    0.055663,
+                    0.009639,
+                    0.373176,
+                    0.087096
+                ],
+                "rect": {
+                    "x": 705,
+                    "y": 1022,
+                    "width": 802,
+                    "height": 720
+                },
+                "polygonList": [],
+                "multiPointList": []
+            }
+        ],
+        "extraData": {}
+    },
+    "video": null,
+    "other": ""
+}
+  */
+  // fileRes.forEach((item, i) => {
+  //   data.push({
+  //     id: i,
+  //     src: `${useSystem.ip}/KunPengYun/Files/${props.alarm.OriginPicture}`,
+  //     rects: item.result_data.task_result.object_list
+  //   })
+  // })
+   data.push({
+      id: fileRes.cameraId,
+      src: `${useSystem.ip}/KunPengYun/Files/${props.alarm.OriginPicture}`,
+      rects: fileRes.resultData.objectList
     })
-  })
+  console.log(fileRes)
 
   imgs.value = data
   thumb.value = data

+ 184 - 0
src/views/VideoMonitoring/components/Speech.vue

@@ -0,0 +1,184 @@
+<template>
+  <div class="speech">
+    <svg
+      v-if="!isSpeech"
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="24"
+      viewBox="0 0 24 24"
+    >
+      <rect width="2.8" height="12" x="1" y="6" fill="#8ac5ff" />
+      <rect width="2.8" height="12" x="5.8" y="6" fill="#8ac5ff" />
+      <rect width="2.8" height="12" x="10.6" y="6" fill="#8ac5ff" />
+      <rect width="2.8" height="12" x="15.4" y="6" fill="#8ac5ff" />
+      <rect width="2.8" height="12" x="20.2" y="6" fill="#8ac5ff" />
+    </svg>
+    <Icon
+      v-else
+      class="icon"
+      icon="svg-spinners:bars-scale-middle"
+      width="24"
+      height="24"
+      animation="0.7"
+      color="#8ac5ff"
+    />
+    <!-- <div class="second">{{ second }}s</div> -->
+    <Icon
+      class="icon"
+      icon="tabler:microphone"
+      width="20"
+      height="20"
+      color="#8ac5ff"
+      @mousedown="speechDown"
+      @mouseup="speechUp"
+      @mouseleave="speechUp"
+    />
+    <!-- <Icon
+        v-if="!isPlay"
+        :disabled="audioChunks.length === 0"
+        @click="speechPlay"
+        class="icon"
+        icon="majesticons:play-circle-line"
+        width="24"
+        height="24"
+        color="#8ac5ff"
+      />
+      <Icon
+        @click="speechStop"
+        class="icon"
+        icon="gg:play-stop-o"
+        width="24"
+        height="24"
+        color="#8ac5ff"
+      /> -->
+  </div>
+</template>
+
+<script setup>
+import { msg } from '@/utils'
+
+const { API_MAILBOX_PUT } = useRequest()
+const isSpeech = ref(false)
+const isPlay = ref(false)
+const audioChunks = ref([])
+let mediaRecorder
+let audioUrl
+let audioBlob
+let audio
+let stream
+const second = ref(0)
+
+// 将录音发送到服务器
+const sendMicrophone = async () => {
+  // const formData = new FormData();
+  // console.log(audioBlob);
+
+  // formData.append('audio', audioBlob, 'recording.wav');
+  try {
+    await API_MAILBOX_PUT(audioBlob, {
+      'Content-Type': 'application/octet-stream'
+    })
+  } catch (error) {
+    msg('error', '上传音频失败')
+    console.error('Error uploading audio:', error)
+    // alert('Error uploading audio');
+  }
+}
+// 获取用户的麦克风权限并开始录音
+const getMicrophone = async () => {
+  let previousTime = Date.now()
+
+  const options = {
+    audioBitsPerSecond: 128000,
+    mimeType: 'audio/webm'
+  }
+
+  mediaRecorder = new MediaRecorder(stream, options)
+  mediaRecorder.ondataavailable = (event) => {
+    second.value += 1
+    const timeNow = Date.now()
+    console.log(`Interval: ${(timeNow - previousTime) / 1000}s`)
+    previousTime = timeNow
+    audioChunks.value.push(event.data)
+  }
+
+  mediaRecorder.onstop = () => {
+    audio = document.createElement('audio')
+    audio.controls = true
+    audioBlob = new Blob(audioChunks.value, {
+      type: 'application/octet-stream'
+    })
+    audioUrl = URL.createObjectURL(audioBlob)
+    audio.src = audioUrl
+    sendMicrophone()
+  }
+
+  if (audio) {
+    audio = null
+    audioChunks.value = []
+  }
+  mediaRecorder.start()
+  msg('warning', '开始录音')
+}
+
+// 停止录音
+const stopMicrophone = async () => {
+  // if (!isSpeech.value) return;
+  mediaRecorder.stop()
+  msg('warning', '录音结束')
+  URL.revokeObjectURL(audioUrl)
+}
+
+// 播放录音
+const playMicrophone = async () => {
+  audio.play()
+}
+
+const speechDown = () => {
+  isSpeech.value = true
+  getMicrophone()
+}
+const speechUp = () => {
+  if (!isSpeech.value) return
+  stopMicrophone()
+  isSpeech.value = false
+}
+
+const speechStop = () => {
+  isPlay.value = false
+}
+const speechPlay = () => {
+  // eslint-disable-next-line no-console
+  console.log('播放开始')
+  isPlay.value = true
+  playMicrophone()
+  audio.onended = () => {
+    // eslint-disable-next-line no-console
+    console.log('播放结束')
+    speechStop()
+  }
+}
+
+onMounted(async () => {
+  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+    try {
+      // 请求麦克风
+      stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+    } catch (error) {
+      console.error('麦克风访问失败', error)
+    }
+  } else {
+    console.log('浏览器不支持getUserMedia')
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.speech {
+  height: 40px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 8px;
+}
+</style>

+ 5 - 2
src/views/VideoMonitoring/pages/RemotePlayback/components/PartBox.vue

@@ -139,9 +139,12 @@ const openWork = (res, index, cName = '.rpb-video') => {
         resolve(true)
       })
     } else {
+      useSystem.videoLoading = true
       nextTick(() => {
-        workerObj['qj'] = useWorker(url, cName)
-        emits('successOpen')
+        workerObj['qj'] = useWorker(url, cName, null, () => {
+          emits('successOpen')
+          useSystem.videoLoading = false
+        })
       })
     }
   })

+ 12 - 2
vite.config.js

@@ -14,13 +14,14 @@ import deletePlugin from 'rollup-plugin-delete'
 
 // const IconsResolver = require('unplugin-icons/resolver')
 
+const outDir = `dist-tt2.0.1.build${new Date().getFullYear()}${new Date().getMonth() + 1}${new Date().getDate()}`;
 // https://vitejs.dev/config/
 export default ({ mode, command }) => {
   // const env = loadEnv(mode, process.cwd())
   return defineConfig({
     base: '/foreground/',
     build: {
-      outDir: 'dist-tt',
+      outDir,
       sourcemap: false,
       minify: true,
       rollupOptions: {
@@ -72,7 +73,7 @@ export default ({ mode, command }) => {
       }),
       Icons({}),
       deletePlugin({
-        targets: ['dist-tt/del'],
+        targets: [`${outDir}/del`],
         hook: 'writeBundle'
       })
     ],
@@ -85,6 +86,15 @@ export default ({ mode, command }) => {
         layout: path.resolve(__dirname, 'src/layout')
       }
     },
+    css: {
+      preprocessorOptions: {
+        scss: {
+          api: 'modern-compiler',
+          //忽略警告提示
+          silenceDeprecations: ['legacy-js-api', 'import']
+        }
+      }
+    },
     // preprocessorOptions: {
     //   scss: {
     //     additionalData: `@import "@/assets/base.scss";`

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů