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

feat(waypoint): implement waypoint recording functionality

- Added waypoint management API with functions to add, update, retrieve, and delete waypoints.
- Introduced new components for waypoint detail and timeline display in the live stream view.
- Implemented composable for managing waypoint recording state and actions, including start/stop recording and playback preview.
- Enhanced type definitions for waypoint data structure to support new features.
yb 14 часов назад
Родитель
Сommit
5c8fac74bf

+ 60 - 0
src/api/waypoint.ts

@@ -0,0 +1,60 @@
+import type { WaypointPayload } from '@/types'
+
+const STORAGE_KEY = 'waypoint_recordings'
+
+function getAll(): WaypointPayload[] {
+  try {
+    return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
+  } catch {
+    return []
+  }
+}
+
+function saveAll(data: WaypointPayload[]) {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+}
+
+function mockDelay(ms = 200) {
+  return new Promise((r) => setTimeout(r, ms))
+}
+
+export async function addWaypoints(data: WaypointPayload) {
+  await mockDelay()
+  const all = getAll()
+  const payload = { ...data, id: Date.now().toString(), createdAt: new Date().toISOString() }
+  all.push(payload)
+  saveAll(all)
+  return { success: true, data: payload }
+}
+
+export async function getWaypointDetail(id: string) {
+  await mockDelay()
+  const all = getAll()
+  const item = all.find((w) => w.id === id)
+  return { success: !!item, data: item || null }
+}
+
+export async function getWaypointsByCameraId(cameraId: string) {
+  await mockDelay()
+  const all = getAll()
+  const items = all.filter((w) => w.cameraId === cameraId)
+  return { success: true, data: items }
+}
+
+export async function updateWaypoints(data: WaypointPayload) {
+  await mockDelay()
+  const all = getAll()
+  const index = all.findIndex((w) => w.id === data.id)
+  if (index === -1) return { success: false, data: null }
+  all[index] = { ...all[index], ...data }
+  saveAll(all)
+  return { success: true, data: all[index] }
+}
+
+export async function deleteWaypoints(id: string) {
+  await mockDelay()
+  const all = getAll()
+  const filtered = all.filter((w) => w.id !== id)
+  saveAll(filtered)
+  return { success: true }
+}

+ 22 - 0
src/types/index.ts

@@ -829,6 +829,28 @@ export interface BindDeviceRequest {
   cameraId: string
 }
 
