Ver Fonte

refactor(lss): streamline input components and enhance layout for improved readability

- Consolidated attributes in input components within the search form and various drawers for better clarity and maintainability.
- Reduced unnecessary line breaks to enhance overall readability of the index.vue file.
- Updated the icon for the scan button to better represent its functionality.
- Improved the structure of the pagination and parameter configuration sections for a more cohesive user experience.
yb há 1 semana atrás
pai
commit
7d1b94948b
2 ficheiros alterados com 561 adições e 34 exclusões
  1. 500 2
      src/views/live-stream/index.vue
  2. 61 32
      src/views/lss/index.vue

+ 500 - 2
src/views/live-stream/index.vue

@@ -222,18 +222,209 @@
         </el-button>
       </template>
     </el-dialog>
+
+    <!-- 流媒体播放抽屉 -->
+    <el-drawer
+      v-model="mediaDrawerVisible"
+      direction="rtl"
+      size="50%"
+      :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>
+          <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>
+
+          <!-- PTZ 云台控制 -->
+          <div class="ptz-panel">
+            <div class="section-title">{{ t('PTZ 云台控制') }}</div>
+
+            <!-- PTZ 方向控制 九宫格 -->
+            <div class="ptz-controls">
+              <div
+                class="ptz-btn"
+                @mousedown="handlePTZ('UP_LEFT')"
+                @mouseup="handlePTZStop"
+                @mouseleave="handlePTZStop"
+              >
+                <el-icon>
+                  <TopLeft />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="handlePTZ('UP')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
+                <el-icon>
+                  <Top />
+                </el-icon>
+              </div>
+              <div
+                class="ptz-btn"
+                @mousedown="handlePTZ('UP_RIGHT')"
+                @mouseup="handlePTZStop"
+                @mouseleave="handlePTZStop"
+              >
+                <el-icon>
+                  <TopRight />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="handlePTZ('LEFT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
+                <el-icon>
+                  <Back />
+                </el-icon>
+              </div>
+              <div class="ptz-btn ptz-center" @click="handlePTZStop">
+                <el-icon>
+                  <Refresh />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="handlePTZ('RIGHT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
+                <el-icon>
+                  <Right />
+                </el-icon>
+              </div>
+              <div
+                class="ptz-btn"
+                @mousedown="handlePTZ('DOWN_LEFT')"
+                @mouseup="handlePTZStop"
+                @mouseleave="handlePTZStop"
+              >
+                <el-icon>
+                  <BottomLeft />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="handlePTZ('DOWN')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
+                <el-icon>
+                  <Bottom />
+                </el-icon>
+              </div>
+              <div
+                class="ptz-btn"
+                @mousedown="handlePTZ('DOWN_RIGHT')"
+                @mouseup="handlePTZStop"
+                @mouseleave="handlePTZStop"
+              >
+                <el-icon>
+                  <BottomRight />
+                </el-icon>
+              </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">
+                <el-icon>
+                  <ZoomOut />
+                </el-icon>
+                <span>{{ t('缩放') }}</span>
+                <el-icon>
+                  <ZoomIn />
+                </el-icon>
+              </div>
+              <el-slider
+                v-model="zoomValue"
+                :min="-100"
+                :max="100"
+                :step="10"
+                :show-tooltip="true"
+                :format-tooltip="formatZoomTooltip"
+                @input="handleZoomChange"
+                @change="handleZoomRelease"
+              />
+            </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>
 </template>
 
 <script setup lang="ts">
 import { ref, reactive, onMounted, computed, watch } from 'vue'
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
-import { Search, RefreshRight, Plus, VideoPlay } from '@element-plus/icons-vue'
+import {
+  Search,
+  RefreshRight,
+  Plus,
+  VideoPlay,
+  Top,
+  Bottom,
+  Back,
+  Right,
+  TopLeft,
+  TopRight,
+  BottomLeft,
+  BottomRight,
+  Refresh,
+  ZoomIn,
+  ZoomOut
+} from '@element-plus/icons-vue'
 import { listLiveStreams, addLiveStream, updateLiveStream, deleteLiveStream } from '@/api/live-stream'
 import { listAllLssNodes } from '@/api/lss'
 import { adminListCameras } from '@/api/camera'
 import { listAllStreamChannels } from '@/api/stream-channel'
