|
|
@@ -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>
|