index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. <template>
  2. <div class="threshold-management">
  3. <div class="main-content">
  4. <div class="point-list-panel">
  5. <div class="panel-title">
  6. <span>设备列表</span>
  7. <el-button :icon="Refresh" text @click="loadDeviceList">刷新</el-button>
  8. </div>
  9. <el-input
  10. v-model="searchKeyword"
  11. placeholder="搜索设备编号/名称"
  12. clearable
  13. :prefix-icon="Search"
  14. size="small"
  15. @input="handleSearch"
  16. />
  17. <div v-loading="listLoading" class="point-list">
  18. <div
  19. v-for="point in deviceList"
  20. :key="point.equipmentId"
  21. class="point-item"
  22. :class="{ active: selectedPoint?.equipmentId === point.equipmentId }"
  23. @click="selectPoint(point)"
  24. >
  25. <div class="point-header">
  26. <span class="point-name">{{ point.equipmentName || point.equipmentCode || '未命名设备' }}</span>
  27. <el-tag :type="getAlarmTagType(point)" size="small">
  28. {{ getAlarmText(point) }}
  29. </el-tag>
  30. </div>
  31. <div class="point-location">
  32. <el-icon><Location /></el-icon>
  33. <span>{{ point.equipmentLocation || '暂无位置' }}</span>
  34. </div>
  35. <div class="point-meta">
  36. <span>编号:{{ point.equipmentCode || '-' }}</span>
  37. <span>类型:{{ point.equipmentTypeName || '-' }}</span>
  38. </div>
  39. <div class="point-status-row">
  40. <el-tag size="small" effect="plain" :type="getOnlineTagType(point.equipmentStatus?.onlineStatus)">
  41. {{ getOnlineText(point.equipmentStatus?.onlineStatus) }}
  42. </el-tag>
  43. <el-tag size="small" effect="plain" :type="getUseStatusTagType(point.equipmentStatus?.currentStatus)">
  44. {{ getUseStatusText(point.equipmentStatus?.currentStatus) }}
  45. </el-tag>
  46. </div>
  47. </div>
  48. <el-empty
  49. v-if="!listLoading && !deviceList.length"
  50. description="暂无井盖监测设备"
  51. :image-size="120"
  52. />
  53. </div>
  54. <div class="pagination-area">
  55. <el-pagination
  56. small
  57. background
  58. layout="total, prev, pager, next"
  59. :total="total"
  60. :page-size="pageSize"
  61. v-model:current-page="currentPage"
  62. @current-change="loadDeviceList"
  63. />
  64. </div>
  65. </div>
  66. <div v-if="selectedPoint" v-loading="detailLoading" class="config-panel">
  67. <div class="info-card">
  68. <div class="card-title">
  69. <span><el-icon><Monitor /></el-icon> 设备基本信息</span>
  70. </div>
  71. <el-descriptions :column="2" border>
  72. <el-descriptions-item label="设备编号">{{ selectedDetail?.equipmentCode || '-' }}</el-descriptions-item>
  73. <el-descriptions-item label="设备名称">{{ selectedDetail?.equipmentName || '-' }}</el-descriptions-item>
  74. <el-descriptions-item label="设备类型">{{ selectedDetail?.equipmentTypeName || '-' }}</el-descriptions-item>
  75. <el-descriptions-item label="设备型号">{{ selectedDetail?.equipmentModel || '-' }}</el-descriptions-item>
  76. <el-descriptions-item label="生产厂商">{{ selectedDetail?.manufacturer || '-' }}</el-descriptions-item>
  77. <el-descriptions-item label="安装时间">{{ formatDateTime(selectedDetail?.installTime) }}</el-descriptions-item>
  78. <el-descriptions-item label="设备位置">{{ selectedDetail?.equipmentLocation || '-' }}</el-descriptions-item>
  79. <el-descriptions-item label="维护人员">{{ selectedDetail?.maintainer || '-' }}</el-descriptions-item>
  80. </el-descriptions>
  81. </div>
  82. <div class="info-card">
  83. <div class="card-title">
  84. <span><el-icon><Warning /></el-icon> 最新监测状态</span>
  85. </div>
  86. <div class="status-grid">
  87. <div class="status-item">
  88. <div class="status-label">在线状态</div>
  89. <div class="status-value">{{ getOnlineText(selectedDetail?.equipmentStatus?.onlineStatus) }}</div>
  90. </div>
  91. <div class="status-item">
  92. <div class="status-label">报警状态</div>
  93. <div class="status-value">{{ getAlarmText(selectedDetail) }}</div>
  94. </div>
  95. <div class="status-item">
  96. <div class="status-label">倾斜角度</div>
  97. <div class="status-value">{{ appendUnit(selectedDetail?.manholeData?.tiltAngle, '°') }}</div>
  98. </div>
  99. <div class="status-item">
  100. <div class="status-label">电池电量</div>
  101. <div class="status-value">{{ appendUnit(selectedDetail?.manholeData?.batteryLevel, '%') }}</div>
  102. </div>
  103. <div class="status-item">
  104. <div class="status-label">信号强度</div>
  105. <div class="status-value">{{ appendUnit(selectedDetail?.manholeData?.signalStrength, '%') }}</div>
  106. </div>
  107. <div class="status-item">
  108. <div class="status-label">水浸状态</div>
  109. <div class="status-value">{{ getBinaryAlarmText(selectedDetail?.manholeData?.waterInfiltrationAlarmStatus) }}</div>
  110. </div>
  111. <div class="status-item">
  112. <div class="status-label">水位状态</div>
  113. <div class="status-value">{{ getBinaryAlarmText(selectedDetail?.manholeData?.waterLevelAlarmStatus) }}</div>
  114. </div>
  115. <div class="status-item">
  116. <div class="status-label">激活状态</div>
  117. <div class="status-value">{{ selectedDetail?.manholeData?.activationStatus || '-' }}</div>
  118. </div>
  119. </div>
  120. </div>
  121. <div class="threshold-card">
  122. <div class="card-title">
  123. <span><el-icon><Setting /></el-icon> 报警阈值设置</span>
  124. <div class="card-actions">
  125. <el-button :icon="Refresh" size="small" @click="resetThresholds">重置</el-button>
  126. <el-button :loading="saveLoading" size="small" type="primary" @click="saveThresholds">保存设置</el-button>
  127. </div>
  128. </div>
  129. <div class="threshold-tip">
  130. 当前版本先对接后端已支持的 3 类阈值:倾斜、水位、温度。
  131. </div>
  132. <el-tabs v-model="activeTab" class="threshold-tabs">
  133. <el-tab-pane
  134. v-for="item in thresholdConfigList"
  135. :key="item.key"
  136. :label="item.label"
  137. :name="item.key"
  138. >
  139. <div class="threshold-item">
  140. <div class="item-label">{{ item.desc }}</div>
  141. <div class="item-control">
  142. <span class="field-label">最小值</span>
  143. <el-input-number
  144. v-model="thresholds[item.key].minValue"
  145. :controls="false"
  146. :precision="item.precision"
  147. :step="item.step"
  148. class="threshold-input"
  149. placeholder="未设置"
  150. />
  151. <span class="unit">{{ item.unit }}</span>
  152. <span class="field-label">最大值</span>
  153. <el-input-number
  154. v-model="thresholds[item.key].maxValue"
  155. :controls="false"
  156. :precision="item.precision"
  157. :step="item.step"
  158. class="threshold-input"
  159. placeholder="未设置"
  160. />
  161. <span class="unit">{{ item.unit }}</span>
  162. </div>
  163. <el-input
  164. v-model="thresholds[item.key].remark"
  165. maxlength="100"
  166. placeholder="请输入备注"
  167. />
  168. <div class="saved-hint">
  169. 当前阈值配置将保存到后端预警规则
  170. </div>
  171. </div>
  172. </el-tab-pane>
  173. </el-tabs>
  174. </div>
  175. </div>
  176. <div v-else class="empty-panel">
  177. <el-empty description="请从左侧选择设备查看阈值配置" :image-size="140" />
  178. </div>
  179. </div>
  180. </div>
  181. </template>
  182. <script setup>
  183. import {onBeforeUnmount, onMounted, ref} from 'vue'
  184. import {ElMessage} from 'element-plus'
  185. import {Location, Monitor, Refresh, Search, Setting, Warning} from '@element-plus/icons-vue'
  186. import {
  187. getManholeAlarmDeviceDetail,
  188. getManholeDevicePage,
  189. saveManholeAlarmThreshold
  190. } from '@/api/pipeNetwork/basic'
  191. const thresholdConfigList = [
  192. {
  193. key: 'tilt',
  194. label: '倾斜监测',
  195. warningType: '倾斜预警',
  196. warningCode: 'WARN-TILT',
  197. unit: '°',
  198. step: 0.1,
  199. precision: 1,
  200. desc: '当倾斜角度小于等于最小值或大于等于最大值时触发报警'
  201. },
  202. {
  203. key: 'waterLevel',
  204. label: '水位监测',
  205. warningType: '水位预警',
  206. warningCode: 'WARN-WATER-LEVEL',
  207. unit: 'cm',
  208. step: 0.1,
  209. precision: 1,
  210. desc: '用于井盖水位报警阈值配置'
  211. },
  212. {
  213. key: 'temperature',
  214. label: '温度监测',
  215. warningType: '温度预警',
  216. warningCode: 'WARN-TEMPERATURE',
  217. unit: '℃',
  218. step: 0.1,
  219. precision: 1,
  220. desc: '用于设备温度报警阈值配置'
  221. }
  222. ]
  223. const searchKeyword = ref('')
  224. const activeTab = ref('tilt')
  225. const listLoading = ref(false)
  226. const detailLoading = ref(false)
  227. const saveLoading = ref(false)
  228. const deviceList = ref([])
  229. const total = ref(0)
  230. const currentPage = ref(1)
  231. const pageSize = 8
  232. const selectedPoint = ref(null)
  233. const selectedDetail = ref(null)
  234. const thresholds = ref(createEmptyThresholds())
  235. // 搜索防抖
  236. let searchTimer = null
  237. function handleSearch() {
  238. clearTimeout(searchTimer)
  239. searchTimer = setTimeout(() => {
  240. currentPage.value = 1
  241. loadDeviceList()
  242. }, 300)
  243. }
  244. function createEmptyThresholds() {
  245. return thresholdConfigList.reduce((acc, item) => {
  246. acc[item.key] = {
  247. id: '',
  248. minValue: null,
  249. maxValue: null,
  250. remark: ''
  251. }
  252. return acc
  253. }, {})
  254. }
  255. function normalizeThresholdList(list) {
  256. const safeList = Array.isArray(list) ? list : []
  257. const result = createEmptyThresholds()
  258. thresholdConfigList.forEach((item) => {
  259. const matched = safeList.find(threshold => threshold.warningCode === item.warningCode)
  260. if (matched) {
  261. result[item.key] = {
  262. id: matched.id || '',
  263. minValue: matched.minValue ?? null,
  264. maxValue: matched.maxValue ?? null,
  265. remark: matched.remark || ''
  266. }
  267. }
  268. })
  269. return result
  270. }
  271. function getOnlineText(status) {
  272. return Number(status) === 1 ? '在线' : '离线'
  273. }
  274. function getOnlineTagType(status) {
  275. return Number(status) === 1 ? 'success' : 'info'
  276. }
  277. function getAlarmText(point) {
  278. return Number(point?.equipmentStatus?.alarmStatus) === 1 ? '报警' : '正常'
  279. }
  280. function getAlarmTagType(point) {
  281. return Number(point?.equipmentStatus?.alarmStatus) === 1 ? 'danger' : 'success'
  282. }
  283. function getUseStatusText(status) {
  284. const map = {
  285. 1: '在用',
  286. 2: '闲置',
  287. 3: '维修',
  288. 4: '报废',
  289. 5: '待入库'
  290. }
  291. return map[Number(status)] || '未知'
  292. }
  293. function getUseStatusTagType(status) {
  294. const map = {
  295. 1: 'success',
  296. 2: 'info',
  297. 3: 'warning',
  298. 4: 'danger',
  299. 5: ''
  300. }
  301. return map[Number(status)] || 'info'
  302. }
  303. function getBinaryAlarmText(status) {
  304. return `${status}` === '1' ? '报警' : `${status}` === '0' ? '正常' : '-'
  305. }
  306. function appendUnit(value, unit) {
  307. if (value === undefined || value === null || value === '') return '-'
  308. return `${value}${unit}`
  309. }
  310. function formatDateTime(value) {
  311. return value || '-'
  312. }
  313. async function loadDeviceList() {
  314. listLoading.value = true
  315. try {
  316. const res = await getManholeDevicePage({
  317. pageNum: currentPage.value,
  318. pageSize,
  319. deviceName: searchKeyword.value.trim() || null,
  320. isQueryManholeData: true
  321. })
  322. const list = res.data?.records || []
  323. deviceList.value = list
  324. total.value = res.data?.total || list.length
  325. if (!list.length) {
  326. selectedPoint.value = null
  327. selectedDetail.value = null
  328. thresholds.value = createEmptyThresholds()
  329. return
  330. }
  331. const currentId = selectedPoint.value?.equipmentId
  332. const currentPoint = list.find(item => item.equipmentId === currentId) || list[0]
  333. await selectPoint(currentPoint)
  334. } catch (error) {
  335. deviceList.value = []
  336. selectedPoint.value = null
  337. selectedDetail.value = null
  338. } finally {
  339. listLoading.value = false
  340. }
  341. }
  342. async function selectPoint(point) {
  343. if (!point?.equipmentId) return
  344. selectedPoint.value = point
  345. await loadDeviceDetail(point.equipmentId)
  346. }
  347. async function loadDeviceDetail(id) {
  348. detailLoading.value = true
  349. try {
  350. const res = await getManholeAlarmDeviceDetail(id)
  351. selectedDetail.value = res?.data || null
  352. thresholds.value = normalizeThresholdList(selectedDetail.value?.warningThresholdList)
  353. } catch (error) {
  354. selectedDetail.value = null
  355. thresholds.value = createEmptyThresholds()
  356. } finally {
  357. detailLoading.value = false
  358. }
  359. }
  360. async function resetThresholds() {
  361. if (!selectedPoint.value?.equipmentId) return
  362. await loadDeviceDetail(selectedPoint.value.equipmentId)
  363. ElMessage.success('已恢复为服务端最新阈值')
  364. }
  365. function hasThresholdValue(item) {
  366. return (
  367. (item.minValue !== null && item.minValue !== undefined) ||
  368. (item.maxValue !== null && item.maxValue !== undefined)
  369. )
  370. }
  371. async function saveThresholds() {
  372. if (!selectedDetail.value?.equipmentCode) {
  373. ElMessage.warning('未获取到设备编码,无法保存')
  374. return
  375. }
  376. const invalidConfigs = thresholdConfigList.filter((config) => {
  377. const formItem = thresholds.value[config.key]
  378. return formItem.id && !hasThresholdValue(formItem)
  379. })
  380. if (invalidConfigs.length) {
  381. ElMessage.warning(`${invalidConfigs[0].label}请至少保留一个阈值`)
  382. return
  383. }
  384. const payloads = thresholdConfigList
  385. .map((config) => {
  386. const formItem = thresholds.value[config.key]
  387. if (!hasThresholdValue(formItem) && !formItem.id) return null
  388. return {
  389. id: formItem.id || undefined,
  390. deviceCode: selectedDetail.value.equipmentCode,
  391. warningType: config.warningType,
  392. warningCode: config.warningCode,
  393. minValue: formItem.minValue,
  394. maxValue: formItem.maxValue,
  395. remark: formItem.remark || ''
  396. }
  397. })
  398. .filter(Boolean)
  399. if (!payloads.length) {
  400. ElMessage.warning('请先填写至少一项阈值')
  401. return
  402. }
  403. saveLoading.value = true
  404. try {
  405. await Promise.all(payloads.map(item => saveManholeAlarmThreshold(item)))
  406. ElMessage.success('阈值保存成功')
  407. await loadDeviceDetail(selectedPoint.value.equipmentId)
  408. } finally {
  409. saveLoading.value = false
  410. }
  411. }
  412. onMounted(() => {
  413. loadDeviceList()
  414. })
  415. onBeforeUnmount(() => {
  416. clearTimeout(searchTimer)
  417. })
  418. </script>
  419. <style scoped>
  420. .threshold-management {
  421. padding: 20px;
  422. background: #f0f2f6;
  423. min-height: 100vh;
  424. }
  425. .main-content {
  426. display: flex;
  427. gap: 20px;
  428. }
  429. .point-list-panel {
  430. width: 340px;
  431. background: #fff;
  432. border-radius: 16px;
  433. padding: 16px;
  434. display: flex;
  435. flex-direction: column;
  436. gap: 12px;
  437. }
  438. .panel-title {
  439. display: flex;
  440. justify-content: space-between;
  441. align-items: center;
  442. font-weight: 600;
  443. }
  444. .point-list {
  445. max-height: calc(100vh - 180px);
  446. overflow-y: auto;
  447. }
  448. .point-item {
  449. border: 1px solid #e5e7eb;
  450. border-radius: 12px;
  451. padding: 14px;
  452. margin-bottom: 12px;
  453. cursor: pointer;
  454. transition: all 0.2s ease;
  455. }
  456. .point-item:hover {
  457. background: #f8fbff;
  458. border-color: #bfdbfe;
  459. }
  460. .point-item.active {
  461. background: #ecf5ff;
  462. border-color: #409eff;
  463. }
  464. .point-header {
  465. display: flex;
  466. justify-content: space-between;
  467. align-items: center;
  468. gap: 8px;
  469. margin-bottom: 8px;
  470. }
  471. .point-name {
  472. font-weight: 600;
  473. color: #303133;
  474. }
  475. .point-location {
  476. display: flex;
  477. align-items: center;
  478. gap: 4px;
  479. color: #606266;
  480. font-size: 12px;
  481. margin-bottom: 8px;
  482. }
  483. .point-meta {
  484. display: flex;
  485. flex-direction: column;
  486. gap: 4px;
  487. font-size: 12px;
  488. color: #409eff;
  489. margin-bottom: 8px;
  490. }
  491. .point-status-row {
  492. display: flex;
  493. gap: 8px;
  494. }
  495. .config-panel {
  496. flex: 1;
  497. display: flex;
  498. flex-direction: column;
  499. gap: 20px;
  500. }
  501. .info-card,
  502. .threshold-card {
  503. background: #fff;
  504. border-radius: 16px;
  505. padding: 18px 20px;
  506. box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
  507. }
  508. .card-title {
  509. display: flex;
  510. justify-content: space-between;
  511. align-items: center;
  512. margin-bottom: 16px;
  513. padding-left: 12px;
  514. border-left: 4px solid #409eff;
  515. font-size: 16px;
  516. font-weight: 600;
  517. }
  518. .card-actions {
  519. display: flex;
  520. gap: 8px;
  521. }
  522. .status-grid {
  523. display: grid;
  524. grid-template-columns: repeat(4, minmax(0, 1fr));
  525. gap: 12px;
  526. }
  527. .status-item {
  528. background: #f8fafc;
  529. border: 1px solid #edf2f7;
  530. border-radius: 12px;
  531. padding: 14px;
  532. }
  533. .status-label {
  534. color: #909399;
  535. font-size: 13px;
  536. margin-bottom: 8px;
  537. }
  538. .status-value {
  539. color: #303133;
  540. font-size: 16px;
  541. font-weight: 600;
  542. }
  543. .threshold-tip {
  544. margin-bottom: 12px;
  545. padding: 10px 12px;
  546. border-radius: 8px;
  547. background: #f4f9ff;
  548. color: #606266;
  549. font-size: 13px;
  550. }
  551. .threshold-item {
  552. padding: 8px 4px 4px;
  553. }
  554. .item-label {
  555. margin-bottom: 16px;
  556. color: #606266;
  557. font-size: 14px;
  558. }
  559. .item-control {
  560. display: flex;
  561. align-items: center;
  562. flex-wrap: wrap;
  563. gap: 8px;
  564. margin-bottom: 16px;
  565. }
  566. .field-label {
  567. color: #303133;
  568. font-weight: 500;
  569. }
  570. .threshold-input {
  571. width: 160px;
  572. }
  573. .unit {
  574. color: #909399;
  575. margin-right: 16px;
  576. }
  577. .saved-hint {
  578. margin-top: 10px;
  579. color: #909399;
  580. font-size: 12px;
  581. }
  582. .empty-panel {
  583. flex: 1;
  584. display: flex;
  585. justify-content: center;
  586. align-items: center;
  587. background: #fff;
  588. border-radius: 16px;
  589. }
  590. .pagination-area {
  591. padding: 8px 0 0;
  592. display: flex;
  593. justify-content: center;
  594. }
  595. </style>