lsyjgl.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846
  1. <template>
  2. <div class="app-container lsyjgl-page">
  3. <!-- ==================== 分类统计 ==================== -->
  4. <div class="stats-panel">
  5. <div class="stats-left">
  6. <div class="stat-item" v-for="item in statItems" :key="item.label">
  7. <div class="stat-value" :class="item.cls">{{ item.value }}</div>
  8. <div class="stat-label">{{ item.label }}</div>
  9. </div>
  10. </div>
  11. <div class="stats-right">
  12. <div class="ring-chart-box" v-for="chart in ringCharts" :key="chart.id">
  13. <div :id="chart.id" class="ring-chart"></div>
  14. <div class="ring-legend">
  15. <div class="legend-row" v-for="(leg, i) in chart.legends.value" :key="i">
  16. <span class="legend-dot" :style="{ background: leg.color }"></span>
  17. <span class="legend-text">{{ leg.name }}</span>
  18. <span class="legend-num">{{ leg.value }}</span>
  19. </div>
  20. </div>
  21. </div>
  22. </div>
  23. </div>
  24. <!-- ==================== 搜索区域 ==================== -->
  25. <el-form ref="queryRef" :model="filters" :inline="true" v-show="showSearch" label-width="80px">
  26. <el-form-item label="预警名称">
  27. <el-input v-model="filters.equipmentName" placeholder="请输入预警名称" clearable style="width: 200px"
  28. @keyup.enter="handleSearch" />
  29. </el-form-item>
  30. <el-form-item label="预警类型">
  31. <el-select v-model="filters.warningType" placeholder="请选择预警类型" clearable filterable style="width: 180px">
  32. <el-option v-for="item in warningTypeOptions" :key="item.dictValue" :label="item.dictLabel"
  33. :value="item.dictValue" />
  34. </el-select>
  35. </el-form-item>
  36. <el-form-item label="预警级别">
  37. <el-select v-model="filters.warningLevel" placeholder="请选择预警级别" clearable style="width: 180px">
  38. <el-option label="严重" :value="1" />
  39. <el-option label="重要" :value="2" />
  40. <el-option label="一般" :value="3" />
  41. </el-select>
  42. </el-form-item>
  43. <el-form-item label="处理状态">
  44. <el-select v-model="filters.status" placeholder="请选择处理状态" clearable style="width: 180px">
  45. <el-option label="已处置" value="HANDLED" />
  46. <el-option label="已解除" value="CLOSED" />
  47. </el-select>
  48. </el-form-item>
  49. <el-form-item label="预警时间" style="width: 308px">
  50. <el-date-picker v-model="filters.dateRange" type="daterange" range-separator="-" start-placeholder="开始日期"
  51. end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 240px" />
  52. </el-form-item>
  53. <el-form-item>
  54. <el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
  55. <el-button icon="Refresh" @click="handleClear">重置</el-button>
  56. </el-form-item>
  57. </el-form>
  58. <!-- ==================== 操作栏 ==================== -->
  59. <el-row :gutter="10" class="mb8">
  60. <el-col :span="1.5">
  61. <el-button type="warning" plain icon="Download" :loading="exporting" @click="handleExport">导出</el-button>
  62. </el-col>
  63. <el-col :span="1.5">
  64. <el-button type="primary" plain icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
  65. </el-col>
  66. <right-toolbar v-model:showSearch="showSearch" @queryTable="loadData"></right-toolbar>
  67. </el-row>
  68. <!-- ==================== 数据表格 ==================== -->
  69. <div>
  70. <el-table :data="tableData" style="width:100%" v-loading="loading"
  71. empty-text="暂无已处置 / 已解除的预警记录">
  72. <el-table-column label="归档编号" width="150" show-overflow-tooltip>
  73. <template #default="{ row }">{{ archiveNoOf(row) }}</template>
  74. </el-table-column>
  75. <el-table-column prop="warningNo" label="预警编号" width="175" show-overflow-tooltip />
  76. <el-table-column prop="equipmentName" label="预警名称" min-width="130" show-overflow-tooltip />
  77. <el-table-column label="预警类型" width="130" align="center" show-overflow-tooltip>
  78. <template #default="{ row }">{{ warningTypeText(row) }}</template>
  79. </el-table-column>
  80. <el-table-column prop="warningLevel" label="预警级别" width="90" align="center">
  81. <template #default="{ row }">
  82. <el-tag :type="levelTagType(row.warningLevel)" size="small">{{ levelText(row.warningLevel) }}</el-tag>
  83. </template>
  84. </el-table-column>
  85. <el-table-column label="预警内容" min-width="190" show-overflow-tooltip>
  86. <template #default="{ row }">{{ row.content || '-' }}</template>
  87. </el-table-column>
  88. <el-table-column prop="ownershipUnit" label="权属单位" width="145" show-overflow-tooltip>
  89. <template #default="{ row }">{{ row.ownershipUnit || '-' }}</template>
  90. </el-table-column>
  91. <el-table-column prop="location" label="位置" min-width="140" show-overflow-tooltip>
  92. <template #default="{ row }">{{ row.location || '-' }}</template>
  93. </el-table-column>
  94. <el-table-column label="预警时间" width="168">
  95. <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
  96. </el-table-column>
  97. <el-table-column prop="publisher" label="发布人" width="110" show-overflow-tooltip>
  98. <template #default="{ row }">{{ row.publisher || '-' }}</template>
  99. </el-table-column>
  100. <el-table-column prop="processor" label="处置人" width="110" show-overflow-tooltip>
  101. <template #default="{ row }">{{ row.processor || '-' }}</template>
  102. </el-table-column>
  103. <el-table-column label="归档时间" width="168">
  104. <template #default="{ row }">{{ formatDateTime(archiveTimeOf(row)) }}</template>
  105. </el-table-column>
  106. <el-table-column prop="status" label="处理状态" width="100" align="center">
  107. <template #default="{ row }">
  108. <el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
  109. </template>
  110. </el-table-column>
  111. <el-table-column label="操作" width="130" fixed="right">
  112. <template #default="{ row }">
  113. <el-button link type="primary" size="small" @click="handleView(row)">查看</el-button>
  114. <el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
  115. </template>
  116. </el-table-column>
  117. </el-table>
  118. <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize"
  119. @pagination="loadData" />
  120. </div>
  121. <!-- ==================== 查看详情弹窗 ==================== -->
  122. <el-drawer v-model="dialogVisible" title="历史预警详情" size="720px" :close-on-click-modal="false"
  123. class="detail-drawer" direction="rtl">
  124. <div class="detail-content" v-if="currentDetail" v-loading="detailLoading">
  125. <div class="section-box">
  126. <div class="section-title">预警信息</div>
  127. <el-descriptions :column="2" border size="small">
  128. <el-descriptions-item label="预警编号">{{ currentDetail.warningNo || '-' }}</el-descriptions-item>
  129. <el-descriptions-item label="预警名称">{{ currentDetail.equipmentName || '-' }}</el-descriptions-item>
  130. <el-descriptions-item label="预警类型">{{ warningTypeText(currentDetail) }}</el-descriptions-item>
  131. <el-descriptions-item label="预警级别">
  132. <el-tag :type="levelTagType(currentDetail.warningLevel)" size="small">{{
  133. levelText(currentDetail.warningLevel) }}</el-tag>
  134. </el-descriptions-item>
  135. <el-descriptions-item label="预警专项">{{ currentDetail.warningSpecial || '-' }}</el-descriptions-item>
  136. <el-descriptions-item label="权属单位">{{ currentDetail.ownershipUnit || '-' }}</el-descriptions-item>
  137. <el-descriptions-item label="预警时间">{{ formatDateTime(currentDetail.createTime) }}</el-descriptions-item>
  138. <el-descriptions-item label="处理状态">
  139. <el-tag :type="statusTagType(currentDetail.status)" size="small">{{ statusText(currentDetail.status)
  140. }}</el-tag>
  141. </el-descriptions-item>
  142. <el-descriptions-item label="发布人">{{ currentDetail.publisher || '-' }}</el-descriptions-item>
  143. <el-descriptions-item label="创建人">{{ currentDetail.createBy || '-' }}</el-descriptions-item>
  144. <el-descriptions-item label="位置" :span="2">{{ currentDetail.location || '-' }}</el-descriptions-item>
  145. <el-descriptions-item label="预警内容" :span="2">
  146. <div class="desc-text">{{ currentDetail.content || '-' }}</div>
  147. </el-descriptions-item>
  148. <el-descriptions-item v-if="currentDetail.remark" label="备注" :span="2">
  149. {{ currentDetail.remark }}
  150. </el-descriptions-item>
  151. </el-descriptions>
  152. </div>
  153. <div class="section-box">
  154. <div class="section-title">归档信息</div>
  155. <el-descriptions :column="2" border size="small">
  156. <el-descriptions-item label="归档编号">{{ archiveNoOf(currentDetail) }}</el-descriptions-item>
  157. <el-descriptions-item label="归档时间">{{ formatDateTime(archiveTimeOf(currentDetail)) }}</el-descriptions-item>
  158. <el-descriptions-item label="处置人">{{ currentDetail.processor || '-' }}</el-descriptions-item>
  159. <el-descriptions-item label="归档状态">{{ statusText(currentDetail.status) }}</el-descriptions-item>
  160. <el-descriptions-item label="发布时间" :span="2">
  161. {{ formatDateTime(currentDetail.publishTime) }}
  162. </el-descriptions-item>
  163. </el-descriptions>
  164. </div>
  165. <div class="section-box">
  166. <div class="section-title">关联设备与预警指标</div>
  167. <el-descriptions v-if="detailDevice" :column="2" border size="small">
  168. <el-descriptions-item label="设备名称">{{ detailDevice.equipmentName || '-' }}</el-descriptions-item>
  169. <el-descriptions-item label="设备编码">{{ detailDevice.equipmentCode || '-' }}</el-descriptions-item>
  170. <el-descriptions-item label="设备类型">{{ equipmentTypeMap.get(String(detailDevice.equipmentTypeId)) || '-'
  171. }}</el-descriptions-item>
  172. <el-descriptions-item label="在线状态">
  173. <el-tag :type="detailDevice.onlineStatus === 1 ? 'success' : 'info'" size="small">
  174. {{ detailDevice.onlineStatus === 1 ? '在线' : '离线' }}
  175. </el-tag>
  176. </el-descriptions-item>
  177. <el-descriptions-item label="安装位置" :span="2">{{ detailDevice.equipmentLocation ||
  178. currentDetail.location || '-' }}</el-descriptions-item>
  179. </el-descriptions>
  180. <el-alert v-else type="info" :closable="false" show-icon class="device-missing"
  181. title="未在设备台账中找到该预警关联的设备,指标数据无法展示" />
  182. <div class="metric-grid">
  183. <div class="metric-card">
  184. <div class="metric-label">实时液位</div>
  185. <div class="metric-value">
  186. <template v-if="detailMetric.level !== null">{{ detailMetric.level
  187. }}<span class="metric-unit">m</span></template>
  188. <span v-else class="metric-empty">—</span>
  189. </div>
  190. </div>
  191. <div class="metric-card">
  192. <div class="metric-label">报警阈值</div>
  193. <div class="metric-value metric-value-text">{{ detailMetric.thresholdText || '未配置' }}</div>
  194. </div>
  195. <div class="metric-card">
  196. <div class="metric-label">采集时间</div>
  197. <div class="metric-value metric-value-text">{{ detailMetric.collectTime || '—' }}</div>
  198. </div>
  199. </div>
  200. <el-table v-if="detailThresholds.length" :data="detailThresholds" border size="small"
  201. empty-text="该设备尚未配置报警阈值">
  202. <el-table-column label="监测指标" prop="warningName" min-width="120" />
  203. <el-table-column label="单位" width="90" align="center">
  204. <template #default="{ row }">{{ row.warningUnit || '-' }}</template>
  205. </el-table-column>
  206. <el-table-column label="阈值范围" min-width="150" align="center">
  207. <template #default="{ row }">{{ formatThreshold(row) }}</template>
  208. </el-table-column>
  209. </el-table>
  210. </div>
  211. </div>
  212. <template #footer>
  213. <el-button @click="dialogVisible = false">关 闭</el-button>
  214. </template>
  215. </el-drawer>
  216. </div>
  217. </template>
  218. <script setup name="Lsyjgl">
  219. import { ref, reactive, computed, onMounted, nextTick } from 'vue'
  220. import { ElMessage, ElMessageBox } from 'element-plus'
  221. import * as XLSX from 'xlsx'
  222. import { saveAs } from 'file-saver'
  223. import * as echarts from 'echarts'
  224. import { getWarningHistoryPage, deleteWarning, getEquipmentTypeList, getLatestWaterLevelByDevice } from '@/api/drainage'
  225. import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
  226. import { pageWarningThresholds } from '@/api/warningThreshold'
  227. import { listWarningTypes } from '@/api/warning/list'
  228. // ==================== 设备类型 / 预警类型映射 ====================
  229. const equipmentTypeMap = ref(new Map())
  230. /** warningType(类型编码) -> 类型名称,来自 warning_type 表 */
  231. const warningTypeMap = ref(new Map())
  232. const warningTypeOptions = ref([])
  233. async function loadEquipmentTypes() {
  234. try {
  235. const res = await getEquipmentTypeList('2')
  236. const list = res.code !== undefined ? res.data : res
  237. const map = new Map()
  238. if (Array.isArray(list)) {
  239. list.forEach(item => {
  240. if (item.id !== undefined && item.id !== null) map.set(String(item.id), item.typeName)
  241. })
  242. }
  243. equipmentTypeMap.value = map
  244. } catch (error) {
  245. console.error('加载设备类型列表失败', error)
  246. }
  247. }
  248. async function loadWarningTypes() {
  249. try {
  250. const res = await listWarningTypes()
  251. const list = res?.data || res?.rows || []
  252. const map = new Map()
  253. if (Array.isArray(list)) {
  254. list.forEach(item => {
  255. if (item.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
  256. })
  257. }
  258. warningTypeOptions.value = Array.isArray(list) ? list : []
  259. warningTypeMap.value = map
  260. } catch (error) {
  261. console.warn('加载预警类型配置失败', error)
  262. }
  263. }
  264. /** 预警类型展示:新数据看 warningType,历史数据回退到 deviceCode 的设备类型名 */
  265. function warningTypeText(row) {
  266. if (!row) return '-'
  267. const byType = row.warningType ? warningTypeMap.value.get(String(row.warningType)) : null
  268. if (byType) return byType
  269. const byDeviceType = row.deviceCode ? equipmentTypeMap.value.get(String(row.deviceCode)) : null
  270. if (byDeviceType) return byDeviceType
  271. return row.warningType || row.deviceCode || '-'
  272. }
  273. // ==================== 等级/状态工具方法 ====================
  274. function levelText(level) {
  275. const map = { 1: '严重', 2: '重要', 3: '一般' }
  276. return map[level] || '-'
  277. }
  278. function levelTagType(level) {
  279. const map = { 1: 'danger', 2: 'warning', 3: '' }
  280. return map[level] || 'info'
  281. }
  282. function statusText(status) {
  283. const map = { DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }
  284. return map[status] ?? '-'
  285. }
  286. function statusTagType(status) {
  287. const map = { DRAFT: 'info', PENDING: 'warning', RECEIVED: 'warning', PROCESSING: 'warning', HANDLED: 'success', CLOSED: '' }
  288. return map[status] || 'info'
  289. }
  290. function formatDateTime(value) {
  291. if (!value) return '-'
  292. return String(value).replace('T', ' ').slice(0, 19)
  293. }
  294. /** 归档编号:预警解除即归档,编号由预警编号派生,稳定可追溯 */
  295. function archiveNoOf(row) {
  296. if (!row?.warningNo) return '-'
  297. return `ARC${String(row.warningNo).replace(/^WRN/, '').slice(-12)}`
  298. }
  299. /** 归档时间:解除/处置动作发生在 update_time,缺失时退回发布时间 */
  300. function archiveTimeOf(row) {
  301. return row?.updateTime || row?.publishTime || null
  302. }
  303. // ==================== 分类统计 ====================
  304. const statItems = computed(() => {
  305. const list = tableData.value
  306. const devices = new Set(list.map(o => o.equipmentName).filter(Boolean))
  307. return [
  308. { label: '归档预警', value: total.value, cls: '' },
  309. { label: '已处置', value: list.filter(o => o.status === 'HANDLED').length, cls: 'green' },
  310. { label: '已解除', value: list.filter(o => o.status === 'CLOSED').length, cls: 'blue' },
  311. { label: '涉及设备', value: devices.size, cls: '' }
  312. ]
  313. })
  314. const statusLegends = computed(() => [
  315. { name: '已处置', value: tableData.value.filter(o => o.status === 'HANDLED').length, color: '#67C23A' },
  316. { name: '已解除', value: tableData.value.filter(o => o.status === 'CLOSED').length, color: '#409EFF' }
  317. ])
  318. const levelLegends = computed(() => [
  319. { name: '严重', value: tableData.value.filter(o => o.warningLevel === 1).length, color: '#F56C6C' },
  320. { name: '重要', value: tableData.value.filter(o => o.warningLevel === 2).length, color: '#E6A23C' },
  321. { name: '一般', value: tableData.value.filter(o => o.warningLevel === 3).length, color: '#909399' }
  322. ])
  323. const ringCharts = [
  324. { id: 'historyStatusChart', legends: statusLegends },
  325. { id: 'historyLevelChart', legends: levelLegends }
  326. ]
  327. function initCharts() {
  328. ringCharts.forEach(chart => {
  329. const dom = document.getElementById(chart.id)
  330. if (!dom) return
  331. const legends = chart.legends.value
  332. const chartInstance = echarts.getInstanceByDom(dom) || echarts.init(dom)
  333. chartInstance.setOption({
  334. tooltip: { trigger: 'item', formatter: '{b}:{c}({d}%)' },
  335. series: [{
  336. type: 'pie',
  337. radius: ['58%', '82%'],
  338. center: ['50%', '50%'],
  339. avoidLabelOverlap: false,
  340. label: { show: false },
  341. labelLine: { show: false },
  342. data: legends.map(item => ({
  343. name: item.name,
  344. value: item.value,
  345. itemStyle: { color: item.color }
  346. }))
  347. }]
  348. }, true)
  349. })
  350. }
  351. // ==================== 表格数据 ====================
  352. const tableData = ref([])
  353. const total = ref(0)
  354. const loading = ref(false)
  355. const pageNum = ref(1)
  356. const pageSize = ref(10)
  357. // ==================== 搜索筛选 ====================
  358. const showSearch = ref(true)
  359. const queryRef = ref()
  360. const filters = reactive({
  361. equipmentName: '',
  362. warningType: '',
  363. warningLevel: '',
  364. status: '',
  365. dateRange: null
  366. })
  367. function buildParams() {
  368. const params = {
  369. historyOnly: true,
  370. equipmentType: 'drainage'
  371. }
  372. if (filters.equipmentName) params.equipmentName = filters.equipmentName
  373. if (filters.warningType) params.warningType = filters.warningType
  374. if (filters.warningLevel) params.warningLevel = filters.warningLevel
  375. if (filters.status !== '' && filters.status !== null) params.status = filters.status
  376. if (filters.dateRange && filters.dateRange.length === 2) {
  377. params.startTime = `${filters.dateRange[0]} 00:00:00`
  378. params.endTime = `${filters.dateRange[1]} 23:59:59`
  379. }
  380. return params
  381. }
  382. // ==================== 数据加载 ====================
  383. async function loadData() {
  384. loading.value = true
  385. try {
  386. // 历史页由后端按历史状态(HANDLED/CLOSED)限定
  387. const res = await getWarningHistoryPage(pageNum.value, pageSize.value, buildParams())
  388. const pageData = res.code !== undefined ? res.data : res
  389. tableData.value = pageData.records || []
  390. total.value = pageData.total || 0
  391. } catch (error) {
  392. console.error('加载历史预警列表失败', error)
  393. ElMessage.error('加载历史预警列表失败')
  394. } finally {
  395. loading.value = false
  396. nextTick(() => initCharts())
  397. }
  398. }
  399. function handleSearch() {
  400. pageNum.value = 1
  401. loadData()
  402. }
  403. function handleClear() {
  404. filters.equipmentName = ''
  405. filters.warningType = ''
  406. filters.warningLevel = ''
  407. filters.status = ''
  408. filters.dateRange = null
  409. pageNum.value = 1
  410. loadData()
  411. }
  412. // ==================== 归档导出 ====================
  413. const exporting = ref(false)
  414. const EXPORT_PAGE_SIZE = 100
  415. const EXPORT_HEADER = [
  416. '归档编号', '预警编号', '预警名称', '预警类型', '预警级别', '预警内容',
  417. '权属单位', '位置', '预警时间', '发布人', '处置人', '归档时间', '处理状态'
  418. ]
  419. function exportValues(row) {
  420. return [
  421. archiveNoOf(row),
  422. row.warningNo || '',
  423. row.equipmentName || '',
  424. warningTypeText(row),
  425. levelText(row.warningLevel),
  426. row.content || '',
  427. row.ownershipUnit || '',
  428. row.location || '',
  429. formatDateTime(row.createTime),
  430. row.publisher || '',
  431. row.processor || '',
  432. formatDateTime(archiveTimeOf(row)),
  433. statusText(row.status)
  434. ]
  435. }
  436. /** 按当前筛选条件翻页拉全量,单页给大值容易被后端分页上限截断 */
  437. async function fetchAllRows() {
  438. const collected = []
  439. let page = 1
  440. while (page <= 50) {
  441. const res = await getWarningHistoryPage(page, EXPORT_PAGE_SIZE, buildParams())
  442. const pageData = res.code !== undefined ? res.data : res
  443. const rows = pageData.records || pageData.rows || []
  444. if (rows.length === 0) break
  445. collected.push(...rows)
  446. const totalCount = Number(pageData.total) || 0
  447. if (rows.length < EXPORT_PAGE_SIZE) break
  448. if (totalCount > 0 && collected.length >= totalCount) break
  449. page += 1
  450. }
  451. return collected
  452. }
  453. async function handleExport() {
  454. exporting.value = true
  455. try {
  456. const rows = await fetchAllRows()
  457. if (rows.length === 0) {
  458. ElMessage.warning('当前条件下没有可导出的归档记录')
  459. return
  460. }
  461. const sheet = XLSX.utils.aoa_to_sheet([EXPORT_HEADER, ...rows.map(exportValues)])
  462. const book = XLSX.utils.book_new()
  463. XLSX.utils.book_append_sheet(book, sheet, '历史预警归档')
  464. const d = new Date()
  465. const pad = (n) => String(n).padStart(2, '0')
  466. const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`
  467. saveAs(new Blob([XLSX.write(book, { bookType: 'xlsx', type: 'array' })]),
  468. `历史预警归档清单_${stamp}.xlsx`)
  469. ElMessage.success(`已导出 ${rows.length} 条归档记录`)
  470. } catch (error) {
  471. console.error('导出归档清单失败', error)
  472. ElMessage.error('导出失败,请稍后重试')
  473. } finally {
  474. exporting.value = false
  475. }
  476. }
  477. // ==================== 操作:删除 ====================
  478. async function handleDelete(row) {
  479. try {
  480. await ElMessageBox.confirm('确认删除该预警?删除后不可恢复。', '提示', { type: 'warning' })
  481. await deleteWarning(row.id)
  482. ElMessage.success('删除成功')
  483. loadData()
  484. } catch (error) {
  485. if (error !== 'cancel') console.error('删除预警失败', error)
  486. }
  487. }
  488. // ==================== 查看详情弹窗 ====================
  489. const dialogVisible = ref(false)
  490. const currentDetail = ref(null)
  491. const detailLoading = ref(false)
  492. const detailDevice = ref(null)
  493. const detailThresholds = ref([])
  494. const waterLevelMap = ref({})
  495. const thresholdMap = ref({})
  496. /** equipmentName -> 设备档案:预警 device_code 存的是类型 id,只能按名称反查设备 */
  497. const deviceByName = ref(null)
  498. const detailMetric = computed(() => {
  499. const code = detailDevice.value?.equipmentCode
  500. const levelItem = code ? waterLevelMap.value[code] : null
  501. const thresholds = code ? (thresholdMap.value[code] || []) : []
  502. return {
  503. level: levelItem && levelItem.value !== undefined && levelItem.value !== null ? levelItem.value : null,
  504. collectTime: levelItem?.time ? formatDateTime(levelItem.time) : '',
  505. thresholdText: thresholds.length > 0 ? thresholds.map(formatThreshold).join(' / ') : ''
  506. }
  507. })
  508. async function handleView(row) {
  509. currentDetail.value = { ...row }
  510. detailDevice.value = null
  511. detailThresholds.value = []
  512. dialogVisible.value = true
  513. detailLoading.value = true
  514. try {
  515. const [device] = await Promise.all([resolveDevice(row.equipmentName), loadLiveMetrics()])
  516. detailDevice.value = device || null
  517. const code = device?.equipmentCode
  518. detailThresholds.value = code ? (thresholdMap.value[code] || []) : []
  519. } catch (error) {
  520. console.warn('预警详情数据加载失败', error)
  521. } finally {
  522. detailLoading.value = false
  523. }
  524. }
  525. async function resolveDevice(equipmentName) {
  526. if (!equipmentName) return null
  527. if (!deviceByName.value) {
  528. const map = new Map()
  529. let page = 1
  530. while (page <= 10) {
  531. const res = await listEquipment({ rootTypeId: 2, pageNum: page, pageSize: 100 })
  532. const pageData = res?.data && Array.isArray(res.data.records) ? res.data : res
  533. const rows = pageData?.records || pageData?.rows || []
  534. if (rows.length === 0) break
  535. rows.forEach((item) => {
  536. if (item?.equipmentName && !map.has(item.equipmentName)) map.set(item.equipmentName, item)
  537. })
  538. if (rows.length < 100) break
  539. page += 1
  540. }
  541. deviceByName.value = map
  542. }
  543. return deviceByName.value.get(equipmentName) || null
  544. }
  545. async function loadLiveMetrics() {
  546. const [levelResult, thresholdResult] = await Promise.allSettled([
  547. getLatestWaterLevelByDevice(),
  548. pageWarningThresholds({ rootTypeId: 2, pageNum: 1, pageSize: 500 })
  549. ])
  550. if (levelResult.status === 'fulfilled') {
  551. const list = Array.isArray(levelResult.value) ? levelResult.value : (levelResult.value?.data || [])
  552. const map = {}
  553. list.forEach((item) => {
  554. if (!item?.deviceCode) return
  555. map[item.deviceCode] = { value: item.waterLevel, time: item.createTime }
  556. })
  557. waterLevelMap.value = map
  558. }
  559. if (thresholdResult.status === 'fulfilled') {
  560. const res = thresholdResult.value
  561. const pageData = res?.data && Array.isArray(res.data.records) ? res.data : res
  562. const rows = pageData?.records || pageData?.rows || []
  563. const map = {}
  564. rows.forEach((row) => {
  565. if (!row?.deviceCode) return
  566. if (!map[row.deviceCode]) map[row.deviceCode] = []
  567. map[row.deviceCode].push(row)
  568. })
  569. thresholdMap.value = map
  570. }
  571. }
  572. /** 阈值展示:dataType 0 区间 / 1 下限 / 2 上限 */
  573. function formatThreshold(item) {
  574. if (!item) return '-'
  575. const unit = item.warningUnit || ''
  576. const min = item.minValue
  577. const max = item.maxValue
  578. if (item.dataType === 1) return min === null || min === undefined ? '-' : `≥ ${min}${unit}`
  579. if (item.dataType === 2) return max === null || max === undefined ? '-' : `≤ ${max}${unit}`
  580. if ((min === null || min === undefined) && (max === null || max === undefined)) return '未设置'
  581. return `${min ?? '-'} ~ ${max ?? '-'}${unit}`
  582. }
  583. onMounted(async () => {
  584. await Promise.all([loadEquipmentTypes(), loadWarningTypes()])
  585. loadData()
  586. })
  587. </script>
  588. <style scoped>
  589. .lsyjgl-page {
  590. background: #fff;
  591. min-height: 100%;
  592. padding: 16px;
  593. }
  594. /* 分类统计 */
  595. .stats-panel {
  596. display: flex;
  597. gap: 16px;
  598. align-items: stretch;
  599. margin-bottom: 16px;
  600. padding: 16px 20px;
  601. border: 1px solid #e4e7ed;
  602. border-radius: 4px;
  603. }
  604. .stats-left {
  605. display: grid;
  606. grid-template-columns: repeat(2, 1fr);
  607. gap: 12px;
  608. width: 300px;
  609. flex-shrink: 0;
  610. }
  611. .stat-item {
  612. border: 1px solid #ebeef5;
  613. border-radius: 4px;
  614. padding: 10px 14px;
  615. background: #fafbfc;
  616. }
  617. .stat-value {
  618. font-size: 24px;
  619. font-weight: 600;
  620. color: #303133;
  621. line-height: 1.2;
  622. }
  623. .stat-value.green {
  624. color: #67c23a;
  625. }
  626. .stat-value.blue {
  627. color: #409eff;
  628. }
  629. .stat-label {
  630. margin-top: 2px;
  631. font-size: 12px;
  632. color: #909399;
  633. }
  634. .stats-right {
  635. flex: 1;
  636. display: flex;
  637. gap: 32px;
  638. justify-content: center;
  639. align-items: center;
  640. }
  641. .ring-chart-box {
  642. display: flex;
  643. align-items: center;
  644. gap: 12px;
  645. }
  646. .ring-chart {
  647. width: 110px;
  648. height: 110px;
  649. }
  650. .ring-legend {
  651. display: flex;
  652. flex-direction: column;
  653. gap: 6px;
  654. }
  655. .legend-row {
  656. display: flex;
  657. align-items: center;
  658. gap: 6px;
  659. font-size: 12px;
  660. color: #606266;
  661. white-space: nowrap;
  662. }
  663. .legend-dot {
  664. width: 8px;
  665. height: 8px;
  666. border-radius: 50%;
  667. }
  668. .legend-num {
  669. font-weight: 600;
  670. color: #303133;
  671. }
  672. /* 搜索区 */
  673. .search-panel {
  674. background: #fff;
  675. border: 1px solid #e4e7ed;
  676. border-radius: 4px;
  677. padding: 16px 20px;
  678. margin-bottom: 16px;
  679. }
  680. .search-form {
  681. display: flex;
  682. align-items: center;
  683. flex-wrap: wrap;
  684. gap: 12px;
  685. }
  686. .form-item {
  687. display: flex;
  688. align-items: center;
  689. }
  690. .form-label {
  691. font-size: 14px;
  692. color: #606266;
  693. white-space: nowrap;
  694. margin-right: 4px;
  695. }
  696. /* 表格区 */
  697. .table-panel {
  698. background: #fff;
  699. border-radius: 4px;
  700. }
  701. /* 详情弹窗 */
  702. .detail-content {
  703. padding: 0 8px;
  704. }
  705. .section-box {
  706. border: 1px solid #ebeef5;
  707. border-radius: 4px;
  708. padding: 16px;
  709. margin-bottom: 16px;
  710. }
  711. .section-title {
  712. font-size: 14px;
  713. font-weight: 600;
  714. color: #303133;
  715. margin-bottom: 12px;
  716. }
  717. .desc-text {
  718. background: #f5f7fa;
  719. border-radius: 4px;
  720. padding: 10px;
  721. font-size: 13px;
  722. color: #606266;
  723. line-height: 1.6;
  724. max-height: 120px;
  725. overflow-y: auto;
  726. }
  727. /* 预警指标卡片 */
  728. .metric-grid {
  729. display: grid;
  730. grid-template-columns: repeat(3, 1fr);
  731. gap: 12px;
  732. margin: 12px 0;
  733. }
  734. .metric-card {
  735. border: 1px solid #ebeef5;
  736. border-radius: 4px;
  737. padding: 12px 14px;
  738. background: #fafbfc;
  739. }
  740. .metric-label {
  741. font-size: 12px;
  742. color: #909399;
  743. margin-bottom: 6px;
  744. }
  745. .metric-value {
  746. font-size: 22px;
  747. font-weight: 600;
  748. color: #303133;
  749. line-height: 1.2;
  750. }
  751. .metric-value-text {
  752. font-size: 14px;
  753. font-weight: 500;
  754. word-break: break-all;
  755. }
  756. .metric-unit {
  757. font-size: 12px;
  758. font-weight: 400;
  759. color: #909399;
  760. margin-left: 2px;
  761. }
  762. .metric-empty {
  763. color: #c0c4cc;
  764. }
  765. .device-missing {
  766. margin-bottom: 12px;
  767. }
  768. </style>