index.vue 53 KB

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