index.vue 46 KB

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