Răsfoiți Sursa

feat: implement multi-video monitoring feature

- Added components for video monitoring, including CameraSelector, VideoCell, VideoGrid, and PTZController.
- Introduced a new monitor page with a grid layout for displaying multiple video feeds.
- Updated global styles to ensure consistent border radius across components.
- Enhanced localization files to support new monitoring features.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yb 2 săptămâni în urmă
părinte
comite
7736bbf325

+ 43 - 0
src/assets/styles/theme/element-override.scss

@@ -13,9 +13,20 @@
   --el-color-primary-light-8: var(--color-primary-light-7);
   --el-color-primary-light-9: var(--color-primary-light-9);
   --el-color-primary-dark-2: var(--color-primary-dark-2);
+
+  // 全局圆角覆盖
+  --el-border-radius-base: var(--radius-base);
+  --el-border-radius-small: var(--radius-sm);
+  --el-border-radius-round: var(--radius-base);
+  --el-border-radius-circle: 50%;
 }
 
 // 按钮
+.el-button {
+  --el-border-radius-base: var(--radius-base);
+  border-radius: var(--radius-base);
+}
+
 .el-button--primary {
   --el-button-bg-color: var(--color-primary);
   --el-button-border-color: var(--color-primary);
@@ -34,11 +45,43 @@
 // 输入框
 .el-input {
   --el-input-border-radius: var(--radius-base);
+
+  .el-input__wrapper {
+    border-radius: var(--radius-base);
+  }
+}
+
+.el-textarea {
+  .el-textarea__inner {
+    border-radius: var(--radius-base);
+  }
 }
 
 // 选择框
 .el-select {
   --el-select-border-radius: var(--radius-base);
+
+  .el-select__wrapper {
+    border-radius: var(--radius-base);
+  }
+}
+
+// 选择框下拉菜单
+.el-select-dropdown {
+  border-radius: var(--radius-base) !important;
+
+  .el-select-dropdown__wrap {
+    border-radius: var(--radius-base);
+  }
+
+  .el-select-dropdown__item {
+    border-radius: 0;
+  }
+}
+
+// 下拉菜单弹出层
+.el-popper {
+  border-radius: var(--radius-base) !important;
 }
 
 // 卡片

+ 5 - 0
src/components.d.ts

@@ -7,6 +7,7 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    CameraSelector: typeof import('./components/monitor/CameraSelector.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
@@ -35,6 +36,7 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElResult: typeof import('element-plus/es')['ElResult']
     ElRow: typeof import('element-plus/es')['ElRow']
+    ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSlider: typeof import('element-plus/es')['ElSlider']
     ElSpace: typeof import('element-plus/es')['ElSpace']
@@ -59,10 +61,13 @@ declare module 'vue' {
     IEpTopRight: typeof import('~icons/ep/top-right')['default']
     LangDropdown: typeof import('./components/LangDropdown.vue')['default']
     PTZController: typeof import('./components/PTZController.vue')['default']
+    PtzOverlay: typeof import('./components/monitor/PtzOverlay.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     ThemeSettings: typeof import('./components/ThemeSettings.vue')['default']
     ThemeSwitch: typeof import('./components/ThemeSwitch.vue')['default']
+    VideoCell: typeof import('./components/monitor/VideoCell.vue')['default']
+    VideoGrid: typeof import('./components/monitor/VideoGrid.vue')['default']
     VideoPlayer: typeof import('./components/VideoPlayer.vue')['default']
   }
   export interface ComponentCustomProperties {

+ 1 - 1
src/components/LangDropdown.vue

@@ -38,7 +38,7 @@ function handleLanguage(lang: string) {
     align-items: center;
     gap: 4px;
     padding: 6px 10px;
-    border-radius: 6px;
+    border-radius: var(--radius-base);
     color: var(--lang-color, #5a5e66);
     font-size: 13px;
     cursor: pointer;

+ 15 - 34
src/components/PTZController.vue

@@ -21,7 +21,9 @@ const isMoving = ref(false)
 const currentDirection = ref<string | null>(null)
 
 // 开始移动
-async function handleStart(direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT') {
+async function handleStart(
+  direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT'
+) {
   if (isMoving.value) return
   isMoving.value = true
   currentDirection.value = direction
@@ -50,20 +52,10 @@ async function handleStop() {
   <div class="ptz-controller">
     <div class="ptz-grid">
       <!-- 第一行 -->
-      <button
-        class="ptz-btn corner"
-        @mousedown="handleStart('UP_LEFT')"
-        @mouseup="handleStop"
-        @mouseleave="handleStop"
-      >
+      <button class="ptz-btn corner" @mousedown="handleStart('UP_LEFT')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-top-left /></el-icon>
       </button>
-      <button
-        class="ptz-btn"
-        @mousedown="handleStart('UP')"
-        @mouseup="handleStop"
-        @mouseleave="handleStop"
-      >
+      <button class="ptz-btn" @mousedown="handleStart('UP')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-up /></el-icon>
       </button>
       <button
@@ -76,24 +68,14 @@ async function handleStop() {
       </button>
 
       <!-- 第二行 -->
-      <button
-        class="ptz-btn"
-        @mousedown="handleStart('LEFT')"
-        @mouseup="handleStop"
-        @mouseleave="handleStop"
-      >
+      <button class="ptz-btn" @mousedown="handleStart('LEFT')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-left /></el-icon>
       </button>
       <div class="ptz-center">
         <el-icon v-if="isMoving" class="spinning"><i-ep-loading /></el-icon>
         <span v-else>PTZ</span>
       </div>
-      <button
-        class="ptz-btn"
-        @mousedown="handleStart('RIGHT')"
-        @mouseup="handleStop"
-        @mouseleave="handleStop"
-      >
+      <button class="ptz-btn" @mousedown="handleStart('RIGHT')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-right /></el-icon>
       </button>
 
@@ -106,12 +88,7 @@ async function handleStop() {
       >
         <el-icon><i-ep-bottom-left /></el-icon>
       </button>
-      <button
-        class="ptz-btn"
-        @mousedown="handleStart('DOWN')"
-        @mouseup="handleStop"
-        @mouseleave="handleStop"
-      >
+      <button class="ptz-btn" @mousedown="handleStart('DOWN')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-down /></el-icon>
       </button>
       <button
@@ -141,7 +118,7 @@ async function handleStop() {
   width: 48px;
   height: 48px;
   border: 1px solid var(--el-border-color);
-  border-radius: 8px;
+  border-radius: var(--radius-base);
   background: var(--el-bg-color);
   cursor: pointer;
   display: flex;
@@ -184,7 +161,11 @@ async function handleStop() {
 }
 
 @keyframes spin {
-  from { transform: rotate(0deg); }
-  to { transform: rotate(360deg); }
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
 }
 </style>

+ 6 - 9
src/components/VideoPlayer.vue

@@ -351,15 +351,12 @@ watch(
 )
 
 // WebRTC 配置变化时重新连接
-watch(
-  [() => props.go2rtcUrl, () => props.streamName],
-  ([newUrl, newStream]) => {
-    if (props.playerType === 'webrtc' && newUrl && newStream) {
-      destroyWebRTC()
-      initWebRTC()
-    }
+watch([() => props.go2rtcUrl, () => props.streamName], ([newUrl, newStream]) => {
+  if (props.playerType === 'webrtc' && newUrl && newStream) {
+    destroyWebRTC()
+    initWebRTC()
   }
-)
+})
 
 onMounted(() => {
   if (props.playerType === 'hls') {
@@ -405,7 +402,7 @@ onBeforeUnmount(() => {
   left: 50%;
   transform: translate(-50%, -50%);
   padding: 12px 24px;
-  border-radius: 8px;
+  border-radius: var(--radius-base);
   background-color: rgba(0, 0, 0, 0.7);
   color: #fff;
   font-size: 14px;

+ 208 - 0
src/components/monitor/CameraSelector.vue

@@ -0,0 +1,208 @@
+<template>
+  <el-dialog
+    :model-value="visible"
+    title="选择摄像头"
+    width="500px"
+    :close-on-click-modal="false"
+    @update:model-value="$emit('update:visible', $event)"
+  >
+    <!-- 搜索框 -->
+    <div class="selector-search">
+      <el-input v-model="searchText" placeholder="搜索摄像头..." clearable :prefix-icon="Search" />
+    </div>
+
+    <!-- 摄像头列表 -->
+    <div class="selector-list">
+      <el-scrollbar height="300px">
+        <div
+          v-for="camera in filteredCameras"
+          :key="camera.id"
+          class="selector-item"
+          :class="{ 'selector-item--selected': selectedCamera?.id === camera.id }"
+          @click="selectedCamera = camera"
+        >
+          <div class="selector-item__radio">
+            <el-icon v-if="selectedCamera?.id === camera.id" color="var(--color-primary)">
+              <CircleCheckFilled />
+            </el-icon>
+            <el-icon v-else color="var(--text-placeholder)">
+              <CircleCheck />
+            </el-icon>
+          </div>
+          <div class="selector-item__info">
+            <div class="selector-item__name">{{ camera.name }}</div>
+            <div class="selector-item__meta">
+              <el-tag size="small" :type="camera.streamType === 'webrtc' ? 'success' : 'primary'">
+                {{ camera.streamType === 'webrtc' ? 'WebRTC' : 'Cloudflare' }}
+              </el-tag>
+              <el-tag size="small" :type="camera.online ? 'success' : 'danger'">
+                {{ camera.online ? '在线' : '离线' }}
+              </el-tag>
+            </div>
+          </div>
+        </div>
+
+        <div v-if="filteredCameras.length === 0" class="selector-empty">
+          <el-empty description="没有找到摄像头" :image-size="80" />
+        </div>
+      </el-scrollbar>
+    </div>
+
+    <template #footer>
+      <el-button @click="$emit('update:visible', false)">取消</el-button>
+      <el-button type="primary" :disabled="!selectedCamera" @click="handleConfirm">确定选择</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue'
+import { Search, CircleCheck, CircleCheckFilled } from '@element-plus/icons-vue'
+import { adminListCameras } from '@/api/camera'
+
+interface CameraItem {
+  id: string
+  name: string
+  streamType: 'webrtc' | 'cloudflare'
+  streamUrl: string
+  online: boolean
+}
+
+interface Props {
+  visible: boolean
+  currentPosition?: number
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  select: [camera: { id: string; name: string; streamType: 'webrtc' | 'cloudflare'; streamUrl: string }]
+}>()
+
+const searchText = ref('')
+const cameras = ref<CameraItem[]>([])
+const selectedCamera = ref<CameraItem | null>(null)
+const loading = ref(false)
+
+const filteredCameras = computed(() => {
+  if (!searchText.value) return cameras.value
+  const keyword = searchText.value.toLowerCase()
+  return cameras.value.filter((camera) => camera.name.toLowerCase().includes(keyword))
+})
+
+// 加载摄像头列表
+async function loadCameras() {
+  loading.value = true
+  try {
+    const res = await adminListCameras()
+    if (res.code === 200 && res.data) {
+      cameras.value = res.data.map((item) => ({
+        id: String(item.id),
+        name: item.name || `摄像头 ${item.id}`,
+        // 根据实际数据判断 streamType,这里暂时默认为 webrtc
+        streamType: (item as any).streamType || 'webrtc',
+        streamUrl: (item as any).streamUrl || (item as any).rtspUrl || '',
+        online: (item as any).online !== false
+      }))
+    }
+  } catch (error) {
+    console.error('Failed to load cameras:', error)
+    // 使用模拟数据
+    cameras.value = [
+      { id: '1', name: '大门入口', streamType: 'webrtc', streamUrl: 'camera1', online: true },
+      {
+        id: '2',
+        name: '停车场',
+        streamType: 'cloudflare',
+        streamUrl: 'b51e49994b6fd9e56b6f1fdfcd339fe6',
+        online: true
+      },
+      { id: '3', name: '仓库', streamType: 'webrtc', streamUrl: 'camera3', online: false },
+      { id: '4', name: '办公区', streamType: 'webrtc', streamUrl: 'camera4', online: true }
+    ]
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleConfirm() {
+  if (!selectedCamera.value) return
+
+  emit('select', {
+    id: selectedCamera.value.id,
+    name: selectedCamera.value.name,
+    streamType: selectedCamera.value.streamType,
+    streamUrl: selectedCamera.value.streamUrl
+  })
+}
+
+// 监听 visible 变化,打开时加载数据
+watch(
+  () => props.visible,
+  (val) => {
+    if (val) {
+      selectedCamera.value = null
+      searchText.value = ''
+      loadCameras()
+    }
+  }
+)
+</script>
+
+<style lang="scss" scoped>
+.selector-search {
+  margin-bottom: 16px;
+}
+
+.selector-list {
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-base);
+}
+
+.selector-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 16px;
+  cursor: pointer;
+  transition: background-color 0.2s;
+  border-bottom: 1px solid var(--border-color-light);
+
+  &:last-child {
+    border-bottom: none;
+  }
+
+  &:hover {
+    background-color: var(--bg-hover);
+  }
+
+  &--selected {
+    background-color: var(--color-primary-light-9);
+  }
+
+  &__radio {
+    font-size: 20px;
+  }
+
+  &__info {
+    flex: 1;
+  }
+
+  &__name {
+    font-size: 14px;
+    font-weight: 500;
+    color: var(--text-primary);
+    margin-bottom: 4px;
+  }
+
+  &__meta {
+    display: flex;
+    gap: 8px;
+  }
+}
+
+.selector-empty {
+  padding: 40px 0;
+}
+</style>

+ 242 - 0
src/components/monitor/PtzOverlay.vue

@@ -0,0 +1,242 @@
+<template>
+  <div class="ptz-overlay">
+    <div class="ptz-controls">
+      <!-- 8方向控制 -->
+      <div class="ptz-directions">
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('UP_LEFT')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><TopLeft /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('UP')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><Top /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('UP_RIGHT')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><TopRight /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('LEFT')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><Back /></el-icon>
+        </div>
+        <div class="ptz-btn ptz-center">
+          <el-icon><Aim /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('RIGHT')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><Right /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('DOWN_LEFT')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><BottomLeft /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('DOWN')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><Bottom /></el-icon>
+        </div>
+        <div
+          class="ptz-btn"
+          @mousedown="handleDirection('DOWN_RIGHT')"
+          @mouseup="handleDirectionStop"
+          @mouseleave="handleDirectionStop"
+        >
+          <el-icon><BottomRight /></el-icon>
+        </div>
+      </div>
+
+      <!-- 垂直缩放滑块 -->
+      <div class="ptz-zoom">
+        <el-icon class="zoom-icon zoom-in"><ZoomIn /></el-icon>
+        <el-slider
+          v-model="zoomValue"
+          vertical
+          :min="-100"
+          :max="100"
+          :step="10"
+          :show-tooltip="false"
+          height="100px"
+          @input="handleZoomChange"
+          @change="handleZoomRelease"
+        />
+        <el-icon class="zoom-icon zoom-out"><ZoomOut /></el-icon>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import {
+  Top,
+  Bottom,
+  Back,
+  Right,
+  TopLeft,
+  TopRight,
+  BottomLeft,
+  BottomRight,
+  Aim,
+  ZoomIn,
+  ZoomOut
+} from '@element-plus/icons-vue'
+import { startPTZ, stopPTZ, startZoom, stopZoom, type PTZConfig, type PTZDirectionKey } from '@/api/ptz'
+
+interface Props {
+  cameraId?: string
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  'ptz-action': [action: string, params?: unknown]
+}>()
+
+const zoomValue = ref(0)
+
+// 默认 PTZ 配置 (后续可以从摄像头配置中获取)
+const ptzConfig: PTZConfig = {
+  host: '192.168.0.64',
+  username: 'admin',
+  password: 'Wxc767718929'
+}
+
+const ptzSpeed = 50
+
+async function handleDirection(direction: PTZDirectionKey) {
+  emit('ptz-action', 'direction', { direction })
+  await startPTZ(ptzConfig, direction, ptzSpeed)
+}
+
+async function handleDirectionStop() {
+  emit('ptz-action', 'stop')
+  await stopPTZ(ptzConfig)
+}
+
+async function handleZoomChange(val: number) {
+  if (val === 0) {
+    await stopZoom(ptzConfig)
+    return
+  }
+
+  const direction = val > 0 ? 'IN' : 'OUT'
+  const speed = Math.abs(val)
+  emit('ptz-action', 'zoom', { direction, speed })
+  await startZoom(ptzConfig, direction, speed)
+}
+
+async function handleZoomRelease() {
+  zoomValue.value = 0
+  await stopZoom(ptzConfig)
+}
+</script>
+
+<style lang="scss" scoped>
+.ptz-overlay {
+  padding: 8px 12px;
+}
+
+.ptz-controls {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.ptz-directions {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 4px;
+}
+
+.ptz-btn {
+  width: 32px;
+  height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: rgba(255, 255, 255, 0.1);
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  border-radius: var(--radius-base);
+  cursor: pointer;
+  transition: all 0.2s;
+  color: #fff;
+
+  &:hover {
+    background-color: rgba(255, 255, 255, 0.2);
+    border-color: rgba(255, 255, 255, 0.4);
+  }
+
+  &:active {
+    background-color: var(--color-primary);
+    border-color: var(--color-primary);
+  }
+
+  .el-icon {
+    font-size: 16px;
+  }
+}
+
+.ptz-center {
+  background-color: rgba(255, 255, 255, 0.05);
+  cursor: default;
+
+  &:hover {
+    background-color: rgba(255, 255, 255, 0.05);
+  }
+}
+
+.ptz-zoom {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+
+  .zoom-icon {
+    color: #fff;
+    font-size: 16px;
+  }
+
+  :deep(.el-slider) {
+    .el-slider__runway {
+      background-color: rgba(255, 255, 255, 0.2);
+    }
+
+    .el-slider__bar {
+      background-color: var(--color-primary);
+    }
+
+    .el-slider__button {
+      width: 14px;
+      height: 14px;
+      border: 2px solid var(--color-primary);
+    }
+  }
+}
+</style>

+ 233 - 0
src/components/monitor/VideoCell.vue

@@ -0,0 +1,233 @@
+<template>
+  <div
+    class="video-cell"
+    :class="{ 'video-cell--empty': !slotData, 'video-cell--hover': isHovering }"
+    @mouseenter="isHovering = true"
+    @mouseleave="isHovering = false"
+    @dblclick="handleDoubleClick"
+  >
+    <!-- 空格子状态 -->
+    <div v-if="!slotData" class="video-cell__empty" @click="$emit('select-camera')">
+      <el-icon :size="40"><Plus /></el-icon>
+      <span>选择摄像头</span>
+    </div>
+
+    <!-- 有视频时 -->
+    <template v-else>
+      <!-- 视频播放器 -->
+      <div ref="videoContainerRef" class="video-cell__player">
+        <VideoPlayer
+          ref="playerRef"
+          :player-type="slotData.streamType === 'webrtc' ? 'webrtc' : 'cloudflare'"
+          :video-id="slotData.streamType === 'cloudflare' ? slotData.streamUrl : undefined"
+          :customer-domain="
+            slotData.streamType === 'cloudflare' ? getCloudflareCustomerDomain(slotData.streamUrl) : undefined
+          "
+          :go2rtc-url="slotData.streamType === 'webrtc' ? getGo2rtcUrl(slotData.streamUrl) : undefined"
+          :stream-name="slotData.streamType === 'webrtc' ? getStreamName(slotData.streamUrl) : undefined"
+          :use-iframe="slotData.streamType === 'cloudflare'"
+          :autoplay="true"
+          :muted="true"
+          :controls="false"
+        />
+      </div>
+
+      <!-- 悬停时显示的控制层 -->
+      <transition name="fade">
+        <div v-show="isHovering" class="video-cell__overlay">
+          <!-- 顶部信息栏 -->
+          <div class="video-cell__header">
+            <span class="video-cell__name">{{ slotData.cameraName }}</span>
+            <div class="video-cell__actions">
+              <el-button size="small" circle @click.stop="handleFullscreen">
+                <el-icon><FullScreen /></el-icon>
+              </el-button>
+              <el-button size="small" circle type="danger" @click.stop="handleRemove">
+                <el-icon><Close /></el-icon>
+              </el-button>
+            </div>
+          </div>
+
+          <!-- 底部 PTZ 控制 -->
+          <PtzOverlay :camera-id="slotData.cameraId" @ptz-action="handlePtzAction" />
+        </div>
+      </transition>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { Plus, FullScreen, Close } from '@element-plus/icons-vue'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import PtzOverlay from './PtzOverlay.vue'
+import type { GridSlot } from '@/composables/useMonitorStore'
+
+interface Props {
+  position: number
+  slotData?: GridSlot
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  'select-camera': []
+  'update-slot': [slot: Partial<GridSlot> | null]
+}>()
+
+const isHovering = ref(false)
+const playerRef = ref<InstanceType<typeof VideoPlayer>>()
+const videoContainerRef = ref<HTMLElement>()
+
+// 解析 Cloudflare 的 customer domain
+function getCloudflareCustomerDomain(url?: string): string {
+  if (!url) return ''
+  // 如果是完整 URL,提取 domain
+  if (url.includes('cloudflarestream.com')) {
+    const match = url.match(/https?:\/\/([^/]+)/)
+    return match ? match[1] : 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
+  }
+  // 如果只是 videoId,返回默认 domain
+  return 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
+}
+
+// 解析 go2rtc URL
+function getGo2rtcUrl(url?: string): string {
+  if (!url) return 'http://localhost:1984'
+  // 如果包含完整 URL
+  if (url.includes('://')) {
+    const match = url.match(/(https?:\/\/[^/]+)/)
+    return match ? match[1] : 'http://localhost:1984'
+  }
+  return 'http://localhost:1984'
+}
+
+// 解析 stream name
+function getStreamName(url?: string): string {
+  if (!url) return ''
+  // 如果是完整 URL,提取 stream name
+  if (url.includes('src=')) {
+    const match = url.match(/src=([^&]+)/)
+    return match ? match[1] : url
+  }
+  return url
+}
+
+function handleDoubleClick() {
+  if (props.slotData) {
+    handleFullscreen()
+  }
+}
+
+function handleFullscreen() {
+  if (videoContainerRef.value) {
+    videoContainerRef.value.requestFullscreen?.()
+  }
+}
+
+function handleRemove() {
+  emit('update-slot', null)
+}
+
+function handlePtzAction(action: string, params?: any) {
+  console.log('PTZ action:', action, params)
+  // PTZ 控制由 PtzOverlay 组件内部处理
+}
+</script>
+
+<style lang="scss" scoped>
+.video-cell {
+  position: relative;
+  background-color: #1a1a1a;
+  border-radius: var(--radius-base);
+  overflow: hidden;
+  cursor: pointer;
+  aspect-ratio: 16 / 9;
+
+  &--empty {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 2px dashed var(--border-color);
+    background-color: var(--bg-hover);
+
+    &:hover {
+      border-color: var(--color-primary);
+      background-color: var(--color-primary-light-9);
+    }
+  }
+
+  &__empty {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 8px;
+    color: var(--text-secondary);
+    width: 100%;
+    height: 100%;
+    justify-content: center;
+
+    .el-icon {
+      color: var(--text-placeholder);
+    }
+
+    span {
+      font-size: 14px;
+    }
+  }
+
+  &__player {
+    width: 100%;
+    height: 100%;
+  }
+
+  &__overlay {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    background: linear-gradient(
+      to bottom,
+      rgba(0, 0, 0, 0.7) 0%,
+      transparent 30%,
+      transparent 70%,
+      rgba(0, 0, 0, 0.7) 100%
+    );
+    pointer-events: none;
+
+    > * {
+      pointer-events: auto;
+    }
+  }
+
+  &__header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 12px;
+  }
+
+  &__name {
+    color: #fff;
+    font-size: 14px;
+    font-weight: 500;
+    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
+  }
+
+  &__actions {
+    display: flex;
+    gap: 4px;
+  }
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 57 - 0
src/components/monitor/VideoGrid.vue

@@ -0,0 +1,57 @@
+<template>
+  <div class="video-grid" :class="`grid-${gridSize}x${gridSize}`">
+    <VideoCell
+      v-for="position in totalCells"
+      :key="position - 1"
+      :position="position - 1"
+      :slot-data="getSlotByPosition(position - 1)"
+      @select-camera="$emit('select-camera', position - 1)"
+      @update-slot="(slot) => $emit('update-slot', position - 1, slot)"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import VideoCell from './VideoCell.vue'
+import type { GridSlot } from '@/composables/useMonitorStore'
+
+interface Props {
+  gridSize: 2 | 3 | 4
+  slots: GridSlot[]
+}
+
+const props = defineProps<Props>()
+
+defineEmits<{
+  'select-camera': [position: number]
+  'update-slot': [position: number, slot: Partial<GridSlot> | null]
+}>()
+
+const totalCells = computed(() => props.gridSize * props.gridSize)
+
+function getSlotByPosition(position: number): GridSlot | undefined {
+  return props.slots.find((slot) => slot.position === position)
+}
+</script>
+
+<style lang="scss" scoped>
+.video-grid {
+  display: grid;
+  gap: 8px;
+  width: 100%;
+  align-content: start;
+
+  &.grid-2x2 {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  &.grid-3x3 {
+    grid-template-columns: repeat(3, 1fr);
+  }
+
+  &.grid-4x4 {
+    grid-template-columns: repeat(4, 1fr);
+  }
+}
+</style>

+ 141 - 0
src/composables/useMonitorStore.ts

@@ -0,0 +1,141 @@
+import { ref, computed } from 'vue'
+
+export interface GridSlot {
+  position: number
+  cameraId?: string
+  cameraName?: string
+  streamType: 'webrtc' | 'cloudflare'
+  streamUrl?: string
+}
+
+export interface MonitorTab {
+  id: string
+  name: string
+  gridSize: 2 | 3 | 4
+  slots: GridSlot[]
+}
+
+const STORAGE_KEY = 'monitor-tabs'
+
+// 生成唯一 ID
+function generateId(): string {
+  return `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
+}
+
+// 创建默认标签
+function createDefaultTab(): MonitorTab {
+  return {
+    id: generateId(),
+    name: '监控组1',
+    gridSize: 2,
+    slots: []
+  }
+}
+
+// 全局状态
+const tabs = ref<MonitorTab[]>([])
+const activeTabId = ref<string>('')
+
+export function useMonitorStore() {
+  // 当前激活的标签
+  const currentTab = computed(() => {
+    return tabs.value.find((tab) => tab.id === activeTabId.value) || null
+  })
+
+  // 从 localStorage 加载
+  function loadFromStorage() {
+    try {
+      const stored = localStorage.getItem(STORAGE_KEY)
+      if (stored) {
+        const parsed = JSON.parse(stored) as { tabs: MonitorTab[]; activeTabId: string }
+        if (parsed.tabs && parsed.tabs.length > 0) {
+          tabs.value = parsed.tabs
+          activeTabId.value = parsed.activeTabId || parsed.tabs[0].id
+          return
+        }
+      }
+    } catch (e) {
+      console.error('Failed to load monitor config from localStorage:', e)
+    }
+
+    // 如果没有存储数据或加载失败,创建默认标签
+    const defaultTab = createDefaultTab()
+    tabs.value = [defaultTab]
+    activeTabId.value = defaultTab.id
+  }
+
+  // 保存到 localStorage
+  function saveToStorage() {
+    try {
+      const data = {
+        tabs: tabs.value,
+        activeTabId: activeTabId.value
+      }
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+    } catch (e) {
+      console.error('Failed to save monitor config to localStorage:', e)
+    }
+  }
+
+  // 添加新标签
+  function addTab() {
+    const newTab: MonitorTab = {
+      id: generateId(),
+      name: `监控组${tabs.value.length + 1}`,
+      gridSize: 2,
+      slots: []
+    }
+    tabs.value.push(newTab)
+    activeTabId.value = newTab.id
+    saveToStorage()
+  }
+
+  // 删除标签
+  function removeTab(tabId: string) {
+    if (tabs.value.length <= 1) return
+
+    const index = tabs.value.findIndex((tab) => tab.id === tabId)
+    if (index === -1) return
+
+    tabs.value.splice(index, 1)
+
+    // 如果删除的是当前激活的标签,切换到其他标签
+    if (activeTabId.value === tabId) {
+      activeTabId.value = tabs.value[Math.max(0, index - 1)].id
+    }
+    saveToStorage()
+  }
+
+  // 更新标签
+  function updateTab(tabId: string, updates: Partial<MonitorTab>) {
+    const tab = tabs.value.find((t) => t.id === tabId)
+    if (tab) {
+      Object.assign(tab, updates)
+    }
+  }
+
+  // 重命名标签
+  function renameTab(tabId: string, newName: string) {
+    updateTab(tabId, { name: newName })
+    saveToStorage()
+  }
+
+  // 清空标签的所有格子
+  function clearTabSlots(tabId: string) {
+    updateTab(tabId, { slots: [] })
+    saveToStorage()
+  }
+
+  return {
+    tabs,
+    activeTabId,
+    currentTab,
+    loadFromStorage,
+    saveToStorage,
+    addTab,
+    removeTab,
+    updateTab,
+    renameTab,
+    clearTabSlots
+  }
+}

+ 2 - 1
src/layout/index.vue

@@ -416,6 +416,7 @@ const menuItems: MenuItem[] = [
   // { path: '/user', title: '用户管理', icon: UserIcon },
   { path: '/cc', title: 'Cloudflare Stream', icon: CloudIcon },
   { path: '/webrtc', title: 'WebRTC 流', icon: ConnectionIcon },
+  { path: '/monitor', title: '多视频监控', icon: CameraIcon },
   {
     path: '/demo',
     title: '视频测试',
@@ -680,7 +681,7 @@ onUnmounted(() => {
     width: 2rem;
     height: 2rem;
     background: #ffffff;
-    border-radius: 0.5rem;
+    border-radius: var(--radius-base);
     display: flex;
     align-items: center;
     justify-content: center;

+ 12 - 2
src/locales/en.json

@@ -57,5 +57,15 @@
   "数据更新时间": "Last Updated",
   "版本": "Version",
   "正常": "Normal",
-  "获取统计数据失败": "Failed to get statistics"
-}
+  "获取统计数据失败": "Failed to get statistics",
+  "Cloudflare Stream 配置": "Cloudflare Stream 配置",
+  "播放域名的子域名部分": "The subdomain part of the playback domain",
+  "仅在前端直接调用 API 时需要(不推荐)": "Only needed when directly calling the API in the frontend (not recommended)",
+  "推荐通过后端代理调用,避免暴露 Token": "Recommended to call through the backend proxy to avoid exposing the Token",
+  "保存配置": "Save Configuration",
+  "测试连接": "Test Connection",
+  "配置说明": "Configuration Description",
+  "如何获取 Customer Subdomain": "How to get Customer Subdomain",
+  "测试播放": "Test Playback",
+  "复制": "Copy"
+}

+ 11 - 1
src/locales/zh-cn.json

@@ -57,5 +57,15 @@
   "数据更新时间": "数据更新时间",
   "版本": "版本",
   "正常": "正常",
-  "获取统计数据失败": "获取统计数据失败"
+  "获取统计数据失败": "获取统计数据失败",
+  "Cloudflare Stream 配置": "Cloudflare Stream 配置",
+  "播放域名的子域名部分": "播放域名的子域名部分",
+  "仅在前端直接调用 API 时需要(不推荐)": "仅在前端直接调用 API 时需要(不推荐)",
+  "推荐通过后端代理调用,避免暴露 Token": "推荐通过后端代理调用,避免暴露 Token",
+  "保存配置": "保存配置",
+  "测试连接": "测试连接",
+  "配置说明": "配置说明",
+  "如何获取 Customer Subdomain": "如何获取 Customer Subdomain",
+  "测试播放": "测试播放",
+  "复制": "复制"
 }

+ 6 - 0
src/router/index.ts

@@ -139,6 +139,12 @@ const routes: RouteRecordRaw[] = [
         name: 'HlsStream',
         component: () => import('@/views/demo/hls-stream.vue'),
         meta: { title: 'M3U8/HLS', icon: 'VideoPlay' }
+      },
+      {
+        path: 'monitor',
+        name: 'Monitor',
+        component: () => import('@/views/monitor/index.vue'),
+        meta: { title: '多视频监控', icon: 'Monitor' }
       }
     ]
   },

+ 1 - 1
src/views/audit/index.vue

@@ -335,7 +335,7 @@ onMounted(() => {
   font-size: 12px;
   background: #f5f7fa;
   padding: 10px;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   max-height: 200px;
   overflow: auto;
   margin: 0;

+ 2 - 2
src/views/camera/channel.vue

@@ -105,7 +105,7 @@ onMounted(() => {
   margin-bottom: 20px;
   padding: 15px 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 
   .title {
     margin-left: 15px;
@@ -118,6 +118,6 @@ onMounted(() => {
   margin-bottom: 20px;
   padding: 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 }
 </style>

+ 3 - 1
src/views/camera/index.vue

@@ -421,7 +421,9 @@ onMounted(() => {
 
 .search-form {
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
+  padding: 0;
+  margin-bottom: 0;
 }
 
 .table-actions {

+ 7 - 7
src/views/camera/stream-test.vue

@@ -243,7 +243,7 @@ function goBack() {
   display: flex;
   align-items: center;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   margin-bottom: 20px;
 
   .title {
@@ -255,19 +255,19 @@ function goBack() {
 
 .config-section {
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 }
 
 .player-section {
   height: 480px;
   margin-bottom: 20px;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   overflow: hidden;
 }
 
 .control-section {
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   margin-bottom: 20px;
 }
 
@@ -277,7 +277,7 @@ function goBack() {
   code {
     background-color: #f5f5f5;
     padding: 2px 6px;
-    border-radius: 4px;
+    border-radius: var(--radius-base);
     font-size: 12px;
   }
 
@@ -289,7 +289,7 @@ function goBack() {
 
 .log-section {
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 
   h4 {
     margin-bottom: 10px;
@@ -300,7 +300,7 @@ function goBack() {
     max-height: 200px;
     overflow-y: auto;
     background-color: #fafafa;
-    border-radius: 4px;
+    border-radius: var(--radius-base);
     padding: 10px;
   }
 

+ 1 - 1
src/views/camera/video.vue

@@ -47,7 +47,7 @@ function goBack() {
   margin-bottom: 20px;
   padding: 15px 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 
   .title {
     margin-left: 15px;

+ 7 - 7
src/views/demo/video-demo.vue

@@ -387,7 +387,7 @@ function onError(error: any) {
   margin-bottom: 20px;
   padding: 15px 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 
   .title {
     font-size: 18px;
@@ -398,13 +398,13 @@ function onError(error: any) {
 .config-section {
   margin-bottom: 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 }
 
 .player-section {
   height: 500px;
   margin-bottom: 20px;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   overflow: hidden;
   background-color: #000;
 
@@ -426,7 +426,7 @@ function onError(error: any) {
 .control-section {
   padding: 15px 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   margin-bottom: 20px;
 }
 
@@ -434,13 +434,13 @@ function onError(error: any) {
   margin-bottom: 20px;
   padding: 15px 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 }
 
 .log-section {
   padding: 15px 20px;
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
 
   .log-header {
     display: flex;
@@ -458,7 +458,7 @@ function onError(error: any) {
     max-height: 200px;
     overflow-y: auto;
     background-color: #fafafa;
-    border-radius: 4px;
+    border-radius: var(--radius-base);
     padding: 10px;
   }
 

+ 4 - 4
src/views/login/index.vue

@@ -292,7 +292,7 @@ function goHelp() {
     :deep(.lang-trigger) {
       background: rgba(0, 0, 0, 0.05);
       padding: 8px 12px;
-      border-radius: 8px;
+      border-radius: var(--radius-base);
     }
   }
 
@@ -334,7 +334,7 @@ function goHelp() {
     width: 2.5rem;
     height: 2.5rem;
     background: #ffffff;
-    border-radius: 0.5rem;
+    border-radius: var(--radius-base);
     display: flex;
     align-items: center;
     justify-content: center;
@@ -451,7 +451,7 @@ function goHelp() {
     width: 2rem;
     height: 2rem;
     background: #000000;
-    border-radius: 0.5rem;
+    border-radius: var(--radius-base);
     display: flex;
     align-items: center;
     justify-content: center;
@@ -610,7 +610,7 @@ function goHelp() {
   &__checkbox {
     width: 1rem;
     height: 1rem;
-    border-radius: 0.25rem;
+    border-radius: var(--radius-sm);
     border: 1px solid #d1d5db;
     margin-right: 0.5rem;
     cursor: pointer;

+ 42 - 1
src/views/machine/index.vue

@@ -7,7 +7,7 @@
     </div>
 
     <!-- 数据表格 -->
-    <el-table v-loading="loading" :data="machineList" border data-id="machine-table">
+    <el-table v-loading="loading" :data="paginatedList" border data-id="machine-table">
       <el-table-column type="index" label="序号" width="60" align="center" />
       <el-table-column prop="machineId" label="机器ID" min-width="120" show-overflow-tooltip />
       <el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip>
@@ -51,6 +51,20 @@
       </el-table-column>
     </el-table>
 
+    <!-- 分页 -->
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        background
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+
     <!-- 新增/编辑弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close data-id="dialog-machine">
       <el-form ref="formRef" :model="form" :rules="rules" label-width="80px" data-id="form-machine">
@@ -98,6 +112,18 @@ const machineList = ref<MachineDTO[]>([])
 const dialogVisible = ref(false)
 const formRef = ref<FormInstance>()
 
+// 分页相关
+const currentPage = ref(1)
+const pageSize = ref(10)
+const total = computed(() => machineList.value.length)
+
+// 分页后的数据
+const paginatedList = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  const end = start + pageSize.value
+  return machineList.value.slice(start, end)
+})
+
 const form = reactive<{
   id?: number
   machineId: string
@@ -221,6 +247,15 @@ async function handleSubmit() {
   })
 }
 
+function handleSizeChange(val: number) {
+  pageSize.value = val
+  currentPage.value = 1
+}
+
+function handleCurrentChange(val: number) {
+  currentPage.value = val
+}
+
 onMounted(() => {
   getList()
 })
@@ -234,4 +269,10 @@ onMounted(() => {
 .table-actions {
   margin-bottom: 15px;
 }
+
+.pagination-container {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
 </style>

+ 226 - 0
src/views/monitor/index.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="monitor-page">
+    <!-- 顶部工具栏 -->
+    <div class="monitor-toolbar">
+      <div class="toolbar-left">
+        <el-button type="primary" @click="addTab" icon="Plus">
+          {{ t('新建标签') }}
+        </el-button>
+        <el-select v-model="currentGridSize" @change="handleGridSizeChange" style="width: 120px">
+          <el-option label="2 x 2" :value="2" />
+          <el-option label="3 x 3" :value="3" />
+          <el-option label="4 x 4" :value="4" />
+        </el-select>
+      </div>
+      <div class="toolbar-right">
+        <el-button @click="saveConfig" icon="FolderChecked">
+          {{ t('保存配置') }}
+        </el-button>
+      </div>
+    </div>
+
+    <!-- 标签栏 -->
+    <div class="monitor-tabs">
+      <el-tabs v-model="activeTabId" type="card" editable @edit="handleTabEdit">
+        <el-tab-pane v-for="tab in tabs" :key="tab.id" :label="tab.name" :name="tab.id" :closable="tabs.length > 1" />
+      </el-tabs>
+    </div>
+
+    <!-- 网格区域 -->
+    <div class="monitor-grid-container">
+      <VideoGrid
+        v-if="currentTab"
+        :grid-size="currentTab.gridSize"
+        :slots="currentTab.slots"
+        @select-camera="handleSelectCamera"
+        @update-slot="handleUpdateSlot"
+      />
+    </div>
+
+    <!-- 摄像头选择器 -->
+    <CameraSelector
+      v-model:visible="selectorVisible"
+      :current-position="selectedPosition"
+      @select="handleCameraSelected"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { Plus, FolderChecked } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import VideoGrid from '@/components/monitor/VideoGrid.vue'
+import CameraSelector from '@/components/monitor/CameraSelector.vue'
+import { useMonitorStore, type GridSlot } from '@/composables/useMonitorStore'
+import { useI18n } from 'vue-i18n'
+
+const { t } = useI18n()
+
+const { tabs, activeTabId, currentTab, addTab, removeTab, updateTab, saveToStorage, loadFromStorage } =
+  useMonitorStore()
+
+const selectorVisible = ref(false)
+const selectedPosition = ref(0)
+
+const currentGridSize = computed({
+  get: () => currentTab.value?.gridSize || 2,
+  set: (val) => {
+    if (currentTab.value) {
+      updateTab(currentTab.value.id, { gridSize: val })
+    }
+  }
+})
+
+function handleGridSizeChange(size: number) {
+  if (currentTab.value) {
+    updateTab(currentTab.value.id, { gridSize: size as 2 | 3 | 4 })
+    saveToStorage()
+  }
+}
+
+function handleTabEdit(targetName: string | number, action: 'add' | 'remove') {
+  if (action === 'add') {
+    addTab()
+  } else if (action === 'remove') {
+    removeTab(String(targetName))
+  }
+}
+
+function handleSelectCamera(position: number) {
+  selectedPosition.value = position
+  selectorVisible.value = true
+}
+
+function handleCameraSelected(camera: {
+  id: string
+  name: string
+  streamType: 'webrtc' | 'cloudflare'
+  streamUrl: string
+}) {
+  if (!currentTab.value) return
+
+  const newSlot: GridSlot = {
+    position: selectedPosition.value,
+    cameraId: camera.id,
+    cameraName: camera.name,
+    streamType: camera.streamType,
+    streamUrl: camera.streamUrl
+  }
+
+  const existingSlots = currentTab.value.slots.filter((s) => s.position !== selectedPosition.value)
+  updateTab(currentTab.value.id, {
+    slots: [...existingSlots, newSlot]
+  })
+
+  selectorVisible.value = false
+  saveToStorage()
+}
+
+function handleUpdateSlot(position: number, slot: Partial<GridSlot> | null) {
+  if (!currentTab.value) return
+
+  if (slot === null) {
+    // 移除格子
+    const newSlots = currentTab.value.slots.filter((s) => s.position !== position)
+    updateTab(currentTab.value.id, { slots: newSlots })
+  } else {
+    // 更新格子
+    const existingSlots = currentTab.value.slots.filter((s) => s.position !== position)
+    updateTab(currentTab.value.id, {
+      slots: [...existingSlots, { position, ...slot } as GridSlot]
+    })
+  }
+  saveToStorage()
+}
+
+function saveConfig() {
+  saveToStorage()
+  ElMessage.success('配置已保存')
+}
+
+onMounted(() => {
+  loadFromStorage()
+})
+</script>
+
+<style lang="scss" scoped>
+.monitor-page {
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - 100px);
+  background-color: var(--bg-page);
+
+  // 全局去除圆角
+  :deep(.el-button) {
+    border-radius: var(--radius-base);
+  }
+
+  :deep(.el-select) {
+    .el-input__wrapper {
+      border-radius: var(--radius-base);
+    }
+  }
+
+  :deep(.el-tabs__item) {
+    border-radius: 0;
+  }
+
+  :deep(.el-tabs--card > .el-tabs__header .el-tabs__item) {
+    border-radius: 0;
+  }
+}
+
+.monitor-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 16px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+  margin-bottom: 12px;
+
+  .toolbar-left {
+    display: flex;
+    gap: 12px;
+    align-items: center;
+  }
+
+  .toolbar-right {
+    display: flex;
+    gap: 12px;
+    align-items: center;
+  }
+}
+
+.monitor-tabs {
+  margin-bottom: 12px;
+
+  :deep(.el-tabs__header) {
+    margin: 0;
+    border-radius: 0;
+  }
+
+  :deep(.el-tabs__item) {
+    height: 36px;
+    line-height: 36px;
+    border-radius: 0;
+  }
+
+  :deep(.el-tabs--card > .el-tabs__header) {
+    border-radius: 0;
+  }
+
+  :deep(.el-tabs--card > .el-tabs__header .el-tabs__nav) {
+    border-radius: 0;
+  }
+}
+
+.monitor-grid-container {
+  flex: 1;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+  padding: 12px;
+  overflow: auto;
+}
+</style>

+ 1 - 1
src/views/stats/index.vue

@@ -285,7 +285,7 @@ onMounted(() => {
 
     .status-bar {
       height: 24px;
-      border-radius: 4px;
+      border-radius: var(--radius-base);
       transition: width 0.3s ease;
       min-width: 20px;
     }

+ 4 - 4
src/views/stream/config.vue

@@ -231,7 +231,7 @@ onMounted(() => {
 .code-block {
   background-color: #f5f7fa;
   padding: 15px;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   font-size: 12px;
   line-height: 1.6;
   overflow-x: auto;
@@ -255,7 +255,7 @@ onMounted(() => {
       flex: 1;
       background-color: #f5f7fa;
       padding: 4px 8px;
-      border-radius: 4px;
+      border-radius: var(--radius-base);
       font-size: 12px;
       word-break: break-all;
     }
@@ -266,7 +266,7 @@ onMounted(() => {
   width: 100%;
   height: 480px;
   background-color: #000;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   overflow: hidden;
 }
 
@@ -289,7 +289,7 @@ onMounted(() => {
   code {
     background-color: #f5f7fa;
     padding: 2px 6px;
-    border-radius: 4px;
+    border-radius: var(--radius-base);
     font-size: 12px;
   }
 }

+ 1 - 1
src/views/stream/live-list.vue

@@ -428,7 +428,7 @@ onMounted(() => {
   width: 100%;
   height: 480px;
   background-color: #000;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   overflow: hidden;
 }
 </style>

+ 9 - 3
src/views/stream/video-list.vue

@@ -36,7 +36,7 @@
             :src="getThumbnailUrl(row.uid)"
             :preview-src-list="[getThumbnailUrl(row.uid)]"
             fit="cover"
-            style="width: 140px; height: 80px; border-radius: 4px"
+            class="thumbnail-image"
             :preview-teleported="true"
           >
             <template #error>
@@ -462,7 +462,13 @@ onMounted(() => {
 
 .search-form {
   background-color: #fff;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
+}
+
+.thumbnail-image {
+  width: 140px;
+  height: 80px;
+  border-radius: var(--radius-base);
 }
 
 .table-actions {
@@ -519,7 +525,7 @@ onMounted(() => {
   width: 100%;
   height: 480px;
   background-color: #000;
-  border-radius: 4px;
+  border-radius: var(--radius-base);
   overflow: hidden;
 }
 </style>