Преглед на файлове

feat(camera): enhance preset functionality and UI improvements

- Added optional fields for preset time and total time in the PresetSetRequest interface.
- Streamlined the live stream search form and table components for better readability and usability.
- Improved the handling of timeline points, including the addition of a context menu for editing and deleting points.
- Enhanced visual feedback for timeline points to indicate passed status during playback.
- Updated the styling of various components for a more cohesive user experience.
yb преди 1 ден
родител
ревизия
2fc2ac26aa
променени са 2 файла, в които са добавени 308 реда и са изтрити 180 реда
  1. 2 0
      src/api/camera.ts
  2. 306 180
      src/views/live-stream/index.vue

+ 2 - 0
src/api/camera.ts

@@ -101,6 +101,8 @@ export interface PresetSetRequest {
   cameraId: string
   presetId: number
   presetName?: string
+  presetTime?: number // 该预置位停留时间(秒)
+  presetTotalTime?: number // 巡航总时长(秒)
 }
 
 export function presetSet(data: PresetSetRequest): Promise<BaseResponse> {

+ 306 - 180
src/views/live-stream/index.vue

@@ -305,7 +305,7 @@
                 <div :class="['timeline-track', { 'is-playing': isTimelinePlaying }]" ref="timelineTrackRef">
                   <!-- 时间轴进度指示器 -->
                   <div class="timeline-progress" :style="{ width: `${timelineProgress}%` }"></div>
-                  <!-- 关键点 -->
+                  <!-- 关键点 - 竖线风格 -->
                   <div
                     v-for="point in timelinePoints"
                     :key="point.id"
@@ -314,15 +314,22 @@
                       {
                         active: point.active,
                         selected: !isTimelinePlaying && selectedPoint?.id === point.id,
-                        dragging: draggingPoint?.id === point.id
+                        dragging: draggingPoint?.id === point.id,
+                        passed: isTimelinePlaying && point.passed
                       }
                     ]"
                     :style="{ left: `${(point.time / timelineDuration) * 100}%` }"
                     @click.stop="!isTimelinePlaying && selectPoint(point)"
                     @mousedown.stop="!isTimelinePlaying && startDragPoint($event, point)"
-                    @contextmenu.prevent="!isTimelinePlaying && handlePointContextMenu($event, point)"
+                    @contextmenu.prevent="!isTimelinePlaying && showPointContextMenu($event, point)"
                   >
-                    <span class="point-label">{{ point.id }}</span>
+                    <!-- 顶部数字标签 -->
+                    <div class="point-number">{{ point.id }}</div>
+                    <!-- 拖拽时显示时间 -->
+                    <div v-if="draggingPoint?.id === point.id" class="point-drag-time">
+                      {{ formatTimelineTime(point.time) }}
+                    </div>
+                    <!-- 悬停 tooltip -->
                     <div class="point-tooltip">
                       <div>{{ point.presetName || `Point ${point.id}` }}</div>
                       <div class="point-time">{{ formatTimelineTime(point.time) }}</div>
@@ -337,46 +344,25 @@
                   </span>
                 </div>
 
-                <!-- 选中点的操作面板(巡航中隐藏) -->
-                <div v-if="selectedPoint && !isTimelinePlaying" class="timeline-point-panel">
-                  <span class="panel-label">
-                    {{ t('当前选中') }}: {{ selectedPoint.presetName || `Point ${selectedPoint.id}` }}
-                  </span>
-                  <!-- 关联已有预置位 -->
-                  <el-select
-                    v-model="linkPresetId"
-                    size="small"
-                    :placeholder="t('关联预置位')"
-                    style="width: 140px"
-                    clearable
-                    @change="handleLinkPreset"
-                  >
-                    <el-option
-                      v-for="preset in ptzPresetList"
-                      :key="preset.id"
-                      :value="preset.id"
-                      :label="preset.name || `Preset ${preset.id}`"
-                    />
-                  </el-select>
-                  <el-button size="small" type="primary" :loading="savingPreset" @click="saveCurrentPoint">
-                    {{ selectedPoint.active ? t('更新位置') : t('保存位置') }}
-                  </el-button>
-                  <el-button size="small" type="danger" @click="deleteSelectedPoint">{{ t('删除') }}</el-button>
-                </div>
-                <!-- 自动映射按钮(巡航中隐藏) -->
-                <div v-if="timelinePoints.length > 0 && !isTimelinePlaying" class="timeline-auto-link">
-                  <el-button
-                    size="small"
-                    type="success"
-                    @click="autoLinkPresets"
-                    :disabled="ptzPresetList.length === 0"
-                  >
+                <!-- 右键菜单 -->
+                <div
+                  v-if="contextMenu.visible"
+                  class="timeline-context-menu"
+                  :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
+                  @click.stop
+                >
+                  <div class="context-menu-item" @click="handleContextMenuUpdate">
                     <el-icon>
