|
@@ -0,0 +1,219 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="app-container video-management">
|
|
|
|
|
+ <el-form inline @submit.prevent="search">
|
|
|
|
|
+ <el-form-item label="设备名称/编码"><el-input v-model="query.query" clearable @keyup.enter="search" /></el-form-item>
|
|
|
|
|
+ <el-form-item label="接入方式"><el-select v-model="query.sourceType" clearable style="width: 150px"><el-option label="手动添加" value="MANUAL" /><el-option label="国标设备" value="GB28181" /></el-select></el-form-item>
|
|
|
|
|
+ <el-form-item><el-button type="primary" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <el-row class="toolbar" :gutter="12">
|
|
|
|
|
+ <el-col :span="1.5"><el-button v-hasPermi="['video:device:add']" type="success" plain @click="openDiscovery">发现注册设备</el-button></el-col>
|
|
|
|
|
+ </el-row>
|
|
|
|
|
+ <el-table v-loading="loading" :data="rows" row-key="videoId">
|
|
|
|
|
+ <el-table-column label="设备名称" prop="deviceName" min-width="150" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="业务设备编码" prop="deviceCode" min-width="160" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="接入方式" width="110"><template #default="{row}"><el-tag :type="row.sourceType === 'GB28181' ? 'success' : 'info'">{{ row.sourceType === 'GB28181' ? '国标' : '手动' }}</el-tag></template></el-table-column>
|
|
|
|
|
+ <el-table-column label="国标设备 / 通道" min-width="210"><template #default="{row}"><div>设备:{{ row.gbDeviceId || '—' }}</div><div v-if="row.gbChannelId">通道:{{ row.gbChannelId }}</div></template></el-table-column>
|
|
|
|
|
+ <el-table-column label="安装位置" prop="location" min-width="150" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="厂商" prop="manufacturer" width="110" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="操作" fixed="right" width="315"><template #default="{row}">
|
|
|
|
|
+ <el-button v-hasPermi="['video:device:play']" link type="primary" @click="openLive(row)">实时播放</el-button>
|
|
|
|
|
+ <el-button v-if="row.sourceType === 'GB28181'" v-hasPermi="['video:device:record']" link type="primary" @click="openRecords(row)">历史录像</el-button>
|
|
|
|
|
+ <el-button v-hasPermi="['video:device:edit']" link type="primary" @click="edit(row)">修改</el-button>
|
|
|
|
|
+ <el-button v-hasPermi="['video:device:remove']" link type="danger" @click="remove(row)">移除</el-button>
|
|
|
|
|
+ </template></el-table-column>
|
|
|
|
|
+ <template #empty><el-empty description="暂无视频设备,请从发现注册设备中纳管" /></template>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
|
|
|
|
|
+
|
|
|
|
|
+ <el-dialog v-model="editVisible" :title="form.videoId ? '修改视频设备' : '新增设备 · 纳管国标通道'" width="860px" append-to-body :close-on-click-modal="false">
|
|
|
|
|
+ <EquipmentEditorFields ref="formRef" v-model="equipmentForm" :types="videoTypeOptions" type-locked>
|
|
|
|
|
+ <div class="video-connection-title">视频接入信息</div>
|
|
|
|
|
+ <el-form-item v-if="form.sourceType === 'MANUAL'" label="视频地址"><el-input v-model="form.videoUrl" maxlength="512" placeholder="历史手动设备的视频地址" /></el-form-item>
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <el-form-item label="国标设备编号"><el-input :model-value="form.gbDeviceId" disabled /></el-form-item>
|
|
|
|
|
+ <el-form-item label="国标通道编号"><el-input :model-value="form.gbChannelId" disabled /></el-form-item>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <el-form-item label="关联场站ID"><el-input v-model="form.stationId" maxlength="64" /></el-form-item>
|
|
|
|
|
+ </EquipmentEditorFields>
|
|
|
|
|
+ <template #footer><el-button @click="editVisible = false" :disabled="saving">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <el-dialog v-model="discoveryVisible" title="发现自动注册的国标设备" width="1000px">
|
|
|
|
|
+ <el-alert title="设备编号标识摄像机或 NVR,通道编号标识具体视频通道。同步目录获取通道清单;点击纳管并保存后加入主列表。" type="info" :closable="false" />
|
|
|
|
|
+ <el-form inline class="discovery-search">
|
|
|
|
|
+ <el-form-item label="设备/通道"><el-input v-model="discoveryQuery.query" clearable @keyup.enter="discover(true)" /></el-form-item>
|
|
|
|
|
+ <el-form-item label="注册状态"><el-select v-model="discoveryQuery.online" clearable style="width: 130px"><el-option label="在线" :value="true" /><el-option label="离线" :value="false" /></el-select></el-form-item>
|
|
|
|
|
+ <el-button type="primary" @click="discover(true)">搜索</el-button><el-button @click="discover()">刷新</el-button><el-button v-hasPermi="['video:device:add']" type="success" :loading="syncingAll" @click="syncAll">同步全部在线设备目录</el-button>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <el-table v-loading="discovering" :data="discovered">
|
|
|
|
|
+ <el-table-column label="设备名称" prop="deviceName" min-width="130" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column label="国标设备编号" prop="gbDeviceId" width="205" />
|
|
|
|
|
+ <el-table-column label="通道名称 / 编号" min-width="205"><template #default="{row}"><div>{{ row.channelName || '尚无通道' }}</div><div>{{ row.gbChannelId }}</div></template></el-table-column>
|
|
|
|
|
+ <el-table-column label="地址" prop="ip" width="130" />
|
|
|
|
|
+ <el-table-column label="注册状态" width="95"><template #default="{row}"><el-tag :type="row.online ? 'success' : 'info'">{{ row.online ? '在线' : '离线' }}</el-tag></template></el-table-column>
|
|
|
|
|
+ <el-table-column label="操作" width="80"><template #default="{row}"><el-button link type="primary" :disabled="!row.gbChannelId" @click="adopt(row)">纳管</el-button></template></el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <pagination v-show="discoveredTotal > 0" :total="discoveredTotal" v-model:page="discoveryQuery.pageNum" v-model:limit="discoveryQuery.pageSize" @pagination="discover()" />
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <el-dialog v-model="viewerVisible" :title="`${selected?.deviceName || ''} · ${playbackMode ? '历史录像' : '实时播放'}`" width="1000px" destroy-on-close :close-on-click-modal="false" @close="closeViewer">
|
|
|
|
|
+ <div v-if="playbackMode" class="record-query">
|
|
|
|
|
+ <el-date-picker v-model="range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" :clearable="false" />
|
|
|
|
|
+ <el-button v-hasPermi="['video:device:record']" type="primary" :loading="queryingRecords" @click="loadRecords">查询录像</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-loading="starting" element-loading-text="正在建立视频连接">
|
|
|
|
|
+ <VideoPlayer v-if="streamUrl" :url="streamUrl" :native="nativePlayer" @error="playerError = $event" />
|
|
|
|
|
+ <el-empty v-else :description="starting ? '正在建立视频连接' : playbackMode ? '选择录像后开始回放' : playerError || '尚未开始播放'" :image-size="80" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-alert v-if="playerError" :title="playerError" type="error" :closable="false" class="play-error" />
|
|
|
|
|
+ <div class="player-actions">
|
|
|
|
|
+ <el-button v-if="!playbackMode" v-hasPermi="['video:device:play']" :loading="starting" @click="playLive">重新播放</el-button>
|
|
|
|
|
+ <el-button :disabled="!sessionId" @click="stopCurrent">停止播放</el-button>
|
|
|
|
|
+ <template v-if="playbackMode && sessionId">
|
|
|
|
|
+ <el-button @click="control(paused ? 'resume' : 'pause')">{{ paused ? '继续' : '暂停' }}</el-button>
|
|
|
|
|
+ <el-select v-model="playSpeed" style="width: 100px" @change="control('speed', $event)"><el-option v-for="speed in [0.25,0.5,1,2,4]" :key="speed" :value="speed" :label="`${speed} 倍速`" /></el-select>
|
|
|
|
|
+ <el-slider v-model="seekSeconds" :max="recordDuration" :show-tooltip="true" class="seek-bar" @change="control('seek', $event)" /><span>{{ seekSeconds }} 秒</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="!playbackMode && selected?.sourceType === 'GB28181'" v-hasPermi="['video:device:ptz']" class="ptz-panel">
|
|
|
|
|
+ <span>云台控制</span>
|
|
|
|
|
+ <div class="ptz-grid"><el-button v-for="item in directions" :key="item[0]" :disabled="!sessionId || starting" @pointerdown.prevent="beginPtz($event, item[0])" @keydown.enter.prevent="pulsePtz(item[0])">{{ item[1] }}</el-button></div>
|
|
|
|
|
+ <div><el-button :disabled="!sessionId" @pointerdown.prevent="beginPtz($event, 'zoomin')">放大</el-button><el-button :disabled="!sessionId" @pointerdown.prevent="beginPtz($event, 'zoomout')">缩小</el-button></div>
|
|
|
|
|
+ <div class="ptz-speed">速度<el-slider v-model="ptzSpeed" :min="1" :max="255" /></div>
|
|
|
|
|
+ <span class="muted">按住移动,松开停止;固定摄像机可能不支持云台。</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-table v-if="playbackMode" v-loading="queryingRecords" :data="records" max-height="260" class="record-table">
|
|
|
|
|
+ <el-table-column label="录像名称" prop="name" min-width="160" show-overflow-tooltip /><el-table-column label="开始时间" prop="startTime" width="180" /><el-table-column label="结束时间" prop="endTime" width="180" /><el-table-column label="录像类型" prop="type" width="100" />
|
|
|
|
|
+ <el-table-column label="操作" width="100"><template #default="{row}"><el-button link type="primary" :disabled="starting" @click="playRecord(row)">回放</el-button></template></el-table-column>
|
|
|
|
|
+ <template #empty><el-empty :description="recordsQueried ? '所选时间段没有设备录像' : '请先查询设备历史录像'" :image-size="60" /></template>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup name="VideoDevices">
|
|
|
|
|
+import { ref, reactive, onBeforeUnmount } from 'vue'
|
|
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
+import VideoPlayer from '@/components/VideoPlayer/index.vue'
|
|
|
|
|
+import EquipmentEditorFields from '@/components/EquipmentEditorFields/index.vue'
|
|
|
|
|
+import { listVideoDevices, getVideoDevice, saveVideoDevice, removeVideoDevice, discoverVideoDevices, syncAllVideoDevices, startLive, queryRecords, startPlayback, ptzVideo, stopVideo, heartbeatVideo, controlPlayback } from '@/api/video'
|
|
|
|
|
+const query = reactive({ pageNum: 1, pageSize: 10, query: '', sourceType: '' }), rows = ref([]), total = ref(0), loading = ref(false)
|
|
|
|
|
+const editVisible = ref(false), form = ref({}), formRef = ref(), saving = ref(false)
|
|
|
|
|
+const equipmentForm = ref({})
|
|
|
|
|
+const videoTypeOptions = [{ id: 2, typeName: '排水', disabled: true, children: [{ id: 20, typeName: '视频监控(排口+内涝点)' }] }]
|
|
|
|
|
+const discoveryVisible = ref(false), discovering = ref(false), syncingAll = ref(false), discovered = ref([]), discoveredTotal = ref(0)
|
|
|
|
|
+const discoveryQuery = reactive({ pageNum: 1, pageSize: 10, query: '', online: undefined })
|
|
|
|
|
+const viewerVisible = ref(false), selected = ref(), playbackMode = ref(false), starting = ref(false), streamUrl = ref(''), nativePlayer = ref(false), playerError = ref(''), sessionId = ref('')
|
|
|
|
|
+const range = ref([]), records = ref([]), queryingRecords = ref(false), recordsQueried = ref(false), paused = ref(false), playSpeed = ref(1), seekSeconds = ref(0), recordDuration = ref(1), ptzSpeed = ref(100)
|
|
|
|
|
+const directions = [['upleft','↖'],['up','↑'],['upright','↗'],['left','←'],['stop','停止'],['right','→'],['downleft','↙'],['down','↓'],['downright','↘']]
|
|
|
|
|
+let viewerGeneration = 0, heartbeat, activePtzDevice = null, ptzTimer
|
|
|
|
|
+async function load() { loading.value = true; try { const result = await listVideoDevices(query); rows.value = result.rows; total.value = result.total } finally { loading.value = false } }
|
|
|
|
|
+function search() { query.pageNum = 1; load() }
|
|
|
|
|
+function reset() { Object.assign(query, { pageNum: 1, query: '', sourceType: '' }); load() }
|
|
|
|
|
+async function edit(row) {
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ form.value = { ...(await getVideoDevice(row.videoId)).data }
|
|
|
|
|
+ equipmentForm.value = { ...form.value, equipmentId: form.value.videoId, equipmentTypeId: 20, currentStatus: form.value.currentStatus ?? 0 }
|
|
|
|
|
+ editVisible.value = true
|
|
|
|
|
+}
|
|
|
|
|
+async function save() {
|
|
|
|
|
+ if (saving.value) return
|
|
|
|
|
+ await formRef.value.validate()
|
|
|
|
|
+ if (form.value.sourceType === 'MANUAL' && !form.value.videoUrl?.trim()) { ElMessage.warning('请输入视频地址'); return }
|
|
|
|
|
+ saving.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ await saveVideoDevice({ ...form.value, equipment: equipmentForm.value, currentStatus: equipmentForm.value.currentStatus,
|
|
|
|
|
+ deviceName: equipmentForm.value.equipmentName, deviceCode: equipmentForm.value.equipmentCode,
|
|
|
|
|
+ location: equipmentForm.value.equipmentLocation, manufacturer: equipmentForm.value.manufacturer, remark: equipmentForm.value.remark })
|
|
|
|
|
+ editVisible.value = false; ElMessage.success('视频设备已保存'); await load()
|
|
|
|
|
+ } finally { saving.value = false }
|
|
|
|
|
+}
|
|
|
|
|
+async function remove(row) { await ElMessageBox.confirm(`确认移除“${row.deviceName}”?`, '移除视频设备', { type: 'warning' }); await removeVideoDevice(row.videoId); await load() }
|
|
|
|
|
+function openDiscovery() { discoveryVisible.value = true; discoveryQuery.pageNum = 1; discover() }
|
|
|
|
|
+async function discover(resetPage = false) { if (resetPage) discoveryQuery.pageNum = 1; discovering.value = true; try { const result = await discoverVideoDevices(discoveryQuery); discovered.value = result.data.rows; discoveredTotal.value = result.data.total } finally { discovering.value = false } }
|
|
|
|
|
+async function syncAll() {
|
|
|
|
|
+ if (syncingAll.value) return
|
|
|
|
|
+ syncingAll.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await syncAllVideoDevices()
|
|
|
|
|
+ const data = result.data || {}
|
|
|
|
|
+ const started = Number(data.started || 0)
|
|
|
|
|
+ const total = Number(data.total || 0)
|
|
|
|
|
+ if (!total) ElMessage.warning('当前没有在线注册设备')
|
|
|
|
|
+ else ElMessage.success(`已向 ${started} 台在线设备发起目录同步,请稍后点击刷新`)
|
|
|
|
|
+ await discover()
|
|
|
|
|
+ } finally { syncingAll.value = false }
|
|
|
|
|
+}
|
|
|
|
|
+function adopt(row) {
|
|
|
|
|
+ form.value = { sourceType: 'GB28181', gbDeviceId: row.gbDeviceId, gbChannelId: row.gbChannelId }
|
|
|
|
|
+ equipmentForm.value = { equipmentName: row.channelName || row.deviceName || row.gbChannelId,
|
|
|
|
|
+ equipmentCode: row.gbDeviceId + '/' + row.gbChannelId, equipmentTypeId: 20, currentStatus: 0, manufacturer: row.manufacturer }
|
|
|
|
|
+ editVisible.value = true
|
|
|
|
|
+}
|
|
|
|
|
+async function openLive(row) { selected.value = row; playbackMode.value = false; viewerVisible.value = true; playerError.value = ''; await playLive() }
|
|
|
|
|
+function dateString(date) { return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}:${String(date.getSeconds()).padStart(2,'0')}` }
|
|
|
|
|
+function openRecords(row) { selected.value = row; playbackMode.value = true; viewerVisible.value = true; records.value = []; recordsQueried.value = false; playerError.value = ''; const now = new Date(); range.value = [dateString(new Date(now.getFullYear(),now.getMonth(),now.getDate())), dateString(now)]; loadRecords() }
|
|
|
|
|
+async function stopCurrent() { clearInterval(heartbeat); heartbeat = null; streamUrl.value = ''; await endPtz(); const id = sessionId.value; if (id) { await stopVideo(id); sessionId.value = '' } }
|
|
|
|
|
+function acceptStream(data) {
|
|
|
|
|
+ sessionId.value = data.sessionId
|
|
|
|
|
+ const secure = location.protocol === 'https:'
|
|
|
|
|
+ streamUrl.value = data.url || (secure ? data.wss_flv || data.https_flv : data.ws_flv || data.flv)
|
|
|
|
|
+ nativePlayer.value = Boolean(data.native) && !/\.flv(?:[?#]|$)|^wss?:/.test(streamUrl.value || '')
|
|
|
|
|
+ if (!streamUrl.value) throw new Error(secure ? '媒体节点没有可用的 HTTPS 播放地址' : '媒体节点未返回播放地址')
|
|
|
|
|
+ heartbeat = setInterval(() => { if (sessionId.value) heartbeatVideo(sessionId.value).catch(() => { playerError.value = '播放会话已断开,请重新播放' }) }, 30000)
|
|
|
|
|
+}
|
|
|
|
|
+async function playLive() {
|
|
|
|
|
+ if (starting.value) return
|
|
|
|
|
+ const version = ++viewerGeneration; starting.value = true; playerError.value = ''
|
|
|
|
|
+ try { await stopCurrent(); const result = await startLive(selected.value.videoId); if (version !== viewerGeneration || !viewerVisible.value) { await stopVideo(result.data.sessionId); return } acceptStream(result.data) }
|
|
|
|
|
+ catch (error) { if (version === viewerGeneration) playerError.value = error.message || '实时播放失败,请检查设备注册及媒体服务' }
|
|
|
|
|
+ finally { if (version === viewerGeneration) starting.value = false }
|
|
|
|
|
+}
|
|
|
|
|
+async function loadRecords() {
|
|
|
|
|
+ if (range.value?.length !== 2 || queryingRecords.value) return
|
|
|
|
|
+ const version = viewerGeneration; queryingRecords.value = true
|
|
|
|
|
+ try { const result = await queryRecords(selected.value.videoId, { startTime: range.value[0], endTime: range.value[1] }); if (version !== viewerGeneration || !viewerVisible.value) return; records.value = result.data?.recordList || []; recordsQueried.value = true }
|
|
|
|
|
+ catch (error) { records.value = []; recordsQueried.value = false }
|
|
|
|
|
+ finally { queryingRecords.value = false }
|
|
|
|
|
+}
|
|
|
|
|
+async function playRecord(row) {
|
|
|
|
|
+ if (starting.value) return
|
|
|
|
|
+ const version = ++viewerGeneration; starting.value = true; playerError.value = ''
|
|
|
|
|
+ try { await stopCurrent(); const result = await startPlayback(selected.value.videoId, { startTime: row.startTime.replace('T',' '), endTime: row.endTime.replace('T',' ') }); if (version !== viewerGeneration || !viewerVisible.value) { await stopVideo(result.data.sessionId); return } acceptStream(result.data); paused.value = false; playSpeed.value = 1; seekSeconds.value = 0; recordDuration.value = Math.max(1, (new Date(row.endTime.replace('T',' ')) - new Date(row.startTime.replace('T',' '))) / 1000) }
|
|
|
|
|
+ catch (error) { if (version === viewerGeneration) playerError.value = error.message || '录像回放失败' }
|
|
|
|
|
+ finally { if (version === viewerGeneration) starting.value = false }
|
|
|
|
|
+}
|
|
|
|
|
+async function control(action, value) { await controlPlayback(sessionId.value, { action, value }); if (action === 'pause') paused.value = true; if (action === 'resume') paused.value = false }
|
|
|
|
|
+function beginPtz(event, command) { event.currentTarget.setPointerCapture?.(event.pointerId); pulsePtz(command, false) }
|
|
|
|
|
+async function pulsePtz(command, keyboard = true) {
|
|
|
|
|
+ if (activePtzDevice) return
|
|
|
|
|
+ if (command === 'stop') { await ptzVideo(selected.value.videoId, { command: 'stop', speed: ptzSpeed.value, zoomSpeed: 8 }); return }
|
|
|
|
|
+ activePtzDevice = selected.value.videoId
|
|
|
|
|
+ // Register release listeners before sending; a release during the request still queues STOP after movement.
|
|
|
|
|
+ activePtzPromise = ptzVideo(activePtzDevice, { command, speed: ptzSpeed.value, zoomSpeed: 8 })
|
|
|
|
|
+ window.addEventListener('pointerup', endPtz, { once: true }); window.addEventListener('pointercancel', endPtz, { once: true }); window.addEventListener('blur', endPtz, { once: true })
|
|
|
|
|
+ ptzTimer = setTimeout(endPtz, keyboard ? 250 : 5000)
|
|
|
|
|
+ try { await activePtzPromise } catch { await endPtz() }
|
|
|
|
|
+}
|
|
|
|
|
+let activePtzPromise
|
|
|
|
|
+async function endPtz() { clearTimeout(ptzTimer); window.removeEventListener('pointerup', endPtz); window.removeEventListener('pointercancel', endPtz); window.removeEventListener('blur', endPtz); const id = activePtzDevice; if (!id) return; activePtzDevice = null; await activePtzPromise.catch(() => {}); await ptzVideo(id, { command: 'stop', speed: 1, zoomSpeed: 1 }).catch(() => {}) }
|
|
|
|
|
+function closeViewer() { viewerGeneration++; starting.value = false; stopCurrent().catch(() => {}); playerError.value = '' }
|
|
|
|
|
+onBeforeUnmount(() => { viewerGeneration++; clearInterval(heartbeat); endPtz(); if (sessionId.value) stopVideo(sessionId.value).catch(() => {}) })
|
|
|
|
|
+load()
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.video-connection-title { margin: 8px 0 12px; padding-left: 10px; border-left: 3px solid #014db2; font-weight: 600; }
|
|
|
|
|
+.toolbar { margin-bottom: 14px; }
|
|
|
|
|
+.discovery-search { margin-top: 20px; }
|
|
|
|
|
+.record-query, .player-actions { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
|
|
|
|
+.player-actions { margin-top: 14px; }
|
|
|
|
|
+.seek-bar { width: 220px; margin: 0 8px; }
|
|
|
|
|
+.play-error { margin-top: 12px; }
|
|
|
|
|
+.ptz-panel { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
|
|
|
|
+.ptz-grid { display: grid; grid-template-columns: repeat(3,55px); gap: 5px; }
|
|
|
|
|
+.ptz-grid .el-button { margin: 0; }
|
|
|
|
|
+.ptz-speed { width: 150px; }
|
|
|
|
|
+.muted { color: #909399; font-size: 12px; }
|
|
|
|
|
+.record-table { margin-top: 12px; }
|
|
|
|
|
+</style>
|