remind.vue 7.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. <template>
  2. <div class="alarm-page">
  3. <div class="alarm-stats"><div v-for="v in alarmLevels" :key="v.value" class="alarm-stat" :style="{ '--level-color': levelColor(v.value) }"><span>{{ v.label }}</span><strong>{{ count(v.value) }}</strong><span class="muted">当前未恢复报警事件</span></div></div>
  4. <el-card shadow="never">
  5. <div class="alarm-toolbar"><div><el-tag :type="refreshError ? 'danger' : 'success'">{{ refreshError ? '刷新失败' : '定时刷新已开启' }}</el-tag><span class="muted"> · 每 30 秒刷新 · {{ refreshedAt || '正在加载' }}</span></div><el-button :icon="manualPause ? VideoPlay : VideoPause" @click="manualPause = !manualPause">{{ manualPause ? '恢复滚动' : '暂停滚动' }}</el-button></div>
  6. <div class="alarm-toolbar"><el-form inline class="alarm-filters"><el-form-item label="报警等级"><el-select v-model="level" clearable placeholder="全部等级" @change="restart"><el-option v-for="v in alarmLevels" :key="v.value" :label="v.label" :value="Number(v.value)" /></el-select></el-form-item></el-form><span class="muted">{{ pending ? `有 ${pending} 条更新,恢复后显示` : `最近更新的 ${visibleRows.length} 条事件` }}</span></div>
  7. <el-alert v-if="refreshError" :title="refreshError" show-icon type="error" :closable="false"><el-button link @click="retry">重新加载</el-button></el-alert>
  8. <div ref="feed" v-loading="loading && !hasLoaded" class="reminder-feed" tabindex="0" @mouseenter="hoverPause = true" @mouseleave="hoverPause = false" @focusin="focusPause = true" @focusout="focusPause = false">
  9. <article v-for="row in visibleRows" :key="row.id" class="reminder-message" :style="{ borderLeftColor: levelColor(row.alarmLevel) }">
  10. <div class="alarm-toolbar"><strong>{{ row.deviceCode }} ({{ row.deviceName || row.deviceCode }}) <el-tag :type="levelTone(row.alarmLevel)">{{ alarmLevels.find(item => Number(item.value) === Number(row.alarmLevel))?.label || row.alarmLevelName || '—' }}</el-tag></strong><span class="muted">{{ row.latestOperationTime || row.createTime }}</span></div>
  11. <div class="muted">报警单号:{{ row.alarmNo || '—' }} · {{ row.equipmentLocation || '暂无安装位置' }}</div>
  12. <div class="alarm-toolbar"><span class="muted">监测:{{ row.monitorStatusName }} · 核实:{{ alarmAuditText(row) }} · 处置:{{ row.orderStatusName }}</span><el-button link type="primary" @click="detailRef.open(row.id)">事件详情</el-button></div>
  13. <p>{{ row.latestOperation || row.alarmContent || '—' }}</p>
  14. </article>
  15. <el-empty v-if="!visibleRows.length && !loading" :description="error ? '数据加载失败' : '暂无报警提醒'" />
  16. </div>
  17. <div class="muted feed-footer">{{ paused ? '视图已暂停,仍持续接收更新' : '自动滚动,悬停或聚焦暂停' }}</div>
  18. </el-card><AlarmDetail ref="detailRef" @visible-change="detailPause = $event" />
  19. </div>
  20. </template>
  21. <script setup name="AlarmRemind">
  22. import { computed, ref, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
  23. import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
  24. import { getAlarmReminders, getAlarmOptions, getAlarmStatistics } from '@/api/alarm'
  25. import { levelTone, levelColor } from './js/alarmOperationsModel'
  26. import { useAlarmScope } from './js/useAlarmScope'
  27. import { alarmAuditText } from '@/utils/alarmLabels'
  28. import AlarmDetail from '../AlarmDetail.vue'
  29. import './styles/alarmOperations.scss'
  30. import { useDict } from '@/utils/dict'
  31. const { alarm_level: alarmLevels } = useDict('alarm_level')
  32. const { scope, scopeKey } = useAlarmScope()
  33. const options = ref({}), stats = ref([]), level = ref(), visibleRows = ref([]), received = ref([])
  34. const manualPause = ref(false), hoverPause = ref(false), focusPause = ref(false), feed = ref(), detailRef = ref()
  35. const loading = ref(false), error = ref(''), refreshedAt = ref(''), active = ref(true)
  36. const hasLoaded = ref(false), statisticsError = ref(''), optionsError = ref('')
  37. const refreshError = computed(() => error.value || statisticsError.value || optionsError.value)
  38. const detailPause = ref(false)
  39. const paused = computed(() => manualPause.value || hoverPause.value || focusPause.value || detailPause.value)
  40. const pending = computed(() => received.value.filter(row => !visibleRows.value.some(old => old.id === row.id && old.latestOperationTime === row.latestOperationTime)).length)
  41. const count = value => Number(stats.value.find(row => Number(row.level) === Number(value))?.total || 0)
  42. let generation = 0, poll, scroll
  43. async function load() {
  44. if (!active.value || loading.value) return
  45. const token = ++generation; loading.value = true
  46. const params = { ...scope.value, alarmStatus: 0 }
  47. // 统计独立刷新,列表不等待统计接口完成。
  48. loadStatistics(params, token)
  49. try {
  50. const page = await getAlarmReminders({ ...params, alarmLevel: level.value, pageNum: 1, pageSize: 100 })
  51. if (token !== generation) return
  52. received.value = page.data.records || []; error.value = ''
  53. // 首次进入、重新筛选或空列表收到数据时,暂停阅读不应阻止显示。
  54. if (!hasLoaded.value || !visibleRows.value.length || !paused.value) visibleRows.value = received.value
  55. hasLoaded.value = true
  56. refreshedAt.value = new Date().toLocaleTimeString()
  57. } catch (e) { if (token === generation) error.value = e.message || '刷新失败,请重试' }
  58. finally { if (token === generation) loading.value = false }
  59. }
  60. async function loadStatistics(params, token) {
  61. try {
  62. const summary = await getAlarmStatistics(params)
  63. if (token === generation) { stats.value = summary.data || []; statisticsError.value = '' }
  64. } catch (e) { if (token === generation) statisticsError.value = e.message || '等级统计刷新失败,请重试' }
  65. }
  66. async function loadOptions() {
  67. const key = scopeKey.value
  68. try {
  69. const result = await getAlarmOptions({ ...scope.value })
  70. if (key === scopeKey.value) { options.value = result.data || {}; optionsError.value = '' }
  71. } catch (e) { if (key === scopeKey.value) optionsError.value = e.message || '报警等级加载失败,请重试' }
  72. }
  73. function restart() {
  74. ++generation; loading.value = false; hasLoaded.value = false
  75. visibleRows.value = []; received.value = []; refreshedAt.value = ''; error.value = ''; statisticsError.value = ''
  76. return load()
  77. }
  78. function retry() { load(); if (optionsError.value) loadOptions() }
  79. watch(paused, value => { if (!value && active.value) visibleRows.value = received.value })
  80. watch(scopeKey, () => { stats.value = []; options.value = {}; optionsError.value = ''; restart(); loadOptions() }, { immediate: true })
  81. onMounted(() => {
  82. poll = setInterval(load, 30000)
  83. scroll = setInterval(() => { const el = feed.value; if (!active.value || paused.value || !el || el.scrollHeight <= el.clientHeight) return; el.scrollTop = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 ? 0 : el.scrollTop + 1 }, 70)
  84. })
  85. onActivated(() => {
  86. if (active.value) return
  87. active.value = true; hasLoaded.value = false; load()
  88. })
  89. onDeactivated(() => {
  90. active.value = false; ++generation; loading.value = false
  91. hoverPause.value = false; focusPause.value = false; detailPause.value = false
  92. })
  93. onBeforeUnmount(() => { ++generation; clearInterval(poll); clearInterval(scroll) })
  94. </script>
  95. <style scoped>
  96. .reminder-feed{height:calc(100vh - 390px);min-height:350px;overflow:auto;padding-right:8px}.reminder-message{border:1px solid #ebeef5;border-left:4px solid;border-radius:6px;padding:18px 20px;margin-bottom:12px;background:#fff}.reminder-message .alarm-toolbar:last-child{margin-bottom:0}.reminder-message p{line-height:1.8;overflow-wrap:anywhere}.feed-footer{margin-top:16px}
  97. </style>