-                      <Link />
+                      <Position />
                     </el-icon>
-                    {{ t('自动映射') }}
-                  </el-button>
-                  <span class="auto-link-hint">{{ t('将点1-N映射到Preset 1-N') }}</span>
+                    {{ t('更新位置') }}
+                  </div>
+                  <div class="context-menu-item danger" @click="handleContextMenuDelete">
+                    <el-icon>
+                      <Delete />
+                    </el-icon>
+                    {{ t('删除') }}
+                  </div>
                 </div>
               </div>
 
@@ -669,7 +655,7 @@ import {
   ZoomOut,
   Close,
   Position,
-  Link
+  Delete
 } from '@element-plus/icons-vue'
 import { Icon } from '@iconify/vue'
 import { useI18n } from 'vue-i18n'
@@ -761,6 +747,7 @@ interface TimelinePoint {
   presetId?: number // 关联的预置位ID (保存后才有)
   presetName?: string // 预置位名称
   active: boolean // 是否已激活(已保存预置位)
+  passed?: boolean // 巡航模式下是否已走过
 }
 
 // localStorage 存储 key
@@ -774,10 +761,17 @@ const selectedPoint = ref<TimelinePoint | null>(null)
 const isTimelinePlaying = ref(false)
 const timelineProgress = ref(0)
 const savingPreset = ref(false)
-const linkPresetId = ref<string | null>(null) // 关联预置位选择
 const isLoopEnabled = ref(false) // 是否循环巡航
 let timelinePlayAbort: AbortController | null = null
 
+// 右键菜单状态
+const contextMenu = ref({
+  visible: false,
+  x: 0,
+  y: 0,
+  point: null as TimelinePoint | null
+})
+
 // 拖拽状态
 const draggingPoint = ref<TimelinePoint | null>(null)
 
@@ -1524,10 +1518,38 @@ async function handleGotoPTZPreset(preset: PresetInfo) {
   }
 }
 
-// 编辑预置位 (设置按钮)
-function handleEditPreset(preset: PTZPresetInfo) {
-  ElMessage.info(`${t('设置预置位')}: ${preset.name || preset.id}`)
-  // TODO: 打开预置位设置对话框
+// 编辑预置位 (设置按钮) - 将当前摄像头位置保存到该预置位
+async function handleEditPreset(preset: PTZPresetInfo) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
+    ElMessage.warning(t('请先配置摄像头连接'))
+    return
+  }
+
+  try {
+    await ElMessageBox.confirm(
+      `${t('将当前摄像头位置保存到预置位')} "${preset.name || `Preset ${preset.id}`}"?`,
+      t('设置预置位'),
+      { type: 'info' }
+    )
+
+    const res = await presetSet({
+      cameraId,
+      presetId: parseInt(preset.id),
+      presetName: preset.name || `Preset ${preset.id}`
+    })
+
+    if (res.code === 200) {
+      ElMessage.success(`${t('预置位设置成功')}: ${preset.name || `Preset ${preset.id}`}`)
+    } else {
+      ElMessage.error(res.errMsg || t('设置失败'))
+    }
+  } catch (error) {
+    // 用户取消确认
+    if ((error as Error).toString().includes('cancel')) return
+    console.error('设置预置位失败', error)
+    ElMessage.error(t('设置失败'))
+  }
 }
 
 // 删除预置位
