index.vue 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535
  1. <template>
  2. <div class="page-container">
  3. <!-- 搜索表单 -->
  4. <div class="search-form">
  5. <el-form :model="searchForm" inline>
  6. <el-form-item>
  7. <el-input v-model.trim="searchForm.streamSn" placeholder="stream sn" clearable @keyup.enter="handleSearch" />
  8. </el-form-item>
  9. <el-form-item>
  10. <el-input v-model.trim="searchForm.name" placeholder="name" clearable @keyup.enter="handleSearch" />
  11. </el-form-item>
  12. <el-form-item>
  13. <el-select v-model="searchForm.lssId" placeholder="LSS" clearable filterable style="width: 180px">
  14. <el-option v-for="lss in lssOptions" :key="lss.lssId" :label="lss.lssId" :value="lss.lssId" />
  15. </el-select>
  16. </el-form-item>
  17. <el-form-item>
  18. <el-input v-model.trim="searchForm.cameraId" placeholder="设备ID" clearable @keyup.enter="handleSearch" />
  19. </el-form-item>
  20. <el-form-item>
  21. <el-button type="primary" :icon="Search" @click="handleSearch">{{ t('查询') }}</el-button>
  22. <el-button :icon="RefreshRight" @click="handleReset">{{ t('重置') }}</el-button>
  23. <el-button type="primary" :icon="Plus" @click="handleAdd">{{ t('新增') }}</el-button>
  24. </el-form-item>
  25. </el-form>
  26. </div>
  27. <!-- 数据表格 -->
  28. <div class="table-wrapper">
  29. <el-table
  30. ref="tableRef"
  31. v-loading="loading"
  32. :data="streamList"
  33. stripe
  34. size="default"
  35. height="100%"
  36. @sort-change="handleSortChange"
  37. >
  38. <el-table-column prop="streamSn" :label="t('stream sn')" width="220" show-overflow-tooltip>
  39. <template #default="{ row }">
  40. <el-link type="primary" @click="handleEdit(row)">{{ row.streamSn }}</el-link>
  41. </template>
  42. </el-table-column>
  43. <el-table-column prop="name" :label="t('名称')" show-overflow-tooltip>
  44. <template #default="{ row }">
  45. <span>{{ row.name }}</span>
  46. </template>
  47. </el-table-column>
  48. <el-table-column prop="lssId" :label="t('LSS')" width="160" show-overflow-tooltip>
  49. <template #default="{ row }">
  50. <span>{{ row.lssId || '-' }}</span>
  51. </template>
  52. </el-table-column>
  53. <el-table-column prop="cameraId" :label="t('设备ID')" show-overflow-tooltip>
  54. <template #default="{ row }">
  55. <span>{{ row.cameraId || '-' }}</span>
  56. </template>
  57. </el-table-column>
  58. <el-table-column prop="pushMethod" :label="t('推流方式')" align="center">
  59. <template #default="{ row }">
  60. <el-tag size="small">{{ row.pushMethod || 'ffmpeg' }}</el-tag>
  61. </template>
  62. </el-table-column>
  63. <el-table-column prop="commandTemplate" :label="t('命令模板')" align="center">
  64. <template #default="{ row }">
  65. <el-link type="primary" @click="openCommandDialog(row)">{{ t('查看') }}</el-link>
  66. </template>
  67. </el-table-column>
  68. <el-table-column :label="t('推流控制')" align="center">
  69. <template #default="{ row }">
  70. {{ row.status === '1' ? t('开启') : t('关闭') }}
  71. <el-switch
  72. :model-value="row.status === '1'"
  73. :loading="row._starting || row._stopping"
  74. inline-prompt
  75. @change="(val: boolean) => handleToggleStream(row, val)"
  76. />
  77. </template>
  78. </el-table-column>
  79. <el-table-column prop="startedAt" :label="t('启动时间')" width="160" align="center">
  80. <template #default="{ row }">
  81. {{ formatDateTime(row.startedAt) }}
  82. </template>
  83. </el-table-column>
  84. <el-table-column prop="stoppedAt" :label="t('关闭时间')" width="160" align="center">
  85. <template #default="{ row }">
  86. {{ formatDateTime(row.stoppedAt) }}
  87. </template>
  88. </el-table-column>
  89. <el-table-column :label="t('操作')" align="center" fixed="right">
  90. <template #default="{ row }">
  91. <el-button type="primary" link @click="handleEdit(row)">
  92. <Icon icon="mdi:note-edit-outline" width="20" height="20" />
  93. </el-button>
  94. <el-button type="primary" link @click="handleViewCloudflare(row)">
  95. <Icon icon="mdi:play-circle-outline" width="20" height="20" />
  96. </el-button>
  97. <el-button type="danger" link @click="handleDelete(row)">
  98. <Icon icon="mdi:delete" width="20" height="20" />
  99. </el-button>
  100. </template>
  101. </el-table-column>
  102. </el-table>
  103. </div>
  104. <!-- 分页 -->
  105. <div class="pagination-container">
  106. <el-pagination
  107. v-model:current-page="currentPage"
  108. v-model:page-size="pageSize"
  109. :page-sizes="[10, 20, 50, 100]"
  110. :total="total"
  111. layout="total, sizes, prev, pager, next, jumper"
  112. background
  113. @size-change="handleSizeChange"
  114. @current-change="handleCurrentChange"
  115. />
  116. </div>
  117. <!-- 新增/编辑抽屉 -->
  118. <el-drawer
  119. v-model="drawerVisible"
  120. direction="rtl"
  121. size="550px"
  122. :with-header="false"
  123. destroy-on-close
  124. class="stream-drawer"
  125. >
  126. <div class="drawer-content">
  127. <div class="drawer-header">{{ drawerTitle }}</div>
  128. <div class="drawer-body">
  129. <el-form ref="formRef" :model="form" :rules="rules" label-width="auto" class="stream-form">
  130. <el-form-item label="名称:" prop="name">
  131. <el-input v-model="form.name" placeholder="例如: 测试推流-001" style="width: 300px" />
  132. </el-form-item>
  133. <el-form-item label="LSS 节点:" prop="lssId">
  134. <el-select v-model="form.lssId" placeholder="请选择 LSS 节点" clearable filterable style="width: 300px">
  135. <el-option
  136. v-for="lss in lssOptions"
  137. :key="lss.lssId"
  138. :label="`${lss.lssId} - ${lss.lssName}`"
  139. :value="lss.lssId"
  140. />
  141. </el-select>
  142. </el-form-item>
  143. <el-form-item label="摄像头:" prop="cameraId">
  144. <el-select v-model="form.cameraId" placeholder="请选择摄像头" clearable filterable style="width: 300px">
  145. <el-option
  146. v-for="camera in cameraOptions"
  147. :key="camera.cameraId"
  148. :label="`${camera.cameraId} - ${camera.cameraName}`"
  149. :value="camera.cameraId"
  150. />
  151. </el-select>
  152. </el-form-item>
  153. <el-form-item label="推流方式:" prop="pushMethod">
  154. <el-select disabled v-model="form.pushMethod" placeholder="请选择" style="width: 300px">
  155. <el-option label="ffmpeg" value="ffmpeg" />
  156. </el-select>
  157. </el-form-item>
  158. <!-- <el-form-item label="超时时间:" prop="timeoutSeconds">
  159. <el-input-number v-model="form.timeoutSeconds" :min="1" :max="300" placeholder="秒" style="width: 150px" />
  160. <span style="margin-left: 8px; color: #909399">秒</span>
  161. </el-form-item> -->
  162. <el-form-item label="命令模板:" prop="commandTemplate">
  163. <div class="code-editor-wrapper">
  164. <CodeEditor
  165. v-model="form.commandTemplate"
  166. language="bash"
  167. height="200px"
  168. placeholder="#!/bin/bash&#10;# FFmpeg 命令模板,可使用 {RTSP_URL} 等变量"
  169. />
  170. </div>
  171. </el-form-item>
  172. </el-form>
  173. </div>
  174. <div class="drawer-footer">
  175. <el-button @click="drawerVisible = false">{{ t('取消') }}</el-button>
  176. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">
  177. {{ isEdit ? t('更新') : t('添加') }}
  178. </el-button>
  179. </div>
  180. </div>
  181. </el-drawer>
  182. <!-- 命令模板查看/编辑弹窗 -->
  183. <el-dialog v-model="commandDialogVisible" :title="t('命令模板')" width="800px" destroy-on-close>
  184. <CodeEditor
  185. v-model="currentCommandTemplate"
  186. language="bash"
  187. height="450px"
  188. placeholder="#!/bin/bash&#10;# FFmpeg 推流命令模板"
  189. />
  190. <template #footer>
  191. <el-button @click="commandDialogVisible = false">{{ t('关闭') }}</el-button>
  192. <el-button type="primary" :loading="commandUpdateLoading" @click="handleUpdateCommandTemplate">
  193. {{ t('更新') }}
  194. </el-button>
  195. </template>
  196. </el-dialog>
  197. <!-- 流媒体播放抽屉 -->
  198. <el-drawer
  199. v-model="mediaDrawerVisible"
  200. direction="rtl"
  201. size="90%"
  202. :with-header="false"
  203. destroy-on-close
  204. class="media-drawer"
  205. >
  206. <!-- 左上角关闭按钮 -->
  207. <div class="drawer-close-btn" @click="mediaDrawerVisible = false">
  208. <el-icon :size="20">
  209. <Close />
  210. </el-icon>
  211. </div>
  212. <div class="media-drawer-content">
  213. <!-- 左侧:视频播放区域 -->
  214. <div class="video-area">
  215. <div class="video-header">
  216. <div class="header-left">
  217. <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
  218. <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
  219. <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
  220. </div>
  221. <el-button type="danger" size="small" @click="mediaDrawerVisible = false">
  222. <Icon icon="mdi:close" width="16" height="16" />
  223. {{ t('关闭') }}
  224. </el-button>
  225. </div>
  226. <div class="player-container">
  227. <div v-if="!playbackInfo.videoId" class="player-placeholder">
  228. <el-icon :size="80" color="#666">
  229. <VideoPlay />
  230. </el-icon>
  231. <p>{{ t('暂无视频流') }}</p>
  232. </div>
  233. <VideoPlayer
  234. v-else
  235. ref="playerRef"
  236. player-type="cloudflare"
  237. :video-id="playbackInfo.videoId"
  238. :customer-domain="playbackInfo.customerDomain"
  239. :use-iframe="true"
  240. :autoplay="playConfig.autoplay"
  241. :muted="playConfig.muted"
  242. :controls="true"
  243. />
  244. </div>
  245. <!-- 底部播放控制 -->
  246. <div class="player-controls">
  247. <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
  248. <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
  249. <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
  250. <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
  251. <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
  252. <el-switch
  253. v-model="playConfig.muted"
  254. :active-text="t('静音')"
  255. :inactive-text="t('有声')"
  256. style="margin-left: 16px"
  257. />
  258. </div>
  259. </div>
  260. <!-- 右侧:PTZ 控制面板 -->
  261. <div class="control-panel">
  262. <!-- PTZ 方向控制 -->
  263. <div class="panel-section">
  264. <div class="section-title">{{ t('PTZ') }}</div>
  265. <div class="ptz-grid">
  266. <div
  267. class="ptz-btn"
  268. @mousedown="handlePTZ('UP_LEFT')"
  269. @mouseup="handlePTZStop"
  270. @mouseleave="handlePTZStop"
  271. >
  272. <el-icon>
  273. <TopLeft />
  274. </el-icon>
  275. </div>
  276. <div class="ptz-btn" @mousedown="handlePTZ('UP')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
  277. <el-icon>
  278. <Top />
  279. </el-icon>
  280. </div>
  281. <div
  282. class="ptz-btn"
  283. @mousedown="handlePTZ('UP_RIGHT')"
  284. @mouseup="handlePTZStop"
  285. @mouseleave="handlePTZStop"
  286. >
  287. <el-icon>
  288. <TopRight />
  289. </el-icon>
  290. </div>
  291. <div class="ptz-btn" @mousedown="handlePTZ('LEFT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
  292. <el-icon>
  293. <Back />
  294. </el-icon>
  295. </div>
  296. <div class="ptz-btn ptz-center" @click="handlePTZStop">
  297. <el-icon>
  298. <Refresh />
  299. </el-icon>
  300. </div>
  301. <div class="ptz-btn" @mousedown="handlePTZ('RIGHT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
  302. <el-icon>
  303. <Right />
  304. </el-icon>
  305. </div>
  306. <div
  307. class="ptz-btn"
  308. @mousedown="handlePTZ('DOWN_LEFT')"
  309. @mouseup="handlePTZStop"
  310. @mouseleave="handlePTZStop"
  311. >
  312. <el-icon>
  313. <BottomLeft />
  314. </el-icon>
  315. </div>
  316. <div class="ptz-btn" @mousedown="handlePTZ('DOWN')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
  317. <el-icon>
  318. <Bottom />
  319. </el-icon>
  320. </div>
  321. <div
  322. class="ptz-btn"
  323. @mousedown="handlePTZ('DOWN_RIGHT')"
  324. @mouseup="handlePTZStop"
  325. @mouseleave="handlePTZStop"
  326. >
  327. <el-icon>
  328. <BottomRight />
  329. </el-icon>
  330. </div>
  331. </div>
  332. <!-- 缩放按钮 -->
  333. <div class="zoom-buttons">
  334. <el-button size="small" @mousedown="handleZoomIn" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
  335. <el-icon>
  336. <ZoomIn />
  337. </el-icon>
  338. </el-button>
  339. <el-button size="small" @mousedown="handleZoomOut" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
  340. <el-icon>
  341. <ZoomOut />
  342. </el-icon>
  343. </el-button>
  344. </div>
  345. <!-- 速度滑块 -->
  346. <div class="speed-slider">
  347. <span class="label">{{ t('速度') }}</span>
  348. <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" size="small" />
  349. <span class="value">{{ ptzSpeed }}</span>
  350. </div>
  351. </div>
  352. <!-- 预置位列表 -->
  353. <div class="panel-section preset-section">
  354. <div class="section-title">
  355. <span>{{ t('预置位') }}</span>
  356. <el-button type="primary" link size="small" @click="loadPresets" :loading="presetsLoading">
  357. <el-icon>
  358. <Refresh />
  359. </el-icon>
  360. </el-button>
  361. </div>
  362. <div class="preset-list" v-loading="presetsLoading">
  363. <div
  364. v-for="preset in presetList"
  365. :key="preset.token"
  366. :class="['preset-item', { active: activePresetToken === preset.token }]"
  367. @click="handleGotoPreset(preset)"
  368. >
  369. <span class="preset-index">{{ preset.token }}</span>
  370. <span class="preset-name">{{ preset.name || `Preset ${preset.token}` }}</span>
  371. </div>
  372. <el-empty
  373. v-if="!presetsLoading && presetList.length === 0"
  374. :description="t('暂无预置位')"
  375. :image-size="60"
  376. />
  377. </div>
  378. </div>
  379. </div>
  380. </div>
  381. </el-drawer>
  382. </div>
  383. </template>
  384. <script setup lang="ts">
  385. import { ref, reactive, onMounted, computed, watch } from 'vue'
  386. import { useRoute, useRouter } from 'vue-router'
  387. import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
  388. import {
  389. Search,
  390. RefreshRight,
  391. Plus,
  392. VideoPlay,
  393. Top,
  394. Bottom,
  395. Back,
  396. Right,
  397. TopLeft,
  398. TopRight,
  399. BottomLeft,
  400. BottomRight,
  401. Refresh,
  402. ZoomIn,
  403. ZoomOut,
  404. Close
  405. } from '@element-plus/icons-vue'
  406. import { Icon } from '@iconify/vue'
  407. import dayjs from 'dayjs'
  408. import { useI18n } from 'vue-i18n'
  409. import { listLiveStreams, addLiveStream, updateLiveStream, deleteLiveStream } from '@/api/live-stream'
  410. import { listAllLssNodes } from '@/api/lss'
  411. import { adminListCameras } from '@/api/camera'
  412. import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
  413. import VideoPlayer from '@/components/VideoPlayer.vue'
  414. import CodeEditor from '@/components/CodeEditor.vue'
  415. import { ptzStart, ptzStop, getPresets, gotoPreset, type PresetInfo } from '@/api/camera'
  416. import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
  417. const { t } = useI18n({ useScope: 'global' })
  418. const route = useRoute()
  419. const router = useRouter()
  420. // 格式化时间
  421. function formatDateTime(dateStr: string | undefined): string {
  422. if (!dateStr) return '-'
  423. return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss')
  424. }
  425. const loading = ref(false)
  426. const submitLoading = ref(false)
  427. const streamList = ref<LiveStreamDTO[]>([])
  428. const drawerVisible = ref(false)
  429. const formRef = ref<FormInstance>()
  430. // 命令模板弹窗
  431. const commandDialogVisible = ref(false)
  432. const currentCommandTemplate = ref('')
  433. const currentStreamId = ref<number | null>(null)
  434. const commandUpdateLoading = ref(false)
  435. // 流媒体播放抽屉
  436. const mediaDrawerVisible = ref(false)
  437. const currentMediaStream = ref<LiveStreamDTO | null>(null)
  438. const playerRef = ref<InstanceType<typeof VideoPlayer>>()
  439. const playbackInfo = ref<{
  440. videoId: string
  441. customerDomain: string
  442. hlsUrl?: string
  443. whepUrl?: string
  444. isLive: boolean
  445. }>({
  446. videoId: '',
  447. customerDomain: '',
  448. isLive: false
  449. })
  450. const playConfig = reactive({
  451. autoplay: true,
  452. muted: true
  453. })
  454. // PTZ 控制
  455. const ptzSpeed = ref(50)
  456. const zoomValue = ref(0)
  457. // 预置位
  458. const presetList = ref<PresetInfo[]>([])
  459. const presetsLoading = ref(false)
  460. const activePresetToken = ref<string | null>(null)
  461. // 下拉选项
  462. const lssOptions = ref<LssNodeDTO[]>([])
  463. const cameraOptions = ref<CameraInfoDTO[]>([])
  464. const channelOptions = ref<StreamChannelDTO[]>([])
  465. // 排序状态
  466. const sortState = reactive<{
  467. prop: string
  468. order: 'ascending' | 'descending' | null
  469. }>({
  470. prop: '',
  471. order: null
  472. })
  473. // 搜索表单
  474. const searchForm = reactive<{
  475. streamSn: string
  476. name: string
  477. lssId: string
  478. cameraId: string
  479. }>({
  480. streamSn: '',
  481. name: '',
  482. lssId: '',
  483. cameraId: ''
  484. })
  485. // 分页相关
  486. const currentPage = ref(1)
  487. const pageSize = ref(20)
  488. const total = ref(0)
  489. // 表单数据
  490. const form = reactive<{
  491. id?: number
  492. name: string
  493. lssId: string
  494. cameraId: string
  495. channelId?: number
  496. pushMethod: string
  497. commandTemplate: string
  498. timeoutSeconds: number
  499. remark: string
  500. enabled: boolean
  501. }>({
  502. name: '',
  503. lssId: '',
  504. cameraId: '',
  505. channelId: undefined,
  506. pushMethod: 'ffmpeg',
  507. commandTemplate: '',
  508. timeoutSeconds: 30,
  509. remark: '',
  510. enabled: true
  511. })
  512. const isEdit = computed(() => !!form.id)
  513. const drawerTitle = computed(() => (isEdit.value ? t('编辑 Live Stream') : t('新增 Live Stream')))
  514. const rules: FormRules = {
  515. name: [{ required: true, message: t('请输入名称'), trigger: 'blur' }],
  516. lssId: [{ required: true, message: t('请选择 LSS 节点'), trigger: 'change' }]
  517. }
  518. async function getList() {
  519. loading.value = true
  520. try {
  521. const params: Record<string, any> = {
  522. page: currentPage.value,
  523. size: pageSize.value
  524. }
  525. if (searchForm.streamSn) {
  526. params.streamSn = searchForm.streamSn
  527. }
  528. if (searchForm.name) {
  529. params.name = searchForm.name
  530. }
  531. if (searchForm.lssId) {
  532. params.lssId = searchForm.lssId
  533. }
  534. if (searchForm.cameraId) {
  535. params.cameraId = searchForm.cameraId
  536. }
  537. if (sortState.prop && sortState.order) {
  538. params.sortBy = sortState.prop
  539. params.sortDir = sortState.order === 'ascending' ? 'ASC' : 'DESC'
  540. }
  541. const res = await listLiveStreams(params)
  542. if (res.success) {
  543. streamList.value = res.data.list
  544. total.value = res.data.total || 0
  545. }
  546. } finally {
  547. loading.value = false
  548. }
  549. }
  550. async function loadOptions() {
  551. try {
  552. // 获取 LSS 节点列表
  553. const lssRes = await listAllLssNodes()
  554. if (lssRes.success && lssRes.data) {
  555. lssOptions.value = lssRes.data || []
  556. }
  557. } catch (error) {
  558. console.error('加载选项失败', error)
  559. }
  560. }
  561. // 监听 LSS 节点变化,加载对应的摄像头列表
  562. watch(
  563. () => form.lssId,
  564. async (newLssId) => {
  565. // 清空当前选中的摄像头
  566. form.cameraId = ''
  567. cameraOptions.value = []
  568. if (newLssId) {
  569. try {
  570. const res = await adminListCameras({ lssId: newLssId, size: 1000 })
  571. if (res.success && res.data) {
  572. cameraOptions.value = res.data.list || []
  573. }
  574. } catch (error) {
  575. console.error('加载摄像头列表失败', error)
  576. }
  577. }
  578. }
  579. )
  580. function handleSearch() {
  581. currentPage.value = 1
  582. getList()
  583. }
  584. function handleReset() {
  585. searchForm.streamSn = ''
  586. searchForm.name = ''
  587. searchForm.lssId = ''
  588. searchForm.cameraId = ''
  589. currentPage.value = 1
  590. sortState.prop = ''
  591. sortState.order = null
  592. getList()
  593. }
  594. function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
  595. sortState.prop = prop || ''
  596. sortState.order = order
  597. getList()
  598. }
  599. function handleAdd() {
  600. Object.assign(form, {
  601. id: undefined,
  602. name: '',
  603. lssId: '',
  604. cameraId: '',
  605. channelId: undefined,
  606. pushMethod: 'ffmpeg',
  607. commandTemplate: '',
  608. timeoutSeconds: 30,
  609. remark: '',
  610. enabled: true
  611. })
  612. drawerVisible.value = true
  613. }
  614. function handleEdit(row: LiveStreamDTO) {
  615. Object.assign(form, {
  616. id: row.id,
  617. name: row.name,
  618. lssId: row.lssId || '',
  619. cameraId: row.cameraId || '',
  620. channelId: row.channelId,
  621. pushMethod: row.pushMethod || 'ffmpeg',
  622. commandTemplate: row.commandTemplate || '',
  623. timeoutSeconds: row.timeoutSeconds || 30,
  624. remark: row.remark || '',
  625. enabled: row.enabled
  626. })
  627. drawerVisible.value = true
  628. }
  629. async function handleDelete(row: LiveStreamDTO) {
  630. // 检查 status 必须为 0(已停止)
  631. if (row.status !== '0') {
  632. ElMessage.warning(t('只能删除已停止的 Live Stream'))
  633. return
  634. }
  635. // // 检查 stoppedAt 必须超过 24 小时
  636. // if (row.stoppedAt) {
  637. // const stoppedTime = dayjs(row.stoppedAt)
  638. // const hoursDiff = dayjs().diff(stoppedTime, 'hour')
  639. // if (hoursDiff < 24) {
  640. // ElMessage.warning(t('停止时间未超过24小时,暂时无法删除'))
  641. // return
  642. // }
  643. // } else {
  644. // // 没有 stoppedAt 时间,不允许删除
  645. // ElMessage.warning(t('停止时间未超过24小时,暂时无法删除'))
  646. // return
  647. // }
  648. try {
  649. await ElMessageBox.confirm(t('确定要删除该 Live Stream 吗?'), t('提示'), {
  650. type: 'warning',
  651. confirmButtonText: t('确定'),
  652. cancelButtonText: t('取消')
  653. })
  654. const res = await deleteLiveStream(row.id)
  655. if (res.success) {
  656. ElMessage.success(t('删除成功'))
  657. getList()
  658. } else {
  659. ElMessage.error(res.errMessage || t('删除失败'))
  660. }
  661. } catch {
  662. // 用户取消
  663. }
  664. }
  665. async function handleSubmit() {
  666. if (!formRef.value) return
  667. await formRef.value.validate(async (valid) => {
  668. if (valid) {
  669. submitLoading.value = true
  670. try {
  671. if (isEdit.value) {
  672. const res = await updateLiveStream({
  673. id: form.id!,
  674. name: form.name,
  675. lssId: form.lssId || undefined,
  676. cameraId: form.cameraId || undefined,
  677. channelId: form.channelId,
  678. pushMethod: form.pushMethod || undefined,
  679. commandTemplate: form.commandTemplate || undefined,
  680. timeoutSeconds: form.timeoutSeconds,
  681. remark: form.remark || undefined,
  682. enabled: form.enabled
  683. })
  684. if (res.success) {
  685. ElMessage.success(t('修改成功'))
  686. drawerVisible.value = false
  687. getList()
  688. } else {
  689. ElMessage.error(res.errMessage || t('修改失败'))
  690. }
  691. } else {
  692. const res = await addLiveStream({
  693. name: form.name,
  694. lssId: form.lssId,
  695. cameraId: form.cameraId,
  696. channelId: form.channelId,
  697. pushMethod: form.pushMethod,
  698. commandTemplate: form.commandTemplate,
  699. timeoutSeconds: form.timeoutSeconds,
  700. remark: form.remark
  701. })
  702. if (res.success) {
  703. ElMessage.success(t('新增成功'))
  704. drawerVisible.value = false
  705. // 清除 URL 上的 lssId 和 action 参数
  706. if (route.query.action || route.query.lssId) {
  707. const newQuery = { ...route.query }
  708. delete newQuery.lssId
  709. delete newQuery.action
  710. router.replace({ path: '/live-stream', query: newQuery })
  711. }
  712. getList()
  713. } else {
  714. ElMessage.error(res.errMessage || t('新增失败'))
  715. }
  716. }
  717. } finally {
  718. submitLoading.value = false
  719. }
  720. }
  721. })
  722. }
  723. // 打开命令模板弹窗
  724. function openCommandDialog(row: LiveStreamDTO) {
  725. currentStreamId.value = row.id
  726. currentCommandTemplate.value = row.commandTemplate || ''
  727. commandDialogVisible.value = true
  728. }
  729. // 更新命令模板
  730. async function handleUpdateCommandTemplate() {
  731. if (!currentStreamId.value) return
  732. commandUpdateLoading.value = true
  733. try {
  734. const res = await updateLiveStream({
  735. id: currentStreamId.value,
  736. commandTemplate: currentCommandTemplate.value
  737. })
  738. if (res.success) {
  739. ElMessage.success(t('更新成功'))
  740. commandDialogVisible.value = false
  741. getList()
  742. } else {
  743. ElMessage.error(res.errMessage || t('更新失败'))
  744. }
  745. } catch (error) {
  746. console.error('更新命令模板失败', error)
  747. ElMessage.error(t('更新失败'))
  748. } finally {
  749. commandUpdateLoading.value = false
  750. }
  751. }
  752. // 切换推流状态
  753. async function handleToggleStream(row: LiveStreamDTO, val: boolean) {
  754. if (val) {
  755. await handleStartStream(row)
  756. } else {
  757. await handleStopStream(row)
  758. }
  759. }
  760. // 启动推流
  761. async function handleStartStream(row: LiveStreamDTO) {
  762. if (!row.cameraId) {
  763. ElMessage.warning(t('请先配置摄像头'))
  764. return
  765. }
  766. row._starting = true
  767. try {
  768. const res = await startStreamTask({
  769. name: row.name,
  770. lssId: row.lssId,
  771. cameraId: row.cameraId
  772. })
  773. if (res.success) {
  774. ElMessage.success(t('推流任务已启动'))
  775. getList()
  776. } else {
  777. ElMessage.error(res.errMessage || t('启动失败'))
  778. }
  779. } catch (error) {
  780. console.error('启动推流失败', error)
  781. ElMessage.error(t('启动推流失败'))
  782. } finally {
  783. row._starting = false
  784. }
  785. }
  786. // 停止推流
  787. async function handleStopStream(row: LiveStreamDTO) {
  788. try {
  789. await ElMessageBox.confirm(t('确定要停止该推流任务吗?'), t('提示'), {
  790. type: 'warning',
  791. confirmButtonText: t('确定'),
  792. cancelButtonText: t('取消')
  793. })
  794. row._stopping = true
  795. const res = await stopStreamTask({ taskId: row.taskStreamSn, lssId: row.lssId })
  796. if (res.success) {
  797. ElMessage.success(t('推流任务已停止'))
  798. getList()
  799. } else {
  800. ElMessage.error(res.errMessage || t('停止失败'))
  801. }
  802. } catch (error) {
  803. if (error !== 'cancel') {
  804. console.error('停止推流失败', error)
  805. ElMessage.error(t('停止推流失败'))
  806. }
  807. } finally {
  808. row._stopping = false
  809. }
  810. }
  811. // 从 playbackUrl 解析 videoId 和 customerDomain
  812. function parsePlaybackUrl(playbackUrl: string): { videoId: string; customerDomain: string } | null {
  813. try {
  814. const url = new URL(playbackUrl)
  815. const customerDomain = url.hostname
  816. // URL 格式: https://domain/{videoId}/webRTC/play 或 https://domain/{videoId}/iframe
  817. const pathParts = url.pathname.split('/').filter(Boolean)
  818. if (pathParts.length > 0) {
  819. return { videoId: pathParts[0], customerDomain }
  820. }
  821. } catch (e) {
  822. console.error('解析 playbackUrl 失败', e)
  823. }
  824. return null
  825. }
  826. // 查看流媒体
  827. async function handleViewCloudflare(row: LiveStreamDTO) {
  828. currentMediaStream.value = row
  829. // 默认值
  830. let videoId = ''
  831. let customerDomain = 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
  832. // 优先从 playbackUrl 解析
  833. if (row.playbackUrl) {
  834. const parsed = parsePlaybackUrl(row.playbackUrl)
  835. if (parsed) {
  836. videoId = parsed.videoId
  837. customerDomain = parsed.customerDomain
  838. }
  839. }
  840. // 尝试获取播放信息
  841. if (row.streamSn) {
  842. try {
  843. const res = await getStreamPlayback(row.streamSn)
  844. if (res.success && res.data) {
  845. playbackInfo.value = {
  846. videoId: videoId || row.streamSn,
  847. customerDomain,
  848. hlsUrl: res.data.hlsUrl,
  849. whepUrl: res.data.whepUrl,
  850. isLive: res.data.isLive
  851. }
  852. } else {
  853. playbackInfo.value = {
  854. videoId: videoId || row.streamSn,
  855. customerDomain,
  856. isLive: false
  857. }
  858. }
  859. } catch (error) {
  860. console.error('获取播放信息失败', error)
  861. playbackInfo.value = {
  862. videoId: videoId || row.streamSn,
  863. customerDomain,
  864. isLive: false
  865. }
  866. }
  867. } else if (videoId) {
  868. playbackInfo.value = {
  869. videoId,
  870. customerDomain,
  871. isLive: false
  872. }
  873. }
  874. mediaDrawerVisible.value = true
  875. }
  876. // 播放控制
  877. function handlePlay() {
  878. playerRef.value?.play()
  879. }
  880. function handlePause() {
  881. playerRef.value?.pause()
  882. }
  883. function handlePlayerStop() {
  884. playerRef.value?.stop()
  885. }
  886. function handleScreenshot() {
  887. playerRef.value?.screenshot()
  888. }
  889. function handleFullscreen() {
  890. playerRef.value?.fullscreen()
  891. }
  892. // PTZ 控制
  893. async function handlePTZ(direction: string) {
  894. if (!currentMediaStream.value?.cameraId) {
  895. ElMessage.warning(t('未配置摄像头'))
  896. return
  897. }
  898. try {
  899. const actionMap: Record<string, string> = {
  900. UP: 'up',
  901. DOWN: 'down',
  902. LEFT: 'left',
  903. RIGHT: 'right',
  904. UP_LEFT: 'up',
  905. UP_RIGHT: 'up',
  906. DOWN_LEFT: 'down',
  907. DOWN_RIGHT: 'down'
  908. }
  909. const action = actionMap[direction] || 'stop'
  910. await ptzStart(currentMediaStream.value.cameraId, action as any, ptzSpeed.value)
  911. } catch (error) {
  912. console.error('PTZ 控制失败', error)
  913. }
  914. }
  915. async function handlePTZStop() {
  916. if (!currentMediaStream.value?.cameraId) return
  917. try {
  918. await ptzStop(currentMediaStream.value.cameraId)
  919. } catch (error) {
  920. console.error('PTZ 停止失败', error)
  921. }
  922. }
  923. // 缩放控制
  924. function formatZoomTooltip(val: number) {
  925. if (val === 0) return t('停止')
  926. return val > 0 ? `${t('放大')} ${val}` : `${t('缩小')} ${Math.abs(val)}`
  927. }
  928. async function handleZoomChange(val: number) {
  929. if (!currentMediaStream.value?.cameraId) return
  930. if (val === 0) {
  931. await ptzStop(currentMediaStream.value.cameraId)
  932. return
  933. }
  934. const action = val > 0 ? 'zoom_in' : 'zoom_out'
  935. await ptzStart(currentMediaStream.value.cameraId, action as any, Math.abs(val))
  936. }
  937. async function handleZoomRelease() {
  938. zoomValue.value = 0
  939. if (!currentMediaStream.value?.cameraId) return
  940. await ptzStop(currentMediaStream.value.cameraId)
  941. }
  942. // 缩放按钮控制
  943. async function handleZoomIn() {
  944. if (!currentMediaStream.value?.cameraId) {
  945. ElMessage.warning(t('未配置摄像头'))
  946. return
  947. }
  948. try {
  949. await ptzStart(currentMediaStream.value.cameraId, 'zoom_in' as any, ptzSpeed.value)
  950. } catch (error) {
  951. console.error('Zoom in 失败', error)
  952. }
  953. }
  954. async function handleZoomOut() {
  955. if (!currentMediaStream.value?.cameraId) {
  956. ElMessage.warning(t('未配置摄像头'))
  957. return
  958. }
  959. try {
  960. await ptzStart(currentMediaStream.value.cameraId, 'zoom_out' as any, ptzSpeed.value)
  961. } catch (error) {
  962. console.error('Zoom out 失败', error)
  963. }
  964. }
  965. // 加载预置位列表
  966. async function loadPresets() {
  967. if (!currentMediaStream.value?.cameraId) {
  968. ElMessage.warning(t('未配置摄像头'))
  969. return
  970. }
  971. presetsLoading.value = true
  972. try {
  973. const res = await getPresets(currentMediaStream.value.cameraId)
  974. if (res.success && res.data) {
  975. presetList.value = res.data
  976. } else {
  977. presetList.value = []
  978. }
  979. } catch (error) {
  980. console.error('加载预置位失败', error)
  981. presetList.value = []
  982. } finally {
  983. presetsLoading.value = false
  984. }
  985. }
  986. // 跳转到预置位
  987. async function handleGotoPreset(preset: PresetInfo) {
  988. if (!currentMediaStream.value?.cameraId) {
  989. ElMessage.warning(t('未配置摄像头'))
  990. return
  991. }
  992. try {
  993. activePresetToken.value = preset.token
  994. const res = await gotoPreset(currentMediaStream.value.cameraId, preset.token)
  995. if (res.success) {
  996. ElMessage.success(`${t('已跳转到预置位')}: ${preset.name || preset.token}`)
  997. } else {
  998. ElMessage.error(res.errMessage || t('跳转失败'))
  999. }
  1000. } catch (error) {
  1001. console.error('跳转预置位失败', error)
  1002. ElMessage.error(t('跳转失败'))
  1003. }
  1004. }
  1005. function handleSizeChange(val: number) {
  1006. pageSize.value = val
  1007. currentPage.value = 1
  1008. getList()
  1009. }
  1010. function handleCurrentChange(val: number) {
  1011. currentPage.value = val
  1012. getList()
  1013. }
  1014. onMounted(async () => {
  1015. // 读取 URL 查询参数
  1016. const queryCameraId = route.query.cameraId as string
  1017. const queryAction = route.query.action as string
  1018. if (queryCameraId) {
  1019. searchForm.cameraId = queryCameraId
  1020. }
  1021. // 先加载选项数据
  1022. await loadOptions()
  1023. getList()
  1024. // 如果是创建操作,自动打开新增抽屉
  1025. if (queryAction === 'create') {
  1026. handleAdd()
  1027. // 如果提供了 cameraId,尝试查找摄像头信息以自动填充 lssId
  1028. if (queryCameraId) {
  1029. try {
  1030. const res = await adminListCameras({ cameraId: queryCameraId, size: 1 })
  1031. if (res.success && res.data?.list?.length > 0) {
  1032. const camera = res.data.list[0]
  1033. if (camera.lssId) {
  1034. form.lssId = camera.lssId
  1035. // lssId 变化会触发 watch,加载该 LSS 下的摄像头列表
  1036. // 等待摄像头列表加载完成后再设置 cameraId
  1037. setTimeout(() => {
  1038. form.cameraId = queryCameraId
  1039. }, 500)
  1040. }
  1041. }
  1042. } catch (error) {
  1043. console.error('获取摄像头信息失败', error)
  1044. }
  1045. }
  1046. }
  1047. })
  1048. </script>
  1049. <style lang="scss" scoped>
  1050. .page-container {
  1051. display: flex;
  1052. flex-direction: column;
  1053. box-sizing: border-box;
  1054. }
  1055. .code-editor-wrapper {
  1056. }
  1057. .search-form {
  1058. flex-shrink: 0;
  1059. margin-bottom: 16px;
  1060. padding: 16px 16px 4px 16px;
  1061. background: #f5f7fa;
  1062. :deep(.el-form-item) {
  1063. margin-bottom: 12px;
  1064. margin-right: 16px;
  1065. }
  1066. :deep(.el-input),
  1067. :deep(.el-select) {
  1068. width: 180px;
  1069. }
  1070. }
  1071. .table-wrapper {
  1072. flex: 1;
  1073. min-height: 0;
  1074. overflow: hidden;
  1075. }
  1076. .pagination-container {
  1077. flex-shrink: 0;
  1078. display: flex;
  1079. justify-content: flex-end;
  1080. padding-top: 16px;
  1081. }
  1082. :deep(.el-table) {
  1083. --el-table-row-hover-bg-color: #f0f0ff;
  1084. .el-table__row--striped td.el-table__cell {
  1085. background-color: #f8f9fc;
  1086. }
  1087. .el-table__header th {
  1088. background-color: #f5f7fa;
  1089. color: #333;
  1090. font-weight: 600;
  1091. }
  1092. }
  1093. // 抽屉样式
  1094. .stream-drawer {
  1095. :deep(.el-drawer__body) {
  1096. padding: 0;
  1097. display: flex;
  1098. flex-direction: column;
  1099. height: 100%;
  1100. }
  1101. }
  1102. .drawer-content {
  1103. display: flex;
  1104. flex-direction: column;
  1105. height: 100%;
  1106. }
  1107. .drawer-header {
  1108. flex-shrink: 0;
  1109. padding: 16px 20px;
  1110. font-size: 16px;
  1111. font-weight: 500;
  1112. color: #303133;
  1113. border-bottom: 1px solid #e5e7eb;
  1114. }
  1115. .drawer-body {
  1116. flex: 1;
  1117. overflow-y: auto;
  1118. padding: 20px;
  1119. }
  1120. .stream-form {
  1121. :deep(.el-form-item) {
  1122. margin-bottom: 18px;
  1123. }
  1124. :deep(.el-form-item__label) {
  1125. color: #606266;
  1126. font-size: 14px;
  1127. }
  1128. }
  1129. .drawer-footer {
  1130. flex-shrink: 0;
  1131. display: flex;
  1132. justify-content: flex-end;
  1133. padding: 12px 20px;
  1134. border-top: 1px solid #e5e7eb;
  1135. gap: 12px;
  1136. }
  1137. // 流媒体播放抽屉样式
  1138. .media-drawer {
  1139. :deep(.el-drawer__body) {
  1140. padding: 0;
  1141. display: flex;
  1142. flex-direction: column;
  1143. height: 100%;
  1144. background-color: #f5f7fa;
  1145. position: relative;
  1146. }
  1147. }
  1148. .drawer-close-btn {
  1149. position: absolute;
  1150. top: 8px;
  1151. left: 8px;
  1152. width: 32px;
  1153. height: 32px;
  1154. display: flex;
  1155. align-items: center;
  1156. justify-content: center;
  1157. // background-color: rgba(0, 0, 0, 0.5);
  1158. // border-radius: 50%;
  1159. cursor: pointer;
  1160. z-index: 10;
  1161. color: #303133;
  1162. transition: all 0.2s;
  1163. // &:hover {
  1164. // background-color: rgba(0, 0, 0, 0.7);
  1165. // }
  1166. }
  1167. .media-drawer-content {
  1168. display: flex;
  1169. height: 100%;
  1170. padding: 16px;
  1171. gap: 16px;
  1172. overflow: hidden;
  1173. }
  1174. // 左侧视频区域
  1175. .video-area {
  1176. flex: 1;
  1177. display: flex;
  1178. flex-direction: column;
  1179. min-width: 0;
  1180. background-color: #fff;
  1181. border-radius: 8px;
  1182. overflow: hidden;
  1183. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  1184. .video-header {
  1185. display: flex;
  1186. align-items: center;
  1187. justify-content: space-between;
  1188. padding: 12px 16px;
  1189. background-color: #fff;
  1190. border-bottom: 1px solid #e5e7eb;
  1191. .header-left {
  1192. display: flex;
  1193. align-items: center;
  1194. gap: 12px;
  1195. }
  1196. .title {
  1197. font-size: 16px;
  1198. font-weight: 600;
  1199. color: #303133;
  1200. }
  1201. }
  1202. .player-container {
  1203. flex: 1;
  1204. min-height: 0;
  1205. background-color: #000;
  1206. display: flex;
  1207. align-items: center;
  1208. justify-content: center;
  1209. .player-placeholder {
  1210. display: flex;
  1211. flex-direction: column;
  1212. align-items: center;
  1213. justify-content: center;
  1214. color: #909399;
  1215. p {
  1216. margin-top: 15px;
  1217. font-size: 14px;
  1218. }
  1219. }
  1220. }
  1221. .player-controls {
  1222. display: flex;
  1223. align-items: center;
  1224. gap: 8px;
  1225. padding: 12px 16px;
  1226. background-color: #fff;
  1227. border-top: 1px solid #e5e7eb;
  1228. }
  1229. }
  1230. // 右侧控制面板
  1231. .control-panel {
  1232. width: 280px;
  1233. flex-shrink: 0;
  1234. display: flex;
  1235. flex-direction: column;
  1236. gap: 16px;
  1237. overflow-y: auto;
  1238. .panel-section {
  1239. background-color: #fff;
  1240. border-radius: 8px;
  1241. padding: 16px;
  1242. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  1243. .section-title {
  1244. display: flex;
  1245. align-items: center;
  1246. justify-content: space-between;
  1247. font-size: 14px;
  1248. font-weight: 600;
  1249. color: #303133;
  1250. margin-bottom: 12px;
  1251. padding-bottom: 8px;
  1252. border-bottom: 1px solid #e5e7eb;
  1253. }
  1254. }
  1255. }
  1256. // PTZ 方向控制网格
  1257. .ptz-grid {
  1258. display: grid;
  1259. grid-template-columns: repeat(3, 1fr);
  1260. gap: 6px;
  1261. margin-bottom: 12px;
  1262. }
  1263. .ptz-btn {
  1264. aspect-ratio: 1;
  1265. display: flex;
  1266. align-items: center;
  1267. justify-content: center;
  1268. background-color: #f5f7fa;
  1269. border: 1px solid #dcdfe6;
  1270. border-radius: 6px;
  1271. cursor: pointer;
  1272. transition: all 0.2s;
  1273. color: #606266;
  1274. &:hover {
  1275. background-color: #ecf5ff;
  1276. border-color: #4f46e5;
  1277. color: #4f46e5;
  1278. }
  1279. &:active {
  1280. background-color: #4f46e5;
  1281. color: #fff;
  1282. }
  1283. .el-icon {
  1284. font-size: 18px;
  1285. }
  1286. &.ptz-center {
  1287. background-color: #e5e7eb;
  1288. &:hover {
  1289. background-color: #4f46e5;
  1290. color: #fff;
  1291. }
  1292. }
  1293. }
  1294. // 缩放按钮
  1295. .zoom-buttons {
  1296. display: flex;
  1297. justify-content: center;
  1298. gap: 12px;
  1299. margin-bottom: 12px;
  1300. .el-button {
  1301. background-color: #f5f7fa;
  1302. border-color: #dcdfe6;
  1303. color: #606266;
  1304. &:hover {
  1305. background-color: #ecf5ff;
  1306. border-color: #4f46e5;
  1307. color: #4f46e5;
  1308. }
  1309. }
  1310. }
  1311. // 速度滑块
  1312. .speed-slider {
  1313. display: flex;
  1314. align-items: center;
  1315. gap: 12px;
  1316. .label {
  1317. font-size: 12px;
  1318. color: #606266;
  1319. flex-shrink: 0;
  1320. }
  1321. .value {
  1322. font-size: 12px;
  1323. color: #4f46e5;
  1324. width: 30px;
  1325. text-align: right;
  1326. }
  1327. :deep(.el-slider) {
  1328. flex: 1;
  1329. }
  1330. }
  1331. // 预置位区域
  1332. .preset-section {
  1333. flex: 1;
  1334. min-height: 0;
  1335. display: flex;
  1336. flex-direction: column;
  1337. .preset-list {
  1338. flex: 1;
  1339. overflow-y: auto;
  1340. display: flex;
  1341. flex-direction: column;
  1342. gap: 6px;
  1343. max-height: 300px;
  1344. }
  1345. .preset-item {
  1346. display: flex;
  1347. align-items: center;
  1348. gap: 10px;
  1349. padding: 10px 12px;
  1350. background-color: #f5f7fa;
  1351. border: 1px solid #dcdfe6;
  1352. border-radius: 6px;
  1353. cursor: pointer;
  1354. transition: all 0.2s;
  1355. &:hover {
  1356. background-color: #ecf5ff;
  1357. border-color: #4f46e5;
  1358. }
  1359. &.active {
  1360. background-color: #4f46e5;
  1361. border-color: #4f46e5;
  1362. .preset-index,
  1363. .preset-name {
  1364. color: #fff;
  1365. }
  1366. .preset-index {
  1367. background-color: rgba(255, 255, 255, 0.2);
  1368. }
  1369. }
  1370. .preset-index {
  1371. width: 28px;
  1372. height: 28px;
  1373. display: flex;
  1374. align-items: center;
  1375. justify-content: center;
  1376. background-color: #e5e7eb;
  1377. border-radius: 4px;
  1378. font-size: 12px;
  1379. font-weight: 600;
  1380. color: #4f46e5;
  1381. }
  1382. .preset-name {
  1383. flex: 1;
  1384. font-size: 13px;
  1385. color: #606266;
  1386. overflow: hidden;
  1387. text-overflow: ellipsis;
  1388. white-space: nowrap;
  1389. }
  1390. }
  1391. :deep(.el-empty) {
  1392. padding: 20px 0;
  1393. .el-empty__description {
  1394. color: #909399;
  1395. }
  1396. }
  1397. }
  1398. </style>