+// ==================== 轨迹录制相关类型 ====================
+
+export interface WaypointItem {
+  id: number
+  startTime: number // ms,相对于录制开始
+  action: string // 'left' | 'right' | 'up' | 'down' | 'zoom_in' | 'zoom_out' 等
+  speed: number
+  duration: number // ms,mousedown 到 mouseup
+}
+
+export interface WaypointPayload {
+  id?: string
+  cameraId: string
+  name: string
+  description: string
+  loopEnabled: boolean
+  loopCount: number
+  waypoints: WaypointItem[]
+  totalDuration?: number
+  createdAt?: string
+}
+
 // 播放信息 DTO
 export interface PlaybackInfoDTO {
   streamSn: string

+ 93 - 60
src/views/live-stream/components/StreamPlayer.vue

@@ -8,16 +8,35 @@
             <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
             <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
             <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
+            <el-button
+              v-if="currentMediaStream && currentMediaStream.status === '1'"
+              type="danger"
+              size="small"
+              :loading="streamStopping"
+              @click="$emit('stopStream')"
+            >
+              {{ t('停止推流') }}
+            </el-button>
+          </div>
+          <div class="header-right">
+            <el-segmented
+              :model-value="playerMode || 'preset'"
+              :options="modeOptions"
+              size="small"
+              @change="(v: any) => $emit('update:playerMode', v)"
+            >
+              <template #default="{ item }">
+                <div class="segmented-item">
+                  <Icon
+                    :icon="item.value === 'preset' ? 'mdi:flag-triangle' : 'mdi:transit-detour'"
+                    width="16"
+                    height="16"
+                  />
+                  <span>{{ item.label }}</span>
+                </div>
+              </template>
+            </el-segmented>
           </div>
-          <el-button
-            v-if="currentMediaStream && currentMediaStream.status === '1'"
-            type="danger"
-            size="small"
-            :loading="streamStopping"
-            @click="$emit('stopStream')"
-          >
-            {{ t('停止推流') }}
-          </el-button>
         </div>
         <div class="player-container">
           <div v-if="!playbackInfo.videoId" class="player-placeholder">
@@ -45,8 +64,26 @@
           </div>
         </div>
 
-        <!-- 时间轴操作条 -->
-        <div class="timeline-container">
+        <!-- 轨迹录制时间轴 -->
+        <WaypointTimeline
+          v-if="playerMode === 'waypoint'"
+          :is-recording="isWaypointRecording || false"
+          :elapsed-time="waypointElapsedTime || 0"
+          :recorded-waypoints="recordedWaypoints || []"
+          :total-duration="waypointTotalDuration || 0"
+          :is-waypoint-playing="isWaypointPlaying || false"
+          :waypoint-progress="waypointProgress || 0"
+          :has-recorded-data="hasWaypointData || false"
+          :format-elapsed-time="formatWaypointTime || (() => '00:00.0')"
+          @start-recording="$emit('startRecording')"
+          @stop-recording="$emit('stopRecording')"
+          @play-preview="$emit('playWaypointPreview')"
+          @stop-preview="$emit('stopWaypointPreview')"
+          @open-detail="$emit('openWaypointDetail')"
+        />
+
+        <!-- 预置位时间轴操作条 -->
+        <div v-else class="timeline-container">
           <div class="timeline-header">
             <span class="timeline-label">{{ t('巡航时间轴') }}</span>
             <el-select
@@ -234,8 +271,8 @@
             </div>
           </el-collapse-item>
 
-          <!-- 预置位列表 -->
-          <el-collapse-item name="preset">
+          <!-- 预置位列表 (仅预置位模式显示) -->
+          <el-collapse-item v-if="playerMode !== 'waypoint'" name="preset">
             <template #title>
               <span class="collapse-title">{{ t('预置位') }}</span>
             </template>
@@ -281,47 +318,7 @@
             </div>
           </el-collapse-item>
 
-          <!-- 摄像头信息 -->
-          <el-collapse-item name="camera">
-            <template #title>
-              <span class="collapse-title">{{ t('摄像头信息') }}</span>
-            </template>
-            <div class="camera-info-content" v-loading="capabilitiesLoading">
-              <template v-if="cameraCapabilities">
-                <div class="info-item">
-                  <span class="info-label">{{ t('最大预置位') }}:</span>
-                  <span class="info-value">{{ cameraCapabilities.maxPresetNum || '-' }}</span>
-                </div>
-                <div class="info-item" v-if="cameraCapabilities.controlProtocol">
-                  <span class="info-label">{{ t('控制协议') }}:</span>
-                  <span class="info-value">{{ cameraCapabilities.controlProtocol.current }}</span>
-                </div>
-                <div class="info-item" v-if="cameraCapabilities.absoluteZoom">
-                  <span class="info-label">{{ t('变焦倍数') }}:</span>
-                  <span class="info-value">
-                    {{ cameraCapabilities.absoluteZoom.min }}x - {{ cameraCapabilities.absoluteZoom.max }}x
-                  </span>
-                </div>
-                <div class="info-item" v-if="cameraCapabilities.support3DPosition !== undefined">
-                  <span class="info-label">{{ t('3D定位') }}:</span>
-                  <span class="info-value">
-                    {{ cameraCapabilities.support3DPosition ? t('支持') : t('不支持') }}
-                  </span>
-                </div>
-                <div class="info-item" v-if="cameraCapabilities.supportPtzLimits !== undefined">
-                  <span class="info-label">{{ t('PTZ限位') }}:</span>
-                  <span class="info-value">
-                    {{ cameraCapabilities.supportPtzLimits ? t('支持') : t('不支持') }}
-                  </span>
-                </div>
-              </template>
-              <el-empty
-                v-else-if="!capabilitiesLoading"
-                :description="currentMediaStream?.cameraId ? t('点击刷新加载') : t('请选择直播流')"
-                :image-size="40"
-              />
-            </div>
-          </el-collapse-item>
+          <!-- 摄像头信息 (暂时隐藏) -->
         </el-collapse>
       </div>
     </div>
@@ -334,8 +331,9 @@ import { useI18n } from 'vue-i18n'
 import { VideoPlay, Position, Delete } from '@element-plus/icons-vue'
 import { Icon } from '@iconify/vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
-import type { LiveStreamDTO } from '@/types'
+import type { LiveStreamDTO, WaypointItem } from '@/types'
 import type { PlaybackInfo, TimelinePoint, PTZCapabilities, LocalPreset } from '../types'
+import WaypointTimeline from './WaypointTimeline.vue'
 
 const { t } = useI18n({ useScope: 'global' })
 
@@ -368,6 +366,16 @@ const props = defineProps<{
   editingPresetName: string
   cameraCapabilities: PTZCapabilities | null
   capabilitiesLoading: boolean
+  // Waypoint mode
+  playerMode?: 'preset' | 'waypoint'
+  isWaypointRecording?: boolean
+  waypointElapsedTime?: number
+  recordedWaypoints?: WaypointItem[]
+  waypointTotalDuration?: number
+  isWaypointPlaying?: boolean
+  waypointProgress?: number
+  hasWaypointData?: boolean
+  formatWaypointTime?: (ms: number) => string
 }>()
 
 const emit = defineEmits<{
@@ -397,8 +405,19 @@ const emit = defineEmits<{
   'update:ptzSpeed': [value: number]
   'update:activePanels': [value: string[]]
   'update:editingPresetName': [value: string]
+  'update:playerMode': [value: 'preset' | 'waypoint']
+  startRecording: []
+  stopRecording: []
+  playWaypointPreview: []
+  stopWaypointPreview: []
+  openWaypointDetail: []
 }>()
 
+const modeOptions = computed(() => [
+  { label: t('预置位模式'), value: 'preset' },
+  { label: t('轨迹录制模式'), value: 'waypoint' }
+])
+
 const timelineDurationModel = computed({
   get: () => props.timelineDuration,
   set: (v) => emit('update:timelineDuration', v)
@@ -470,6 +489,18 @@ const editingPresetNameModel = computed({
       font-weight: 600;
       color: #303133;
     }
+
+    .header-right {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+
+    .segmented-item {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+    }
   }
 
   .player-container {
@@ -792,11 +823,12 @@ const editingPresetNameModel = computed({
     border: none;
 
     :deep(.el-collapse-item) {
+      border: 1px solid #eee;
       margin-bottom: 8px;
       background-color: #fff;
-      border-radius: 8px;
+      // border-radius: 8px;
       overflow: hidden;
-      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+      // box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
 
       .el-collapse-item__header {
         padding: 0 16px;
@@ -814,7 +846,7 @@ const editingPresetNameModel = computed({
       }
 
       .el-collapse-item__content {
-        padding: 0;
+        padding: 10px;
       }
     }
 
@@ -831,7 +863,7 @@ const editingPresetNameModel = computed({
   display: grid;
   grid-template-columns: repeat(3, 1fr);
   gap: 6px;
-  margin: 12px;
+  // margin: 12px;
 }
 
 .ptz-btn {
@@ -875,7 +907,8 @@ const editingPresetNameModel = computed({
   display: flex;
   justify-content: center;
   gap: 12px;
-  margin-bottom: 12px;
+  margin-top: 10px;
+  margin-bottom: 10px;
 
   .el-button {
     background-color: #f5f7fa;

+ 144 - 0
src/views/live-stream/components/WaypointDetailDrawer.vue

@@ -0,0 +1,144 @@
+<template>
+  <el-drawer
+    :model-value="visible"
+    :title="t('轨迹详情')"
+    direction="rtl"
+    size="500px"
+    @update:model-value="$emit('update:visible', $event)"
+    destroy-on-close
+  >
+    <div class="waypoint-detail">
+      <el-form label-width="80px" size="default">
+        <el-form-item :label="t('名称')">
+          <el-input v-model="localName" :placeholder="t('轨迹名称')" />
+        </el-form-item>
+        <el-form-item :label="t('描述')">
+          <el-input v-model="localDescription" type="textarea" :rows="3" :placeholder="t('轨迹描述')" />
+        </el-form-item>
+        <el-form-item :label="t('总时长')">
+          <span>{{ formatDuration(totalDuration) }}</span>
+        </el-form-item>
+        <el-form-item :label="t('循环')">
+          <el-switch v-model="localLoopEnabled" style="margin-right: 12px" />
+          <el-input-number
+            v-if="localLoopEnabled"
+            v-model="localLoopCount"
+            :min="1"
+            :max="99"
+            size="small"
+            style="width: 100px"
+          />
+        </el-form-item>
+      </el-form>
+
+      <el-divider>{{ t('操作记录') }} ({{ waypoints.length }})</el-divider>
+
+      <el-table :data="waypoints" size="small" max-height="400">
+        <el-table-column prop="id" label="#" width="50" />
+        <el-table-column :label="t('时间')" width="90">
+          <template #default="{ row }">
+            {{ formatDuration(row.startTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('动作')" width="100">
+          <template #default="{ row }">
+            <el-tag size="small">{{ row.action }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('速度')" width="70" align="center">
+          <template #default="{ row }">
+            {{ row.speed }}
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('持续时间')" width="100">
+          <template #default="{ row }">{{ (row.duration / 1000).toFixed(1) }}s</template>
+        </el-table-column>
+        <el-table-column :label="t('操作')" width="60" align="center">
+          <template #default="{ $index }">
+            <el-button type="danger" link size="small" @click="$emit('deleteWaypoint', $index)">
+              <Icon icon="mdi:delete" width="16" height="16" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <template #footer>
+      <div class="drawer-footer">
+        <el-button @click="$emit('update:visible', false)">{{ t('取消') }}</el-button>
+        <el-button type="primary" @click="handleSave">{{ t('保存') }}</el-button>
+      </div>
+    </template>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { Icon } from '@iconify/vue'
+import type { WaypointItem } from '@/types'
+
+const { t } = useI18n({ useScope: 'global' })
+
+const props = defineProps<{
+  visible: boolean
+  waypoints: WaypointItem[]
+  totalDuration: number
+  name: string
+  description: string
+  loopEnabled: boolean
+  loopCount: number
+}>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  save: [data: { name: string; description: string; loopEnabled: boolean; loopCount: number }]
+  deleteWaypoint: [index: number]
+}>()
+
+const localName = ref(props.name)
+const localDescription = ref(props.description)
+const localLoopEnabled = ref(props.loopEnabled)
+const localLoopCount = ref(props.loopCount)
+
+watch(
+  () => props.visible,
+  (v) => {
+    if (v) {
+      localName.value = props.name
+      localDescription.value = props.description
+      localLoopEnabled.value = props.loopEnabled
+      localLoopCount.value = props.loopCount
+    }
+  }
+)
+
+function formatDuration(ms: number): string {
+  const sec = Math.floor(ms / 1000)
+  const min = Math.floor(sec / 60)
+  const s = sec % 60
+  return `${String(min).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+}
+
+function handleSave() {
+  emit('save', {
+    name: localName.value,
+    description: localDescription.value,
+    loopEnabled: localLoopEnabled.value,
+    loopCount: localLoopCount.value
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.waypoint-detail {
+  padding: 0 16px;
+}
+
+.drawer-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding: 0 16px;
+}
+</style>

+ 240 - 0
src/views/live-stream/components/WaypointTimeline.vue

@@ -0,0 +1,240 @@
+<template>
+  <div class="waypoint-timeline-container">
+    <div class="waypoint-timeline-header">
+      <span class="timeline-label">{{ t('轨迹录制') }}</span>
+
+      <!-- 录制状态指示 -->
+      <div v-if="isRecording" class="recording-indicator">
+        <span class="recording-dot" />
+        <span class="recording-text">REC</span>
+        <span class="recording-time">{{ formatElapsedTime(elapsedTime) }}</span>
+      </div>
+
+      <!-- 录制控制按钮 -->
+      <el-button v-if="!isRecording && !hasRecordedData" size="small" type="danger" @click="$emit('startRecording')">
+        {{ t('开始录制') }}
+      </el-button>
+      <el-button v-if="isRecording" size="small" type="warning" @click="$emit('stopRecording')">
+        {{ t('停止录制') }}
+      </el-button>
+
+      <!-- 录制完成后的操作 -->
+      <template v-if="hasRecordedData">
+        <el-button size="small" type="danger" @click="$emit('startRecording')">
+          {{ t('重新录制') }}
+        </el-button>
+        <el-button size="small" type="primary" :loading="isWaypointPlaying" @click="$emit('playPreview')">
+          {{ isWaypointPlaying ? t('回放中...') : t('预览') }}
+        </el-button>
+        <el-button v-if="isWaypointPlaying" size="small" type="danger" @click="$emit('stopPreview')">
+          {{ t('停止') }}
+        </el-button>
+        <el-button size="small" @click="$emit('openDetail')">
+          {{ t('编辑') }}
+        </el-button>
+      </template>
+    </div>
+
+    <!-- 时间轴轨道 -->
+    <div class="waypoint-track">
+      <!-- 进度条(录制中或回放中) -->
+      <div v-if="isRecording || isWaypointPlaying" class="waypoint-progress" :style="{ width: progressWidth }" />
+
+      <!-- 旗子标记 -->
+      <div
+        v-for="wp in displayWaypoints"
+        :key="wp.id"
+        class="waypoint-flag"
+        :style="{ left: wp.position + '%' }"
+        :title="`${wp.action} @ ${formatMs(wp.startTime)}`"
+      >
+        <Icon :icon="getActionIcon(wp.action)" width="14" height="14" />
+      </div>
+    </div>
+
+    <!-- 时间刻度 -->
+    <div class="waypoint-scale">
+      <span class="scale-mark">00:00</span>
+      <span class="scale-mark">{{ formatMs(displayDuration / 2) }}</span>
+      <span class="scale-mark">{{ formatMs(displayDuration) }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { Icon } from '@iconify/vue'
+import type { WaypointItem } from '@/types'
+
+const { t } = useI18n({ useScope: 'global' })
+
+const props = defineProps<{
+  isRecording: boolean
+  elapsedTime: number
+  recordedWaypoints: WaypointItem[]
+  totalDuration: number
+  isWaypointPlaying: boolean
+  waypointProgress: number
+  hasRecordedData: boolean
+  formatElapsedTime: (ms: number) => string
+}>()
+
+defineEmits<{
+  startRecording: []
+  stopRecording: []
+  playPreview: []
+  stopPreview: []
+  openDetail: []
+}>()
+
+const displayDuration = computed(() => {
+  if (props.isRecording) return props.elapsedTime || 1
+  return props.totalDuration || 1
+})
+
+const progressWidth = computed(() => {
+  if (props.isRecording) return '100%'
+  return props.waypointProgress + '%'
+})
+
+const displayWaypoints = computed(() => {
+  const dur = displayDuration.value
+  return props.recordedWaypoints.map((wp) => ({
+    ...wp,
+    position: Math.min((wp.startTime / dur) * 100, 100)
+  }))
+})
+
+function formatMs(ms: number): string {
+  const sec = Math.floor(ms / 1000)
+  const min = Math.floor(sec / 60)
+  const s = sec % 60
+  return `${String(min).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+}
+
+const actionIconMap: Record<string, string> = {
+  up: 'mdi:arrow-up',
+  down: 'mdi:arrow-down',
+  left: 'mdi:arrow-left',
+  right: 'mdi:arrow-right',
+  up_left: 'mdi:arrow-top-left',
+  up_right: 'mdi:arrow-top-right',
+  down_left: 'mdi:arrow-bottom-left',
+  down_right: 'mdi:arrow-bottom-right',
+  zoom_in: 'mdi:magnify-plus',
+  zoom_out: 'mdi:magnify-minus'
+}
+
+function getActionIcon(action: string): string {
+  return actionIconMap[action] || 'mdi:flag'
+}
+</script>
+
+<style scoped lang="scss">
+.waypoint-timeline-container {
+  background: #1a1a1a;
+  padding: 12px 16px;
+  flex-shrink: 0;
+}
+
+.waypoint-timeline-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  .timeline-label {
+    color: #fff;
+    font-size: 13px;
+    font-weight: 500;
+  }
+}
+
+.recording-indicator {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+
+  .recording-dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+    background: #ef4444;
+    animation: blink 1s ease-in-out infinite;
+  }
+
+  .recording-text {
+    color: #ef4444;
+    font-size: 12px;
+    font-weight: 700;
+  }
+
+  .recording-time {
+    color: #fff;
+    font-size: 13px;
+    font-family: monospace;
+  }
+}
+
+@keyframes blink {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.3;
+  }
+}
+
+.waypoint-track {
+  position: relative;
+  height: 12px;
+  background: #374151;
+  border-radius: 6px;
+  margin-bottom: 8px;
+
+  .waypoint-progress {
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 100%;
+    background: linear-gradient(to right, #ef4444, #f87171);
+    border-radius: 6px 0 0 6px;
+    transition: width 0.1s linear;
+  }
+
+  .waypoint-flag {
+    position: absolute;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    width: 22px;
+    height: 22px;
+    background: #fbbf24;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 5;
+    color: #000;
+    cursor: default;
+    transition: left 0.3s ease;
+
+    &:hover {
+      transform: translate(-50%, -50%) scale(1.2);
+      background: #fcd34d;
+    }
+  }
+}
+
+.waypoint-scale {
+  display: flex;
+  justify-content: space-between;
+  padding: 0 2px;
+
+  .scale-mark {
+    font-size: 10px;
+    color: #6b7280;
+  }
+}
+</style>

+ 258 - 0
src/views/live-stream/composables/useWaypointRecording.ts

@@ -0,0 +1,258 @@
+import { ref, computed, type Ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { addWaypoints, updateWaypoints } from '@/api/waypoint'
+import type { WaypointItem, WaypointPayload, LiveStreamDTO } from '@/types'
+import { ptzControl } from '@/api/camera'
+
+export function useWaypointRecording(currentMediaStream: Ref<LiveStreamDTO | null>) {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const isRecording = ref(false)
+  const recordingStartTime = ref(0)
+  const recordedWaypoints = ref<WaypointItem[]>([])
+  const currentWaypoint = ref<Partial<WaypointItem> | null>(null)
+  const totalDuration = ref(0)
+  const elapsedTime = ref(0)
+  const waypointName = ref('')
+  const waypointDescription = ref('')
+  const isWaypointPlaying = ref(false)
+  const waypointProgress = ref(0)
+  const loopEnabled = ref(false)
+  const loopCount = ref(1)
+  const savedWaypointId = ref<string | null>(null)
+  const showDetailDrawer = ref(false)
+
+  let timerInterval: ReturnType<typeof setInterval> | null = null
+  let playbackTimer: ReturnType<typeof setTimeout> | null = null
+  let playbackAbort = false
+
+  const hasRecordedData = computed(() => recordedWaypoints.value.length > 0 && !isRecording.value)
+
+  function startRecording() {
+    isRecording.value = true
+    recordingStartTime.value = Date.now()
+    recordedWaypoints.value = []
+    currentWaypoint.value = null
+    totalDuration.value = 0
+    elapsedTime.value = 0
+    savedWaypointId.value = null
+
+    timerInterval = setInterval(() => {
+      elapsedTime.value = Date.now() - recordingStartTime.value
+    }, 100)
+  }
+
+  function stopRecording() {
+    isRecording.value = false
+    totalDuration.value = Date.now() - recordingStartTime.value
+    if (currentWaypoint.value) {
+      completeCurrentWaypoint()
+    }
+    if (timerInterval) {
+      clearInterval(timerInterval)
+      timerInterval = null
+    }
+  }
+
+  function captureWaypoint(action: string, speed: number) {
+    if (!isRecording.value) return
+    currentWaypoint.value = {
+      id: recordedWaypoints.value.length + 1,
+      startTime: Date.now() - recordingStartTime.value,
+      action,
+      speed,
+      duration: 0
+    }
+  }
+
+  function completeCurrentWaypoint() {
+    if (!currentWaypoint.value) return
+    const wp = currentWaypoint.value
+    wp.duration = Date.now() - recordingStartTime.value - (wp.startTime || 0)
+    recordedWaypoints.value.push(wp as WaypointItem)
+    currentWaypoint.value = null
+  }
+
+  function deleteWaypoint(index: number) {
+    recordedWaypoints.value.splice(index, 1)
+    // renumber
+    recordedWaypoints.value.forEach((wp, i) => {
+      wp.id = i + 1
+    })
+  }
+
+  async function saveWaypointsData(cameraId: string) {
+    const payload: WaypointPayload = {
+      cameraId,
+      name: waypointName.value || `轨迹 ${new Date().toLocaleTimeString()}`,
+      description: waypointDescription.value,
+      loopEnabled: loopEnabled.value,
+      loopCount: loopCount.value,
+      waypoints: recordedWaypoints.value,
+      totalDuration: totalDuration.value
+    }
+
+    if (savedWaypointId.value) {
+      payload.id = savedWaypointId.value
+      const res = await updateWaypoints(payload)
+      if (res.success) {
+        ElMessage.success(t('轨迹更新成功'))
+      }
+      return res
+    }
+
+    const res = await addWaypoints(payload)
+    if (res.success && res.data) {
+      savedWaypointId.value = res.data.id!
+      ElMessage.success(t('轨迹保存成功'))
+    }
+    return res
+  }
+
+  async function updateWaypointsData() {
+    if (!savedWaypointId.value) return
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) return
+
+    const payload: WaypointPayload = {
+      id: savedWaypointId.value,
+      cameraId,
+      name: waypointName.value,
+      description: waypointDescription.value,
+      loopEnabled: loopEnabled.value,
+      loopCount: loopCount.value,
+      waypoints: recordedWaypoints.value,
+      totalDuration: totalDuration.value
+    }
+    const res = await updateWaypoints(payload)
+    if (res.success) {
+      ElMessage.success(t('轨迹更新成功'))
+    }
+    return res
+  }
+
+  async function playWaypointsPreview() {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId || recordedWaypoints.value.length === 0) return
+
+    isWaypointPlaying.value = true
+    waypointProgress.value = 0
+    playbackAbort = false
+
+    const doPlay = async () => {
+      const startTs = Date.now()
+
+      // progress updater
+      const progressInterval = setInterval(() => {
+        if (playbackAbort) {
+          clearInterval(progressInterval)
+          return
+        }
+        const elapsed = Date.now() - startTs
+        waypointProgress.value = Math.min((elapsed / totalDuration.value) * 100, 100)
+      }, 50)
+
+      for (const wp of recordedWaypoints.value) {
+        if (playbackAbort) break
+
+        // wait until this waypoint's start time
+        const now = Date.now() - startTs
+        const waitTime = wp.startTime - now
+        if (waitTime > 0) {
+          await new Promise((r) => {
+            playbackTimer = setTimeout(r, waitTime)
+          })
+        }
+        if (playbackAbort) break
+
+        // execute PTZ action
+        await ptzControl({ cameraId, action: wp.action as any, speed: wp.speed })
+
+        // wait for duration then stop
+        if (wp.duration > 0) {
+          await new Promise((r) => {
+            playbackTimer = setTimeout(r, wp.duration)
+          })
+        }
+        if (playbackAbort) break
+
+        await ptzControl({ cameraId, action: 'stop' })
+      }
+
+      clearInterval(progressInterval)
+
+      // wait until total duration
+      if (!playbackAbort) {
+        const remaining = totalDuration.value - (Date.now() - startTs)
+        if (remaining > 0) {
+          await new Promise((r) => {
+            playbackTimer = setTimeout(r, remaining)
+          })
+        }
+      }
+    }
+
+    const loops = loopEnabled.value ? loopCount.value : 1
+    for (let i = 0; i < loops; i++) {
+      if (playbackAbort) break
+      waypointProgress.value = 0
+      await doPlay()
+    }
+
+    waypointProgress.value = 100
+    isWaypointPlaying.value = false
+  }
+
+  function stopWaypointPreview() {
+    playbackAbort = true
+    if (playbackTimer) {
+      clearTimeout(playbackTimer)
+      playbackTimer = null
+    }
+    isWaypointPlaying.value = false
+    waypointProgress.value = 0
+
+    // stop PTZ
+    const cameraId = currentMediaStream.value?.cameraId
+    if (cameraId) {
+      ptzControl({ cameraId, action: 'stop' })
+    }
+  }
+
+  function formatElapsedTime(ms: number): string {
+    const totalSec = Math.floor(ms / 1000)
+    const min = Math.floor(totalSec / 60)
+    const sec = totalSec % 60
+    const tenths = Math.floor((ms % 1000) / 100)
+    return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${tenths}`
+  }
+
+  return {
+    isRecording,
+    recordingStartTime,
+    recordedWaypoints,
+    currentWaypoint,
+    totalDuration,
+    elapsedTime,
+    waypointName,
+    waypointDescription,
+    isWaypointPlaying,
+    waypointProgress,
+    loopEnabled,
+    loopCount,
+    savedWaypointId,
+    showDetailDrawer,
+    hasRecordedData,
+    startRecording,
+    stopRecording,
+    captureWaypoint,
+    completeCurrentWaypoint,
+    deleteWaypoint,
+    saveWaypointsData,
+    updateWaypointsData,
+    playWaypointsPreview,
+    stopWaypointPreview,
+    formatElapsedTime
+  }
+}

+ 119 - 4
src/views/live-stream/index.vue

@@ -198,10 +198,10 @@
           @show-context-menu="showPointContextMenu"
           @context-menu-update="handleContextMenuUpdate"
           @context-menu-delete="handleContextMenuDelete"
-          @ptz="handlePTZ"
-          @ptz-stop="handlePTZStop"
-          @zoom-in="handleZoomIn"
-          @zoom-out="handleZoomOut"
+          @ptz="wrappedHandlePTZ"
+          @ptz-stop="wrappedHandlePTZStop"
+          @zoom-in="wrappedZoomIn"
+          @zoom-out="wrappedZoomOut"
           @save-preset-name="savePresetName"
           @cancel-edit-preset-name="cancelEditPresetName"
           @start-edit-preset-name="startEditPresetName"
@@ -213,6 +213,21 @@
           @update:ptz-speed="ptzSpeed = $event"
           @update:active-panels="activePanels = $event"
           @update:editing-preset-name="editingPresetName = $event"
+          :player-mode="playerMode"
+          :is-waypoint-recording="isWaypointRecording"
+          :waypoint-elapsed-time="waypointElapsedTime"
+          :recorded-waypoints="recordedWaypoints"
+          :waypoint-total-duration="waypointTotalDuration"
+          :is-waypoint-playing="isWaypointPlaying"
+          :waypoint-progress="waypointProgress"
+          :has-waypoint-data="hasWaypointData"
+          :format-waypoint-time="formatElapsedTime"
+          @update:player-mode="playerMode = $event"
+          @start-recording="startRecording"
+          @stop-recording="handleStopRecording"
+          @play-waypoint-preview="playWaypointsPreview"
+          @stop-waypoint-preview="stopWaypointPreview"
+          @open-waypoint-detail="waypointDetailVisible = true"
         />
       </div>
     </el-drawer>
@@ -236,6 +251,19 @@
         </div>
       </template>
     </el-drawer>
+
+    <!-- 轨迹详情抽屉 -->
+    <WaypointDetailDrawer
+      v-model:visible="waypointDetailVisible"
+      :waypoints="recordedWaypoints"
+      :total-duration="waypointTotalDuration"
+      :name="waypointName"
+      :description="waypointDescription"
+      :loop-enabled="waypointLoopEnabled"
+      :loop-count="waypointLoopCount"
+      @save="handleWaypointDetailSave"
+      @delete-waypoint="deleteWaypointItem"
+    />
   </div>
 </template>
 
@@ -257,6 +285,8 @@ import { usePlayback } from './composables/usePlayback'
 import { useStreamControl } from './composables/useStreamControl'
 import { useTimeline } from './composables/useTimeline'
 import { usePTZ } from './composables/usePTZ'
+import { useWaypointRecording } from './composables/useWaypointRecording'
+import WaypointDetailDrawer from './components/WaypointDetailDrawer.vue'
 
 const { t } = useI18n({ useScope: 'global' })
 const route = useRoute()
@@ -363,6 +393,91 @@ const {
   selectedPoint
 })
 
+// Player mode
+const playerMode = ref<'preset' | 'waypoint'>('preset')
+
+// Waypoint recording
+const {
+  isRecording: isWaypointRecording,
+  elapsedTime: waypointElapsedTime,
+  recordedWaypoints,
+  totalDuration: waypointTotalDuration,
+  isWaypointPlaying,
+  waypointProgress,
+  waypointName,
+  waypointDescription,
+  loopEnabled: waypointLoopEnabled,
+  loopCount: waypointLoopCount,
+  showDetailDrawer: waypointDetailVisible,
+  hasRecordedData: hasWaypointData,
+  startRecording,
+  stopRecording,
+  captureWaypoint,
+  completeCurrentWaypoint,
+  deleteWaypoint: deleteWaypointItem,
+  saveWaypointsData,
+  updateWaypointsData,
+  playWaypointsPreview,
+  stopWaypointPreview,
+  formatElapsedTime
+} = useWaypointRecording(currentMediaStream)
+
+// Wrap PTZ handlers to intercept during waypoint recording
+function wrappedHandlePTZ(direction: string) {
+  if (isWaypointRecording.value && direction !== 'stop') {
+    captureWaypoint(direction, ptzSpeed.value)
+  }
+  if (direction === 'stop') {
+    if (isWaypointRecording.value) completeCurrentWaypoint()
+    return handlePTZStop()
+  }
+  return handlePTZ(direction)
+}
+
+function wrappedHandlePTZStop() {
+  if (isWaypointRecording.value) {
+    completeCurrentWaypoint()
+  }
+  return handlePTZStop()
+}
+
+function wrappedZoomIn() {
+  if (isWaypointRecording.value) {
+    captureWaypoint('zoom_in', ptzSpeed.value)
+  }
+  return handleZoomIn()
+}
+
+function wrappedZoomOut() {
+  if (isWaypointRecording.value) {
+    captureWaypoint('zoom_out', ptzSpeed.value)
+  }
+  return handleZoomOut()
+}
+
+// Waypoint handlers
+function handleStopRecording() {
+  stopRecording()
+  const cameraId = currentMediaStream.value?.cameraId
+  if (cameraId) {
+    saveWaypointsData(cameraId)
+  }
+}
+
+function handleWaypointDetailSave(data: {
+  name: string
+  description: string
+  loopEnabled: boolean
+  loopCount: number
+}) {
+  waypointName.value = data.name
+  waypointDescription.value = data.description
+  waypointLoopEnabled.value = data.loopEnabled
+  waypointLoopCount.value = data.loopCount
+  updateWaypointsData()
+  waypointDetailVisible.value = false
+}
+
 // Orchestration wrappers
 function onAdd() {
   handleAdd({