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

feat(camera): add preset management APIs and enhance live stream controls

- Introduced new functions for managing camera presets: getPresets, gotoPreset, setPreset, and removePreset, allowing users to interact with camera preset positions.
- Updated LiveStreamDTO to reflect changes in status representation, using string literals for better clarity.
- Enhanced the live stream view by integrating preset controls, including a user-friendly interface for navigating and managing presets.
- Improved layout and styling for better user experience in the live stream management interface.
yb 1 неделя назад
Родитель
Сommit
2924b407c6
3 измененных файлов с 438 добавлено и 224 удалено
  1. 34 0
      src/api/camera.ts
  2. 1 1
      src/types/index.ts
  3. 403 223
      src/views/live-stream/index.vue

+ 34 - 0
src/api/camera.ts

@@ -106,6 +106,40 @@ export function adminPTZControl(data: AdminPTZRequest): Promise<IBaseResponse<bo
   return post('/admin/cameras/ptz', data)
 }
 
+// ==================== Preset APIs ====================
+
+// 预置位类型
+export interface PresetInfo {
+  token: string
+  name: string
+}
+
+// 获取预置位列表
+export function getPresets(cameraId: string): Promise<IListResponse<PresetInfo>> {
+  return get(`/camera/${cameraId}/presets`)
+}
+
+// 跳转到预置位
+export function gotoPreset(cameraId: string, presetToken: string): Promise<BaseResponse> {
+  return post(`/camera/${cameraId}/preset/goto`, undefined, {
+    params: { presetToken }
+  })
+}
+
+// 设置预置位 (保存当前位置)
+export function setPreset(cameraId: string, presetName?: string): Promise<IBaseResponse<PresetInfo>> {
+  return post(`/camera/${cameraId}/preset/set`, undefined, {
+    params: presetName ? { presetName } : undefined
+  })
+}
+
+// 删除预置位
+export function removePreset(cameraId: string, presetToken: string): Promise<BaseResponse> {
+  return post(`/camera/${cameraId}/preset/remove`, undefined, {
+    params: { presetToken }
+  })
+}
+
 // ==================== 兼容旧代码的别名 ====================
 
 // 获取设备列表 (兼容 - 使用不分页接口)

+ 1 - 1
src/types/index.ts

