index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. <!--监测设备台账(窨井设备)-->
  2. <template>
  3. <div class="manhole-cover-management">
  4. <!-- 页面头部 -->
  5. <div class="page-header">
  6. <div class="stats-section">
  7. <el-card class="stat-card" shadow="hover">
  8. <div class="stat-content">
  9. <el-icon><Monitor /></el-icon>
  10. <div>
  11. <div class="stat-label">在线设备</div>
  12. <div class="stat-number">{{ onlineDevicesCount }}</div>
  13. </div>
  14. </div>
  15. </el-card>
  16. <el-card class="stat-card" shadow="hover">
  17. <div class="stat-content">
  18. <el-icon><CircleClose /></el-icon>
  19. <div>
  20. <div class="stat-label">离线设备</div>
  21. <div class="stat-number">{{ offlineDevicesCount }}</div>
  22. </div>
  23. </div>
  24. </el-card>
  25. <el-card class="stat-card" shadow="hover">
  26. <div class="stat-content">
  27. <el-icon><Location /></el-icon>
  28. <div>
  29. <div class="stat-label">设备总数</div>
  30. <div class="stat-number">{{ totalDevicesCount }}</div>
  31. </div>
  32. </div>
  33. </el-card>
  34. </div>
  35. </div>
  36. <!-- 筛选工具栏 -->
  37. <div class="filter-bar">
  38. <el-input
  39. v-model="searchKeyword"
  40. placeholder="搜索设备名称"
  41. clearable
  42. style="width: 240px"
  43. :prefix-icon="Search"
  44. @keyup.enter="handleSearch"
  45. @clear="handleSearch"
  46. />
  47. <el-select v-model="typeFilter" placeholder="设备类型" clearable style="width: 160px" @change="handleSearch">
  48. <el-option v-for="t in typeOptions" :key="t.value" :label="t.label" :value="t.value" />
  49. </el-select>
  50. <el-button type="primary" :icon="Refresh" @click="resetFilters">重置筛选</el-button>
  51. </div>
  52. <!-- 设备列表展示区 -->
  53. <div class="device-list-container">
  54. <el-table
  55. :data="devices"
  56. v-loading="loading"
  57. stripe
  58. border
  59. style="width: 100%"
  60. :row-class-name="tableRowClassName"
  61. @row-click="handleRowClick"
  62. >
  63. <el-table-column prop="deviceCode" label="设备编号" width="150" show-overflow-tooltip />
  64. <el-table-column prop="deviceName" label="设备名称" width="180" show-overflow-tooltip />
  65. <el-table-column prop="typeName" label="设备类型" width="130">
  66. <template #default="{ row }">
  67. <el-tag size="small" effect="plain">{{ getTypeText(row) }}</el-tag>
  68. </template>
  69. </el-table-column>
  70. <el-table-column prop="location" label="设备位置" min-width="200" show-overflow-tooltip />
  71. <el-table-column prop="lastMaintainDate" label="最近运维时间" width="130">
  72. <template #default="{ row }">{{ row.lastMaintainDate || '—' }}</template>
  73. </el-table-column>
  74. <el-table-column prop="onlineStatus" label="在线状态" width="110" align="center">
  75. <template #default="{ row }">
  76. <el-tag :type="getOnlineTagType(row.onlineStatus)" size="small">{{ getOnlineText(row.onlineStatus) }}</el-tag>
  77. </template>
  78. </el-table-column>
  79. <el-table-column label="操作" width="180" fixed="right">
  80. <template #default="{ row }">
  81. <el-button link type="primary" size="small" @click.stop="showDetail(row)">详情</el-button>
  82. <el-button link type="success" size="small" @click.stop="openRelDialog(row)">关联井下设备</el-button>
  83. </template>
  84. </el-table-column>
  85. </el-table>
  86. <!-- 分页 -->
  87. <div class="pagination-wrapper">
  88. <el-pagination
  89. background
  90. layout="total, prev, pager, next"
  91. :total="total"
  92. :page-size="pageSize"
  93. v-model:current-page="currentPage"
  94. @current-change="loadDevices"
  95. />
  96. </div>
  97. </div>
  98. <!-- 设备详情抽屉 -->
  99. <el-drawer v-model="drawerVisible" title="设备电子档案 - 详细信息" size="40%" direction="rtl" v-loading="detailLoading" class="detail-drawer">
  100. <template v-if="selectedDevice">
  101. <el-descriptions :column="1" border>
  102. <el-descriptions-item label="设备编号">{{ selectedDevice.deviceCode }}</el-descriptions-item>
  103. <el-descriptions-item label="设备名称">{{ selectedDevice.deviceName }}</el-descriptions-item>
  104. <el-descriptions-item label="设备类型">{{ getTypeText(selectedDevice) }}</el-descriptions-item>
  105. <el-descriptions-item label="在线状态">
  106. <el-tag :type="getOnlineTagType(selectedDevice.onlineStatus)">{{ getOnlineText(selectedDevice.onlineStatus) }}</el-tag>
  107. </el-descriptions-item>
  108. <el-descriptions-item label="设备位置">{{ selectedDevice.location || '—' }}</el-descriptions-item>
  109. <el-descriptions-item label="最近运维时间">{{ selectedDevice.lastMaintainDate || '—' }}</el-descriptions-item>
  110. <el-descriptions-item label="电池电量/健康度" v-if="selectedDevice.batteryLevel !== null && selectedDevice.batteryLevel !== undefined && selectedDevice.batteryLevel !== ''">{{ selectedDevice.batteryLevel }}%</el-descriptions-item>
  111. <el-descriptions-item label="最后通讯时间">{{ selectedDevice.lastCommunication || '—' }}</el-descriptions-item>
  112. </el-descriptions>
  113. <el-divider content-position="left">动态监测指标</el-divider>
  114. <div class="monitor-metrics">
  115. <el-row :gutter="12">
  116. <el-col :span="12"><el-statistic title="井盖倾斜角" :value="selectedDevice.metrics?.tilt ?? '—'" suffix="°" /></el-col>
  117. <el-col :span="12"><el-statistic title="水浸状态" :value="selectedDevice.metrics?.waterIntrusion ? '已浸水' : '正常'" /></el-col>
  118. <el-col :span="12"><el-statistic title="温度" :value="selectedDevice.metrics?.temperature ?? '—'" suffix="℃" /></el-col>
  119. <el-col :span="12"><el-statistic title="信号量" :value="selectedDevice.metrics?.signal ?? '—'" /></el-col>
  120. </el-row>
  121. </div>
  122. <el-divider content-position="left">预警阈值</el-divider>
  123. <el-table v-if="selectedDevice.warningThresholds.length" :data="selectedDevice.warningThresholds" size="small" border>
  124. <el-table-column prop="warningType" label="预警类型" width="100" />
  125. <el-table-column prop="warningCode" label="预警编码" width="120" />
  126. <el-table-column prop="minValue" label="最小值" width="90" />
  127. <el-table-column prop="maxValue" label="最大值" width="90" />
  128. <el-table-column prop="remark" label="备注" show-overflow-tooltip />
  129. </el-table>
  130. <div v-else style="color:#909399;font-size:13px;">暂无预警阈值配置</div>
  131. <div style="margin-top: 20px; text-align: right;">
  132. <el-button type="primary" @click="closeDrawer">关闭</el-button>
  133. </div>
  134. </template>
  135. </el-drawer>
  136. <!-- 关联设备管理对话框 -->
  137. <el-dialog v-model="relDialogVisible" :title="`井下设备关联 - ${relDevice?.deviceName || ''}`" width="860px" @close="onRelDialogClose">
  138. <div class="rel-container">
  139. <!-- 已关联设备列表 -->
  140. <div class="rel-section">
  141. <div class="rel-section-title">
  142. <span>已关联设备</span>
  143. <el-tag type="info" size="small">{{ relList.length }} 台</el-tag>
  144. </div>
  145. <el-table :data="relList" border size="small" v-loading="relLoading" max-height="240">
  146. <el-table-column prop="equipmentCode" label="设备编号" min-width="140" show-overflow-tooltip />
  147. <el-table-column prop="equipmentName" label="设备名称" min-width="140" show-overflow-tooltip />
  148. <el-table-column prop="equipmentLocation" label="位置" min-width="160" show-overflow-tooltip />
  149. <el-table-column label="操作" width="90" align="center">
  150. <template #default="{ row }">
  151. <el-button link type="danger" size="small" @click="removeRel(row)">取消关联</el-button>
  152. </template>
  153. </el-table-column>
  154. </el-table>
  155. </div>
  156. <!-- 搜索可选设备 -->
  157. <div class="rel-section">
  158. <div class="rel-section-title">
  159. <span>添加关联设备</span>
  160. <el-input
  161. v-model="relSearchKeyword"
  162. placeholder="搜索设备编号/名称/位置"
  163. clearable
  164. size="small"
  165. style="width: 260px"
  166. :prefix-icon="Search"
  167. @keyup.enter="searchRelDevices"
  168. @clear="searchRelDevices"
  169. />
  170. </div>
  171. <el-table :data="searchResults" border size="small" v-loading="relSearching" max-height="240" @selection-change="handleSearchSelectionChange">
  172. <el-table-column type="selection" width="45" />
  173. <el-table-column prop="equipmentCode" label="设备编号" min-width="140" show-overflow-tooltip />
  174. <el-table-column prop="equipmentName" label="设备名称" min-width="140" show-overflow-tooltip />
  175. <el-table-column prop="equipmentLocation" label="位置" min-width="160" show-overflow-tooltip />
  176. <el-table-column label="类型" width="120">
  177. <template #default="{ row }">
  178. <el-tag size="small" effect="plain">{{ getTypeText(row) }}</el-tag>
  179. </template>
  180. </el-table-column>
  181. </el-table>
  182. <div class="rel-search-footer">
  183. <el-button type="primary" size="small" :disabled="!searchSelection.length" :loading="relSaving" @click="batchAddRel">关联选中设备 ({{ searchSelection.length }})</el-button>
  184. </div>
  185. </div>
  186. </div>
  187. </el-dialog>
  188. </div>
  189. </template>
  190. <script setup>
  191. import { ref, onMounted } from 'vue'
  192. import { Monitor, CircleClose, Location, Search, Refresh } from '@element-plus/icons-vue'
  193. import { getManholeDevicePage, getManholeDeviceById, getEquipmentTypeList, searchManholeRelDevices, getManholeDeviceRels, addManholeDeviceRel, deleteManholeDeviceRel } from '@/api/pipeNetwork/basic'
  194. import { ElMessage } from 'element-plus'
  195. // 窨井设备类别:供水窨井(type_id=5)/ 排水窨井(type_id=9)/ 燃气窨井(type_id=10)
  196. const MANHOLE_CATEGORY_MAP = { '5': '供水窨井', '9': '排水窨井', '10': '燃气窨井' }
  197. // 设备列表 & 分页
  198. const devices = ref([])
  199. const loading = ref(false)
  200. const total = ref(0)
  201. const currentPage = ref(1)
  202. const pageSize = 10
  203. // 筛选条件
  204. const searchKeyword = ref('')
  205. const typeFilter = ref('')
  206. // 窨井类别选项(value=窨井类别ID:供水窨井5/排水窨井9/燃气窨井10)
  207. const typeOptions = ref([
  208. { value: '5', label: '供水窨井' },
  209. { value: '9', label: '排水窨井' },
  210. { value: '10', label: '燃气窨井' }
  211. ])
  212. // 全部类型:id -> { typeId, typeName, parentTypeId }
  213. const typeIdMap = ref({})
  214. // 统计
  215. const onlineDevicesCount = ref(0)
  216. const offlineDevicesCount = ref(0)
  217. const totalDevicesCount = ref(0)
  218. // 加载全部设备类型(用于类型列归属显示)
  219. async function loadTypeOptions() {
  220. try {
  221. const res = await getEquipmentTypeList()
  222. const all = res.data || []
  223. all.forEach(t => { typeIdMap.value[t.id] = t })
  224. } catch (e) { /* 类型接口异常时,类型列回退显示 typeName */ }
  225. }
  226. // 设备类型显示文本(按所属窨井类别显示)
  227. const getTypeText = (row) => {
  228. const t = typeIdMap.value[row.equipmentTypeId]
  229. if (!t) return row.typeName || '—'
  230. return MANHOLE_CATEGORY_MAP[t.parentTypeId] || MANHOLE_CATEGORY_MAP[row.equipmentTypeId] || t.typeName || '—'
  231. }
  232. // 加载设备列表(后端已按窨井设备类型过滤)
  233. async function loadDevices() {
  234. loading.value = true
  235. try {
  236. const res = await getManholeDevicePage({
  237. pageNum: currentPage.value,
  238. pageSize,
  239. deviceName: searchKeyword.value || null,
  240. equipmentTypeId: typeFilter.value || null,
  241. isQueryManholeData: true
  242. })
  243. const rows = res.data?.records || []
  244. devices.value = rows.map(d => ({
  245. equipmentId: d.equipmentId,
  246. equipmentTypeId: d.equipmentTypeId,
  247. deviceCode: d.equipmentCode,
  248. deviceName: d.equipmentName,
  249. typeName: d.equipmentTypeName,
  250. location: d.equipmentLocation,
  251. onlineStatus: d.equipmentStatus?.onlineStatus,
  252. lastMaintainDate: d.equipmentStatus?.lastMaintainDate,
  253. batteryLevel: d.manholeData?.batteryLevel,
  254. lastCommunication: d.manholeData?.uploadTime || d.manholeData?.createTime,
  255. metrics: d.manholeData ? {
  256. tilt: d.manholeData.tiltAngle,
  257. waterIntrusion: Number(d.manholeData.waterInfiltrationAlarmStatus) === 1,
  258. temperature: d.manholeData.temperatureValue,
  259. signal: d.manholeData.signalStrength
  260. } : null
  261. }))
  262. total.value = res.data?.total || 0
  263. } catch (e) {
  264. ElMessage.error('设备列表加载失败')
  265. } finally {
  266. loading.value = false
  267. }
  268. }
  269. // 统计(基于当前筛选条件下的全量设备)
  270. async function loadStats() {
  271. try {
  272. const res = await getManholeDevicePage({
  273. pageNum: 1,
  274. pageSize: 1000,
  275. deviceName: searchKeyword.value || null,
  276. equipmentTypeId: typeFilter.value || null,
  277. isQueryManholeData: false
  278. })
  279. const rows = res.data?.records || []
  280. totalDevicesCount.value = res.data?.total || rows.length
  281. onlineDevicesCount.value = rows.filter(d => Number(d.equipmentStatus?.onlineStatus) === 1).length
  282. offlineDevicesCount.value = rows.length - onlineDevicesCount.value
  283. } catch (e) {
  284. onlineDevicesCount.value = 0
  285. offlineDevicesCount.value = 0
  286. totalDevicesCount.value = 0
  287. }
  288. }
  289. // 在线状态
  290. const getOnlineText = (status) => {
  291. return Number(status) === 1 ? '在线' : '离线'
  292. }
  293. const getOnlineTagType = (status) => {
  294. return Number(status) === 1 ? 'success' : 'info'
  295. }
  296. // 搜索/筛选变化
  297. const handleSearch = () => {
  298. currentPage.value = 1
  299. loadDevices()
  300. loadStats()
  301. }
  302. const resetFilters = () => {
  303. searchKeyword.value = ''
  304. typeFilter.value = ''
  305. currentPage.value = 1
  306. loadDevices()
  307. loadStats()
  308. }
  309. const tableRowClassName = ({ row }) => {
  310. if (Number(row.onlineStatus) !== 1) return 'inactive-row'
  311. return ''
  312. }
  313. // 详情
  314. const drawerVisible = ref(false)
  315. const detailLoading = ref(false)
  316. const selectedDevice = ref(null)
  317. // 映射详情接口返回数据
  318. const mapDeviceDetail = (d) => ({
  319. equipmentId: d.equipmentId,
  320. equipmentTypeId: d.equipmentTypeId,
  321. deviceCode: d.equipmentCode,
  322. deviceName: d.equipmentName,
  323. typeName: d.equipmentTypeName,
  324. location: d.equipmentLocation,
  325. onlineStatus: d.equipmentStatus?.onlineStatus,
  326. lastMaintainDate: d.equipmentStatus?.lastMaintainDate,
  327. batteryLevel: d.manholeData?.batteryLevel,
  328. lastCommunication: d.manholeData?.uploadTime || d.manholeData?.createTime,
  329. metrics: d.manholeData ? {
  330. tilt: d.manholeData.tiltAngle,
  331. waterIntrusion: Number(d.manholeData.waterInfiltrationAlarmStatus) === 1,
  332. temperature: d.manholeData.temperatureValue,
  333. signal: d.manholeData.signalStrength
  334. } : null,
  335. warningThresholds: d.warningThresholdList || []
  336. })
  337. const showDetail = async (device) => {
  338. drawerVisible.value = true
  339. detailLoading.value = true
  340. try {
  341. const res = await getManholeDeviceById(device.equipmentId)
  342. selectedDevice.value = res.data ? mapDeviceDetail(res.data) : null
  343. } catch (e) {
  344. selectedDevice.value = null
  345. ElMessage.error('设备详情加载失败')
  346. } finally {
  347. detailLoading.value = false
  348. }
  349. }
  350. const handleRowClick = (row) => {
  351. showDetail(row)
  352. }
  353. const closeDrawer = () => {
  354. drawerVisible.value = false
  355. }
  356. // ============ 关联设备管理 ============
  357. const relDialogVisible = ref(false)
  358. const relDevice = ref(null)
  359. const relList = ref([])
  360. const relLoading = ref(false)
  361. const relSearchKeyword = ref('')
  362. const searchResults = ref([])
  363. const relSearching = ref(false)
  364. const searchSelection = ref([])
  365. const relSaving = ref(false)
  366. const openRelDialog = async (device) => {
  367. relDevice.value = device
  368. relDialogVisible.value = true
  369. relSearchKeyword.value = ''
  370. searchResults.value = []
  371. searchSelection.value = []
  372. await loadRelList(device.equipmentId)
  373. await searchRelDevices()
  374. }
  375. const loadRelList = async (equipmentId) => {
  376. relLoading.value = true
  377. try {
  378. const res = await getManholeDeviceRels(equipmentId)
  379. relList.value = res.data || []
  380. } catch (e) {
  381. relList.value = []
  382. ElMessage.error('关联设备列表加载失败')
  383. } finally {
  384. relLoading.value = false
  385. }
  386. }
  387. const searchRelDevices = async () => {
  388. relSearching.value = true
  389. try {
  390. const keyword = relSearchKeyword.value.trim()
  391. // 排除已关联设备ID + 自身设备ID
  392. const excludeIds = [
  393. ...relList.value.map(r => r.equipmentId),
  394. relDevice.value?.equipmentId
  395. ].filter(Boolean).join(',')
  396. const res = await searchManholeRelDevices({
  397. keyword: keyword || undefined,
  398. excludeIds: excludeIds || undefined
  399. })
  400. searchResults.value = res.data || []
  401. } catch (e) {
  402. searchResults.value = []
  403. ElMessage.error('设备搜索失败')
  404. } finally {
  405. relSearching.value = false
  406. }
  407. }
  408. const handleSearchSelectionChange = (selection) => {
  409. searchSelection.value = selection || []
  410. }
  411. const batchAddRel = async () => {
  412. if (!searchSelection.value.length) return
  413. relSaving.value = true
  414. try {
  415. for (const item of searchSelection.value) {
  416. await addManholeDeviceRel({
  417. manholeId: relDevice.value.equipmentId,
  418. equipmentId: item.equipmentId
  419. })
  420. }
  421. ElMessage.success(`已关联 ${searchSelection.value.length} 台设备`)
  422. searchSelection.value = []
  423. relSearchKeyword.value = ''
  424. await loadRelList(relDevice.value.equipmentId)
  425. await searchRelDevices()
  426. } catch (e) {
  427. const msg = e?.message || e?.msg || '关联失败'
  428. ElMessage.error(msg)
  429. } finally {
  430. relSaving.value = false
  431. }
  432. }
  433. const removeRel = async (row) => {
  434. try {
  435. await deleteManholeDeviceRel(row.relId)
  436. ElMessage.success('已取消关联')
  437. await loadRelList(relDevice.value.equipmentId)
  438. await searchRelDevices()
  439. } catch (e) {
  440. const msg = e?.message || e?.msg || '取消关联失败'
  441. ElMessage.error(msg)
  442. }
  443. }
  444. const onRelDialogClose = () => {
  445. relList.value = []
  446. searchResults.value = []
  447. searchSelection.value = []
  448. relSearchKeyword.value = ''
  449. }
  450. // 初始化
  451. onMounted(() => {
  452. loadTypeOptions()
  453. loadDevices()
  454. loadStats()
  455. })
  456. </script>
  457. <style scoped>
  458. .manhole-cover-management {
  459. padding: 20px;
  460. background: #f5f7fa;
  461. min-height: 100vh;
  462. }
  463. .page-header {
  464. display: flex;
  465. justify-content: space-between;
  466. align-items: center;
  467. margin-bottom: 20px;
  468. flex-wrap: wrap;
  469. }
  470. .stats-section {
  471. display: flex;
  472. gap: 16px;
  473. }
  474. .stat-card {
  475. width: 160px;
  476. border-radius: 10px;
  477. }
  478. .stat-content {
  479. display: flex;
  480. align-items: center;
  481. gap: 12px;
  482. }
  483. .stat-content .el-icon {
  484. font-size: 32px;
  485. color: #409eff;
  486. }
  487. .stat-label {
  488. font-size: 14px;
  489. color: #6c757d;
  490. }
  491. .stat-number {
  492. font-size: 24px;
  493. font-weight: bold;
  494. color: #2c3e50;
  495. }
  496. .filter-bar {
  497. margin-bottom: 20px;
  498. display: flex;
  499. gap: 12px;
  500. flex-wrap: wrap;
  501. background: white;
  502. padding: 12px 16px;
  503. border-radius: 12px;
  504. box-shadow: 0 2px 8px rgba(0,0,0,0.05);
  505. }
  506. .device-list-container {
  507. background: white;
  508. border-radius: 12px;
  509. box-shadow: 0 2px 12px rgba(0,0,0,0.08);
  510. overflow: hidden;
  511. }
  512. .pagination-wrapper {
  513. display: flex;
  514. justify-content: flex-end;
  515. padding: 15px 20px;
  516. background: white;
  517. border-top: 1px solid #e4e7ed;
  518. }
  519. .monitor-metrics {
  520. background: #f8f9fc;
  521. border-radius: 8px;
  522. padding: 10px;
  523. }
  524. :deep(.inactive-row) {
  525. --el-table-tr-bg-color: #f5f5f5;
  526. color: #a8a8a8;
  527. }
  528. .rel-container {
  529. display: flex;
  530. flex-direction: column;
  531. gap: 20px;
  532. }
  533. .rel-section {
  534. border: 1px solid #ebeef5;
  535. border-radius: 8px;
  536. padding: 12px;
  537. }
  538. .rel-section-title {
  539. display: flex;
  540. align-items: center;
  541. justify-content: space-between;
  542. margin-bottom: 10px;
  543. font-size: 14px;
  544. font-weight: 600;
  545. color: #2c3e50;
  546. }
  547. .rel-search-footer {
  548. margin-top: 10px;
  549. text-align: right;
  550. }
  551. </style>