-import { startStreamTask, stopStreamTask } from '@/api/stream-push'
+import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import { ptzStart, ptzStop } from '@/api/camera'
 import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
 import dayjs from 'dayjs'
 import { useI18n } from 'vue-i18n'
@@ -290,6 +481,29 @@ const currentCommandTemplate = ref('')
 const currentStreamId = ref<number | null>(null)
 const commandUpdateLoading = ref(false)
 
+// 流媒体播放抽屉
+const mediaDrawerVisible = ref(false)
+const currentMediaStream = ref<LiveStreamDTO | null>(null)
+const playerRef = ref<InstanceType<typeof VideoPlayer>>()
+const playbackInfo = ref<{
+  videoId: string
+  customerDomain: string
+  hlsUrl?: string
+  whepUrl?: string
+  isLive: boolean
+}>({
+  videoId: '',
+  customerDomain: '',
+  isLive: false
+})
+const playConfig = reactive({
+  autoplay: true,
+  muted: true
+})
+// PTZ 控制
+const ptzSpeed = ref(50)
+const zoomValue = ref(0)
+
 // 下拉选项
 const lssOptions = ref<LssNodeDTO[]>([])
 const cameraOptions = ref<CameraInfoDTO[]>([])
@@ -623,6 +837,118 @@ async function handleStopStream(row: LiveStreamDTO) {
   }
 }
 
+// 查看流媒体
+async function handleViewCloudflare(row: LiveStreamDTO) {
+  currentMediaStream.value = row
+
+  // 尝试获取播放信息
+  if (row.streamSn) {
+    try {
+      const res = await getStreamPlayback(row.streamSn)
+      if (res.success && res.data) {
+        // 从播放信息中提取 Cloudflare 配置
+        playbackInfo.value = {
+          videoId: row.streamSn,
+          customerDomain: 'customer-pj89kn2ke2tcuh19.cloudflarestream.com',
+          hlsUrl: res.data.hlsUrl,
+          whepUrl: res.data.whepUrl,
+          isLive: res.data.isLive
+        }
+      }
+    } catch (error) {
+      console.error('获取播放信息失败', error)
+      // 使用默认配置
+      playbackInfo.value = {
+        videoId: row.streamSn,
+        customerDomain: 'customer-pj89kn2ke2tcuh19.cloudflarestream.com',
+        isLive: false
+      }
+    }
+  }
+
+  mediaDrawerVisible.value = true
+}
+
+// 播放控制
+function handlePlay() {
+  playerRef.value?.play()
+}
+
+function handlePause() {
+  playerRef.value?.pause()
+}
+
+function handlePlayerStop() {
+  playerRef.value?.stop()
+}
+
+function handleScreenshot() {
+  playerRef.value?.screenshot()
+}
+
+function handleFullscreen() {
+  playerRef.value?.fullscreen()
+}
+
+// PTZ 控制
+async function handlePTZ(direction: string) {
+  if (!currentMediaStream.value?.cameraId) {
+    ElMessage.warning(t('未配置摄像头'))
+    return
+  }
+
+  try {
+    const actionMap: Record<string, string> = {
+      UP: 'up',
+      DOWN: 'down',
+      LEFT: 'left',
+      RIGHT: 'right',
+      UP_LEFT: 'up',
+      UP_RIGHT: 'up',
+      DOWN_LEFT: 'down',
+      DOWN_RIGHT: 'down'
+    }
+    const action = actionMap[direction] || 'stop'
+    await ptzStart(currentMediaStream.value.cameraId, action as any, ptzSpeed.value)
+  } catch (error) {
+    console.error('PTZ 控制失败', error)
+  }
+}
+
+async function handlePTZStop() {
+  if (!currentMediaStream.value?.cameraId) return
+
+  try {
+    await ptzStop(currentMediaStream.value.cameraId)
+  } catch (error) {
+    console.error('PTZ 停止失败', error)
+  }
+}
+
+// 缩放控制
+function formatZoomTooltip(val: number) {
+  if (val === 0) return t('停止')
+  return val > 0 ? `${t('放大')} ${val}` : `${t('缩小')} ${Math.abs(val)}`
+}
+
+async function handleZoomChange(val: number) {
+  if (!currentMediaStream.value?.cameraId) return
+
+  if (val === 0) {
+    await ptzStop(currentMediaStream.value.cameraId)
+    return
+  }
+
+  const action = val > 0 ? 'zoom_in' : 'zoom_out'
+  await ptzStart(currentMediaStream.value.cameraId, action as any, Math.abs(val))
+}
+
+async function handleZoomRelease() {
+  zoomValue.value = 0
+  if (!currentMediaStream.value?.cameraId) return
+  await ptzStop(currentMediaStream.value.cameraId)
+}
+
 function handleSizeChange(val: number) {
   pageSize.value = val
   currentPage.value = 1
@@ -826,4 +1152,176 @@ onMounted(() => {
     }
   }
 }
