index.vue 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649
  1. <template>
  2. <div class="page-container">
  3. <!-- 搜索表单 -->
  4. <div class="search-form">
  5. <el-form :model="searchForm" inline data-id="search-form">
  6. <el-form-item>
  7. <el-input
  8. v-model.trim="searchForm.lssId"
  9. :placeholder="t('LSS ID')"
  10. clearable
  11. data-id="search-keyword"
  12. @keyup.enter="handleSearch"
  13. />
  14. </el-form-item>
  15. <el-form-item>
  16. <el-input
  17. v-model.trim="searchForm.lssName"
  18. :placeholder="t('名称')"
  19. clearable
  20. data-id="search-keyword"
  21. @keyup.enter="handleSearch"
  22. />
  23. </el-form-item>
  24. <el-form-item>
  25. <el-select v-model="searchForm.status" :placeholder="t('心跳')" clearable data-id="search-enabled">
  26. <el-option :label="t('全部')" value="" />
  27. <el-option :label="t('活跃')" value="active" />
  28. <el-option :label="t('待机')" value="hold" />
  29. <el-option :label="t('离线')" value="dead" />
  30. </el-select>
  31. </el-form-item>
  32. <el-form-item>
  33. <el-button type="primary" :icon="Search" data-id="btn-search" @click="handleSearch">
  34. {{ t('查询') }}
  35. </el-button>
  36. <el-button :icon="RefreshRight" data-id="btn-reset" @click="handleReset">{{ t('重置') }}</el-button>
  37. </el-form-item>
  38. </el-form>
  39. </div>
  40. <!-- 数据表格 -->
  41. <div class="table-wrapper">
  42. <el-table
  43. ref="tableRef"
  44. v-loading="loading"
  45. :data="lssList"
  46. stripe
  47. size="default"
  48. data-id="lss-table"
  49. height="100%"
  50. @sort-change="handleSortChange"
  51. >
  52. <el-table-column prop="lssId" :label="t('LSS ID')" min-width="120" sortable="custom" show-overflow-tooltip />
  53. <el-table-column prop="lssName" :label="t('名称')" min-width="140" sortable="custom" show-overflow-tooltip />
  54. <el-table-column prop="address" :label="t('地址')" min-width="180" sortable="custom" show-overflow-tooltip />
  55. <el-table-column prop="ip" :label="t('IP')" min-width="180" sortable="custom" show-overflow-tooltip />
  56. <el-table-column :label="t('心跳')" width="220" align="center">
  57. <template #default="{ row }">
  58. {{
  59. row.status === 'active'
  60. ? t('活跃')
  61. : row.status === 'hold'
  62. ? t('待机')
  63. : row.status === 'dead'
  64. ? t('离线')
  65. : '-'
  66. }}
  67. |
  68. {{ formatTime(row.lastHeartbeatAt) }}
  69. </template>
  70. </el-table-column>
  71. <el-table-column :label="t('设备列表')" align="center">
  72. <template #default="{ row }">
  73. <el-button type="primary" link @click="handleEdit(row, 'camera')">
  74. <Icon icon="mdi:cctv" width="20" height="20" />
  75. </el-button>
  76. </template>
  77. </el-table-column>
  78. <el-table-column :label="t('ably')" align="center" fixed="right">
  79. <template #default="{ row }">
  80. {{ row.ablyClientId }}
  81. </template>
  82. </el-table-column>
  83. <el-table-column :label="t('操作')" align="center" fixed="right">
  84. <template #default="{ row }">
  85. <el-button type="primary" link @click="handleEdit(row, 'detail')">
  86. <Icon icon="mdi:note-edit-outline" width="20" height="20" />
  87. </el-button>
  88. <el-button type="primary" link @click="handleScanDevices(row)">
  89. <Icon icon="mdi:radar" width="20" height="20" />
  90. </el-button>
  91. <el-button type="danger" link @click="handleDelete(row)">
  92. <Icon icon="mdi:delete" width="20" height="20" />
  93. </el-button>
  94. </template>
  95. </el-table-column>
  96. </el-table>
  97. </div>
  98. <!-- LSS 详情抽屉 -->
  99. <el-drawer v-model="detailDrawerVisible" :title="t('LSS 节点详情')" direction="rtl" size="500px" destroy-on-close>
  100. <el-descriptions :column="1" border>
  101. <el-descriptions-item label="LSS ID">{{ currentLss?.lssId }}</el-descriptions-item>
  102. <el-descriptions-item label="名称">{{ currentLss?.lssName }}</el-descriptions-item>
  103. <el-descriptions-item label="地址">{{ currentLss?.address }}</el-descriptions-item>
  104. <el-descriptions-item label="机器 ID">{{ currentLss?.machineId || '-' }}</el-descriptions-item>
  105. <el-descriptions-item label="状态">
  106. <el-tag :type="getStatusTagType(currentLss?.status)" size="small">
  107. {{ formatStatus(currentLss?.status) }}
  108. </el-tag>
  109. </el-descriptions-item>
  110. <el-descriptions-item label="任务数">
  111. {{ currentLss?.currentTasks }} / {{ currentLss?.maxTasks }}
  112. </el-descriptions-item>
  113. <el-descriptions-item label="FFmpeg 版本">{{ currentLss?.ffmpegVersion || '-' }}</el-descriptions-item>
  114. <el-descriptions-item label="系统信息">{{ currentLss?.systemInfo || '-' }}</el-descriptions-item>
  115. <el-descriptions-item label="启用状态">
  116. <el-tag :type="currentLss?.enabled ? 'success' : 'info'" size="small">
  117. {{ currentLss?.enabled ? '已启用' : '已禁用' }}
  118. </el-tag>
  119. </el-descriptions-item>
  120. <el-descriptions-item label="创建时间">{{ formatTime(currentLss?.createdAt) }}</el-descriptions-item>
  121. <el-descriptions-item label="更新时间">{{ formatTime(currentLss?.updatedAt) }}</el-descriptions-item>
  122. </el-descriptions>
  123. </el-drawer>
  124. <!-- LSS 编辑抽屉 -->
  125. <el-drawer
  126. v-model="lssEditDrawerVisible"
  127. direction="rtl"
  128. :size="editDrawerSize"
  129. :with-header="false"
  130. destroy-on-close
  131. class="lss-edit-drawer"
  132. >
  133. <div class="drawer-content">
  134. <!-- 顶部 Tabs -->
  135. <el-tabs v-model="editActiveTab" class="drawer-tabs">
  136. <el-tab-pane :label="t('LSS详情')" name="detail" />
  137. <el-tab-pane :label="t('摄像头列表')" name="camera" />
  138. <el-tab-pane :label="t('推币机列表')" name="pusher" />
  139. </el-tabs>
  140. <div class="drawer-body">
  141. <!-- LSS 详情 Tab -->
  142. <div v-show="editActiveTab === 'detail'" class="lss-detail-form">
  143. <el-form ref="lssEditFormRef" :model="lssEditForm" label-width="80px" label-position="left">
  144. <el-form-item label="LSS ID:">
  145. <span class="form-value">{{ currentLss?.lssId }}</span>
  146. </el-form-item>
  147. <el-form-item :label="t('名称') + ':'" prop="lssName">
  148. <el-input v-model="lssEditForm.lssName" placeholder="请输入名称" style="width: 180px" />
  149. </el-form-item>
  150. <el-form-item :label="t('地址') + ':'" prop="address">
  151. <el-input v-model="lssEditForm.address" placeholder="请输入地址" />
  152. </el-form-item>
  153. <el-form-item :label="t('IP') + ':'">
  154. <span class="form-value">{{ currentLss?.ip }}</span>
  155. </el-form-item>
  156. <el-form-item :label="t('心跳') + ':'">
  157. <span class="heartbeat-status" :class="getHeartbeatClass(currentLss?.status)">
  158. {{ formatHeartbeat(currentLss) }}
  159. <span class="heartbeat-dot" :class="getHeartbeatClass(currentLss?.status)"></span>
  160. </span>
  161. <el-tooltip placement="right" effect="light">
  162. <template #content>
  163. <div class="heartbeat-tooltip">
  164. <div class="tooltip-title">{{ t('心跳状态') }}:</div>
  165. <div>{{ t('活跃') }} - {{ t('持续返回中,并且频繁') }}</div>
  166. <div>{{ t('待机') }} - {{ t('五分钟内有返回') }}</div>
  167. <div>{{ t('离线') }} - {{ t('五分钟内没有返回') }}</div>
  168. <div class="tooltip-format">{{ t('表现形式为') }}:</div>
  169. <div class="tooltip-example">{{ t('Status') }} [yy-mm-dd 00:00:00]</div>
  170. </div>
  171. </template>
  172. <el-icon class="heartbeat-info-icon">
  173. <QuestionFilled />
  174. </el-icon>
  175. </el-tooltip>
  176. </el-form-item>
  177. <el-form-item :label="t('ably') + ':'" prop="ably">
  178. <div class="textarea-wrapper">
  179. <el-input
  180. disabled
  181. type="textarea"
  182. :rows="8"
  183. v-model="lssEditForm.ably"
  184. :placeholder="t('请输入ably信息')"
  185. maxlength="1000"
  186. show-word-limit
  187. />
  188. </div>
  189. </el-form-item>
  190. </el-form>
  191. </div>
  192. <!-- 摄像头列表 Tab -->
  193. <div v-show="editActiveTab === 'camera'" v-loading="cameraLoading" class="tab-content-wrapper">
  194. <div class="camera-toolbar">
  195. <el-form :model="cameraSearchForm" inline>
  196. <el-form-item>
  197. <el-input
  198. v-model.trim="cameraSearchForm.cameraId"
  199. :placeholder="t('设备ID')"
  200. clearable
  201. style="width: 200px"
  202. @keyup.enter="handleCameraSearch"
  203. />
  204. </el-form-item>
  205. <el-form-item>
  206. <el-input
  207. v-model.trim="cameraSearchForm.cameraName"
  208. :placeholder="t('名称')"
  209. clearable
  210. style="width: 200px"
  211. @keyup.enter="handleCameraSearch"
  212. />
  213. </el-form-item>
  214. <el-form-item>
  215. <el-select v-model="cameraSearchForm.status" :placeholder="t('状态')" clearable style="width: 120px">
  216. <el-option :label="t('全部')" value="" />
  217. <el-option :label="t('在线')" value="ative" />
  218. <el-option :label="t('待机')" value="hold" />
  219. <el-option :label="t('离线')" value="dead" />
  220. </el-select>
  221. </el-form-item>
  222. <el-form-item>
  223. <el-button type="primary" :icon="Search" @click="handleCameraSearch">{{ t('查询') }}</el-button>
  224. <el-button :icon="RefreshRight" @click="handleCameraReset">{{ t('重置') }}</el-button>
  225. <el-button type="primary" :icon="Plus" @click="handleAddCamera">{{ t('新增') }}</el-button>
  226. </el-form-item>
  227. </el-form>
  228. </div>
  229. <el-empty v-if="!cameraLoading && cameraList.length === 0" :description="t('暂无关联设备')" />
  230. <el-table v-else :data="cameraList" stripe size="small" border>
  231. <el-table-column prop="cameraId" :label="t('设备ID')" min-width="100" show-overflow-tooltip />
  232. <el-table-column prop="cameraName" :label="t('名称')" min-width="100" show-overflow-tooltip />
  233. <el-table-column :label="t('状态(心跳)')" min-width="140">
  234. <template #default="{ row }">
  235. <span :class="['status-text', row.status === 'active' ? 'status-active' : 'status-dead']">
  236. {{ formatCameraStatus(row) }}
  237. </span>
  238. </template>
  239. </el-table-column>
  240. <el-table-column :label="t('参数配置')" min-width="80" align="center">
  241. <template #default="{ row }">
  242. <el-button type="primary" link @click="handleViewConfig(row)">{{ t('查看') }}</el-button>
  243. </template>
  244. </el-table-column>
  245. <el-table-column :label="t('运行参数')" min-width="80" align="center">
  246. <template #default="{ row }">
  247. <el-button type="primary" link @click="handleViewRunParams(row)">{{ t('查看') }}</el-button>
  248. </template>
  249. </el-table-column>
  250. <el-table-column prop="brand" :label="t('厂商')" min-width="90">
  251. <template #default="{ row }">
  252. {{ formatBrand(row.vendorName) }}
  253. </template>
  254. </el-table-column>
  255. <el-table-column prop="model" :label="t('型号')" min-width="130" show-overflow-tooltip />
  256. <el-table-column :label="t('添加时间')" min-width="140">
  257. <template #default="{ row }">
  258. {{ formatTime(row.createdAt) }}
  259. </template>
  260. </el-table-column>
  261. <el-table-column :label="t('设备控制')" min-width="100" align="center" fixed="right">
  262. <template #default="{ row }">
  263. <el-button type="primary" link @click="handleEditCamera(row)">
  264. <Icon icon="mdi:note-edit-outline" width="20" height="20" />
  265. </el-button>
  266. <el-button type="danger" link @click="handleDeleteCamera(row)">
  267. <Icon icon="mdi:delete" width="20" height="20" />
  268. </el-button>
  269. <el-button
  270. :tooltip="t('查看Cloudflare Stream')"
  271. link
  272. :class="['crosshairs-btn', { active: row.streamSn }]"
  273. @click="handleViewCamera(row)"
  274. >
  275. <Icon icon="mdi:crosshairs" width="20" height="20" />
  276. </el-button>
  277. </template>
  278. </el-table-column>
  279. </el-table>
  280. <div v-if="cameraList.length > 0" class="camera-count">
  281. {{ t('共') }} {{ cameraList.length }} {{ t('个设备') }}
  282. </div>
  283. </div>
  284. <!-- 推币机列表 Tab -->
  285. <div v-show="editActiveTab === 'pusher'" class="tab-content">
  286. <el-empty :description="t('暂无推币机数据')" />
  287. </div>
  288. </div>
  289. <div class="drawer-footer">
  290. <el-button @click="lssEditDrawerVisible = false">{{ t('取消') }}</el-button>
  291. <el-button type="primary" :loading="lssUpdating" @click="handleUpdateLss">{{ t('更新') }}</el-button>
  292. </div>
  293. </div>
  294. </el-drawer>
  295. <!-- 设备列表抽屉 -->
  296. <el-drawer
  297. v-model="cameraDrawerVisible"
  298. :title="`${t('设备列表')} - ${currentLss?.lssName || ''}`"
  299. direction="rtl"
  300. size="80%"
  301. destroy-on-close
  302. class="device-drawer"
  303. >
  304. <el-tabs v-model="deviceActiveTab" class="device-tabs">
  305. <el-tab-pane :label="t('摄像头列表')" name="camera">
  306. <div v-loading="cameraLoading" class="tab-content-wrapper">
  307. <div class="camera-toolbar">
  308. <el-form :model="cameraSearchForm" inline>
  309. <el-form-item>
  310. <el-input
  311. v-model.trim="cameraSearchForm.keyword"
  312. :placeholder="t('IP / 设备ID / 名称')"
  313. clearable
  314. style="width: 200px"
  315. @keyup.enter="handleCameraSearch"
  316. />
  317. </el-form-item>
  318. <el-form-item>
  319. <el-select v-model="cameraSearchForm.status" :placeholder="t('状态')" clearable style="width: 120px">
  320. <el-option :label="t('全部')" value="" />
  321. <el-option :label="t('在线')" value="ONLINE" />
  322. <el-option :label="t('离线')" value="OFFLINE" />
  323. </el-select>
  324. </el-form-item>
  325. <el-form-item>
  326. <el-button type="primary" :icon="Search" @click="handleCameraSearch">{{ t('查询') }}</el-button>
  327. <el-button :icon="RefreshRight" @click="handleCameraReset">{{ t('重置') }}</el-button>
  328. </el-form-item>
  329. </el-form>
  330. <el-button type="primary" :icon="Plus" @click="handleAddCamera">{{ t('新增') }}</el-button>
  331. </div>
  332. <el-empty v-if="!cameraLoading && cameraList.length === 0" :description="t('暂无关联设备')" />
  333. <el-table v-else :data="cameraList" stripe size="small" border>
  334. <!-- <el-table-column prop="ip" label="本地IP" min-width="110" /> -->
  335. <el-table-column prop="cameraId" :label="t('设备ID')" min-width="100" show-overflow-tooltip />
  336. <el-table-column prop="cameraName" :label="t('名称')" min-width="100" show-overflow-tooltip />
  337. <el-table-column :label="t('状态(心跳)')" min-width="140">
  338. <template #default="{ row }">
  339. <span :class="['status-text', row.status === 'active' ? 'status-active' : 'status-dead']">
  340. {{ formatCameraStatus(row) }}
  341. </span>
  342. </template>
  343. </el-table-column>
  344. <el-table-column :label="t('参数配置')" min-width="80" align="center">
  345. <template #default="{ row }">
  346. <el-button type="primary" link @click="handleViewConfig(row)">查看</el-button>
  347. </template>
  348. </el-table-column>
  349. <el-table-column :label="t('运行参数')" min-width="80" align="center">
  350. <template #default="{ row }">
  351. <el-button type="primary" link @click="handleViewRunParams(row)">查看</el-button>
  352. </template>
  353. </el-table-column>
  354. <el-table-column prop="brand" :label="t('厂商')" min-width="90">
  355. <template #default="{ row }">
  356. {{ formatBrand(row.brand) }}
  357. </template>
  358. </el-table-column>
  359. <el-table-column prop="model" :label="t('型号')" min-width="130" show-overflow-tooltip />
  360. <el-table-column :label="t('添加时间')" min-width="140">
  361. <template #default="{ row }">
  362. {{ formatTime(row.createdAt) }}
  363. </template>
  364. </el-table-column>
  365. <el-table-column :label="t('设备控制')" min-width="100" align="center" fixed="right">
  366. <template #default="{ row }">
  367. <el-button type="primary" link :icon="Edit" @click="handleEditCamera(row)" />
  368. <el-button type="danger" link :icon="Delete" @click="handleDeleteCamera(row)" />
  369. <el-button link :class="['crosshairs-btn', { active: !row.streamSn }]" @click="handleViewCamera(row)">
  370. <Icon icon="mdi:crosshairs" />
  371. </el-button>
  372. </template>
  373. </el-table-column>
  374. </el-table>
  375. <div v-if="cameraList.length > 0" class="camera-count">共 {{ cameraList.length }} 个设备</div>
  376. </div>
  377. </el-tab-pane>
  378. <el-tab-pane label="推币机列表" name="pusher">
  379. <div class="tab-content-wrapper">
  380. <div class="camera-toolbar">
  381. <el-form inline>
  382. <el-form-item>
  383. <el-input placeholder="设备ID / 名称" clearable style="width: 200px" />
  384. </el-form-item>
  385. <el-form-item>
  386. <el-select placeholder="状态" clearable style="width: 120px">
  387. <el-option label="全部" value="" />
  388. <el-option label="在线" value="ONLINE" />
  389. <el-option label="离线" value="OFFLINE" />
  390. </el-select>
  391. </el-form-item>
  392. <el-form-item>
  393. <el-button type="primary" :icon="Search">查询</el-button>
  394. <el-button :icon="RefreshRight">重置</el-button>
  395. </el-form-item>
  396. </el-form>
  397. <el-button type="primary" :icon="Plus">{{ t('新增') }}</el-button>
  398. </div>
  399. <el-empty description="暂无推币机数据" />
  400. </div>
  401. </el-tab-pane>
  402. <el-tab-pane label="其他设备" name="other">
  403. <div class="tab-content-wrapper">
  404. <div class="camera-toolbar">
  405. <el-form inline>
  406. <el-form-item>
  407. <el-input placeholder="设备ID / 名称" clearable style="width: 200px" />
  408. </el-form-item>
  409. <el-form-item>
  410. <el-select placeholder="状态" clearable style="width: 120px">
  411. <el-option label="全部" value="" />
  412. <el-option label="在线" value="ONLINE" />
  413. <el-option label="离线" value="OFFLINE" />
  414. </el-select>
  415. </el-form-item>
  416. <el-form-item>
  417. <el-button type="primary" :icon="Search">查询</el-button>
  418. <el-button :icon="RefreshRight">重置</el-button>
  419. </el-form-item>
  420. </el-form>
  421. <el-button type="primary" :icon="Plus">{{ t('新增') }}</el-button>
  422. </div>
  423. <el-empty description="暂无其他设备数据" />
  424. </div>
  425. </el-tab-pane>
  426. </el-tabs>
  427. </el-drawer>
  428. <!-- 摄像头编辑抽屉 -->
  429. <el-drawer
  430. v-model="cameraDialogVisible"
  431. :title="isEditCamera ? '编辑摄像头' : '添加摄像头'"
  432. direction="rtl"
  433. size="500px"
  434. :close-on-click-modal="false"
  435. destroy-on-close
  436. class="camera-edit-drawer"
  437. >
  438. <el-form ref="cameraFormRef" :model="cameraForm" :rules="cameraRules" label-width="100px">
  439. <!-- <el-form-item label="IP 地址" prop="ip">
  440. <el-input v-model="cameraForm.ip" :disabled="isEditCamera" placeholder="请输入 IP 地址" />
  441. </el-form-item> -->
  442. <el-form-item label="设备ID" prop="cameraId">
  443. <el-input v-model="cameraForm.cameraId" :disabled="isEditCamera" placeholder="请输入设备ID" />
  444. </el-form-item>
  445. <el-form-item label="设备名称" prop="cameraName">
  446. <el-input v-model="cameraForm.cameraName" placeholder="请输入设备名称" />
  447. </el-form-item>
  448. <el-form-item label="厂商" prop="vendorName">
  449. <el-select
  450. v-model="cameraForm.vendorName"
  451. placeholder="请选择摄像头"
  452. style="width: 100%"
  453. filterable
  454. @change="handleVendorSelect"
  455. >
  456. <el-option
  457. v-for="vendor in [
  458. { id: 'hikvision', name: '海康威视' },
  459. { id: 'dahua', name: '大华' },
  460. { id: 'uniview', name: '宇视' },
  461. { id: 'other', name: '其他' }
  462. ]"
  463. :key="vendor.id"
  464. :label="vendor.name"
  465. :value="vendor.id"
  466. />
  467. </el-select>
  468. </el-form-item>
  469. <el-form-item label="型号" prop="model">
  470. <el-input v-model="cameraForm.model" placeholder="请输入型号" />
  471. </el-form-item>
  472. <!-- <el-form-item label="摄像头型号" prop="cameraId">
  473. <el-select v-model="cameraForm.selectedVendorId" placeholder="请选择摄像头" style="width: 100%" filterable
  474. @change="handleVendorSelect">
  475. <el-option v-for="vendor in cameraVendorList" :key="vendor.id" :label="vendor.name" :value="vendor.id" />
  476. </el-select>
  477. </el-form-item> -->
  478. <!-- <el-form-item label="名称" prop="name">
  479. <el-input v-model="cameraForm.name" placeholder="请输入名称" />
  480. </el-form-item> -->
  481. <!-- <el-form-item label="端口" prop="port">
  482. <el-input-number v-model="cameraForm.port" :min="1" :max="65535" style="width: 100%" />
  483. </el-form-item> -->
  484. <!-- <el-form-item label="用户名" prop="username">
  485. <el-input v-model="cameraForm.username" placeholder="请输入用户名" />
  486. </el-form-item>
  487. <el-form-item label="密码" prop="password">
  488. <el-input v-model="cameraForm.password" type="password" show-password placeholder="请输入密码" />
  489. </el-form-item> -->
  490. <el-form-item label="参数配置">
  491. <CodeEditor
  492. v-model="cameraForm.paramConfig"
  493. language="json"
  494. height="200px"
  495. placeholder="请输入参数配置 (JSON)"
  496. />
  497. </el-form-item>
  498. <br />
  499. <el-form-item label="设备运行参数">
  500. <CodeEditor
  501. v-model="cameraForm.runtimeParams"
  502. language="json"
  503. height="200px"
  504. placeholder="设备运行参数 (JSON)"
  505. />
  506. </el-form-item>
  507. </el-form>
  508. <template #footer>
  509. <div class="drawer-footer">
  510. <el-button @click="cameraDialogVisible = false">{{ t('取消') }}</el-button>
  511. <el-button type="primary" :loading="cameraSubmitting" @click="handleSubmitCamera">{{ t('确定') }}</el-button>
  512. </div>
  513. </template>
  514. </el-drawer>
  515. <!-- 参数配置/运行参数抽屉 -->
  516. <el-drawer
  517. v-model="paramsDialogVisible"
  518. :title="paramsDialogTitle"
  519. direction="rtl"
  520. size="500px"
  521. :close-on-click-modal="false"
  522. destroy-on-close
  523. class="params-drawer"
  524. >
  525. <CodeEditor
  526. v-model="paramsContent"
  527. language="json"
  528. height="500px"
  529. :placeholder="paramsDialogType === 'config' ? '请输入参数配置(JSON 格式)' : '请输入运行参数(JSON 格式)'"
  530. />
  531. <template #footer>
  532. <div class="drawer-footer">
  533. <el-button @click="paramsDialogVisible = false">{{ t('取消') }}</el-button>
  534. <el-button type="primary" :loading="paramsSubmitting" @click="handleSaveParams">{{ t('更新') }}</el-button>
  535. </div>
  536. </template>
  537. </el-drawer>
  538. <!-- 分页 -->
  539. <div class="pagination-container">
  540. <el-pagination
  541. v-model:current-page="currentPage"
  542. v-model:page-size="pageSize"
  543. :page-sizes="[10, 20, 50, 100]"
  544. :total="total"
  545. layout="total, sizes, prev, pager, next, jumper"
  546. background
  547. @size-change="handleSizeChange"
  548. @current-change="handleCurrentChange"
  549. />
  550. </div>
  551. </div>
  552. </template>
  553. <script setup lang="ts">
  554. import { ref, reactive, onMounted, computed, watch } from 'vue'
  555. import { Search, RefreshRight, Delete, View, Edit, VideoCamera, Plus, QuestionFilled } from '@element-plus/icons-vue'
  556. import { ElMessage, ElMessageBox } from 'element-plus'
  557. import { listLssNodes, deleteLssNode, setLssNodeEnabled, updateLssNode } from '@/api/lss'
  558. import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminGetCamera } from '@/api/camera'
  559. import { listCameraVendors } from '@/api/camera-vendor'
  560. import { Icon } from '@iconify/vue'
  561. import CodeEditor from '@/components/CodeEditor.vue'
  562. import type {
  563. LssNodeDTO,
  564. LssNodeStatus,
  565. LssNodeListRequest,
  566. LssHeartbeatStatus,
  567. CameraInfoDTO,
  568. CameraAddRequest,
  569. CameraUpdateRequest,
  570. CameraVendorDTO,
  571. IAbly,
  572. CameraHeartbeatStatus
  573. } from '@/types'
  574. import type { FormInstance, FormRules } from 'element-plus'
  575. import dayjs from 'dayjs'
  576. import { useI18n } from 'vue-i18n'
  577. import { useRouter } from 'vue-router'
  578. const { t } = useI18n({ useScope: 'global' })
  579. const router = useRouter()
  580. // 格式化状态显示
  581. function formatStatus(status: LssNodeStatus | undefined): string {
  582. switch (status) {
  583. case 'active':
  584. return '在线'
  585. case 'hold':
  586. return '离线'
  587. case 'dead':
  588. return '离线'
  589. default:
  590. return '离线'
  591. }
  592. }
  593. // 获取状态标签类型
  594. function getStatusTagType(status: LssNodeStatus | undefined): 'success' | 'danger' | 'warning' | 'info' {
  595. switch (status) {
  596. case 'ONLINE':
  597. return 'success'
  598. case 'OFFLINE':
  599. return 'danger'
  600. case 'BUSY':
  601. return 'warning'
  602. case 'MAINTENANCE':
  603. return 'info'
  604. default:
  605. return 'info'
  606. }
  607. }
  608. // 格式化时间
  609. function formatTime(time: string | undefined): string {
  610. if (!time) return '-'
  611. return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
  612. }
  613. // 格式化摄像头状态
  614. function formatCameraStatus(row: CameraInfoDTO): string {
  615. if (row.status === 'active') {
  616. // 大约5秒钟
  617. return `active [${formatTime(row.updatedAt)}]`
  618. } else if (row.status === 'hold') {
  619. // 大约5分钟没有返回
  620. return `hold [${formatTime(row.updatedAt)}]`
  621. } else {
  622. // 大约10分钟没有返回
  623. return `dead (离线)`
  624. }
  625. }
  626. // 当前激活的摄像头 ID
  627. const activeCameraId = ref<number | null>(null)
  628. function handleViewCamera(row: CameraInfoDTO) {
  629. // 切换激活状态
  630. router.push(`/live-stream?cameraId=${row.cameraId}`)
  631. }
  632. // 格式化品牌
  633. function formatBrand(brand: string | undefined): string {
  634. const brandMap: Record<string, string> = {
  635. hikvision: 'HIKVISION',
  636. dahua: 'DAHUA',
  637. uniview: 'UNIVIEW',
  638. other: '其他'
  639. }
  640. return brand ? brandMap[brand] || brand.toUpperCase() : '-'
  641. }
  642. // 验证 JSON 格式
  643. function isValidJson(str: string): boolean {
  644. if (!str || !str.trim()) return true // 空值视为有效
  645. try {
  646. JSON.parse(str)
  647. return true
  648. } catch {
  649. return false
  650. }
  651. }
  652. // 格式化心跳状态
  653. function formatHeartbeat(lss: LssNodeDTO | null | undefined): string {
  654. if (!lss) return '-'
  655. const status = lss.heartbeat || (lss.status === 'active' ? 'active' : lss.status === 'hold' ? 'hold' : 'dead')
  656. const time = lss.heartbeatTime || lss.updatedAt
  657. if (status === 'active') {
  658. return `active [${formatTime(time)}]`
  659. } else if (status === 'hold') {
  660. return `hold [${formatTime(time)}]`
  661. } else {
  662. return `dead (离线)`
  663. }
  664. }
  665. // 获取心跳状态样式类
  666. function getHeartbeatClass(status: LssHeartbeatStatus | undefined): string {
  667. switch (status) {
  668. case 'active':
  669. return 'status-active'
  670. case 'hold':
  671. return 'status-hold'
  672. case 'dead':
  673. default:
  674. return 'status-dead'
  675. }
  676. }
  677. // 查看参数配置
  678. function handleViewConfig(row: CameraInfoDTO) {
  679. paramsCamera.value = row
  680. paramsDialogType.value = 'config'
  681. paramsDialogTitle.value = `参数配置 - ${row.cameraName}`
  682. paramsContent.value = row.paramConfig || ''
  683. paramsDialogVisible.value = true
  684. }
  685. // 查看运行参数
  686. function handleViewRunParams(row: CameraInfoDTO) {
  687. paramsCamera.value = row
  688. paramsDialogType.value = 'run'
  689. paramsDialogTitle.value = `运行参数 - ${row.cameraName}`
  690. paramsContent.value = row.runtimeParams || ''
  691. paramsDialogVisible.value = true
  692. }
  693. // 保存参数配置/运行参数
  694. async function handleSaveParams() {
  695. if (!paramsCamera.value) return
  696. paramsSubmitting.value = true
  697. try {
  698. const data: CameraUpdateRequest = {
  699. id: paramsCamera.value.id
  700. }
  701. if (paramsDialogType.value === 'config') {
  702. data.paramConfig = paramsContent.value
  703. } else {
  704. data.runtimeParams = paramsContent.value
  705. }
  706. const res = await adminUpdateCamera(data)
  707. if (res.success) {
  708. ElMessage.success('保存成功')
  709. paramsDialogVisible.value = false
  710. // 更新本地数据
  711. if (paramsDialogType.value === 'config') {
  712. paramsCamera.value.configParams = paramsContent.value
  713. } else {
  714. paramsCamera.value.runParams = paramsContent.value
  715. }
  716. } else {
  717. ElMessage.error(res.errMessage || '保存失败')
  718. }
  719. } catch (error) {
  720. console.error('保存参数失败', error)
  721. ElMessage.error('保存失败')
  722. } finally {
  723. paramsSubmitting.value = false
  724. }
  725. }
  726. const loading = ref(false)
  727. const lssList = ref<(LssNodeDTO & { _switching?: boolean })[]>([])
  728. const tableRef = ref()
  729. // 抽屉状态
  730. const detailDrawerVisible = ref(false)
  731. const currentLss = ref<LssNodeDTO | null>(null)
  732. // LSS 编辑抽屉状态
  733. const lssEditDrawerVisible = ref(false)
  734. const lssUpdating = ref(false)
  735. const editActiveTab = ref('detail')
  736. const lssEditFormRef = ref<FormInstance>()
  737. const lssEditForm = reactive({
  738. lssName: '',
  739. address: '',
  740. ip: '',
  741. ably: ''
  742. })
  743. // 根据当前 tab 计算抽屉宽度
  744. const editDrawerSize = computed(() => {
  745. return editActiveTab.value === 'detail' ? '500px' : '80%'
  746. })
  747. // 设备列表抽屉状态
  748. const cameraDrawerVisible = ref(false)
  749. const cameraLoading = ref(false)
  750. const deviceActiveTab = ref('camera')
  751. const cameraList = ref<CameraInfoDTO[]>([])
  752. const cameraVendorList = ref<CameraVendorDTO[]>([])
  753. // 摄像头搜索表单
  754. const cameraSearchForm = reactive({
  755. cameraId: '',
  756. cameraName: '',
  757. status: '' as CameraHeartbeatStatus | ''
  758. })
  759. // 摄像头编辑弹窗状态
  760. const cameraDialogVisible = ref(false)
  761. const cameraFormRef = ref<FormInstance>()
  762. const isEditCamera = ref(false)
  763. const cameraSubmitting = ref(false)
  764. const currentCamera = ref<CameraInfoDTO | null>(null)
  765. // 参数配置/运行参数弹窗状态
  766. const paramsDialogVisible = ref(false)
  767. const paramsDialogTitle = ref('')
  768. const paramsDialogType = ref<'config' | 'run'>('config')
  769. const paramsContent = ref('')
  770. const paramsSubmitting = ref(false)
  771. const paramsCamera = ref<CameraInfoDTO | null>(null)
  772. // 摄像头表单
  773. const cameraForm = reactive({
  774. selectedVendorId: null as number | null,
  775. cameraId: '',
  776. cameraName: '',
  777. vendorName: '',
  778. model: '',
  779. ip: '',
  780. port: 80,
  781. username: '',
  782. password: '',
  783. brand: '',
  784. capability: 'switch_only' as 'switch_only' | 'ptz_enabled',
  785. rtspUrl: '',
  786. channelNo: '',
  787. remark: '',
  788. enabled: true,
  789. paramConfig: '',
  790. runtimeParams: ''
  791. })
  792. // 摄像头表单验证规则(动态)
  793. const cameraRules = computed<FormRules>(() => ({}))
  794. // 排序状态
  795. const sortState = reactive<{
  796. sortBy: string
  797. sortDir: 'ASC' | 'DESC' | undefined
  798. }>({
  799. sortBy: '',
  800. sortDir: undefined
  801. })
  802. // 搜索表单
  803. const searchForm = reactive<{
  804. lssId: string
  805. lssName: string
  806. status: LssNodeStatus | ''
  807. }>({
  808. lssId: '',
  809. lssName: '',
  810. status: ''
  811. })
  812. // 分页相关
  813. const currentPage = ref(1)
  814. const pageSize = ref(20)
  815. const total = ref(0)
  816. async function getList() {
  817. loading.value = true
  818. try {
  819. const params: LssNodeListRequest = {
  820. page: currentPage.value,
  821. size: pageSize.value
  822. }
  823. if (searchForm.lssId) {
  824. params.lssId = searchForm.lssId
  825. }
  826. if (searchForm.lssName) {
  827. params.lssName = searchForm.lssName
  828. }
  829. if (searchForm.status) {
  830. params.status = searchForm.status
  831. }
  832. if (sortState.sortBy) {
  833. params.sortBy = sortState.sortBy
  834. params.sortDir = sortState.sortDir
  835. }
  836. const res = await listLssNodes(params)
  837. if (res.success && res.data) {
  838. lssList.value = res.data.list
  839. total.value = res.data.total || 0
  840. } else {
  841. ElMessage.error(res.errMessage || '获取列表失败')
  842. }
  843. } catch (error) {
  844. console.error('获取 LSS 列表失败', error)
  845. ElMessage.error('获取列表失败')
  846. } finally {
  847. loading.value = false
  848. }
  849. }
  850. function handleSearch() {
  851. currentPage.value = 1
  852. getList()
  853. }
  854. function handleReset() {
  855. searchForm.lssId = ''
  856. searchForm.lssName = ''
  857. searchForm.status = ''
  858. currentPage.value = 1
  859. getList()
  860. }
  861. function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
  862. sortState.sortBy = prop || ''
  863. sortState.sortDir = order === 'ascending' ? 'ASC' : order === 'descending' ? 'DESC' : undefined
  864. getList()
  865. }
  866. function handleSizeChange(val: number) {
  867. pageSize.value = val
  868. currentPage.value = 1
  869. getList()
  870. }
  871. function handleCurrentChange(val: number) {
  872. currentPage.value = val
  873. getList()
  874. }
  875. function handleViewDetail(row: LssNodeDTO) {
  876. currentLss.value = row
  877. detailDrawerVisible.value = true
  878. }
  879. function handleScanDevices(row: LssNodeDTO) {
  880. console.log(row)
  881. }
  882. function handleEdit(row: LssNodeDTO, tab: 'detail' | 'camera' | 'pusher') {
  883. currentLss.value = row
  884. lssEditForm.lssName = row.lssName || ''
  885. lssEditForm.address = row.address || ''
  886. lssEditForm.ably = JSON.stringify(row.ably)
  887. editActiveTab.value = tab
  888. lssEditDrawerVisible.value = true
  889. // 每次打开抽屉都刷新摄像头列表
  890. cameraSearchForm.cameraId = ''
  891. cameraSearchForm.cameraName = ''
  892. cameraSearchForm.status = ''
  893. loadCameraList()
  894. }
  895. async function handleCameraList(row: LssNodeDTO) {
  896. currentLss.value = row
  897. cameraSearchForm.cameraId = ''
  898. cameraSearchForm.cameraName = ''
  899. cameraSearchForm.status = ''
  900. deviceActiveTab.value = 'camera'
  901. cameraDrawerVisible.value = true
  902. await loadCameraList()
  903. }
  904. async function handleUpdateLss() {
  905. if (!currentLss.value) return
  906. lssUpdating.value = true
  907. try {
  908. const res = await updateLssNode({
  909. lssId: currentLss.value.lssId,
  910. lssName: lssEditForm.lssName,
  911. address: lssEditForm.address,
  912. ablyInfo: lssEditForm.ably
  913. })
  914. if (res.success) {
  915. ElMessage.success('更新成功')
  916. lssEditDrawerVisible.value = false
  917. getList()
  918. } else {
  919. ElMessage.error(res.errMessage || '更新失败')
  920. }
  921. } catch (error) {
  922. console.error('更新 LSS 失败', error)
  923. ElMessage.error('更新失败')
  924. } finally {
  925. lssUpdating.value = false
  926. }
  927. }
  928. async function loadCameraList() {
  929. if (!currentLss.value) return
  930. cameraLoading.value = true
  931. cameraList.value = []
  932. try {
  933. const params: any = { lssId: currentLss.value.lssId }
  934. if (cameraSearchForm.cameraId) {
  935. params.cameraId = cameraSearchForm.cameraId
  936. }
  937. if (cameraSearchForm.cameraName) {
  938. params.cameraName = cameraSearchForm.cameraName
  939. }
  940. if (cameraSearchForm.status) {
  941. params.status = cameraSearchForm.status
  942. }
  943. const res = await adminListCameras(params)
  944. if (res.success && res.data) {
  945. cameraList.value = res.data.list || []
  946. } else {
  947. ElMessage.error(res.errMessage || '获取摄像头列表失败')
  948. }
  949. } catch (error) {
  950. console.error('获取摄像头列表失败', error)
  951. ElMessage.error('获取摄像头列表失败')
  952. } finally {
  953. cameraLoading.value = false
  954. }
  955. }
  956. function handleCameraSearch() {
  957. loadCameraList()
  958. }
  959. function handleCameraReset() {
  960. cameraSearchForm.cameraId = ''
  961. cameraSearchForm.cameraName = ''
  962. cameraSearchForm.status = ''
  963. loadCameraList()
  964. }
  965. function resetCameraForm() {
  966. cameraForm.selectedVendorId = null
  967. cameraForm.cameraId = ''
  968. cameraForm.cameraName = ''
  969. cameraForm.vendorName = ''
  970. cameraForm.model = ''
  971. cameraForm.ip = ''
  972. cameraForm.port = 80
  973. cameraForm.username = ''
  974. cameraForm.password = ''
  975. cameraForm.brand = ''
  976. cameraForm.capability = 'switch_only'
  977. cameraForm.rtspUrl = ''
  978. cameraForm.model = ''
  979. cameraForm.channelNo = ''
  980. cameraForm.remark = ''
  981. cameraForm.enabled = true
  982. cameraForm.paramConfig = ''
  983. cameraForm.runtimeParams = ''
  984. cameraFormRef.value?.clearValidate()
  985. }
  986. async function loadCameraVendorList() {
  987. try {
  988. const res = await listCameraVendors({ enabled: true })
  989. if (res.success && res.data) {
  990. cameraVendorList.value = res.data.list || []
  991. }
  992. } catch (error) {
  993. console.error('获取厂商列表失败', error)
  994. }
  995. }
  996. function handleVendorSelect(vendorId: number) {
  997. const vendor = cameraVendorList.value.find((v) => v.id === vendorId)
  998. if (vendor) {
  999. cameraForm.brand = vendor.code
  1000. // 设置厂商默认端口
  1001. if (vendor.defaultPort) {
  1002. cameraForm.port = vendor.defaultPort
  1003. }
  1004. // 根据厂商设置默认能力
  1005. cameraForm.capability = vendor.supportPtz ? 'ptz_enabled' : 'switch_only'
  1006. }
  1007. }
  1008. async function handleAddCamera() {
  1009. isEditCamera.value = false
  1010. currentCamera.value = null
  1011. resetCameraForm()
  1012. await loadCameraVendorList()
  1013. cameraDialogVisible.value = true
  1014. }
  1015. async function handleEditCamera(row: CameraInfoDTO) {
  1016. isEditCamera.value = true
  1017. try {
  1018. // 通过 API 获取摄像头详情
  1019. const res = await adminGetCamera(row.id)
  1020. if (!res.success || !res.data) {
  1021. ElMessage.error(res.errMessage || '获取摄像头详情失败')
  1022. return
  1023. }
  1024. const camera = res.data
  1025. currentCamera.value = camera
  1026. cameraForm.selectedVendorId = null
  1027. cameraForm.cameraId = camera.cameraId
  1028. cameraForm.cameraName = camera.cameraName
  1029. cameraForm.vendorName = camera.vendorName || ''
  1030. cameraForm.model = camera.model || ''
  1031. cameraForm.ip = camera.ip
  1032. cameraForm.port = camera.port || 80
  1033. cameraForm.username = camera.username || ''
  1034. cameraForm.password = ''
  1035. cameraForm.brand = camera.brand || ''
  1036. cameraForm.capability = camera.capability || 'switch_only'
  1037. cameraForm.rtspUrl = camera.rtspUrl || ''
  1038. cameraForm.model = camera.model || ''
  1039. cameraForm.channelNo = camera.channelNo || ''
  1040. cameraForm.remark = camera.remark || ''
  1041. cameraForm.enabled = camera.enabled
  1042. cameraForm.paramConfig = camera.paramConfig || ''
  1043. cameraForm.runtimeParams = camera.runtimeParams || ''
  1044. await loadCameraVendorList()
  1045. cameraDialogVisible.value = true
  1046. } catch (error) {
  1047. console.error('获取摄像头详情失败', error)
  1048. ElMessage.error('获取摄像头详情失败')
  1049. }
  1050. }
  1051. async function handleSubmitCamera() {
  1052. if (!cameraFormRef.value) return
  1053. await cameraFormRef.value.validate(async (valid) => {
  1054. if (!valid) return
  1055. // 验证 JSON 格式
  1056. if (!isValidJson(cameraForm.paramConfig)) {
  1057. ElMessage.error('参数配置格式错误,请输入有效的 JSON')
  1058. return
  1059. }
  1060. if (!isValidJson(cameraForm.runtimeParams)) {
  1061. ElMessage.error('设备运行参数格式错误,请输入有效的 JSON')
  1062. return
  1063. }
  1064. cameraSubmitting.value = true
  1065. try {
  1066. if (isEditCamera.value) {
  1067. // 编辑模式:更新摄像头信息
  1068. if (!currentCamera.value) {
  1069. ElMessage.error('摄像头信息错误')
  1070. return
  1071. }
  1072. const data: CameraUpdateRequest = {
  1073. id: currentCamera.value.id,
  1074. cameraName: cameraForm.cameraName,
  1075. vendorName: cameraForm.vendorName,
  1076. model: cameraForm.model,
  1077. port: cameraForm.port,
  1078. username: cameraForm.username,
  1079. brand: cameraForm.brand,
  1080. capability: cameraForm.capability,
  1081. lssId: currentLss.value?.lssId,
  1082. rtspUrl: cameraForm.rtspUrl,
  1083. channelNo: cameraForm.channelNo,
  1084. remark: cameraForm.remark,
  1085. enabled: cameraForm.enabled,
  1086. paramConfig: cameraForm.paramConfig,
  1087. runtimeParams: cameraForm.runtimeParams
  1088. }
  1089. if (cameraForm.password) {
  1090. data.password = cameraForm.password
  1091. }
  1092. const res = await adminUpdateCamera(data)
  1093. if (res.success) {
  1094. ElMessage.success('更新成功')
  1095. cameraDialogVisible.value = false
  1096. loadCameraList()
  1097. } else {
  1098. ElMessage.error(res.errMessage || '更新失败')
  1099. }
  1100. } else {
  1101. // 新增模式:创建摄像头并绑定到当前 LSS
  1102. const data: CameraAddRequest = {
  1103. cameraId: cameraForm.cameraId,
  1104. cameraName: cameraForm.cameraName,
  1105. vendorName: cameraForm.vendorName,
  1106. model: cameraForm.model,
  1107. paramConfig: cameraForm.paramConfig,
  1108. runtimeParams: cameraForm.runtimeParams,
  1109. lssId: currentLss.value?.lssId
  1110. }
  1111. const res = await adminAddCamera(data)
  1112. if (res.success) {
  1113. ElMessage.success('添加成功')
  1114. cameraDialogVisible.value = false
  1115. loadCameraList()
  1116. } else {
  1117. ElMessage.error(res.errMessage || '添加失败')
  1118. }
  1119. }
  1120. } catch (error) {
  1121. console.error('保存摄像头失败', error)
  1122. ElMessage.error('操作失败')
  1123. } finally {
  1124. cameraSubmitting.value = false
  1125. }
  1126. })
  1127. }
  1128. async function handleDeleteCamera(row: CameraInfoDTO) {
  1129. try {
  1130. await ElMessageBox.confirm(`确定要删除摄像头 "${row.name}" 吗?`, '提示', {
  1131. type: 'warning'
  1132. })
  1133. const res = await adminDeleteCamera(row.id)
  1134. if (res.success) {
  1135. ElMessage.success('删除成功')
  1136. loadCameraList()
  1137. } else {
  1138. ElMessage.error(res.errMessage || '删除失败')
  1139. }
  1140. } catch (error) {
  1141. if (error !== 'cancel') {
  1142. console.error('删除摄像头失败', error)
  1143. ElMessage.error('删除失败')
  1144. }
  1145. }
  1146. }
  1147. async function handleToggleEnabled(row: LssNodeDTO & { _switching?: boolean }, enabled: boolean) {
  1148. row._switching = true
  1149. try {
  1150. const res = await setLssNodeEnabled(row.lssId, enabled)
  1151. if (res.success) {
  1152. ElMessage.success(enabled ? '已启用' : '已禁用')
  1153. } else {
  1154. // 恢复原状态
  1155. row.enabled = !enabled
  1156. ElMessage.error(res.errMessage || '操作失败')
  1157. }
  1158. } catch (error) {
  1159. row.enabled = !enabled
  1160. console.error('切换启用状态失败', error)
  1161. ElMessage.error('操作失败')
  1162. } finally {
  1163. row._switching = false
  1164. }
  1165. }
  1166. async function handleDelete(row: LssNodeDTO) {
  1167. try {
  1168. await ElMessageBox.confirm(`确定要删除 LSS 节点 "${row.lssName}" 吗?`, '提示', {
  1169. type: 'warning'
  1170. })
  1171. const res = await deleteLssNode(row.lssId)
  1172. if (res.success) {
  1173. ElMessage.success('删除成功')
  1174. getList()
  1175. } else {
  1176. ElMessage.error(res.errMessage || '删除失败')
  1177. }
  1178. } catch (error) {
  1179. if (error !== 'cancel') {
  1180. console.error('删除失败', error)
  1181. ElMessage.error('删除失败')
  1182. }
  1183. }
  1184. }
  1185. // 监听 tab 切换,加载对应数据
  1186. watch(editActiveTab, (newTab) => {
  1187. if (newTab === 'camera' && currentLss.value) {
  1188. cameraSearchForm.keyword = ''
  1189. cameraSearchForm.status = ''
  1190. loadCameraList()
  1191. }
  1192. })
  1193. onMounted(() => {
  1194. getList()
  1195. })
  1196. </script>
  1197. <style lang="scss" scoped>
  1198. .page-container {
  1199. display: flex;
  1200. flex-direction: column;
  1201. box-sizing: border-box;
  1202. }
  1203. .search-form {
  1204. flex-shrink: 0;
  1205. margin-bottom: 16px;
  1206. padding: 16px 16px 4px 16px;
  1207. background: #f5f7fa;
  1208. :deep(.el-form-item) {
  1209. margin-bottom: 12px;
  1210. margin-right: 16px;
  1211. }
  1212. :deep(.el-input),
  1213. :deep(.el-select) {
  1214. width: 160px;
  1215. }
  1216. :deep(.el-button--primary) {
  1217. background-color: #4f46e5;
  1218. border-color: #4f46e5;
  1219. &:hover,
  1220. &:focus {
  1221. background-color: #6366f1;
  1222. border-color: #6366f1;
  1223. }
  1224. }
  1225. }
  1226. .table-wrapper {
  1227. flex: 1;
  1228. min-height: 0;
  1229. overflow: hidden;
  1230. }
  1231. .pagination-container {
  1232. flex-shrink: 0;
  1233. display: flex;
  1234. justify-content: flex-end;
  1235. padding-top: 16px;
  1236. :deep(.el-pagination) {
  1237. .el-pager li.is-active {
  1238. background-color: #4f46e5;
  1239. color: #fff;
  1240. }
  1241. .el-pager li:not(.is-active):hover {
  1242. color: #4f46e5;
  1243. }
  1244. .btn-prev:hover,
  1245. .btn-next:hover {
  1246. color: #4f46e5;
  1247. }
  1248. }
  1249. }
  1250. .camera-toolbar {
  1251. display: flex;
  1252. justify-content: space-between;
  1253. align-items: flex-start;
  1254. margin-bottom: 16px;
  1255. :deep(.el-form) {
  1256. .el-form-item {
  1257. margin-bottom: 0;
  1258. margin-right: 12px;
  1259. }
  1260. }
  1261. }
  1262. .camera-count {
  1263. margin-top: 16px;
  1264. text-align: right;
  1265. color: #666;
  1266. font-size: 14px;
  1267. }
  1268. .status-text {
  1269. font-size: 12px;
  1270. }
  1271. // 十字瞄准按钮样式
  1272. .crosshairs-btn {
  1273. color: var(--color-info);
  1274. transition: color 0.2s;
  1275. &.active {
  1276. color: var(--color-primary);
  1277. }
  1278. }
  1279. .status-active {
  1280. color: #67c23a;
  1281. }
  1282. .status-hold {
  1283. color: #e6a23c;
  1284. }
  1285. .status-dead {
  1286. color: #f56c6c;
  1287. }
  1288. // 抽屉样式
  1289. :deep(.el-drawer) {
  1290. .el-drawer__header {
  1291. margin-bottom: 0;
  1292. padding: 16px 20px;
  1293. border-bottom: 1px solid #e5e7eb;
  1294. }
  1295. .el-drawer__body {
  1296. padding: 16px;
  1297. }
  1298. .el-descriptions {
  1299. .el-descriptions__label {
  1300. width: 100px;
  1301. font-weight: 600;
  1302. }
  1303. }
  1304. }
  1305. // LSS 编辑抽屉样式
  1306. .lss-edit-drawer {
  1307. :deep(.el-drawer__body) {
  1308. padding: 0;
  1309. display: flex;
  1310. flex-direction: column;
  1311. height: 100%;
  1312. }
  1313. }
  1314. .drawer-content {
  1315. display: flex;
  1316. flex-direction: column;
  1317. height: 100%;
  1318. }
  1319. .drawer-tabs {
  1320. flex-shrink: 0;
  1321. border-bottom: 1px solid #e5e7eb;
  1322. :deep(.el-tabs__header) {
  1323. margin: 0;
  1324. padding: 0 20px;
  1325. }
  1326. :deep(.el-tabs__nav-wrap::after) {
  1327. display: none;
  1328. }
  1329. :deep(.el-tabs__item) {
  1330. height: 48px;
  1331. line-height: 48px;
  1332. font-size: 14px;
  1333. color: #606266;
  1334. &.is-active {
  1335. color: #409eff;
  1336. font-weight: 500;
  1337. }
  1338. &:hover {
  1339. color: #409eff;
  1340. }
  1341. }
  1342. :deep(.el-tabs__active-bar) {
  1343. background-color: #409eff;
  1344. }
  1345. }
  1346. .drawer-body {
  1347. flex: 1;
  1348. overflow-y: auto;
  1349. padding: 20px;
  1350. }
  1351. .lss-detail-form {
  1352. :deep(.el-form-item) {
  1353. margin-bottom: 18px;
  1354. }
  1355. :deep(.el-form-item__label) {
  1356. color: #606266;
  1357. font-size: 14px;
  1358. }
  1359. .form-value {
  1360. line-height: 32px;
  1361. color: #303133;
  1362. font-size: 14px;
  1363. }
  1364. .textarea-wrapper {
  1365. width: 100%;
  1366. }
  1367. }
  1368. .heartbeat-status {
  1369. display: inline-flex;
  1370. align-items: center;
  1371. gap: 6px;
  1372. line-height: 32px;
  1373. font-size: 14px;
  1374. }
  1375. .heartbeat-dot {
  1376. display: inline-block;
  1377. width: 8px;
  1378. height: 8px;
  1379. border-radius: 50%;
  1380. &.status-active {
  1381. background-color: #67c23a;
  1382. }
  1383. &.status-hold {
  1384. background-color: #e6a23c;
  1385. }
  1386. &.status-dead {
  1387. background-color: #f56c6c;
  1388. }
  1389. }
  1390. .heartbeat-info-icon {
  1391. margin-left: 8px;
  1392. color: #909399;
  1393. cursor: pointer;
  1394. &:hover {
  1395. color: #409eff;
  1396. }
  1397. }
  1398. .heartbeat-tooltip {
  1399. font-size: 12px;
  1400. line-height: 1.6;
  1401. .tooltip-title {
  1402. font-weight: 500;
  1403. margin-bottom: 4px;
  1404. }
  1405. .tooltip-format {
  1406. margin-top: 8px;
  1407. font-weight: 500;
  1408. }
  1409. .tooltip-example {
  1410. color: #409eff;
  1411. }
  1412. }
  1413. .tab-content {
  1414. min-height: 200px;
  1415. }
  1416. // 设备列表抽屉样式
  1417. .device-drawer {
  1418. :deep(.el-drawer__body) {
  1419. padding: 0;
  1420. }
  1421. }
  1422. .device-tabs {
  1423. height: 100%;
  1424. display: flex;
  1425. flex-direction: column;
  1426. :deep(.el-tabs__header) {
  1427. margin: 0;
  1428. padding: 0 20px;
  1429. flex-shrink: 0;
  1430. border-bottom: 1px solid #e5e7eb;
  1431. }
  1432. :deep(.el-tabs__nav-wrap::after) {
  1433. display: none;
  1434. }
  1435. :deep(.el-tabs__item) {
  1436. height: 48px;
  1437. line-height: 48px;
  1438. font-size: 14px;
  1439. color: #606266;
  1440. &.is-active {
  1441. color: #409eff;
  1442. font-weight: 500;
  1443. }
  1444. &:hover {
  1445. color: #409eff;
  1446. }
  1447. }
  1448. :deep(.el-tabs__active-bar) {
  1449. background-color: #409eff;
  1450. }
  1451. :deep(.el-tabs__content) {
  1452. flex: 1;
  1453. overflow: hidden;
  1454. padding: 16px;
  1455. }
  1456. :deep(.el-tab-pane) {
  1457. height: 100%;
  1458. overflow-y: auto;
  1459. }
  1460. }
  1461. .tab-content-wrapper {
  1462. height: 100%;
  1463. }
  1464. .drawer-footer {
  1465. flex-shrink: 0;
  1466. display: flex;
  1467. justify-content: flex-end;
  1468. padding: 12px 20px;
  1469. border-top: 1px solid #e5e7eb;
  1470. gap: 12px;
  1471. }
  1472. // 表格样式
  1473. :deep(.el-table) {
  1474. --el-table-row-hover-bg-color: #f0f0ff;
  1475. .el-table__row--striped td.el-table__cell {
  1476. background-color: #f8f9fc;
  1477. }
  1478. .el-table__header th {
  1479. background-color: #f5f7fa;
  1480. color: #333;
  1481. font-weight: 600;
  1482. }
  1483. .el-button--primary.is-link {
  1484. color: #4f46e5;
  1485. &:hover {
  1486. color: #6366f1;
  1487. }
  1488. }
  1489. .caret-wrapper {
  1490. .sort-caret.ascending {
  1491. border-bottom-color: #4f46e5;
  1492. }
  1493. .sort-caret.descending {
  1494. border-top-color: #4f46e5;
  1495. }
  1496. }
  1497. }
  1498. </style>