@@ -522,7 +522,7 @@ export interface LiveStreamDTO {
   pushMethod?: string
   commandTemplate?: string
   timeoutSeconds?: number
-  status: LiveStreamStatus
+  status: '1' | '0' // 开启或暂停
   enabled: boolean
   remark?: string
   createdAt: string

+ 403 - 223
src/views/live-stream/index.vue

@@ -36,25 +36,29 @@
         height="100%"
         @sort-change="handleSortChange"
       >
-        <el-table-column prop="streamSn" :label="t('流水号')" width="220" show-overflow-tooltip />
+        <el-table-column prop="streamSn" :label="t('stream sn')" width="220" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-link type="primary" @click="handleEdit(row)">{{ row.streamSn }}</el-link>
+          </template>
+        </el-table-column>
         <el-table-column prop="name" :label="t('名称')" show-overflow-tooltip>
           <template #default="{ row }">
-            <el-link type="primary" @click="handleEdit(row)">{{ row.name }}</el-link>
+            <span>{{ row.name }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="lssId" :label="t('LSS 节点')" width="160" show-overflow-tooltip>
+        <el-table-column prop="lssId" :label="t('LSS')" width="160" show-overflow-tooltip>
           <template #default="{ row }">
             <span>{{ row.lssId || '-' }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="cameraId" :label="t('摄像头')" width="120" show-overflow-tooltip>
+        <el-table-column prop="cameraId" :label="t('设备ID')" width="120" show-overflow-tooltip>
           <template #default="{ row }">
             <span>{{ row.cameraId || '-' }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="channelId" :label="t('通道ID')" width="80" align="center">
+        <el-table-column prop="pushMethod" :label="t('推流方式')" width="120" align="center">
           <template #default="{ row }">
-            <span>{{ row.channelId ?? '-' }}</span>
+            <el-tag size="small">{{ row.pushMethod || 'ffmpeg' }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="commandTemplate" :label="t('命令模板')" width="100" align="center">
@@ -62,26 +66,16 @@
             <el-link type="primary" @click="openCommandDialog(row)">{{ t('查看') }}</el-link>
           </template>
         </el-table-column>
-        <el-table-column :label="t('推流控制')" width="120" align="center">
+        <el-table-column :label="t('推流控制')" width="100" align="center">
           <template #default="{ row }">
-            <el-button
-              v-if="row.status === 'idle'"
-              type="success"
-              size="small"
-              :loading="row._starting"
-              @click="handleStartStream(row)"
-            >
-              {{ t('启动') }}
-            </el-button>
-            <el-button
-              v-if="row.status === 'streaming'"
-              type="danger"
-              size="small"
-              :loading="row._stopping"
-              @click="handleStopStream(row)"
-            >
-              {{ t('停止') }}
-            </el-button>
+            <el-switch
+              :model-value="row.status === '1'"
+              :loading="row._starting || row._stopping"
+              :active-text="t('开启')"
+              :inactive-text="t('关闭')"
+              inline-prompt
+              @change="(val: boolean) => handleToggleStream(row, val)"
+            />
           </template>
         </el-table-column>
         <el-table-column prop="pushMethod" :label="t('推流方式')" width="100" align="center">
@@ -212,14 +206,7 @@
     <!-- 命令模板查看/编辑弹窗 -->
     <el-dialog v-model="commandDialogVisible" :title="t('命令模板')" width="700px" destroy-on-close>
       <div class="command-content">
-        <el-input
-          v-model="currentCommandTemplate"
-          type="textarea"
-          :rows="12"
-          :placeholder="t('请输入 FFmpeg 命令模板')"
-          maxlength="2000"
-          show-word-limit
-        />
+        <JsonEditor v-model="currentCommandTemplate" height="400px" />
       </div>
       <template #footer>
         <el-button @click="commandDialogVisible = false">{{ t('关闭') }}</el-button>
@@ -233,62 +220,60 @@
     <el-drawer
       v-model="mediaDrawerVisible"
       direction="rtl"
-      size="90%"
+      size="95%"
       :with-header="false"
       destroy-on-close
       class="media-drawer"
     >
       <div class="media-drawer-content">
-        <!-- 标题 -->
-        <div class="media-drawer-header">
-          <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>
-        </div>
-
-        <!-- 视频播放区域 - 独占一行 -->
-        <div class="player-section">
-          <div v-if="!playbackInfo.videoId" class="player-placeholder">
-            <el-icon :size="60" color="#ddd">
-              <VideoPlay />
-            </el-icon>
-            <p>{{ t('暂无视频流') }}</p>
+        <!-- 左侧:视频播放区域 -->
+        <div class="video-area">
+          <div class="video-header">
+            <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>
           </div>
-          <VideoPlayer
-            v-else
-            ref="playerRef"
-            player-type="cloudflare"
-            :video-id="playbackInfo.videoId"
-            :customer-domain="playbackInfo.customerDomain"
-            :use-iframe="true"
-            :autoplay="playConfig.autoplay"
-            :muted="playConfig.muted"
-            :controls="true"
-          />
-        </div>
-
-        <!-- 控制区域 -->
-        <div class="control-ptz-container">
-          <!-- 播放控制按钮 -->
-          <div class="control-section">
-            <div class="section-title">{{ t('播放控制') }}</div>
-            <el-space wrap>
-              <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
-              <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
-              <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
-              <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
-              <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
-              <el-divider direction="vertical" />
-              <el-switch v-model="playConfig.muted" :active-text="t('静音')" :inactive-text="t('有声')" />
-            </el-space>
+          <div class="player-container">
+            <div v-if="!playbackInfo.videoId" class="player-placeholder">
+              <el-icon :size="80" color="#666">
+                <VideoPlay />
+              </el-icon>
+              <p>{{ t('暂无视频流') }}</p>
+            </div>
+            <VideoPlayer
+              v-else
+              ref="playerRef"
+              player-type="cloudflare"
+              :video-id="playbackInfo.videoId"
+              :customer-domain="playbackInfo.customerDomain"
+              :use-iframe="true"
+              :autoplay="playConfig.autoplay"
+              :muted="playConfig.muted"
+              :controls="true"
+            />
           </div>
+          <!-- 底部播放控制 -->
+          <div class="player-controls">
+            <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
+            <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
+            <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
+            <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
+            <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
+            <el-switch
+              v-model="playConfig.muted"
+              :active-text="t('静音')"
+              :inactive-text="t('有声')"
+              style="margin-left: 16px"
+            />
+          </div>
+        </div>
 
-          <!-- PTZ 云台控制 -->
-          <div class="ptz-panel">
-            <div class="section-title">{{ t('PTZ 云台控制') }}</div>
-
-            <!-- PTZ 方向控制 九宫格 -->
-            <div class="ptz-controls">
+        <!-- 右侧:PTZ 控制面板 -->
+        <div class="control-panel">
+          <!-- PTZ 方向控制 -->
+          <div class="panel-section">
+            <div class="section-title">{{ t('PTZ') }}</div>
+            <div class="ptz-grid">
               <div
                 class="ptz-btn"
                 @mousedown="handlePTZ('UP_LEFT')"
@@ -356,49 +341,56 @@
               </div>
             </div>
 
-            <!-- 速度控制 -->
-            <div class="speed-control">
-              <div class="control-label">
-                <span>{{ t('速度') }}: {{ ptzSpeed }}</span>
-              </div>
-              <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" :show-tooltip="true" />
-            </div>
-
-            <!-- 缩放控制 -->
-            <div class="zoom-controls">
-              <div class="zoom-header">
+            <!-- 缩放按钮 -->
+            <div class="zoom-buttons">
+              <el-button size="small" @mousedown="handleZoomIn" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
+                <el-icon>
+                  <ZoomIn />
+                </el-icon>
+              </el-button>
+              <el-button size="small" @mousedown="handleZoomOut" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
                 <el-icon>
                   <ZoomOut />
                 </el-icon>
-                <span>{{ t('缩放') }}</span>
+              </el-button>
+            </div>
+
+            <!-- 速度滑块 -->
+            <div class="speed-slider">
+              <span class="label">{{ t('速度') }}</span>
+              <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" size="small" />
+              <span class="value">{{ ptzSpeed }}</span>
+            </div>
+          </div>
+
+          <!-- 预置位列表 -->
+          <div class="panel-section preset-section">
+            <div class="section-title">
+              <span>{{ t('预置位') }}</span>
+              <el-button type="primary" link size="small" @click="loadPresets" :loading="presetsLoading">
                 <el-icon>
-                  <ZoomIn />
+                  <Refresh />
                 </el-icon>
+              </el-button>
+            </div>
+            <div class="preset-list" v-loading="presetsLoading">
+              <div
+                v-for="preset in presetList"
+                :key="preset.token"
+                :class="['preset-item', { active: activePresetToken === preset.token }]"
+                @click="handleGotoPreset(preset)"
+              >
+                <span class="preset-index">{{ preset.token }}</span>
+                <span class="preset-name">{{ preset.name || `Preset ${preset.token}` }}</span>
               </div>
-              <el-slider
-                v-model="zoomValue"
-                :min="-100"
-                :max="100"
-                :step="10"
-                :show-tooltip="true"
-                :format-tooltip="formatZoomTooltip"
-                @input="handleZoomChange"
-                @change="handleZoomRelease"
+              <el-empty
+                v-if="!presetsLoading && presetList.length === 0"
+                :description="t('暂无预置位')"
+                :image-size="60"
               />
             </div>
           </div>
         </div>
-
-        <!-- 底部信息 -->
-        <!-- <div class="media-info">
-          <el-descriptions :column="2" size="small" border>
-            <el-descriptions-item :label="t('流水号')">{{ currentMediaStream?.streamSn || '-' }}</el-descriptions-item>
-            <el-descriptions-item :label="t('状态')">{{ getStatusLabel(currentMediaStream?.status)
-              }}</el-descriptions-item>
-            <el-descriptions-item :label="t('摄像头')">{{ currentMediaStream?.cameraId || '-' }}</el-descriptions-item>
-            <el-descriptions-item :label="t('LSS 节点')">{{ currentMediaStream?.lssId || '-' }}</el-descriptions-item>
-          </el-descriptions>
-        </div> -->
       </div>
     </el-drawer>
   </div>
@@ -430,7 +422,8 @@ import { adminListCameras } from '@/api/camera'
 import { listAllStreamChannels } from '@/api/stream-channel'
 import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
 import VideoPlayer from '@/components/VideoPlayer.vue'
-import { ptzStart, ptzStop } from '@/api/camera'
+import { ptzStart, ptzStop, getPresets, gotoPreset, type PresetInfo } from '@/api/camera'
+import JsonEditor from '@/components/JsonEditor.vue'
 import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
 import dayjs from 'dayjs'
 import { useI18n } from 'vue-i18n'
@@ -506,6 +499,11 @@ const playConfig = reactive({
 const ptzSpeed = ref(50)
 const zoomValue = ref(0)
 
+// 预置位
+const presetList = ref<PresetInfo[]>([])
+const presetsLoading = ref(false)
+const activePresetToken = ref<string | null>(null)
+
 // 下拉选项
 const lssOptions = ref<LssNodeDTO[]>([])
 const cameraOptions = ref<CameraInfoDTO[]>([])
@@ -784,6 +782,15 @@ async function handleUpdateCommandTemplate() {
   }
 }
 
+// 切换推流状态
+async function handleToggleStream(row: LiveStreamDTO, val: boolean) {
+  if (val) {
+    await handleStartStream(row)
+  } else {
+    await handleStopStream(row)
+  }
+}
+
 // 启动推流
 async function handleStartStream(row: LiveStreamDTO) {
   if (!row.cameraId) {
@@ -822,7 +829,7 @@ async function handleStopStream(row: LiveStreamDTO) {
     })
 
     row._stopping = true
-    const res = await stopStreamTask({ taskId: row.streamSn })
+    const res = await stopStreamTask({ taskId: row.taskStreamSn, lssId: row.lssId })
     if (res.success) {
       ElMessage.success(t('推流任务已停止'))
       getList()
@@ -990,6 +997,75 @@ async function handleZoomRelease() {
   await ptzStop(currentMediaStream.value.cameraId)
 }
 
+// 缩放按钮控制
+async function handleZoomIn() {
+  if (!currentMediaStream.value?.cameraId) {
+    ElMessage.warning(t('未配置摄像头'))
+    return
+  }
+  try {
+    await ptzStart(currentMediaStream.value.cameraId, 'zoom_in' as any, ptzSpeed.value)
+  } catch (error) {
+    console.error('Zoom in 失败', error)
+  }
+}
+
+async function handleZoomOut() {
+  if (!currentMediaStream.value?.cameraId) {
+    ElMessage.warning(t('未配置摄像头'))
+    return
+  }
+  try {
+    await ptzStart(currentMediaStream.value.cameraId, 'zoom_out' as any, ptzSpeed.value)
+  } catch (error) {
+    console.error('Zoom out 失败', error)
+  }
+}
+
+// 加载预置位列表
+async function loadPresets() {
+  if (!currentMediaStream.value?.cameraId) {
+    ElMessage.warning(t('未配置摄像头'))
+    return
+  }
+
+  presetsLoading.value = true
+  try {
+    const res = await getPresets(currentMediaStream.value.cameraId)
+    if (res.success && res.data) {
+      presetList.value = res.data
+    } else {
+      presetList.value = []
+    }
+  } catch (error) {
+    console.error('加载预置位失败', error)
+    presetList.value = []
+  } finally {
+    presetsLoading.value = false
+  }
+}
+
+// 跳转到预置位
+async function handleGotoPreset(preset: PresetInfo) {
+  if (!currentMediaStream.value?.cameraId) {
+    ElMessage.warning(t('未配置摄像头'))
+    return
+  }
+
+  try {
+    activePresetToken.value = preset.token
+    const res = await gotoPreset(currentMediaStream.value.cameraId, preset.token)
+    if (res.success) {
+      ElMessage.success(t('已跳转到预置位') + `: ${preset.name || preset.token}`)
+    } else {
+      ElMessage.error(res.errMessage || t('跳转失败'))
+    }
+  } catch (error) {
+    console.error('跳转预置位失败', error)
+    ElMessage.error(t('跳转失败'))
+  }
+}
+
 function handleSizeChange(val: number) {
   pageSize.value = val
   currentPage.value = 1
@@ -1201,169 +1277,273 @@ onMounted(() => {
     display: flex;
     flex-direction: column;
     height: 100%;
-    background-color: #f5f7fa;
+    background-color: #1a1a2e;
   }
 }
 
 .media-drawer-content {
   display: flex;
-  flex-direction: column;
   height: 100%;
   padding: 16px;
   gap: 16px;
-  overflow: auto;
+  overflow: hidden;
 }
 
-.media-drawer-header {
+// 左侧视频区域
+.video-area {
+  flex: 1;
   display: flex;
-  align-items: center;
-  gap: 12px;
-  padding-bottom: 12px;
-  border-bottom: 1px solid #e5e7eb;
-
-  .title {
-    font-size: 16px;
-    font-weight: 600;
-    color: #303133;
-  }
-}
-
-.player-section {
-  width: 100%;
-  min-height: 300px;
+  flex-direction: column;
+  min-width: 0;
+  background-color: #16213e;
   border-radius: 8px;
   overflow: hidden;
-  background-color: #000;
 
-  .player-placeholder {
-    height: 100%;
+  .video-header {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px 16px;
+    background-color: #0f3460;
+
+    .title {
+      font-size: 16px;
+      font-weight: 600;
+      color: #fff;
+    }
+  }
+
+  .player-container {
+    flex: 1;
+    min-height: 0;
+    background-color: #000;
     display: flex;
-    flex-direction: column;
     align-items: center;
     justify-content: center;
-    color: #909399;
 
-    p {
-      margin-top: 15px;
-      font-size: 14px;
+    .player-placeholder {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      color: #666;
+
+      p {
+        margin-top: 15px;
+        font-size: 14px;
+      }
     }
   }
+
+  .player-controls {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 12px 16px;
+    background-color: #0f3460;
+  }
 }
 
-.control-ptz-container {
+// 右侧控制面板
+.control-panel {
+  width: 280px;
+  flex-shrink: 0;
   display: flex;
+  flex-direction: column;
   gap: 16px;
-}
+  overflow-y: auto;
 
-.control-section {
-  flex: 1;
-  background-color: #fff;
-  border-radius: 8px;
-  padding: 16px;
+  .panel-section {
+    background-color: #16213e;
+    border-radius: 8px;
+    padding: 16px;
 
-  .section-title {
-    font-size: 14px;
-    font-weight: 600;
-    color: #303133;
-    margin-bottom: 12px;
-    padding-bottom: 8px;
-    border-bottom: 1px solid #e5e7eb;
+    .section-title {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      font-size: 14px;
+      font-weight: 600;
+      color: #fff;
+      margin-bottom: 12px;
+      padding-bottom: 8px;
+      border-bottom: 1px solid #0f3460;
+    }
   }
 }
 
-.ptz-panel {
-  width: 200px;
-  background-color: #fff;
-  border-radius: 8px;
-  padding: 16px;
+// PTZ 方向控制网格
+.ptz-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 6px;
+  margin-bottom: 12px;
+}
 
-  .section-title {
-    font-size: 14px;
-    font-weight: 600;
-    color: #303133;
-    margin-bottom: 12px;
-    padding-bottom: 8px;
-    border-bottom: 1px solid #e5e7eb;
+.ptz-btn {
+  aspect-ratio: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #0f3460;
+  border: 1px solid #1a4a7a;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  color: #94a3b8;
+
+  &:hover {
+    background-color: #1a4a7a;
+    border-color: #3b82f6;
+    color: #3b82f6;
   }
 
-  .ptz-controls {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr);
-    gap: 6px;
+  &:active {
+    background-color: #3b82f6;
+    color: #fff;
   }
 
-  .ptz-btn {
-    aspect-ratio: 1;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    background-color: #f5f7fa;
-    border: 1px solid #dcdfe6;
-    border-radius: 6px;
-    cursor: pointer;
-    transition: all 0.2s;
-    color: #606266;
+  .el-icon {
+    font-size: 18px;
+  }
 
-    &:hover {
-      background-color: #ecf5ff;
-      border-color: #4f46e5;
-      color: #4f46e5;
-    }
+  &.ptz-center {
+    background-color: #1a4a7a;
 
-    &:active {
-      background-color: #4f46e5;
+    &:hover {
+      background-color: #2563eb;
       color: #fff;
     }
-
-    .el-icon {
-      font-size: 18px;
-    }
   }
+}
+
+// 缩放按钮
+.zoom-buttons {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  margin-bottom: 12px;
 
-  .ptz-center {
-    background-color: #e5e7eb;
+  .el-button {
+    background-color: #0f3460;
+    border-color: #1a4a7a;
+    color: #94a3b8;
 
     &:hover {
-      background-color: #ecf5ff;
+      background-color: #1a4a7a;
+      border-color: #3b82f6;
+      color: #3b82f6;
     }
   }
+}
 
-  .speed-control {
-    margin-top: 12px;
-    padding-top: 12px;
-    border-top: 1px solid #e5e7eb;
+// 速度滑块
+.speed-slider {
+  display: flex;
+  align-items: center;
+  gap: 12px;
 
-    .control-label {
-      font-size: 12px;
-      color: #909399;
-      margin-bottom: 6px;
+  .label {
+    font-size: 12px;
+    color: #94a3b8;
+    flex-shrink: 0;
+  }
+
+  .value {
+    font-size: 12px;
+    color: #3b82f6;
+    width: 30px;
+    text-align: right;
+  }
+
+  :deep(.el-slider) {
+    flex: 1;
+
+    .el-slider__runway {
+      background-color: #0f3460;
+    }
+
+    .el-slider__bar {
+      background-color: #3b82f6;
+    }
+
+    .el-slider__button {
+      border-color: #3b82f6;
     }
   }
+}
 
-  .zoom-controls {
-    margin-top: 12px;
-    padding-top: 12px;
-    border-top: 1px solid #e5e7eb;
+// 预置位区域
+.preset-section {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
 
-    .zoom-header {
+  .preset-list {
+    flex: 1;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    max-height: 300px;
+  }
+
+  .preset-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 10px 12px;
+    background-color: #0f3460;
+    border: 1px solid #1a4a7a;
+    border-radius: 6px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      background-color: #1a4a7a;
+      border-color: #3b82f6;
+    }
+
+    &.active {
+      background-color: #3b82f6;
+      border-color: #3b82f6;
+
+      .preset-index,
+      .preset-name {
+        color: #fff;
+      }
+    }
+
+    .preset-index {
+      width: 28px;
+      height: 28px;
       display: flex;
       align-items: center;
-      justify-content: space-between;
+      justify-content: center;
+      background-color: #1a4a7a;
+      border-radius: 4px;
       font-size: 12px;
-      color: #909399;
-      margin-bottom: 6px;
+      font-weight: 600;
+      color: #3b82f6;
+    }
 
-      span {
-        flex: 1;
-        text-align: center;
-      }
+    .preset-name {
+      flex: 1;
+      font-size: 13px;
+      color: #94a3b8;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
     }
   }
-}
 
-.media-info {
-  background-color: #fff;
-  border-radius: 8px;
-  padding: 16px;
+  :deep(.el-empty) {
+    padding: 20px 0;
+
+    .el-empty__description {
+      color: #64748b;
+    }
+  }
 }
 </style>