+
+// 流媒体播放抽屉样式
+.media-drawer {
+  :deep(.el-drawer__body) {
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    background-color: #f5f7fa;
+  }
+}
+
+.media-drawer-content {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 16px;
+  gap: 16px;
+}
+
+.media-drawer-header {
+  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%;
+  height: 380px;
+  border-radius: 8px;
+  overflow: hidden;
+  background-color: #000;
+
+  .player-placeholder {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    color: #909399;
+
+    p {
+      margin-top: 15px;
+      font-size: 14px;
+    }
+  }
+}
+
+.control-ptz-container {
+  display: flex;
+  gap: 16px;
+}
+
+.control-section {
+  flex: 1;
+  background-color: #fff;
+  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;
+  }
+}
+
+.ptz-panel {
+  width: 200px;
+  background-color: #fff;
+  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;
+  }
+
+  .ptz-controls {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 6px;
+  }
+
+  .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;
+
+    &:hover {
+      background-color: #ecf5ff;
+      border-color: #4f46e5;
+      color: #4f46e5;
+    }
+
+    &:active {
+      background-color: #4f46e5;
+      color: #fff;
+    }
+
+    .el-icon {
+      font-size: 18px;
+    }
+  }
+
+  .ptz-center {
+    background-color: #e5e7eb;
+
+    &:hover {
+      background-color: #ecf5ff;
+    }
+  }
+
+  .speed-control {
+    margin-top: 12px;
+    padding-top: 12px;
+    border-top: 1px solid #e5e7eb;
+
+    .control-label {
+      font-size: 12px;
+      color: #909399;
+      margin-bottom: 6px;
+    }
+  }
+
+  .zoom-controls {
+    margin-top: 12px;
+    padding-top: 12px;
+    border-top: 1px solid #e5e7eb;
+
+    .zoom-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      font-size: 12px;
+      color: #909399;
+      margin-bottom: 6px;
+
+      span {
+        flex: 1;
+        text-align: center;
+      }
+    }
+  }
+}
+
+.media-info {
+  background-color: #fff;
+  border-radius: 8px;
+  padding: 16px;
+}
 </style>

+ 61 - 32
src/views/lss/index.vue

@@ -108,7 +108,7 @@
           <template #default="{ row }">
             <el-button type="primary" link :icon="Edit" @click="handleEdit(row)" />
             <el-button type="primary" link @click="handleScanDevices(row)">
-              <Icon icon="mdi:scan" />
+              <Icon icon="mdi:radar" />
             </el-button>
             <el-button type="danger" link :icon="Delete" @click="handleDelete(row)" />
           </template>
@@ -480,19 +480,29 @@
       </template>
     </el-drawer>
 
-    <!-- 参数配置/运行参数弹窗 -->
-    <el-dialog v-model="paramsDialogVisible" :title="paramsDialogTitle" width="600px" :close-on-click-modal="false">
+    <!-- 参数配置/运行参数抽屉 -->
+    <el-drawer
+      v-model="paramsDialogVisible"
+      :title="paramsDialogTitle"
+      direction="rtl"
+      size="500px"
+      :close-on-click-modal="false"
+      destroy-on-close
+      class="params-drawer"
+    >
       <el-input
         v-model="paramsContent"
         type="textarea"
-        :rows="15"
+        :rows="20"
         :placeholder="paramsDialogType === 'config' ? '请输入参数配置(JSON 格式)' : '请输入运行参数(JSON 格式)'"
       />
       <template #footer>
