index.vue 77 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777
  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
  8. v-model.trim="searchForm.streamSn"
  9. :placeholder="t('stream sn')"
  10. clearable
  11. @keyup.enter="handleSearch"
  12. />
  13. </el-form-item>
  14. <el-form-item>
  15. <el-input v-model.trim="searchForm.name" :placeholder="t('名称')" clearable @keyup.enter="handleSearch" />
  16. </el-form-item>
  17. <el-form-item>
  18. <el-select v-model="searchForm.lssId" placeholder="LSS" clearable filterable style="width: 180px">
  19. <el-option v-for="lss in lssOptions" :key="lss.lssId" :label="lss.lssId" :value="lss.lssId" />
  20. </el-select>
  21. </el-form-item>
  22. <el-form-item>
  23. <el-input
  24. v-model.trim="searchForm.cameraId"
  25. :placeholder="t('设备ID')"
  26. clearable
  27. @keyup.enter="handleSearch"
  28. />
  29. </el-form-item>
  30. <el-form-item>
  31. <el-button type="primary" :icon="Search" @click="handleSearch">{{ t('查询') }}</el-button>
  32. <el-button type="info" :icon="RefreshRight" @click="handleReset">{{ t('重置') }}</el-button>
  33. <el-button type="primary" :icon="Plus" @click="handleAdd">{{ t('新增') }}</el-button>
  34. </el-form-item>
  35. </el-form>
  36. </div>
  37. <!-- 数据表格 -->
  38. <div class="table-wrapper">
  39. <el-table
  40. ref="tableRef"
  41. v-loading="loading"
  42. :data="streamList"
  43. stripe
  44. size="default"
  45. height="100%"
  46. @sort-change="handleSortChange"
  47. >
  48. <el-table-column prop="streamSn" :label="t('stream sn')" width="220" show-overflow-tooltip>
  49. <template #default="{ row }">
  50. <el-link type="primary" @click="handleEdit(row)">{{ row.streamSn }}</el-link>
  51. </template>
  52. </el-table-column>
  53. <el-table-column prop="name" :label="t('名称')" show-overflow-tooltip>
  54. <template #default="{ row }">
  55. <span>{{ row.name }}</span>
  56. </template>
  57. </el-table-column>
  58. <el-table-column prop="lssId" :label="t('LSS')" width="160" show-overflow-tooltip>
  59. <template #default="{ row }">
  60. <span>{{ row.lssId || '-' }}</span>
  61. </template>
  62. </el-table-column>
  63. <el-table-column prop="cameraId" :label="t('设备ID')" show-overflow-tooltip>
  64. <template #default="{ row }">
  65. <span>{{ row.cameraId || '-' }}</span>
  66. </template>
  67. </el-table-column>
  68. <el-table-column prop="pushMethod" :label="t('推流方式')" align="center">
  69. <template #default="{ row }">
  70. <el-tag size="small">{{ row.pushMethod || 'ffmpeg' }}</el-tag>
  71. </template>
  72. </el-table-column>
  73. <el-table-column prop="commandTemplate" :label="t('命令模板')" align="center">
  74. <template #default="{ row }">
  75. <el-link type="primary" @click="openCommandDialog(row)">{{ t('查看') }}</el-link>
  76. </template>
  77. </el-table-column>
  78. <el-table-column :label="t('推流控制')" width="110" align="center">
  79. <template #default="{ row }">
  80. {{ row.status === '1' ? t('开启') : t('关闭') }}
  81. <el-switch
  82. :model-value="row.status === '1'"
  83. :loading="row._starting || row._stopping"
  84. inline-prompt
  85. @change="(val: boolean) => handleToggleStream(row, val)"
  86. />
  87. </template>
  88. </el-table-column>
  89. <el-table-column prop="startedAt" :label="t('启动时间')" width="165" align="center">
  90. <template #default="{ row }">
  91. {{ formatTime(row.startedAt) }}
  92. </template>
  93. </el-table-column>
  94. <el-table-column prop="stoppedAt" :label="t('关闭时间')" width="165" align="center">
  95. <template #default="{ row }">
  96. {{ formatTime(row.stoppedAt) }}
  97. </template>
  98. </el-table-column>
  99. <el-table-column :label="t('操作')" fixed="right" min-width="130">
  100. <template #default="{ row }">
  101. <el-button type="primary" link @click="handleEdit(row)">
  102. <Icon icon="mdi:note-edit-outline" width="20" height="20" />
  103. </el-button>
  104. <el-button data-id="live-stream-play-btn" type="primary" link @click="handleViewCloudflare(row)">
  105. <Icon icon="mdi:play-circle-outline" width="20" height="20" />
  106. </el-button>
  107. <el-button type="danger" link @click="handleDelete(row)">
  108. <Icon icon="mdi:delete" width="20" height="20" />
  109. </el-button>
  110. </template>
  111. </el-table-column>
  112. </el-table>
  113. </div>
  114. <!-- 分页 -->
  115. <div class="pagination-container">
  116. <el-pagination
  117. v-model:current-page="currentPage"
  118. v-model:page-size="pageSize"
  119. :page-sizes="[10, 20, 50, 100]"
  120. :total="total"
  121. layout="total, sizes, prev, pager, next, jumper"
  122. background
  123. @size-change="handleSizeChange"
  124. @current-change="handleCurrentChange"
  125. />
  126. </div>
  127. <!-- 合并的编辑/播放抽屉 -->
  128. <el-drawer
  129. v-model="drawerVisible"
  130. direction="rtl"
  131. :size="activeDrawerTab === 'edit' ? '800px' : '90%'"
  132. :with-header="false"
  133. destroy-on-close
  134. class="combined-drawer"
  135. >
  136. <div class="drawer-content">
  137. <!-- 顶部 Tabs -->
  138. <el-tabs v-model="activeDrawerTab" class="drawer-tabs">
  139. <el-tab-pane :label="t('编辑')" name="edit" />
  140. <el-tab-pane :label="t('播放')" name="play" :disabled="!isEdit" />
  141. </el-tabs>
  142. <!-- 编辑 Tab 内容 -->
  143. <div v-show="activeDrawerTab === 'edit'" class="tab-content edit-content">
  144. <div class="drawer-body">
  145. <el-form ref="formRef" :model="form" :rules="rules" label-width="auto" class="stream-form">
  146. <el-form-item :label="t('名称') + ':'" prop="name">
  147. <el-input v-model="form.name" :placeholder="t('例如: 测试推流-001')" style="width: 300px" />
  148. </el-form-item>
  149. <el-form-item :label="t('LSS 节点') + ':'" prop="lssId">
  150. <el-select
  151. v-model="form.lssId"
  152. :placeholder="t('请选择 LSS 节点')"
  153. clearable
  154. filterable
  155. style="width: 300px"
  156. >
  157. <el-option
  158. v-for="lss in lssOptions"
  159. :key="lss.lssId"
  160. :label="`${lss.lssId} - ${lss.lssName}`"
  161. :value="lss.lssId"
  162. />
  163. </el-select>
  164. </el-form-item>
  165. <el-form-item :label="t('摄像头') + ':'" prop="cameraId">
  166. <el-select
  167. v-model="form.cameraId"
  168. :placeholder="t('请选择摄像头')"
  169. clearable
  170. filterable
  171. style="width: 300px"
  172. >
  173. <el-option
  174. v-for="camera in cameraOptions"
  175. :key="camera.cameraId"
  176. :label="`${camera.cameraId} - ${camera.cameraName}`"
  177. :value="camera.cameraId"
  178. />
  179. </el-select>
  180. </el-form-item>
  181. <el-form-item :label="t('推流方式') + ':'" prop="pushMethod">
  182. <el-select disabled v-model="form.pushMethod" :placeholder="t('请选择')" style="width: 300px">
  183. <el-option label="ffmpeg" value="ffmpeg" />
  184. </el-select>
  185. </el-form-item>
  186. <el-form-item :label="t('命令模板') + ':'" prop="commandTemplate">
  187. <CodeEditor
  188. v-model="form.commandTemplate"
  189. language="bash"
  190. height="400px"
  191. placeholder="#!/bin/bash&#10;# FFmpeg 命令模板,可使用 {RTSP_URL} 等变量"
  192. />
  193. </el-form-item>
  194. </el-form>
  195. </div>
  196. <div class="drawer-footer">
  197. <el-button @click="drawerVisible = false">{{ t('取消') }}</el-button>
  198. <el-button type="primary" :loading="submitLoading" @click="handleSubmit">
  199. {{ isEdit ? t('更新') : t('添加') }}
  200. </el-button>
  201. </div>
  202. </div>
  203. <!-- 播放 Tab 内容 -->
  204. <div v-show="activeDrawerTab === 'play'" class="tab-content play-content">
  205. <div class="media-drawer-content">
  206. <!-- 左侧:视频播放区域 -->
  207. <div class="video-area">
  208. <div class="video-header">
  209. <div class="header-left">
  210. <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
  211. <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
  212. <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
  213. </div>
  214. <!-- <el-button type="danger" size="small" @click="drawerVisible = false">
  215. <Icon icon="mdi:close" width="16" height="16" />
  216. {{ t('关闭') }}
  217. </el-button> -->
  218. <el-button
  219. v-if="currentMediaStream && currentMediaStream.status === '1'"
  220. type="danger"
  221. size="small"
  222. :loading="streamStopping"
  223. @click="handleStopStreamFromPlayer"
  224. >
  225. {{ t('停止推流') }}
  226. </el-button>
  227. </div>
  228. <div class="player-container">
  229. <div v-if="!playbackInfo.videoId" class="player-placeholder">
  230. <el-icon :size="80" color="#666">
  231. <VideoPlay />
  232. </el-icon>
  233. <p>{{ t('暂无视频流') }}</p>
  234. </div>
  235. <VideoPlayer
  236. v-else
  237. ref="playerRef"
  238. player-type="cloudflare"
  239. :video-id="playbackInfo.videoId"
  240. :customer-domain="playbackInfo.customerDomain"
  241. :use-iframe="true"
  242. :autoplay="playConfig.autoplay"
  243. :muted="playConfig.muted"
  244. :controls="true"
  245. />
  246. <!-- 开始推流按钮 -->
  247. <div v-if="currentMediaStream && currentMediaStream.status !== '1'" class="stream-control-overlay">
  248. <el-button type="success" size="large" :loading="streamStarting" @click="handleStartStreamFromPlayer">
  249. {{ t('开始推流') }}
  250. </el-button>
  251. </div>
  252. </div>
  253. <!-- 时间轴操作条 -->
  254. <div class="timeline-container">
  255. <!-- 时间轴头部 -->
  256. <div class="timeline-header">
  257. <span class="timeline-label">{{ t('巡航时间轴') }}</span>
  258. <el-select
  259. v-model="timelineDuration"
  260. size="small"
  261. style="width: 100px"
  262. :disabled="isTimelinePlaying"
  263. @change="handleDurationChange"
  264. >
  265. <el-option :value="60" :label="t('1分钟')" />
  266. <el-option :value="180" :label="t('3分钟')" />
  267. <el-option :value="300" :label="t('5分钟')" />
  268. <el-option :value="600" :label="t('10分钟')" />
  269. </el-select>
  270. <el-button size="small" :disabled="isTimelinePlaying" @click="addTimelinePoint()">
  271. + {{ t('添加点') }}
  272. </el-button>
  273. <el-switch
  274. v-model="isLoopEnabled"
  275. :disabled="isTimelinePlaying"
  276. size="small"
  277. :active-text="t('循环')"
  278. style="margin-left: 8px"
  279. />
  280. <el-button
  281. size="small"
  282. type="primary"
  283. :loading="isTimelinePlaying"
  284. :disabled="!hasActivePoints"
  285. @click="playTimeline"
  286. >
  287. <el-icon v-if="!isTimelinePlaying">
  288. <VideoPlay />
  289. </el-icon>
  290. {{ isTimelinePlaying ? t('巡航中...') : t('播放巡航') }}
  291. </el-button>
  292. <el-button v-if="isTimelinePlaying" size="small" type="danger" @click="stopTimeline">
  293. {{ t('停止') }}
  294. </el-button>
  295. </div>
  296. <!-- 时间轴轨道 -->
  297. <div :class="['timeline-track', { 'is-playing': isTimelinePlaying }]" ref="timelineTrackRef">
  298. <!-- 时间轴进度指示器 -->
  299. <div class="timeline-progress" :style="{ width: `${timelineProgress}%` }"></div>
  300. <!-- 关键点 -->
  301. <div
  302. v-for="point in timelinePoints"
  303. :key="point.id"
  304. :class="[
  305. 'timeline-point',
  306. {
  307. active: point.active,
  308. selected: !isTimelinePlaying && selectedPoint?.id === point.id,
  309. dragging: draggingPoint?.id === point.id
  310. }
  311. ]"
  312. :style="{ left: `${(point.time / timelineDuration) * 100}%` }"
  313. @click.stop="!isTimelinePlaying && selectPoint(point)"
  314. @mousedown.stop="!isTimelinePlaying && startDragPoint($event, point)"
  315. @contextmenu.prevent="!isTimelinePlaying && handlePointContextMenu($event, point)"
  316. >
  317. <span class="point-label">{{ point.id }}</span>
  318. <div class="point-tooltip">
  319. <div>{{ point.presetName || `Point ${point.id}` }}</div>
  320. <div class="point-time">{{ formatTimelineTime(point.time) }}</div>
  321. </div>
  322. </div>
  323. </div>
  324. <!-- 时间刻度 -->
  325. <div class="timeline-scale">
  326. <span v-for="i in Math.floor(timelineDuration / 60) + 1" :key="i" class="scale-mark">
  327. {{ formatTimelineTime((i - 1) * 60) }}
  328. </span>
  329. </div>
  330. <!-- 选中点的操作面板(巡航中隐藏) -->
  331. <div v-if="selectedPoint && !isTimelinePlaying" class="timeline-point-panel">
  332. <span class="panel-label">
  333. {{ t('当前选中') }}: {{ selectedPoint.presetName || `Point ${selectedPoint.id}` }}
  334. </span>
  335. <!-- 关联已有预置位 -->
  336. <el-select
  337. v-model="linkPresetId"
  338. size="small"
  339. :placeholder="t('关联预置位')"
  340. style="width: 140px"
  341. clearable
  342. @change="handleLinkPreset"
  343. >
  344. <el-option
  345. v-for="preset in ptzPresetList"
  346. :key="preset.id"
  347. :value="preset.id"
  348. :label="preset.name || `Preset ${preset.id}`"
  349. />
  350. </el-select>
  351. <el-button size="small" type="primary" :loading="savingPreset" @click="saveCurrentPoint">
  352. {{ selectedPoint.active ? t('更新位置') : t('保存位置') }}
  353. </el-button>
  354. <el-button size="small" type="danger" @click="deleteSelectedPoint">{{ t('删除') }}</el-button>
  355. </div>
  356. <!-- 自动映射按钮(巡航中隐藏) -->
  357. <div v-if="timelinePoints.length > 0 && !isTimelinePlaying" class="timeline-auto-link">
  358. <el-button
  359. size="small"
  360. type="success"
  361. @click="autoLinkPresets"
  362. :disabled="ptzPresetList.length === 0"
  363. >
  364. <el-icon>
  365. <Link />
  366. </el-icon>
  367. {{ t('自动映射') }}
  368. </el-button>
  369. <span class="auto-link-hint">{{ t('将点1-N映射到Preset 1-N') }}</span>
  370. </div>
  371. </div>
  372. <!-- 底部播放控制 -->
  373. <!-- <div class="player-controls">
  374. <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
  375. <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
  376. <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
  377. <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
  378. <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
  379. <el-switch
  380. v-model="playConfig.muted"
  381. :active-text="t('静音')"
  382. :inactive-text="t('有声')"
  383. style="margin-left: 16px"
  384. />
  385. <el-divider direction="vertical" />
  386. </div> -->
  387. </div>
  388. <!-- 右侧:PTZ 控制面板 -->
  389. <div class="control-panel">
  390. <el-collapse v-model="activePanels" class="ptz-collapse">
  391. <!-- PTZ 方向控制 -->
  392. <el-collapse-item name="ptz">
  393. <template #title>
  394. <span class="collapse-title">{{ t('PTZ') }}</span>
  395. </template>
  396. <div class="ptz-grid">
  397. <div
  398. class="ptz-btn"
  399. @mousedown="handlePTZ('UP_LEFT')"
  400. @mouseup="handlePTZStop"
  401. @mouseleave="handlePTZStop"
  402. >
  403. <el-icon>
  404. <TopLeft />
  405. </el-icon>
  406. </div>
  407. <div
  408. class="ptz-btn"
  409. @mousedown="handlePTZ('UP')"
  410. @mouseup="handlePTZStop"
  411. @mouseleave="handlePTZStop"
  412. >
  413. <el-icon>
  414. <Top />
  415. </el-icon>
  416. </div>
  417. <div
  418. class="ptz-btn"
  419. @mousedown="handlePTZ('UP_RIGHT')"
  420. @mouseup="handlePTZStop"
  421. @mouseleave="handlePTZStop"
  422. >
  423. <el-icon>
  424. <TopRight />
  425. </el-icon>
  426. </div>
  427. <div
  428. class="ptz-btn"
  429. @mousedown="handlePTZ('LEFT')"
  430. @mouseup="handlePTZStop"
  431. @mouseleave="handlePTZStop"
  432. >
  433. <el-icon>
  434. <Back />
  435. </el-icon>
  436. </div>
  437. <div class="ptz-btn ptz-center" @click="handlePTZStop">
  438. <el-icon>
  439. <Refresh />
  440. </el-icon>
  441. </div>
  442. <div
  443. class="ptz-btn"
  444. @mousedown="handlePTZ('RIGHT')"
  445. @mouseup="handlePTZStop"
  446. @mouseleave="handlePTZStop"
  447. >
  448. <el-icon>
  449. <Right />
  450. </el-icon>
  451. </div>
  452. <div
  453. class="ptz-btn"
  454. @mousedown="handlePTZ('DOWN_LEFT')"
  455. @mouseup="handlePTZStop"
  456. @mouseleave="handlePTZStop"
  457. >
  458. <el-icon>
  459. <BottomLeft />
  460. </el-icon>
  461. </div>
  462. <div
  463. class="ptz-btn"
  464. @mousedown="handlePTZ('DOWN')"
  465. @mouseup="handlePTZStop"
  466. @mouseleave="handlePTZStop"
  467. >
  468. <el-icon>
  469. <Bottom />
  470. </el-icon>
  471. </div>
  472. <div
  473. class="ptz-btn"
  474. @mousedown="handlePTZ('DOWN_RIGHT')"
  475. @mouseup="handlePTZStop"
  476. @mouseleave="handlePTZStop"
  477. >
  478. <el-icon>
  479. <BottomRight />
  480. </el-icon>
  481. </div>
  482. </div>
  483. <!-- 缩放按钮 -->
  484. <div class="zoom-buttons">
  485. <el-button
  486. size="small"
  487. @mousedown="handleZoomIn"
  488. @mouseup="handlePTZStop"
  489. @mouseleave="handlePTZStop"
  490. >
  491. <el-icon>
  492. <ZoomIn />
  493. </el-icon>
  494. </el-button>
  495. <el-button
  496. size="small"
  497. @mousedown="handleZoomOut"
  498. @mouseup="handlePTZStop"
  499. @mouseleave="handlePTZStop"
  500. >
  501. <el-icon>
  502. <ZoomOut />
  503. </el-icon>
  504. </el-button>
  505. </div>
  506. <!-- 速度滑块 -->
  507. <div class="speed-slider">
  508. <span class="label">{{ t('速度') }}</span>
  509. <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" size="small" />
  510. <span class="value">{{ ptzSpeed }}</span>
  511. </div>
  512. </el-collapse-item>
  513. <!-- 预置位列表 -->
  514. <el-collapse-item name="preset">
  515. <template #title>
  516. <div class="collapse-title-with-action">
  517. <span class="collapse-title">{{ t('预置位') }}</span>
  518. <el-button
  519. type="primary"
  520. link
  521. size="small"
  522. @click.stop="loadPTZPresets"
  523. :loading="presetsLoading"
  524. >
  525. <el-icon>
  526. <Refresh />
  527. </el-icon>
  528. </el-button>
  529. </div>
  530. </template>
  531. <div class="preset-list" v-loading="presetsLoading">
  532. <div
  533. v-for="preset in ptzPresetList"
  534. :key="preset.id"
  535. :class="['preset-item', { active: activePresetId === preset.id.toString() }]"
  536. >
  537. <span class="preset-index">{{ preset.id }}</span>
  538. <span class="preset-name">{{ preset.name || `Preset ${preset.id}` }}</span>
  539. <div class="preset-actions">
  540. <el-tooltip :content="t('跳转')" placement="top">
  541. <el-icon class="action-icon" @click="handleGotoPTZPreset(preset)">
  542. <Position />
  543. </el-icon>
  544. </el-tooltip>
  545. <el-tooltip :content="t('设置')" placement="top">
  546. <el-icon class="action-icon" @click="handleEditPreset(preset)">
  547. <Setting />
  548. </el-icon>
  549. </el-tooltip>
  550. <el-tooltip :content="t('删除')" placement="top">
  551. <el-icon class="action-icon delete" @click="handleDeletePreset(preset)">
  552. <Close />
  553. </el-icon>
  554. </el-tooltip>
  555. </div>
  556. </div>
  557. <el-empty
  558. v-if="!presetsLoading && ptzPresetList.length === 0"
  559. :description="t('暂无预置位')"
  560. :image-size="60"
  561. />
  562. </div>
  563. </el-collapse-item>
  564. <!-- 摄像头信息 -->
  565. <el-collapse-item name="camera">
  566. <template #title>
  567. <span class="collapse-title">{{ t('摄像头信息') }}</span>
  568. </template>
  569. <div class="camera-info-content" v-loading="capabilitiesLoading">
  570. <template v-if="cameraCapabilities">
  571. <div class="info-item">
  572. <span class="info-label">{{ t('最大预置位') }}:</span>
  573. <span class="info-value">{{ cameraCapabilities.maxPresetNum || '-' }}</span>
  574. </div>
  575. <div class="info-item" v-if="cameraCapabilities.controlProtocol">
  576. <span class="info-label">{{ t('控制协议') }}:</span>
  577. <span class="info-value">{{ cameraCapabilities.controlProtocol.current }}</span>
  578. </div>
  579. <div class="info-item" v-if="cameraCapabilities.absoluteZoom">
  580. <span class="info-label">{{ t('变焦倍数') }}:</span>
  581. <span class="info-value">
  582. {{ cameraCapabilities.absoluteZoom.min }}x - {{ cameraCapabilities.absoluteZoom.max }}x
  583. </span>
  584. </div>
  585. <div class="info-item" v-if="cameraCapabilities.support3DPosition !== undefined">
  586. <span class="info-label">{{ t('3D定位') }}:</span>
  587. <span class="info-value">
  588. {{ cameraCapabilities.support3DPosition ? t('支持') : t('不支持') }}
  589. </span>
  590. </div>
  591. <div class="info-item" v-if="cameraCapabilities.supportPtzLimits !== undefined">
  592. <span class="info-label">{{ t('PTZ限位') }}:</span>
  593. <span class="info-value">
  594. {{ cameraCapabilities.supportPtzLimits ? t('支持') : t('不支持') }}
  595. </span>
  596. </div>
  597. </template>
  598. <el-empty
  599. v-else-if="!capabilitiesLoading"
  600. :description="currentMediaStream?.cameraId ? t('点击刷新加载') : t('请选择直播流')"
  601. :image-size="40"
  602. />
  603. </div>
  604. </el-collapse-item>
  605. </el-collapse>
  606. </div>
  607. </div>
  608. </div>
  609. </div>
  610. </el-drawer>
  611. <!-- 命令模板查看/编辑抽屉 -->
  612. <el-drawer v-model="commandDialogVisible" :title="t('命令模板')" direction="rtl" size="800px" destroy-on-close>
  613. <div class="command-template-container">
  614. <CodeEditor
  615. v-model="currentCommandTemplate"
  616. language="bash"
  617. height="450px"
  618. placeholder="#!/bin/bash&#10;# FFmpeg 推流命令模板"
  619. />
  620. </div>
  621. <template #footer>
  622. <div class="drawer-footer">
  623. <el-button @click="commandDialogVisible = false">{{ t('取消') }}</el-button>
  624. <el-button type="primary" :loading="commandUpdateLoading" @click="handleUpdateCommandTemplate">
  625. {{ t('更新') }}
  626. </el-button>
  627. </div>
  628. </template>
  629. </el-drawer>
  630. </div>
  631. </template>
  632. <script setup lang="ts">
  633. import { ref, reactive, onMounted, computed, watch } from 'vue'
  634. import { useRoute, useRouter } from 'vue-router'
  635. import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
  636. import {
  637. Search,
  638. RefreshRight,
  639. Plus,
  640. VideoPlay,
  641. Top,
  642. Bottom,
  643. Back,
  644. Right,
  645. TopLeft,
  646. TopRight,
  647. BottomLeft,
  648. BottomRight,
  649. Refresh,
  650. ZoomIn,
  651. ZoomOut,
  652. Close,
  653. Position,
  654. Link
  655. } from '@element-plus/icons-vue'
  656. import { Icon } from '@iconify/vue'
  657. import { useI18n } from 'vue-i18n'
  658. import { formatTime } from '@/utils/dayjs'
  659. import { listLiveStreams, addLiveStream, updateLiveStream, deleteLiveStream } from '@/api/live-stream'
  660. import { listAllLssNodes } from '@/api/lss'
  661. import { adminListCameras } from '@/api/camera'
  662. import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
  663. import VideoPlayer from '@/components/VideoPlayer.vue'
  664. import CodeEditor from '@/components/CodeEditor.vue'
  665. import {
  666. type PresetInfo,
  667. presetList,
  668. presetGoto,
  669. presetSet,
  670. presetRemove,
  671. getPTZCapabilities,
  672. ptzControl
  673. } from '@/api/camera'
  674. import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO, PTZAction } from '@/types'
  675. const { t } = useI18n({ useScope: 'global' })
  676. const route = useRoute()
  677. const router = useRouter()
  678. const loading = ref(false)
  679. const submitLoading = ref(false)
  680. const streamList = ref<LiveStreamDTO[]>([])
  681. const drawerVisible = ref(false)
  682. const formRef = ref<FormInstance>()
  683. // 命令模板弹窗
  684. const commandDialogVisible = ref(false)
  685. const currentCommandTemplate = ref('')
  686. const currentStreamId = ref<number | null>(null)
  687. const commandUpdateLoading = ref(false)
  688. // 合并抽屉的 tab 状态
  689. const activeDrawerTab = ref<'edit' | 'play'>('edit')
  690. const currentMediaStream = ref<LiveStreamDTO | null>(null)
  691. const streamStarting = ref(false)
  692. const streamStopping = ref(false)
  693. const playerRef = ref<InstanceType<typeof VideoPlayer>>()
  694. const playbackInfo = ref<{
  695. videoId: string
  696. customerDomain: string
  697. hlsUrl?: string
  698. whepUrl?: string
  699. isLive: boolean
  700. }>({
  701. videoId: '',
  702. customerDomain: '',
  703. isLive: false
  704. })
  705. const playConfig = reactive({
  706. autoplay: true,
  707. muted: true
  708. })
  709. // PTZ 控制
  710. const ptzSpeed = ref(50)
  711. const zoomValue = ref(0)
  712. // 预置位
  713. const presetsLoading = ref(false)
  714. // PTZ 预置位 (camera API)
  715. interface PTZPresetInfo {
  716. id: string
  717. name: string
  718. }
  719. interface PTZCapabilities {
  720. maxPresetNum?: number
  721. [key: string]: unknown
  722. }
  723. const ptzPresetList = ref<PresetInfo[]>([])
  724. const activePresetId = ref<string | null>(null)
  725. const cameraCapabilities = ref<PTZCapabilities | null>(null)
  726. const capabilitiesLoading = ref(false)
  727. // 可折叠面板
  728. const activePanels = ref(['ptz', 'preset', 'camera'])
  729. // ==================== 时间轴相关 ====================
  730. // 时间轴关键点类型
  731. interface TimelinePoint {
  732. id: number // 点的序号 (1, 2, 3...)
  733. time: number // 时间位置(秒)
  734. presetId?: number // 关联的预置位ID (保存后才有)
  735. presetName?: string // 预置位名称
  736. active: boolean // 是否已激活(已保存预置位)
  737. }
  738. // localStorage 存储 key
  739. const TIMELINE_STORAGE_KEY = 'ptz_timeline_config'
  740. // 时间轴状态
  741. const timelineTrackRef = ref<HTMLElement | null>(null)
  742. const timelineDuration = ref(180) // 总时长(秒),默认3分钟
  743. const timelinePoints = ref<TimelinePoint[]>([])
  744. const selectedPoint = ref<TimelinePoint | null>(null)
  745. const isTimelinePlaying = ref(false)
  746. const timelineProgress = ref(0)
  747. const savingPreset = ref(false)
  748. const linkPresetId = ref<string | null>(null) // 关联预置位选择
  749. const isLoopEnabled = ref(false) // 是否循环巡航
  750. let timelinePlayAbort: AbortController | null = null
  751. // 拖拽状态
  752. const draggingPoint = ref<TimelinePoint | null>(null)
  753. // 计算属性:是否有已激活的点
  754. const hasActivePoints = computed(() => timelinePoints.value.some((p) => p.active))
  755. // 下拉选项
  756. const lssOptions = ref<LssNodeDTO[]>([])
  757. const cameraOptions = ref<CameraInfoDTO[]>([])
  758. const channelOptions = ref<StreamChannelDTO[]>([])
  759. // 排序状态
  760. const sortState = reactive<{
  761. prop: string
  762. order: 'ascending' | 'descending' | null
  763. }>({
  764. prop: '',
  765. order: null
  766. })
  767. // 搜索表单
  768. const searchForm = reactive<{
  769. streamSn: string
  770. name: string
  771. lssId: string
  772. cameraId: string
  773. }>({
  774. streamSn: '',
  775. name: '',
  776. lssId: '',
  777. cameraId: ''
  778. })
  779. // 分页相关
  780. const currentPage = ref(1)
  781. const pageSize = ref(20)
  782. const total = ref(0)
  783. // 表单数据
  784. const form = reactive<{
  785. id?: number
  786. name: string
  787. lssId: string
  788. cameraId: string
  789. channelId?: number
  790. pushMethod: string
  791. commandTemplate: string
  792. timeoutSeconds: number
  793. remark: string
  794. enabled: boolean
  795. }>({
  796. name: '',
  797. lssId: '',
  798. cameraId: '',
  799. channelId: undefined,
  800. pushMethod: 'ffmpeg',
  801. commandTemplate: '',
  802. timeoutSeconds: 30,
  803. remark: '',
  804. enabled: true
  805. })
  806. const isEdit = computed(() => !!form.id)
  807. const drawerTitle = computed(() => (isEdit.value ? t('编辑 Live Stream') : t('新增 Live Stream')))
  808. const rules: FormRules = {
  809. name: [{ required: true, message: t('请输入名称'), trigger: 'blur' }],
  810. lssId: [{ required: true, message: t('请选择 LSS 节点'), trigger: 'change' }]
  811. }
  812. async function getList() {
  813. loading.value = true
  814. try {
  815. const params: Record<string, any> = {
  816. page: currentPage.value,
  817. size: pageSize.value
  818. }
  819. if (searchForm.streamSn) {
  820. params.streamSn = searchForm.streamSn
  821. }
  822. if (searchForm.name) {
  823. params.name = searchForm.name
  824. }
  825. if (searchForm.lssId) {
  826. params.lssId = searchForm.lssId
  827. }
  828. if (searchForm.cameraId) {
  829. params.cameraId = searchForm.cameraId
  830. }
  831. if (sortState.prop && sortState.order) {
  832. params.sortBy = sortState.prop
  833. params.sortDir = sortState.order === 'ascending' ? 'ASC' : 'DESC'
  834. }
  835. const res = await listLiveStreams(params)
  836. if (res.success) {
  837. streamList.value = res.data.list
  838. total.value = res.data.total || 0
  839. }
  840. } finally {
  841. loading.value = false
  842. }
  843. }
  844. async function loadOptions() {
  845. try {
  846. // 获取 LSS 节点列表
  847. const lssRes = await listAllLssNodes()
  848. if (lssRes.success && lssRes.data) {
  849. lssOptions.value = lssRes.data || []
  850. }
  851. } catch (error) {
  852. console.error('加载选项失败', error)
  853. }
  854. }
  855. // 监听 LSS 节点变化,加载对应的摄像头列表
  856. watch(
  857. () => form.lssId,
  858. async (newLssId) => {
  859. // 清空当前选中的摄像头
  860. form.cameraId = ''
  861. cameraOptions.value = []
  862. if (newLssId) {
  863. try {
  864. const res = await adminListCameras({ lssId: newLssId, size: 1000 })
  865. if (res.success && res.data) {
  866. cameraOptions.value = res.data.list || []
  867. }
  868. } catch (error) {
  869. console.error('加载摄像头列表失败', error)
  870. }
  871. }
  872. }
  873. )
  874. function handleSearch() {
  875. currentPage.value = 1
  876. getList()
  877. }
  878. function handleReset() {
  879. searchForm.streamSn = ''
  880. searchForm.name = ''
  881. searchForm.lssId = ''
  882. searchForm.cameraId = ''
  883. currentPage.value = 1
  884. sortState.prop = ''
  885. sortState.order = null
  886. getList()
  887. }
  888. function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
  889. sortState.prop = prop || ''
  890. sortState.order = order
  891. getList()
  892. }
  893. function handleAdd() {
  894. Object.assign(form, {
  895. id: undefined,
  896. name: '',
  897. lssId: '',
  898. cameraId: '',
  899. channelId: undefined,
  900. pushMethod: 'ffmpeg',
  901. commandTemplate: '',
  902. timeoutSeconds: 30,
  903. remark: '',
  904. enabled: true
  905. })
  906. currentMediaStream.value = null
  907. activeDrawerTab.value = 'edit'
  908. drawerVisible.value = true
  909. }
  910. function handleEdit(row: LiveStreamDTO) {
  911. Object.assign(form, {
  912. id: row.id,
  913. name: row.name,
  914. lssId: row.lssId || '',
  915. cameraId: row.cameraId || '',
  916. channelId: row.channelId,
  917. pushMethod: row.pushMethod || 'ffmpeg',
  918. commandTemplate: row.commandTemplate || '',
  919. timeoutSeconds: row.timeoutSeconds || 30,
  920. remark: row.remark || '',
  921. enabled: row.enabled
  922. })
  923. currentMediaStream.value = row
  924. activeDrawerTab.value = 'edit'
  925. drawerVisible.value = true
  926. }
  927. async function handleDelete(row: LiveStreamDTO) {
  928. // 检查 status 必须为 0(已停止)
  929. if (row.status !== '0') {
  930. ElMessage.warning(t('只能删除已停止的 Live Stream'))
  931. return
  932. }
  933. // // 检查 stoppedAt 必须超过 24 小时
  934. // if (row.stoppedAt) {
  935. // const stoppedTime = dayjs(row.stoppedAt)
  936. // const hoursDiff = dayjs().diff(stoppedTime, 'hour')
  937. // if (hoursDiff < 24) {
  938. // ElMessage.warning(t('停止时间未超过24小时,暂时无法删除'))
  939. // return
  940. // }
  941. // } else {
  942. // // 没有 stoppedAt 时间,不允许删除
  943. // ElMessage.warning(t('停止时间未超过24小时,暂时无法删除'))
  944. // return
  945. // }
  946. try {
  947. await ElMessageBox.confirm(t('确定要删除该 Live Stream 吗?'), t('提示'), {
  948. type: 'warning',
  949. confirmButtonText: t('确定'),
  950. cancelButtonText: t('取消')
  951. })
  952. const res = await deleteLiveStream(row.id)
  953. if (res.success) {
  954. ElMessage.success(t('删除成功'))
  955. getList()
  956. } else {
  957. ElMessage.error(res.errMessage || t('删除失败'))
  958. }
  959. } catch {
  960. // 用户取消
  961. }
  962. }
  963. async function handleSubmit() {
  964. if (!formRef.value) return
  965. await formRef.value.validate(async (valid) => {
  966. if (valid) {
  967. submitLoading.value = true
  968. try {
  969. if (isEdit.value) {
  970. const res = await updateLiveStream({
  971. id: form.id!,
  972. name: form.name,
  973. lssId: form.lssId || undefined,
  974. cameraId: form.cameraId || undefined,
  975. channelId: form.channelId,
  976. pushMethod: form.pushMethod || undefined,
  977. commandTemplate: form.commandTemplate || undefined,
  978. timeoutSeconds: form.timeoutSeconds,
  979. remark: form.remark || undefined,
  980. enabled: form.enabled
  981. })
  982. if (res.success) {
  983. ElMessage.success(t('修改成功'))
  984. drawerVisible.value = false
  985. getList()
  986. } else {
  987. ElMessage.error(res.errMessage || t('修改失败'))
  988. }
  989. } else {
  990. const res = await addLiveStream({
  991. name: form.name,
  992. lssId: form.lssId,
  993. cameraId: form.cameraId,
  994. channelId: form.channelId,
  995. pushMethod: form.pushMethod,
  996. commandTemplate: form.commandTemplate,
  997. timeoutSeconds: form.timeoutSeconds,
  998. remark: form.remark
  999. })
  1000. if (res.success) {
  1001. ElMessage.success(t('新增成功'))
  1002. drawerVisible.value = false
  1003. // 清除 URL 上的 lssId 和 action 参数
  1004. if (route.query.action || route.query.lssId) {
  1005. const newQuery = { ...route.query }
  1006. delete newQuery.lssId
  1007. delete newQuery.action
  1008. router.replace({ path: '/live-stream', query: newQuery })
  1009. }
  1010. getList()
  1011. } else {
  1012. ElMessage.error(res.errMessage || t('新增失败'))
  1013. }
  1014. }
  1015. } finally {
  1016. submitLoading.value = false
  1017. }
  1018. }
  1019. })
  1020. }
  1021. // 打开命令模板弹窗
  1022. function openCommandDialog(row: LiveStreamDTO) {
  1023. currentStreamId.value = row.id
  1024. currentCommandTemplate.value = row.commandTemplate || ''
  1025. commandDialogVisible.value = true
  1026. }
  1027. // 更新命令模板
  1028. async function handleUpdateCommandTemplate() {
  1029. if (!currentStreamId.value) return
  1030. commandUpdateLoading.value = true
  1031. try {
  1032. const res = await updateLiveStream({
  1033. id: currentStreamId.value,
  1034. commandTemplate: currentCommandTemplate.value
  1035. })
  1036. if (res.success) {
  1037. ElMessage.success(t('更新成功'))
  1038. commandDialogVisible.value = false
  1039. getList()
  1040. } else {
  1041. ElMessage.error(res.errMessage || t('更新失败'))
  1042. }
  1043. } catch (error) {
  1044. console.error('更新命令模板失败', error)
  1045. ElMessage.error(t('更新失败'))
  1046. } finally {
  1047. commandUpdateLoading.value = false
  1048. }
  1049. }
  1050. // 切换推流状态
  1051. async function handleToggleStream(row: LiveStreamDTO, val: boolean) {
  1052. if (val) {
  1053. await handleStartStream(row)
  1054. } else {
  1055. await handleStopStream(row)
  1056. }
  1057. }
  1058. // 启动推流
  1059. async function handleStartStream(row: LiveStreamDTO) {
  1060. if (!row.cameraId) {
  1061. ElMessage.warning(t('请先配置摄像头'))
  1062. return
  1063. }
  1064. row._starting = true
  1065. try {
  1066. const res = await startStreamTask({
  1067. name: row.name,
  1068. lssId: row.lssId,
  1069. cameraId: row.cameraId,
  1070. commandTemplate: row.commandTemplate
  1071. })
  1072. if (res.success) {
  1073. ElMessage.success(t('推流任务已启动'))
  1074. getList()
  1075. } else {
  1076. ElMessage.error(res.errMessage || t('启动失败'))
  1077. }
  1078. } catch (error) {
  1079. console.error('启动推流失败', error)
  1080. ElMessage.error(t('启动推流失败'))
  1081. } finally {
  1082. row._starting = false
  1083. }
  1084. }
  1085. // 从播放器窗口启动推流
  1086. async function handleStartStreamFromPlayer() {
  1087. if (!currentMediaStream.value) return
  1088. if (!currentMediaStream.value.cameraId) {
  1089. ElMessage.warning(t('请先配置摄像头'))
  1090. return
  1091. }
  1092. streamStarting.value = true
  1093. try {
  1094. const res = await startStreamTask({
  1095. name: currentMediaStream.value.name,
  1096. lssId: currentMediaStream.value.lssId,
  1097. cameraId: currentMediaStream.value.cameraId,
  1098. commandTemplate: currentMediaStream.value.commandTemplate
  1099. })
  1100. if (res.success) {
  1101. ElMessage.success(t('推流任务已启动'))
  1102. // 更新当前流的状态
  1103. currentMediaStream.value.status = '1'
  1104. // 刷新播放信息
  1105. if (currentMediaStream.value.streamSn) {
  1106. try {
  1107. const playbackRes = await getStreamPlayback(currentMediaStream.value.streamSn)
  1108. if (playbackRes.success && playbackRes.data) {
  1109. playbackInfo.value = {
  1110. ...playbackInfo.value,
  1111. hlsUrl: playbackRes.data.hlsUrl,
  1112. whepUrl: playbackRes.data.whepUrl,
  1113. isLive: playbackRes.data.isLive
  1114. }
  1115. }
  1116. } catch (e) {
  1117. console.error('刷新播放信息失败', e)
  1118. }
  1119. }
  1120. getList()
  1121. } else {
  1122. ElMessage.error(res.errMessage || t('启动失败'))
  1123. }
  1124. } catch (error) {
  1125. console.error('启动推流失败', error)
  1126. ElMessage.error(t('启动推流失败'))
  1127. } finally {
  1128. streamStarting.value = false
  1129. }
  1130. }
  1131. // 从播放器窗口停止推流
  1132. async function handleStopStreamFromPlayer() {
  1133. if (!currentMediaStream.value) return
  1134. try {
  1135. await ElMessageBox.confirm(t('确定要停止该推流任务吗?'), t('提示'), {
  1136. type: 'warning',
  1137. confirmButtonText: t('确定'),
  1138. cancelButtonText: t('取消')
  1139. })
  1140. streamStopping.value = true
  1141. const res = await stopStreamTask({
  1142. taskId: currentMediaStream.value.taskStreamSn,
  1143. lssId: currentMediaStream.value.lssId
  1144. })
  1145. if (res.success) {
  1146. ElMessage.success(t('推流任务已停止'))
  1147. // 更新当前流的状态
  1148. currentMediaStream.value.status = '0'
  1149. playbackInfo.value.isLive = false
  1150. getList()
  1151. } else {
  1152. ElMessage.error(res.errMessage || t('停止失败'))
  1153. }
  1154. } catch (error) {
  1155. if (error !== 'cancel') {
  1156. console.error('停止推流失败', error)
  1157. ElMessage.error(t('停止推流失败'))
  1158. }
  1159. } finally {
  1160. streamStopping.value = false
  1161. }
  1162. }
  1163. // 停止推流
  1164. async function handleStopStream(row: LiveStreamDTO) {
  1165. try {
  1166. await ElMessageBox.confirm(t('确定要停止该推流任务吗?'), t('提示'), {
  1167. type: 'warning',
  1168. confirmButtonText: t('确定'),
  1169. cancelButtonText: t('取消')
  1170. })
  1171. row._stopping = true
  1172. const res = await stopStreamTask({ taskId: row.taskStreamSn, lssId: row.lssId })
  1173. if (res.success) {
  1174. ElMessage.success(t('推流任务已停止'))
  1175. getList()
  1176. } else {
  1177. ElMessage.error(res.errMessage || t('停止失败'))
  1178. }
  1179. } catch (error) {
  1180. if (error !== 'cancel') {
  1181. console.error('停止推流失败', error)
  1182. ElMessage.error(t('停止推流失败'))
  1183. }
  1184. } finally {
  1185. row._stopping = false
  1186. }
  1187. }
  1188. // 从 playbackUrl 解析 videoId 和 customerDomain
  1189. function parsePlaybackUrl(playbackUrl: string): { videoId: string; customerDomain: string } | null {
  1190. try {
  1191. const url = new URL(playbackUrl)
  1192. const customerDomain = url.hostname
  1193. // URL 格式: https://domain/{videoId}/webRTC/play 或 https://domain/{videoId}/iframe
  1194. const pathParts = url.pathname.split('/').filter(Boolean)
  1195. if (pathParts.length > 0) {
  1196. return { videoId: pathParts[0], customerDomain }
  1197. }
  1198. } catch (e) {
  1199. console.error('解析 playbackUrl 失败', e)
  1200. }
  1201. return null
  1202. }
  1203. // 查看流媒体
  1204. async function handleViewCloudflare(row: LiveStreamDTO) {
  1205. currentMediaStream.value = row
  1206. // 同时填充表单数据,以便用户可以切换到编辑 tab
  1207. Object.assign(form, {
  1208. id: row.id,
  1209. name: row.name,
  1210. lssId: row.lssId || '',
  1211. cameraId: row.cameraId || '',
  1212. channelId: row.channelId,
  1213. pushMethod: row.pushMethod || 'ffmpeg',
  1214. commandTemplate: row.commandTemplate || '',
  1215. timeoutSeconds: row.timeoutSeconds || 30,
  1216. remark: row.remark || '',
  1217. enabled: row.enabled
  1218. })
  1219. // 默认值
  1220. let videoId = ''
  1221. let customerDomain = 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
  1222. // 优先从 playbackUrl 解析
  1223. if (row.playbackUrl) {
  1224. const parsed = parsePlaybackUrl(row.playbackUrl)
  1225. if (parsed) {
  1226. videoId = parsed.videoId
  1227. customerDomain = parsed.customerDomain
  1228. }
  1229. }
  1230. // 尝试获取播放信息
  1231. if (row.streamSn) {
  1232. try {
  1233. const res = await getStreamPlayback(row.streamSn)
  1234. if (res.success && res.data) {
  1235. playbackInfo.value = {
  1236. videoId: videoId || row.streamSn,
  1237. customerDomain,
  1238. hlsUrl: res.data.hlsUrl,
  1239. whepUrl: res.data.whepUrl,
  1240. isLive: res.data.isLive
  1241. }
  1242. } else {
  1243. playbackInfo.value = {
  1244. videoId: videoId || row.streamSn,
  1245. customerDomain,
  1246. isLive: false
  1247. }
  1248. }
  1249. } catch (error) {
  1250. console.error('获取播放信息失败', error)
  1251. playbackInfo.value = {
  1252. videoId: videoId || row.streamSn,
  1253. customerDomain,
  1254. isLive: false
  1255. }
  1256. }
  1257. } else if (videoId) {
  1258. playbackInfo.value = {
  1259. videoId,
  1260. customerDomain,
  1261. isLive: false
  1262. }
  1263. }
  1264. activeDrawerTab.value = 'play'
  1265. drawerVisible.value = true
  1266. // 自动加载 PTZ 预置位和能力信息
  1267. if (hasCameraConnection()) {
  1268. loadPTZPresets()
  1269. loadCameraCapabilities()
  1270. }
  1271. // 加载时间轴配置
  1272. loadTimelineConfig()
  1273. }
  1274. // 播放控制
  1275. function handlePlay() {
  1276. playerRef.value?.play()
  1277. }
  1278. function handlePause() {
  1279. playerRef.value?.pause()
  1280. }
  1281. function handlePlayerStop() {
  1282. playerRef.value?.stop()
  1283. }
  1284. function handleScreenshot() {
  1285. playerRef.value?.screenshot()
  1286. }
  1287. function handleFullscreen() {
  1288. playerRef.value?.fullscreen()
  1289. }
  1290. // PTZ 控制 (使用直连 PTZ API)
  1291. // PTZ 方向映射
  1292. const directionToAction: Record<string, PTZAction> = {
  1293. UP: 'up',
  1294. DOWN: 'down',
  1295. LEFT: 'left',
  1296. RIGHT: 'right',
  1297. STOP: 'stop'
  1298. }
  1299. async function handlePTZ(direction: string) {
  1300. const cameraId = currentMediaStream.value?.cameraId
  1301. if (!cameraId) {
  1302. ElMessage.warning(t('请先选择直播流'))
  1303. return
  1304. }
  1305. try {
  1306. const command = directionToAction[direction] || 'stop'
  1307. const res = await ptzControl({ cameraId, command, speed: ptzSpeed.value })
  1308. if (!res.success) {
  1309. console.error('PTZ 控制失败', res.errMsg)
  1310. }
  1311. } catch (error) {
  1312. console.error('PTZ 控制失败', error)
  1313. }
  1314. }
  1315. async function handlePTZStop() {
  1316. const cameraId = currentMediaStream.value?.cameraId
  1317. if (!cameraId) return
  1318. try {
  1319. await ptzControl({ cameraId, command: 'stop' })
  1320. } catch (error) {
  1321. console.error('PTZ 停止失败', error)
  1322. }
  1323. }
  1324. // 缩放控制
  1325. function formatZoomTooltip(val: number) {
  1326. if (val === 0) return t('停止')
  1327. return val > 0 ? `${t('放大')} ${val}` : `${t('缩小')} ${Math.abs(val)}`
  1328. }
  1329. async function handleZoomChange(val: number) {
  1330. const cameraId = currentMediaStream.value?.cameraId
  1331. if (!cameraId) return
  1332. if (val === 0) {
  1333. await ptzControl({ cameraId, command: 'stop' })
  1334. return
  1335. }
  1336. const command = val > 0 ? 'zoom_in' : 'zoom_out'
  1337. await ptzControl({ cameraId, command, speed: Math.abs(val) })
  1338. }
  1339. async function handleZoomRelease() {
  1340. zoomValue.value = 0
  1341. const cameraId = currentMediaStream.value?.cameraId
  1342. if (!cameraId) return
  1343. await ptzControl({ cameraId, command: 'stop' })
  1344. }
  1345. // 缩放按钮控制
  1346. async function handleZoomIn() {
  1347. const cameraId = currentMediaStream.value?.cameraId
  1348. if (!cameraId) {
  1349. ElMessage.warning(t('请先选择直播流'))
  1350. return
  1351. }
  1352. try {
  1353. const res = await ptzControl({ cameraId, command: 'zoom_in', speed: ptzSpeed.value })
  1354. if (!res.success) {
  1355. console.error('Zoom in 失败', res.errMsg)
  1356. }
  1357. } catch (error) {
  1358. console.error('Zoom in 失败', error)
  1359. }
  1360. }
  1361. async function handleZoomOut() {
  1362. const cameraId = currentMediaStream.value?.cameraId
  1363. if (!cameraId) {
  1364. ElMessage.warning(t('请先选择直播流'))
  1365. return
  1366. }
  1367. try {
  1368. const res = await ptzControl({ cameraId, command: 'zoom_out', speed: ptzSpeed.value })
  1369. if (!res.success) {
  1370. console.error('Zoom out 失败', res.errMsg)
  1371. }
  1372. } catch (error) {
  1373. console.error('Zoom out 失败', error)
  1374. }
  1375. }
  1376. // ==================== PTZ 直连 API ====================
  1377. // 检查摄像头连接配置
  1378. function hasCameraConnection(): boolean {
  1379. return !!currentMediaStream.value?.cameraId
  1380. }
  1381. // 加载 PTZ 预置位 (通过 camera API)
  1382. async function loadPTZPresets() {
  1383. const cameraId = currentMediaStream.value?.cameraId
  1384. if (!cameraId) {
  1385. ElMessage.warning(t('请先选择直播流'))
  1386. return
  1387. }
  1388. presetsLoading.value = true
  1389. try {
  1390. const res = await presetList({ cameraId })
  1391. if (res.code === 200 && res.data) {
  1392. ptzPresetList.value = res.data as PresetInfo[]
  1393. } else {
  1394. ptzPresetList.value = []
  1395. if (!res.success) {
  1396. ElMessage.error(res.errMsg || t('加载预置位失败'))
  1397. }
  1398. }
  1399. } catch (error) {
  1400. console.error('加载 PTZ 预置位失败', error)
  1401. ptzPresetList.value = []
  1402. } finally {
  1403. presetsLoading.value = false
  1404. }
  1405. }
  1406. // 跳转到 PTZ 预置位 (通过 camera API)
  1407. async function handleGotoPTZPreset(preset: PresetInfo) {
  1408. const cameraId = currentMediaStream.value?.cameraId
  1409. if (!cameraId) {
  1410. ElMessage.warning(t('请先配置摄像头连接'))
  1411. return
  1412. }
  1413. try {
  1414. activePresetId.value = preset.id
  1415. const res = await presetGoto({ cameraId, presetId: parseInt(preset.id) })
  1416. if (res.code === 200) {
  1417. ElMessage.success(`${t('已跳转到预置位')}: ${preset.name || preset.id}`)
  1418. } else {
  1419. ElMessage.error(res.errMsg || t('跳转失败'))
  1420. }
  1421. } catch (error) {
  1422. console.error('跳转 PTZ 预置位失败', error)
  1423. ElMessage.error(t('跳转失败'))
  1424. }
  1425. }
  1426. // 编辑预置位 (设置按钮)
  1427. function handleEditPreset(preset: PTZPresetInfo) {
  1428. ElMessage.info(`${t('设置预置位')}: ${preset.name || preset.id}`)
  1429. // TODO: 打开预置位设置对话框
  1430. }
  1431. // 删除预置位
  1432. async function handleDeletePreset(preset: PTZPresetInfo) {
  1433. const cameraId = currentMediaStream.value?.cameraId
  1434. if (!cameraId) {
  1435. ElMessage.warning(t('请先配置摄像头连接'))
  1436. return
  1437. }
  1438. try {
  1439. await ElMessageBox.confirm(`${t('确定删除预置位')} "${preset.name || `Preset ${preset.id}`}"?`, t('删除确认'), {
  1440. type: 'warning'
  1441. })
  1442. const res = await presetRemove({ cameraId, presetId: parseInt(preset.id) })
  1443. if (res.success) {
  1444. ElMessage.success(t('删除成功'))
  1445. // 刷新预置位列表
  1446. loadPTZPresets()
  1447. } else {
  1448. ElMessage.error(res.errMsg || t('删除失败'))
  1449. }
  1450. } catch (error) {
  1451. if (error !== 'cancel') {
  1452. console.error('删除预置位失败', error)
  1453. ElMessage.error(t('删除失败'))
  1454. }
  1455. }
  1456. }
  1457. // 加载摄像头能力信息 (通过 camera API)
  1458. async function loadCameraCapabilities() {
  1459. const cameraId = currentMediaStream.value?.cameraId
  1460. if (!cameraId) {
  1461. return
  1462. }
  1463. capabilitiesLoading.value = true
  1464. try {
  1465. const res = await getPTZCapabilities({ cameraId })
  1466. if (res.success && res.data) {
  1467. cameraCapabilities.value = res.data as PTZCapabilities
  1468. } else {
  1469. cameraCapabilities.value = null
  1470. }
  1471. } catch (error) {
  1472. console.error('加载摄像头能力失败', error)
  1473. cameraCapabilities.value = null
  1474. } finally {
  1475. capabilitiesLoading.value = false
  1476. }
  1477. }
  1478. // ==================== 时间轴功能 ====================
  1479. // 格式化时间轴时间 (秒 -> m:ss)
  1480. function formatTimelineTime(seconds: number): string {
  1481. const mins = Math.floor(seconds / 60)
  1482. const secs = seconds % 60
  1483. return `${mins}:${secs.toString().padStart(2, '0')}`
  1484. }
  1485. // 开始拖拽点
  1486. function startDragPoint(e: MouseEvent, point: TimelinePoint) {
  1487. // 只响应鼠标左键
  1488. if (e.button !== 0) return
  1489. draggingPoint.value = point
  1490. selectedPoint.value = point
  1491. // 添加全局事件监听
  1492. document.addEventListener('mousemove', handleDragMove)
  1493. document.addEventListener('mouseup', handleDragEnd)
  1494. // 防止文本选择
  1495. e.preventDefault()
  1496. }
  1497. // 拖拽移动
  1498. function handleDragMove(e: MouseEvent) {
  1499. if (!draggingPoint.value) return
  1500. const track = timelineTrackRef.value
  1501. if (!track) return
  1502. const rect = track.getBoundingClientRect()
  1503. const x = e.clientX - rect.left
  1504. const percent = Math.max(0, Math.min(1, x / rect.width))
  1505. const time = Math.round(percent * timelineDuration.value)
  1506. // 更新点的时间位置
  1507. draggingPoint.value.time = time
  1508. }
  1509. // 结束拖拽
  1510. function handleDragEnd() {
  1511. if (draggingPoint.value) {
  1512. // 重新排序并保存
  1513. sortAndRenumberPoints()
  1514. saveTimelineConfig()
  1515. draggingPoint.value = null
  1516. }
  1517. // 移除全局事件监听
  1518. document.removeEventListener('mousemove', handleDragMove)
  1519. document.removeEventListener('mouseup', handleDragEnd)
  1520. }
  1521. // 添加关键点
  1522. function addTimelinePoint(time?: number) {
  1523. const newId = timelinePoints.value.length > 0 ? Math.max(...timelinePoints.value.map((p) => p.id)) + 1 : 1
  1524. const newTime = time ?? Math.round(timelineDuration.value / 2)
  1525. // 确保时间在有效范围内
  1526. const clampedTime = Math.max(0, Math.min(newTime, timelineDuration.value))
  1527. const newPoint: TimelinePoint = {
  1528. id: newId,
  1529. time: clampedTime,
  1530. active: false
  1531. }
  1532. timelinePoints.value.push(newPoint)
  1533. sortAndRenumberPoints()
  1534. saveTimelineConfig()
  1535. selectPoint(newPoint)
  1536. }
  1537. // 按时间排序并重新编号
  1538. function sortAndRenumberPoints() {
  1539. timelinePoints.value.sort((a, b) => a.time - b.time)
  1540. timelinePoints.value.forEach((point, index) => {
  1541. point.id = index + 1
  1542. })
  1543. }
  1544. // 选中点
  1545. function selectPoint(point: TimelinePoint) {
  1546. selectedPoint.value = point
  1547. // 如果已有预置位,跳转到该位置
  1548. const cameraId = currentMediaStream.value?.cameraId
  1549. if (point.presetId && cameraId) {
  1550. presetGoto({ cameraId, presetId: point.presetId }).then((res) => {
  1551. if (res.success) {
  1552. ElMessage.success(`${t('已跳转到')}: ${point.presetName || `Point ${point.id}`}`)
  1553. }
  1554. })
  1555. }
  1556. }
  1557. // 保存当前点的预置位
  1558. async function saveCurrentPoint() {
  1559. if (!selectedPoint.value) return
  1560. const cameraId = currentMediaStream.value?.cameraId
  1561. if (!cameraId) {
  1562. ElMessage.warning(t('请先选择直播流'))
  1563. return
  1564. }
  1565. const point = selectedPoint.value
  1566. // 使用时间轴专用的预置位ID范围 (100+)
  1567. const presetIdNum = point.presetId || 100 + point.id
  1568. const presetName = `Timeline_${point.id}`
  1569. savingPreset.value = true
  1570. try {
  1571. const res = await presetSet({ cameraId, presetId: presetIdNum, presetName })
  1572. if (res.success) {
  1573. point.presetId = presetIdNum
  1574. point.presetName = presetName
  1575. point.active = true
  1576. saveTimelineConfig()
  1577. ElMessage.success(`${t('已保存')} ${presetName}`)
  1578. } else {
  1579. ElMessage.error(res.errMsg || t('保存失败'))
  1580. }
  1581. } catch (error) {
  1582. console.error('保存预置位失败', error)
  1583. ElMessage.error(t('保存失败'))
  1584. } finally {
  1585. savingPreset.value = false
  1586. }
  1587. }
  1588. // 删除选中的点
  1589. function deleteSelectedPoint() {
  1590. if (!selectedPoint.value) return
  1591. const index = timelinePoints.value.findIndex((p) => p.id === selectedPoint.value!.id)
  1592. if (index !== -1) {
  1593. timelinePoints.value.splice(index, 1)
  1594. sortAndRenumberPoints()
  1595. saveTimelineConfig()
  1596. selectedPoint.value = null
  1597. ElMessage.success(t('已删除'))
  1598. }
  1599. }
  1600. // 关联已有预置位
  1601. function handleLinkPreset(presetId: string | null) {
  1602. if (!selectedPoint.value || !presetId) {
  1603. linkPresetId.value = null
  1604. return
  1605. }
  1606. const preset = ptzPresetList.value.find((p) => p.id === presetId)
  1607. if (preset) {
  1608. selectedPoint.value.presetId = parseInt(preset.id)
  1609. selectedPoint.value.presetName = preset.name || `Preset ${preset.id}`
  1610. selectedPoint.value.active = true
  1611. saveTimelineConfig()
  1612. ElMessage.success(`${t('已关联')}: ${selectedPoint.value.presetName}`)
  1613. linkPresetId.value = null
  1614. }
  1615. }
  1616. // 自动映射:点1-N 对应 Preset 1-N
  1617. function autoLinkPresets() {
  1618. if (ptzPresetList.value.length === 0) {
  1619. ElMessage.warning(t('暂无可用预置位'))
  1620. return
  1621. }
  1622. // 按ID排序预置位列表(取前几个数字小的)
  1623. const sortedPresets = [...ptzPresetList.value]
  1624. .filter((p) => !isNaN(parseInt(p.id)))
  1625. .sort((a, b) => parseInt(a.id) - parseInt(b.id))
  1626. let linkedCount = 0
  1627. timelinePoints.value.forEach((point, index) => {
  1628. if (index < sortedPresets.length) {
  1629. const preset = sortedPresets[index]
  1630. point.presetId = parseInt(preset.id)
  1631. point.presetName = preset.name || `Preset ${preset.id}`
  1632. point.active = true
  1633. linkedCount++
  1634. }
  1635. })
  1636. if (linkedCount > 0) {
  1637. saveTimelineConfig()
  1638. ElMessage.success(`${t('已映射')} ${linkedCount} ${t('个点位')}`)
  1639. } else {
  1640. ElMessage.warning(t('没有可映射的点位'))
  1641. }
  1642. }
  1643. // 右键菜单删除点
  1644. function handlePointContextMenu(e: MouseEvent, point: TimelinePoint) {
  1645. ElMessageBox.confirm(`${t('确定删除')} "${point.presetName || `Point ${point.id}`}"?`, t('删除确认'), {
  1646. type: 'warning'
  1647. })
  1648. .then(() => {
  1649. const index = timelinePoints.value.findIndex((p) => p.id === point.id)
  1650. if (index !== -1) {
  1651. timelinePoints.value.splice(index, 1)
  1652. sortAndRenumberPoints()
  1653. saveTimelineConfig()
  1654. if (selectedPoint.value?.id === point.id) {
  1655. selectedPoint.value = null
  1656. }
  1657. ElMessage.success(t('已删除'))
  1658. }
  1659. })
  1660. .catch(() => {})
  1661. }
  1662. // 时长改变时重新分配点的时间
  1663. function handleDurationChange() {
  1664. // 如果有点超出新时长,调整它们
  1665. timelinePoints.value.forEach((point) => {
  1666. if (point.time > timelineDuration.value) {
  1667. point.time = timelineDuration.value
  1668. }
  1669. })
  1670. saveTimelineConfig()
  1671. }
  1672. // 进度条动画帧ID
  1673. let progressAnimationId: number | null = null
  1674. let loopStartTime = 0
  1675. // 播放巡航
  1676. async function playTimeline() {
  1677. const activePoints = timelinePoints.value.filter((p) => p.active).sort((a, b) => a.time - b.time)
  1678. if (activePoints.length === 0) {
  1679. ElMessage.warning(t('请先设置至少一个点位'))
  1680. return
  1681. }
  1682. const cameraId = currentMediaStream.value?.cameraId
  1683. if (!cameraId) {
  1684. ElMessage.warning(t('请先选择直播流'))
  1685. return
  1686. }
  1687. isTimelinePlaying.value = true
  1688. timelineProgress.value = 0
  1689. selectedPoint.value = null // 清除选中状态
  1690. timelinePlayAbort = new AbortController()
  1691. const totalDuration = timelineDuration.value * 1000 // 转为毫秒
  1692. // 启动进度条动画(持续更新)
  1693. function updateProgress() {
  1694. if (!isTimelinePlaying.value) return
  1695. const elapsed = Date.now() - loopStartTime
  1696. const progress = Math.min((elapsed / totalDuration) * 100, 100)
  1697. timelineProgress.value = progress
  1698. progressAnimationId = requestAnimationFrame(updateProgress)
  1699. }
  1700. try {
  1701. // 循环播放(do-while 支持循环模式)
  1702. do {
  1703. loopStartTime = Date.now()
  1704. timelineProgress.value = 0
  1705. // 启动/重启进度条动画
  1706. if (progressAnimationId) {
  1707. cancelAnimationFrame(progressAnimationId)
  1708. }
  1709. progressAnimationId = requestAnimationFrame(updateProgress)
  1710. // 播放各个点
  1711. for (let i = 0; i < activePoints.length; i++) {
  1712. if (timelinePlayAbort?.signal.aborted) break
  1713. const point = activePoints[i]
  1714. const targetTime = (point.time / timelineDuration.value) * totalDuration
  1715. const waitTime = targetTime - (Date.now() - loopStartTime)
  1716. // 等待到达该点的时间
  1717. if (waitTime > 0) {
  1718. await sleep(waitTime, timelinePlayAbort.signal)
  1719. }
  1720. if (timelinePlayAbort?.signal.aborted) break
  1721. // 跳转到该预置位
  1722. if (point.presetId) {
  1723. await presetGoto({ cameraId, presetId: point.presetId })
  1724. }
  1725. }
  1726. // 等待剩余时间
  1727. const remainingTime = totalDuration - (Date.now() - loopStartTime)
  1728. if (remainingTime > 0 && !timelinePlayAbort?.signal.aborted) {
  1729. await sleep(remainingTime, timelinePlayAbort.signal)
  1730. }
  1731. } while (isLoopEnabled.value && !timelinePlayAbort?.signal.aborted)
  1732. // 播放完成(非循环模式或被停止)
  1733. if (!timelinePlayAbort?.signal.aborted) {
  1734. timelineProgress.value = 100
  1735. await sleep(500)
  1736. ElMessage.success(t('巡航完成'))
  1737. }
  1738. } catch (error) {
  1739. if ((error as Error).name !== 'AbortError') {
  1740. console.error('巡航播放失败', error)
  1741. ElMessage.error(t('巡航播放失败'))
  1742. }
  1743. } finally {
  1744. // 停止进度条动画
  1745. if (progressAnimationId) {
  1746. cancelAnimationFrame(progressAnimationId)
  1747. progressAnimationId = null
  1748. }
  1749. isTimelinePlaying.value = false
  1750. timelineProgress.value = 0
  1751. timelinePlayAbort = null
  1752. }
  1753. }
  1754. // 停止巡航
  1755. function stopTimeline() {
  1756. // 停止进度条动画
  1757. if (progressAnimationId) {
  1758. cancelAnimationFrame(progressAnimationId)
  1759. progressAnimationId = null
  1760. }
  1761. if (timelinePlayAbort) {
  1762. timelinePlayAbort.abort()
  1763. timelinePlayAbort = null
  1764. }
  1765. isTimelinePlaying.value = false
  1766. timelineProgress.value = 0
  1767. }
  1768. // 带取消功能的 sleep
  1769. function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  1770. return new Promise((resolve, reject) => {
  1771. const timeout = setTimeout(resolve, ms)
  1772. signal?.addEventListener('abort', () => {
  1773. clearTimeout(timeout)
  1774. reject(new DOMException('Aborted', 'AbortError'))
  1775. })
  1776. })
  1777. }
  1778. // 保存时间轴到 localStorage
  1779. function saveTimelineConfig() {
  1780. const config = {
  1781. duration: timelineDuration.value,
  1782. points: timelinePoints.value,
  1783. streamSn: currentMediaStream.value?.streamSn
  1784. }
  1785. localStorage.setItem(TIMELINE_STORAGE_KEY, JSON.stringify(config))
  1786. }
  1787. // 从 localStorage 加载时间轴
  1788. function loadTimelineConfig() {
  1789. const saved = localStorage.getItem(TIMELINE_STORAGE_KEY)
  1790. if (saved) {
  1791. try {
  1792. const config = JSON.parse(saved)
  1793. // 只有当 streamSn 匹配时才加载
  1794. if (config.streamSn === currentMediaStream.value?.streamSn) {
  1795. timelineDuration.value = config.duration || 180
  1796. timelinePoints.value = config.points || []
  1797. selectedPoint.value = null
  1798. } else {
  1799. // 不同的流,重置时间轴
  1800. resetTimeline()
  1801. }
  1802. } catch {
  1803. resetTimeline()
  1804. }
  1805. }
  1806. }
  1807. // 重置时间轴
  1808. function resetTimeline() {
  1809. timelineDuration.value = 180
  1810. timelinePoints.value = []
  1811. selectedPoint.value = null
  1812. isTimelinePlaying.value = false
  1813. timelineProgress.value = 0
  1814. }
  1815. function handleSizeChange(val: number) {
  1816. pageSize.value = val
  1817. currentPage.value = 1
  1818. getList()
  1819. }
  1820. function handleCurrentChange(val: number) {
  1821. currentPage.value = val
  1822. getList()
  1823. }
  1824. onMounted(async () => {
  1825. // 读取 URL 查询参数
  1826. const queryCameraId = route.query.cameraId as string
  1827. const queryAction = route.query.action as string
  1828. if (queryCameraId) {
  1829. searchForm.cameraId = queryCameraId
  1830. }
  1831. // 先加载选项数据
  1832. await loadOptions()
  1833. getList()
  1834. // 如果是创建操作,自动打开新增抽屉
  1835. if (queryAction === 'create') {
  1836. handleAdd()
  1837. // 如果提供了 cameraId,尝试查找摄像头信息以自动填充 lssId
  1838. if (queryCameraId) {
  1839. try {
  1840. const res = await adminListCameras({ cameraId: queryCameraId, size: 1 })
  1841. if (res.success && res.data?.list?.length > 0) {
  1842. const camera = res.data.list[0]
  1843. if (camera.lssId) {
  1844. form.lssId = camera.lssId
  1845. // lssId 变化会触发 watch,加载该 LSS 下的摄像头列表
  1846. // 等待摄像头列表加载完成后再设置 cameraId
  1847. setTimeout(() => {
  1848. form.cameraId = queryCameraId
  1849. }, 500)
  1850. }
  1851. }
  1852. } catch (error) {
  1853. console.error('获取摄像头信息失败', error)
  1854. }
  1855. }
  1856. }
  1857. })
  1858. </script>
  1859. <style lang="scss" scoped>
  1860. .page-container {
  1861. display: flex;
  1862. flex-direction: column;
  1863. box-sizing: border-box;
  1864. }
  1865. .command-template-container {
  1866. padding: 0 20px;
  1867. }
  1868. .search-form {
  1869. flex-shrink: 0;
  1870. margin-bottom: 16px;
  1871. padding: 16px 16px 4px 16px;
  1872. background: #f5f7fa;
  1873. :deep(.el-form-item) {
  1874. margin-bottom: 12px;
  1875. margin-right: 16px;
  1876. }
  1877. :deep(.el-input),
  1878. :deep(.el-select) {
  1879. width: 180px;
  1880. }
  1881. }
  1882. .table-wrapper {
  1883. flex: 1;
  1884. min-height: 0;
  1885. overflow: hidden;
  1886. }
  1887. .pagination-container {
  1888. flex-shrink: 0;
  1889. display: flex;
  1890. justify-content: flex-end;
  1891. padding-top: 16px;
  1892. }
  1893. :deep(.el-table) {
  1894. --el-table-row-hover-bg-color: #f0f0ff;
  1895. .el-table__row--striped td.el-table__cell {
  1896. background-color: #f8f9fc;
  1897. }
  1898. .el-table__header th {
  1899. background-color: #f5f7fa;
  1900. color: #333;
  1901. font-weight: 600;
  1902. }
  1903. }
  1904. // 合并抽屉样式
  1905. .combined-drawer {
  1906. :deep(.el-drawer__body) {
  1907. padding: 0;
  1908. display: flex;
  1909. flex-direction: column;
  1910. height: 100%;
  1911. }
  1912. }
  1913. .drawer-content {
  1914. display: flex;
  1915. flex-direction: column;
  1916. height: 100%;
  1917. }
  1918. .drawer-tabs {
  1919. flex-shrink: 0;
  1920. padding: 0 20px;
  1921. border-bottom: 1px solid #e5e7eb;
  1922. :deep(.el-tabs__header) {
  1923. margin: 0;
  1924. }
  1925. :deep(.el-tabs__nav-wrap::after) {
  1926. display: none;
  1927. }
  1928. :deep(.el-tabs__item) {
  1929. font-size: 15px;
  1930. padding: 0 20px;
  1931. height: 48px;
  1932. line-height: 48px;
  1933. }
  1934. }
  1935. .tab-content {
  1936. flex: 1;
  1937. min-height: 0;
  1938. display: flex;
  1939. flex-direction: column;
  1940. overflow: hidden;
  1941. }
  1942. .edit-content {
  1943. .drawer-body {
  1944. flex: 1;
  1945. overflow-y: auto;
  1946. }
  1947. }
  1948. .play-content {
  1949. //background-color: #f5f7fa;
  1950. }
  1951. .drawer-header {
  1952. flex-shrink: 0;
  1953. padding: 16px 20px;
  1954. font-size: 16px;
  1955. font-weight: 500;
  1956. color: #303133;
  1957. border-bottom: 1px solid #e5e7eb;
  1958. }
  1959. .drawer-body {
  1960. flex: 1;
  1961. overflow-y: auto;
  1962. padding: 20px;
  1963. }
  1964. .stream-form {
  1965. :deep(.el-form-item) {
  1966. margin-bottom: 18px;
  1967. }
  1968. :deep(.el-form-item__label) {
  1969. color: #606266;
  1970. font-size: 14px;
  1971. }
  1972. }
  1973. .drawer-footer {
  1974. flex-shrink: 0;
  1975. display: flex;
  1976. justify-content: flex-end;
  1977. padding: 12px 20px;
  1978. border-top: 1px solid #e5e7eb;
  1979. gap: 12px;
  1980. }
  1981. // 流媒体播放抽屉样式
  1982. .media-drawer {
  1983. :deep(.el-drawer__body) {
  1984. padding: 0;
  1985. display: flex;
  1986. flex-direction: column;
  1987. height: 100%;
  1988. background-color: #f5f7fa;
  1989. position: relative;
  1990. }
  1991. }
  1992. .drawer-close-btn {
  1993. position: absolute;
  1994. top: 8px;
  1995. left: 8px;
  1996. width: 32px;
  1997. height: 32px;
  1998. display: flex;
  1999. align-items: center;
  2000. justify-content: center;
  2001. // background-color: rgba(0, 0, 0, 0.5);
  2002. // border-radius: 50%;
  2003. cursor: pointer;
  2004. z-index: 10;
  2005. color: #303133;
  2006. transition: all 0.2s;
  2007. // &:hover {
  2008. // background-color: rgba(0, 0, 0, 0.7);
  2009. // }
  2010. }
  2011. .media-drawer-content {
  2012. display: flex;
  2013. height: 100%;
  2014. padding: 20px;
  2015. gap: 8px;
  2016. overflow: hidden;
  2017. }
  2018. // 左侧视频区域
  2019. .video-area {
  2020. flex: 1;
  2021. display: flex;
  2022. flex-direction: column;
  2023. min-width: 0;
  2024. background-color: #e1e1e1;
  2025. // border-radius: 0px;
  2026. overflow: hidden;
  2027. // box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  2028. .video-header {
  2029. display: flex;
  2030. align-items: center;
  2031. justify-content: space-between;
  2032. padding: 12px 16px;
  2033. background-color: #f5f7fa;
  2034. .header-left {
  2035. display: flex;
  2036. align-items: center;
  2037. gap: 12px;
  2038. }
  2039. .title {
  2040. font-size: 16px;
  2041. font-weight: 600;
  2042. color: #303133;
  2043. }
  2044. }
  2045. .player-container {
  2046. flex: 1;
  2047. min-height: 0;
  2048. background-color: #000;
  2049. display: flex;
  2050. align-items: center;
  2051. justify-content: center;
  2052. position: relative;
  2053. .player-placeholder {
  2054. display: flex;
  2055. flex-direction: column;
  2056. align-items: center;
  2057. justify-content: center;
  2058. color: #909399;
  2059. p {
  2060. margin-top: 15px;
  2061. font-size: 14px;
  2062. }
  2063. }
  2064. .stream-control-overlay {
  2065. position: absolute;
  2066. top: 50%;
  2067. left: 50%;
  2068. transform: translate(-50%, -50%);
  2069. z-index: 10;
  2070. .el-button {
  2071. font-size: 18px;
  2072. padding: 16px 32px;
  2073. border-radius: 0;
  2074. }
  2075. }
  2076. }
  2077. .player-controls {
  2078. display: flex;
  2079. align-items: center;
  2080. gap: 8px;
  2081. padding: 12px 16px;
  2082. background: #e5e7eb;
  2083. }
  2084. }
  2085. // 时间轴容器
  2086. .timeline-container {
  2087. background: #1a1a1a;
  2088. padding: 12px 16px;
  2089. flex-shrink: 0;
  2090. .timeline-header {
  2091. display: flex;
  2092. align-items: center;
  2093. gap: 12px;
  2094. margin-bottom: 12px;
  2095. .timeline-label {
  2096. color: #fff;
  2097. font-size: 13px;
  2098. font-weight: 500;
  2099. }
  2100. :deep(.el-select) {
  2101. .el-input__wrapper {
  2102. background-color: #333;
  2103. border-color: #444;
  2104. }
  2105. .el-input__inner {
  2106. color: #fff;
  2107. }
  2108. }
  2109. }
  2110. .timeline-track {
  2111. position: relative;
  2112. height: 32px;
  2113. background: linear-gradient(to right, #374151, #374151);
  2114. border-radius: 4px;
  2115. margin-bottom: 8px;
  2116. &.is-playing {
  2117. cursor: not-allowed;
  2118. .timeline-point {
  2119. cursor: not-allowed;
  2120. pointer-events: none;
  2121. }
  2122. }
  2123. .timeline-progress {
  2124. position: absolute;
  2125. top: 0;
  2126. left: 0;
  2127. height: 100%;
  2128. background: linear-gradient(to right, #dc2626, #f87171);
  2129. border-radius: 4px 0 0 4px;
  2130. pointer-events: none;
  2131. transition: width 0.3s ease;
  2132. }
  2133. .timeline-point {
  2134. position: absolute;
  2135. top: 50%;
  2136. transform: translate(-50%, -50%);
  2137. width: 24px;
  2138. height: 24px;
  2139. border-radius: 50%;
  2140. background: #6b7280; // 未激活 - 灰色
  2141. border: 2px solid #fff;
  2142. cursor: grab;
  2143. display: flex;
  2144. align-items: center;
  2145. justify-content: center;
  2146. font-size: 11px;
  2147. color: #fff;
  2148. font-weight: bold;
  2149. z-index: 5;
  2150. transition: all 0.2s;
  2151. &:hover {
  2152. transform: translate(-50%, -50%) scale(1.15);
  2153. .point-tooltip {
  2154. opacity: 1;
  2155. visibility: visible;
  2156. }
  2157. }
  2158. &.active {
  2159. background: #22c55e; // 激活 - 绿色
  2160. }
  2161. &.selected {
  2162. box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
  2163. transform: translate(-50%, -50%) scale(1.2);
  2164. z-index: 10;
  2165. .point-tooltip {
  2166. opacity: 1;
  2167. visibility: visible;
  2168. }
  2169. }
  2170. &.dragging {
  2171. cursor: grabbing;
  2172. transform: translate(-50%, -50%) scale(1.3);
  2173. box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.6);
  2174. z-index: 20;
  2175. transition: none; // 拖拽时禁用过渡动画
  2176. .point-tooltip {
  2177. opacity: 1;
  2178. visibility: visible;
  2179. }
  2180. }
  2181. .point-label {
  2182. line-height: 1;
  2183. }
  2184. .point-tooltip {
  2185. position: absolute;
  2186. bottom: calc(100% + 8px);
  2187. left: 50%;
  2188. transform: translateX(-50%);
  2189. background: rgba(0, 0, 0, 0.85);
  2190. color: #fff;
  2191. padding: 6px 10px;
  2192. border-radius: 4px;
  2193. font-size: 11px;
  2194. white-space: nowrap;
  2195. opacity: 0;
  2196. visibility: hidden;
  2197. transition: all 0.2s;
  2198. pointer-events: none;
  2199. .point-time {
  2200. color: #9ca3af;
  2201. font-size: 10px;
  2202. margin-top: 2px;
  2203. }
  2204. &::after {
  2205. content: '';
  2206. position: absolute;
  2207. top: 100%;
  2208. left: 50%;
  2209. transform: translateX(-50%);
  2210. border: 5px solid transparent;
  2211. border-top-color: rgba(0, 0, 0, 0.85);
  2212. }
  2213. }
  2214. }
  2215. }
  2216. .timeline-scale {
  2217. display: flex;
  2218. justify-content: space-between;
  2219. padding: 0 2px;
  2220. .scale-mark {
  2221. font-size: 10px;
  2222. color: #6b7280;
  2223. }
  2224. }
  2225. .timeline-point-panel {
  2226. display: flex;
  2227. align-items: center;
  2228. gap: 12px;
  2229. margin-top: 12px;
  2230. padding: 10px 12px;
  2231. background: #2a2a2a;
  2232. border-radius: 4px;
  2233. flex-wrap: wrap;
  2234. .panel-label {
  2235. color: #d1d5db;
  2236. font-size: 12px;
  2237. flex: 1;
  2238. min-width: 100%;
  2239. }
  2240. }
  2241. .timeline-auto-link {
  2242. display: flex;
  2243. align-items: center;
  2244. gap: 8px;
  2245. margin-top: 8px;
  2246. padding: 8px 12px;
  2247. background: #1f1f1f;
  2248. border-radius: 4px;
  2249. .auto-link-hint {
  2250. color: #6b7280;
  2251. font-size: 11px;
  2252. }
  2253. }
  2254. }
  2255. // 右侧控制面板
  2256. .control-panel {
  2257. width: 280px;
  2258. flex-shrink: 0;
  2259. display: flex;
  2260. flex-direction: column;
  2261. overflow-y: auto;
  2262. .ptz-collapse {
  2263. border: none;
  2264. :deep(.el-collapse-item) {
  2265. margin-bottom: 8px;
  2266. background-color: #fff;
  2267. border-radius: 8px;
  2268. overflow: hidden;
  2269. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  2270. .el-collapse-item__header {
  2271. padding: 0 16px;
  2272. height: 44px;
  2273. border-bottom: 1px solid #e5e7eb;
  2274. background-color: #f5f7fa;
  2275. &.is-active {
  2276. border-bottom-color: #e5e7eb;
  2277. }
  2278. }
  2279. .el-collapse-item__wrap {
  2280. border-bottom: none;
  2281. }
  2282. .el-collapse-item__content {
  2283. padding: 0;
  2284. // padding-bottom: 12px;
  2285. }
  2286. }
  2287. .collapse-title {
  2288. font-size: 14px;
  2289. font-weight: 600;
  2290. color: #303133;
  2291. }
  2292. .collapse-title-with-action {
  2293. display: flex;
  2294. align-items: center;
  2295. justify-content: space-between;
  2296. width: 100%;
  2297. padding-right: 8px;
  2298. .collapse-title {
  2299. font-size: 14px;
  2300. font-weight: 600;
  2301. color: #303133;
  2302. }
  2303. }
  2304. }
  2305. .panel-section {
  2306. background-color: #fff;
  2307. border-radius: 8px;
  2308. padding: 16px;
  2309. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  2310. .section-title {
  2311. display: flex;
  2312. align-items: center;
  2313. justify-content: space-between;
  2314. font-size: 14px;
  2315. font-weight: 600;
  2316. color: #303133;
  2317. margin-bottom: 12px;
  2318. padding-bottom: 8px;
  2319. border-bottom: 1px solid #e5e7eb;
  2320. }
  2321. }
  2322. }
  2323. // PTZ 方向控制网格
  2324. .ptz-grid {
  2325. display: grid;
  2326. grid-template-columns: repeat(3, 1fr);
  2327. gap: 6px;
  2328. margin-bottom: 12px;
  2329. }
  2330. .ptz-btn {
  2331. aspect-ratio: 1;
  2332. display: flex;
  2333. align-items: center;
  2334. justify-content: center;
  2335. background-color: #f5f7fa;
  2336. border: 1px solid #dcdfe6;
  2337. border-radius: 6px;
  2338. cursor: pointer;
  2339. transition: all 0.2s;
  2340. color: #606266;
  2341. &:hover {
  2342. background-color: #ecf5ff;
  2343. border-color: #4f46e5;
  2344. color: #4f46e5;
  2345. }
  2346. &:active {
  2347. background-color: #4f46e5;
  2348. color: #fff;
  2349. }
  2350. .el-icon {
  2351. font-size: 18px;
  2352. }
  2353. &.ptz-center {
  2354. background-color: #e5e7eb;
  2355. &:hover {
  2356. background-color: #4f46e5;
  2357. color: #fff;
  2358. }
  2359. }
  2360. }
  2361. // 缩放按钮
  2362. .zoom-buttons {
  2363. display: flex;
  2364. justify-content: center;
  2365. gap: 12px;
  2366. margin-bottom: 12px;
  2367. .el-button {
  2368. background-color: #f5f7fa;
  2369. border-color: #dcdfe6;
  2370. color: #606266;
  2371. &:hover {
  2372. background-color: #ecf5ff;
  2373. border-color: #4f46e5;
  2374. color: #4f46e5;
  2375. }
  2376. }
  2377. }
  2378. // 速度滑块
  2379. .speed-slider {
  2380. display: flex;
  2381. align-items: center;
  2382. gap: 12px;
  2383. .label {
  2384. font-size: 12px;
  2385. color: #606266;
  2386. flex-shrink: 0;
  2387. }
  2388. .value {
  2389. font-size: 12px;
  2390. color: #4f46e5;
  2391. width: 30px;
  2392. text-align: right;
  2393. }
  2394. :deep(.el-slider) {
  2395. flex: 1;
  2396. }
  2397. }
  2398. // 预置位区域
  2399. .preset-list {
  2400. display: flex;
  2401. flex-direction: column;
  2402. gap: 2px;
  2403. max-height: 250px;
  2404. overflow-y: auto;
  2405. }
  2406. .preset-item {
  2407. display: flex;
  2408. align-items: center;
  2409. gap: 8px;
  2410. padding: 8px 10px;
  2411. background-color: #fff;
  2412. border-bottom: 1px solid #ebeef5;
  2413. cursor: default;
  2414. transition: all 0.2s;
  2415. &:hover {
  2416. background-color: #f5f7fa;
  2417. .preset-actions {
  2418. opacity: 1;
  2419. visibility: visible;
  2420. }
  2421. }
  2422. &.active {
  2423. background-color: #ecf5ff;
  2424. .preset-index {
  2425. background-color: #4f46e5;
  2426. color: #fff;
  2427. }
  2428. .preset-name {
  2429. color: #4f46e5;
  2430. font-weight: 500;
  2431. }
  2432. }
  2433. .preset-index {
  2434. width: 24px;
  2435. height: 24px;
  2436. display: flex;
  2437. align-items: center;
  2438. justify-content: center;
  2439. background-color: #f0f0f0;
  2440. border-radius: 4px;
  2441. font-size: 12px;
  2442. font-weight: 500;
  2443. color: #606266;
  2444. flex-shrink: 0;
  2445. }
  2446. .preset-name {
  2447. flex: 1;
  2448. font-size: 13px;
  2449. color: #606266;
  2450. overflow: hidden;
  2451. text-overflow: ellipsis;
  2452. white-space: nowrap;
  2453. }
  2454. .preset-actions {
  2455. display: flex;
  2456. align-items: center;
  2457. gap: 6px;
  2458. opacity: 0;
  2459. visibility: hidden;
  2460. transition: all 0.2s;
  2461. .action-icon {
  2462. width: 20px;
  2463. height: 20px;
  2464. display: flex;
  2465. align-items: center;
  2466. justify-content: center;
  2467. cursor: pointer;
  2468. color: #909399;
  2469. transition: color 0.2s;
  2470. &:hover {
  2471. color: #4f46e5;
  2472. }
  2473. &.delete:hover {
  2474. color: #f56c6c;
  2475. }
  2476. }
  2477. }
  2478. }
  2479. // 摄像头信息区域
  2480. .camera-info-content {
  2481. min-height: 60px;
  2482. }
  2483. .info-item {
  2484. display: flex;
  2485. justify-content: space-between;
  2486. align-items: center;
  2487. padding: 6px 0;
  2488. border-bottom: 1px dashed #e5e7eb;
  2489. &:last-child {
  2490. border-bottom: none;
  2491. }
  2492. }
  2493. .info-label {
  2494. font-size: 12px;
  2495. color: #909399;
  2496. }
  2497. .info-value {
  2498. font-size: 12px;
  2499. color: #303133;
  2500. font-weight: 500;
  2501. }
  2502. </style>