index.vue 40 KB

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