@@ -1637,24 +1659,60 @@ function handleDragEnd() {
   document.removeEventListener('mouseup', handleDragEnd)
 }
 
-// 添加关键点
-function addTimelinePoint(time?: number) {
+// 添加关键点并自动保存预置位
+async function addTimelinePoint(time?: number) {
+  const cameraId = currentMediaStream.value?.cameraId
+  if (!cameraId) {
+    ElMessage.warning(t('请先配置摄像头连接'))
+    return
+  }
+
   const newId = timelinePoints.value.length > 0 ? Math.max(...timelinePoints.value.map((p) => p.id)) + 1 : 1
   const newTime = time ?? Math.round(timelineDuration.value / 2)
 
   // 确保时间在有效范围内
   const clampedTime = Math.max(0, Math.min(newTime, timelineDuration.value))
 
-  const newPoint: TimelinePoint = {
-    id: newId,
-    time: clampedTime,
-    active: false
-  }
+  // 使用时间轴专用的预置位ID范围 (100+)
+  const presetIdNum = 100 + newId
+  const presetName = `Timeline_${newId}`
 
-  timelinePoints.value.push(newPoint)
-  sortAndRenumberPoints()
-  saveTimelineConfig()
-  selectPoint(newPoint)
+  // 先保存当前摄像头位置到预置位
+  try {
+    const res = await presetSet({
+      cameraId,
+      presetId: presetIdNum,
+      presetName,
+      presetTime: 5, // 默认停留5秒
+      presetTotalTime: timelineDuration.value
+    })
+
+    if (res.success) {
+      // 保存成功后添加打点
+      const newPoint: TimelinePoint = {
+        id: newId,
+        time: clampedTime,
+        presetId: presetIdNum,
+        presetName: presetName,
+        active: true // 已保存预置位,标记为激活
+      }
+
+      timelinePoints.value.push(newPoint)
+      sortAndRenumberPoints()
+      saveTimelineConfig()
+      selectPoint(newPoint)
+
+      // 刷新预置位列表
+      loadPTZPresets()
+
+      ElMessage.success(`${t('已添加打点')} ${newId}`)
+    } else {
+      ElMessage.error(res.errMsg || t('添加失败'))
+    }
+  } catch (error) {
+    console.error('添加打点失败', error)
+    ElMessage.error(t('添加失败'))
+  }
 }
 
 // 按时间排序并重新编号
@@ -1697,12 +1755,20 @@ async function saveCurrentPoint() {
 
   savingPreset.value = true
   try {
-    const res = await presetSet({ cameraId, presetId: presetIdNum, presetName })
+    const res = await presetSet({
+      cameraId,
+      presetId: presetIdNum,
+      presetName,
+      presetTime: 5,
+      presetTotalTime: timelineDuration.value
+    })
     if (res.success) {
       point.presetId = presetIdNum
       point.presetName = presetName
       point.active = true
       saveTimelineConfig()
+      // 刷新预置位列表
+      loadPTZPresets()
       ElMessage.success(`${t('已保存')} ${presetName}`)
     } else {
       ElMessage.error(res.errMsg || t('保存失败'))
@@ -1715,87 +1781,78 @@ async function saveCurrentPoint() {
   }
 }
 
-// 删除选中的点
-function deleteSelectedPoint() {
-  if (!selectedPoint.value) return
+// 显示右键菜单
+function showPointContextMenu(e: MouseEvent, point: TimelinePoint) {
+  e.preventDefault()
+  selectedPoint.value = point
+  contextMenu.value = {
+    visible: true,
+    x: e.clientX,
+    y: e.clientY,
+    point
+  }
 
-  const index = timelinePoints.value.findIndex((p) => p.id === selectedPoint.value!.id)
-  if (index !== -1) {
-    timelinePoints.value.splice(index, 1)
-    sortAndRenumberPoints()
-    saveTimelineConfig()
-    selectedPoint.value = null
-    ElMessage.success(t('已删除'))
+  // 点击其他地方关闭菜单
+  const closeMenu = () => {
+    contextMenu.value.visible = false
+    document.removeEventListener('click', closeMenu)
   }
+  setTimeout(() => {
+    document.addEventListener('click', closeMenu)
+  }, 0)
 }
 
-// 关联已有预置位
-function handleLinkPreset(presetId: string | null) {
-  if (!selectedPoint.value || !presetId) {
-    linkPresetId.value = null
-    return
-  }
+// 右键菜单 - 更新位置
+async function handleContextMenuUpdate() {
+  const point = contextMenu.value.point
+  if (!point) return
 
-  const preset = ptzPresetList.value.find((p) => p.id === presetId)
-  if (preset) {
-    selectedPoint.value.presetId = parseInt(preset.id)
-    selectedPoint.value.presetName = preset.name || `Preset ${preset.id}`
-    selectedPoint.value.active = true
-    saveTimelineConfig()
-    ElMessage.success(`${t('已关联')}: ${selectedPoint.value.presetName}`)
-    linkPresetId.value = null
-  }
+  contextMenu.value.visible = false
+  selectedPoint.value = point
+
+  // 调用 saveCurrentPoint 保存当前摄像头位置
+  await saveCurrentPoint()
 }
 
-// 自动映射:点1-N 对应 Preset 1-N
-function autoLinkPresets() {
-  if (ptzPresetList.value.length === 0) {
-    ElMessage.warning(t('暂无可用预置位'))
-    return
-  }
+// 右键菜单 - 删除
+async function handleContextMenuDelete() {
+  const point = contextMenu.value.point
+  if (!point) return
 
-  // 按ID排序预置位列表(取前几个数字小的)
-  const sortedPresets = [...ptzPresetList.value]
-    .filter((p) => !isNaN(parseInt(p.id)))
-    .sort((a, b) => parseInt(a.id) - parseInt(b.id))
+  contextMenu.value.visible = false
 
-  let linkedCount = 0
-  timelinePoints.value.forEach((point, index) => {
-    if (index < sortedPresets.length) {
-      const preset = sortedPresets[index]
-      point.presetId = parseInt(preset.id)
-      point.presetName = preset.name || `Preset ${preset.id}`
-      point.active = true
-      linkedCount++
-    }
-  })
+  try {
+    await ElMessageBox.confirm(`${t('确定删除')} "${point.presetName || `Point ${point.id}`}"?`, t('删除确认'), {
+      type: 'warning'
+    })
 
-  if (linkedCount > 0) {
-    saveTimelineConfig()
-    ElMessage.success(`${t('已映射')} ${linkedCount} ${t('个点位')}`)
-  } else {
-    ElMessage.warning(t('没有可映射的点位'))
-  }
-}
+    const cameraId = currentMediaStream.value?.cameraId
 
-// 右键菜单删除点
-function handlePointContextMenu(e: MouseEvent, point: TimelinePoint) {
-  ElMessageBox.confirm(`${t('确定删除')} "${point.presetName || `Point ${point.id}`}"?`, t('删除确认'), {
-    type: 'warning'
-  })
-    .then(() => {
-      const index = timelinePoints.value.findIndex((p) => p.id === point.id)
-      if (index !== -1) {
-        timelinePoints.value.splice(index, 1)
-        sortAndRenumberPoints()
-        saveTimelineConfig()
-        if (selectedPoint.value?.id === point.id) {
-          selectedPoint.value = null
-        }
-        ElMessage.success(t('已删除'))
+    // 如果有预置位,同时删除预置位
+    if (point.presetId && cameraId) {
+      await presetRemove({ cameraId, presetId: point.presetId })
+    }
+
+    // 删除打点
+    const index = timelinePoints.value.findIndex((p) => p.id === point.id)
+    if (index !== -1) {
+      timelinePoints.value.splice(index, 1)
+      sortAndRenumberPoints()
+      saveTimelineConfig()
+      if (selectedPoint.value?.id === point.id) {
+        selectedPoint.value = null
       }
-    })
-    .catch(() => {})
+      // 刷新预置位列表
+      if (cameraId) {
+        loadPTZPresets()
+      }
+      ElMessage.success(t('已删除'))
+    }
+  } catch (error) {
+    // 用户取消确认
+    if ((error as Error).toString().includes('cancel')) return
+    console.error('删除打点失败', error)
+  }
 }
 
 // 时长改变时重新分配点的时间
@@ -1830,6 +1887,8 @@ async function playTimeline() {
   isTimelinePlaying.value = true
   timelineProgress.value = 0
   selectedPoint.value = null // 清除选中状态
+  // 重置所有点的 passed 状态
+  timelinePoints.value.forEach((p) => (p.passed = false))
   timelinePlayAbort = new AbortController()
 
   const totalDuration = timelineDuration.value * 1000 // 转为毫秒
@@ -1848,6 +1907,8 @@ async function playTimeline() {
     do {
       loopStartTime = Date.now()
       timelineProgress.value = 0
+      // 循环开始时重置 passed 状态
+      timelinePoints.value.forEach((p) => (p.passed = false))
 
       // 启动/重启进度条动画
       if (progressAnimationId) {
@@ -1873,6 +1934,8 @@ async function playTimeline() {
         // 跳转到该预置位
         if (point.presetId) {
           await presetGoto({ cameraId, presetId: point.presetId })
+          // 标记该点已走过
+          point.passed = true
         }
       }
 
@@ -1903,6 +1966,8 @@ async function playTimeline() {
     isTimelinePlaying.value = false
     timelineProgress.value = 0
     timelinePlayAbort = null
+    // 清除 passed 状态
+    timelinePoints.value.forEach((p) => (p.passed = false))
   }
 }
 
@@ -1919,6 +1984,8 @@ function stopTimeline() {
   }
   isTimelinePlaying.value = false
   timelineProgress.value = 0
+  // 清除 passed 状态
+  timelinePoints.value.forEach((p) => (p.passed = false))
 }
 
 // 带取消功能的 sleep
@@ -2319,10 +2386,11 @@ onMounted(async () => {
 
   .timeline-track {
     position: relative;
-    height: 32px;
-    background: linear-gradient(to right, #374151, #374151);
-    border-radius: 4px;
+    height: 24px;
+    background: #374151;
+    border-radius: 2px;
     margin-bottom: 8px;
+    cursor: pointer;
 
     &.is-playing {
       cursor: not-allowed;
@@ -2338,33 +2406,31 @@ onMounted(async () => {
       top: 0;
       left: 0;
       height: 100%;
-      background: linear-gradient(to right, #dc2626, #f87171);
-      border-radius: 4px 0 0 4px;
+      background: linear-gradient(to right, #dc2626, #ef4444);
+      border-radius: 2px 0 0 2px;
       pointer-events: none;
       transition: width 0.3s ease;
     }
 
+    // 圆形打点
     .timeline-point {
       position: absolute;
       top: 50%;
-      transform: translate(-50%, -50%);
       width: 24px;
       height: 24px;
-      border-radius: 50%;
+      transform: translate(-50%, -50%);
       background: #6b7280; // 未激活 - 灰色
-      border: 2px solid #fff;
+      border-radius: 50%;
       cursor: grab;
+      z-index: 5;
+      transition: all 0.15s ease;
       display: flex;
       align-items: center;
       justify-content: center;
-      font-size: 11px;
-      color: #fff;
-      font-weight: bold;
-      z-index: 5;
-      transition: all 0.2s;
 
       &:hover {
         transform: translate(-50%, -50%) scale(1.15);
+        background: #9ca3af;
 
         .point-tooltip {
           opacity: 1;
@@ -2373,12 +2439,18 @@ onMounted(async () => {
       }
 
       &.active {
-        background: #22c55e; // 激活 - 绿色
+        background: #ffffff; // 激活 - 白色
+        box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
+
+        .point-number {
+          color: #000;
+        }
       }
 
       &.selected {
-        box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
-        transform: translate(-50%, -50%) scale(1.2);
+        transform: translate(-50%, -50%) scale(1.15);
+        background: #3b82f6; // 选中 - 蓝色
+        box-shadow: 0 0 12px rgba(59, 130, 246, 0.7);
         z-index: 10;
 
         .point-tooltip {
@@ -2387,29 +2459,77 @@ onMounted(async () => {
         }
       }
 
+      &.passed {
+        background: #22c55e; // 巡航走过 - 绿色
+        box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
+      }
+
       &.dragging {
         cursor: grabbing;
-        transform: translate(-50%, -50%) scale(1.3);
-        box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.6);
+        transform: translate(-50%, -50%) scale(1.2);
+        background: #fbbf24; // 拖拽中 - 黄色
+        box-shadow: 0 0 14px rgba(251, 191, 36, 0.8);
         z-index: 20;
-        transition: none; // 拖拽时禁用过渡动画
+        transition: none;
 
-        .point-tooltip {
+        .point-drag-time {
           opacity: 1;
           visibility: visible;
         }
+
+        .point-tooltip {
+          opacity: 0;
+          visibility: hidden;
+        }
+
+        .point-number {
+          color: #000;
+        }
       }
 
-      .point-label {
+      // 圆内数字
+      .point-number {
+        color: #fff;
+        font-size: 11px;
+        font-weight: 600;
+        pointer-events: none;
         line-height: 1;
       }
 
+      // 拖拽时显示时间
+      .point-drag-time {
+        position: absolute;
+        bottom: calc(100% + 6px);
+        left: 50%;
+        transform: translateX(-50%);
+        background: #fbbf24;
+        color: #000;
+        padding: 4px 8px;
+        border-radius: 4px;
+        font-size: 12px;
+        font-weight: 600;
+        white-space: nowrap;
+        opacity: 0;
+        visibility: hidden;
+        pointer-events: none;
+
+        &::after {
+          content: '';
+          position: absolute;
+          top: 100%;
+          left: 50%;
+          transform: translateX(-50%);
+          border: 5px solid transparent;
+          border-top-color: #fbbf24;
+        }
+      }
+
       .point-tooltip {
         position: absolute;
-        bottom: calc(100% + 8px);
+        bottom: calc(100% + 6px);
         left: 50%;
         transform: translateX(-50%);
-        background: rgba(0, 0, 0, 0.85);
+        background: rgba(0, 0, 0, 0.9);
         color: #fff;
         padding: 6px 10px;
         border-radius: 4px;
@@ -2417,7 +2537,7 @@ onMounted(async () => {
         white-space: nowrap;
         opacity: 0;
         visibility: hidden;
-        transition: all 0.2s;
+        transition: all 0.15s ease;
         pointer-events: none;
 
         .point-time {
@@ -2433,7 +2553,7 @@ onMounted(async () => {
           left: 50%;
           transform: translateX(-50%);
           border: 5px solid transparent;
-          border-top-color: rgba(0, 0, 0, 0.85);
+          border-top-color: rgba(0, 0, 0, 0.9);
         }
       }
     }
@@ -2450,36 +2570,42 @@ onMounted(async () => {
     }
   }
 
-  .timeline-point-panel {
-    display: flex;
-    align-items: center;
-    gap: 12px;
-    margin-top: 12px;
-    padding: 10px 12px;
+  // 右键菜单
+  .timeline-context-menu {
+    position: fixed;
     background: #2a2a2a;
-    border-radius: 4px;
-    flex-wrap: wrap;
+    border: 1px solid #404040;
+    border-radius: 6px;
+    padding: 4px 0;
+    min-width: 140px;
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+    z-index: 1000;
+
+    .context-menu-item {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 8px 12px;
+      color: #e5e7eb;
+      font-size: 13px;
+      cursor: pointer;
+      transition: background 0.15s ease;
 
-    .panel-label {
-      color: #d1d5db;
-      font-size: 12px;
-      flex: 1;
-      min-width: 100%;
-    }
-  }
+      .el-icon {
+        font-size: 16px;
+      }
 
-  .timeline-auto-link {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    margin-top: 8px;
-    padding: 8px 12px;
-    background: #1f1f1f;
-    border-radius: 4px;
+      &:hover {
+        background: #374151;
+      }
 
-    .auto-link-hint {
-      color: #6b7280;
-      font-size: 11px;
+      &.danger {
+        color: #f87171;
+
+        &:hover {
+          background: rgba(239, 68, 68, 0.15);
+        }
+      }
     }
   }
 }