-        <el-button @click="paramsDialogVisible = false">{{ t('取消') }}</el-button>
-        <el-button type="primary" :loading="paramsSubmitting" @click="handleSaveParams">{{ t('更新') }}</el-button>
+        <div class="drawer-footer">
+          <el-button @click="paramsDialogVisible = false">{{ t('取消') }}</el-button>
+          <el-button type="primary" :loading="paramsSubmitting" @click="handleSaveParams">{{ t('更新') }}</el-button>
+        </div>
       </template>
-    </el-dialog>
+    </el-drawer>
 
     <!-- 分页 -->
     <div class="pagination-container">
@@ -515,7 +525,7 @@ import { ref, reactive, onMounted, computed, watch } from 'vue'
 import { Search, RefreshRight, Delete, View, Edit, VideoCamera, Plus, QuestionFilled } from '@element-plus/icons-vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { listLssNodes, deleteLssNode, setLssNodeEnabled, updateLssNode } from '@/api/lss'
-import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera } from '@/api/camera'
+import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminGetCamera } from '@/api/camera'
 import { listCameraVendors } from '@/api/camera-vendor'
 import { Icon } from '@iconify/vue'
 import type {
@@ -790,15 +800,19 @@ async function getList() {
       page: currentPage.value,
       size: pageSize.value
     }
-    if (searchForm.keyword) {
-      params.keyword = searchForm.keyword
+
+    if (searchForm.lssId) {
+      params.lssId = searchForm.lssId
+    }
+
+    if (searchForm.lssName) {
+      params.lssName = searchForm.lssName
     }
+
     if (searchForm.status) {
       params.status = searchForm.status
     }
-    if (searchForm.enabled !== null) {
-      params.enabled = searchForm.enabled
-    }
+
     if (sortState.sortBy) {
       params.sortBy = sortState.sortBy
       params.sortDir = sortState.sortDir
@@ -996,25 +1010,40 @@ async function handleAddCamera() {
 
 async function handleEditCamera(row: CameraInfoDTO) {
   isEditCamera.value = true
-  currentCamera.value = row
-  cameraForm.selectedVendorId = null
-  cameraForm.cameraId = row.cameraId
-  cameraForm.name = row.name
-  cameraForm.ip = row.ip
-  cameraForm.port = row.port || 80
-  cameraForm.username = row.username || ''
-  cameraForm.password = ''
-  cameraForm.brand = row.brand || ''
-  cameraForm.capability = row.capability || 'switch_only'
-  cameraForm.rtspUrl = row.rtspUrl || ''
-  cameraForm.model = row.model || ''
-  cameraForm.channelNo = row.channelNo || ''
-  cameraForm.remark = row.remark || ''
-  cameraForm.enabled = row.enabled
-  cameraForm.configParams = row.configParams || ''
-  cameraForm.runParams = row.runParams || ''
-  await loadCameraVendorList()
-  cameraDialogVisible.value = true
+
+  try {
+    // 通过 API 获取摄像头详情
+    const res = await adminGetCamera(row.id)
+    if (!res.success || !res.data) {
+      ElMessage.error(res.errMessage || '获取摄像头详情失败')
+      return
+    }
+
+    const camera = res.data
+    currentCamera.value = camera
+    cameraForm.selectedVendorId = null
+    cameraForm.cameraId = camera.cameraId
+    cameraForm.name = camera.name
+    cameraForm.ip = camera.ip
+    cameraForm.port = camera.port || 80
+    cameraForm.username = camera.username || ''
+    cameraForm.password = ''
+    cameraForm.brand = camera.brand || ''
+    cameraForm.capability = camera.capability || 'switch_only'
+    cameraForm.rtspUrl = camera.rtspUrl || ''
+    cameraForm.model = camera.model || ''
+    cameraForm.channelNo = camera.channelNo || ''
+    cameraForm.remark = camera.remark || ''
+    cameraForm.enabled = camera.enabled
+    cameraForm.configParams = camera.configParams || ''
+    cameraForm.runParams = camera.runParams || ''
+
+    await loadCameraVendorList()
+    cameraDialogVisible.value = true
+  } catch (error) {
+    console.error('获取摄像头详情失败', error)
+    ElMessage.error('获取摄像头详情失败')
+  }
 }
 
 async function handleSubmitCamera() {