AlarmStatus.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. <template>
  2. <div class="alarm-status">
  3. <!-- 页面标题 -->
  4. <div class="page-header">
  5. <h2 class="page-title">告警显示</h2>
  6. </div>
  7. <!-- 概览信息栏 -->
  8. <a-card :bordered="false" class="overview-card">
  9. <div class="overview-bar">
  10. <div class="overview-item">
  11. <span class="overview-label">告警总数:</span>
  12. <a-tag color="red">{{ alarmCount }}</a-tag>
  13. </div>
  14. <div class="overview-item">
  15. <span class="overview-label">事件总数:</span>
  16. <a-tag color="blue">{{ events.length }}</a-tag>
  17. </div>
  18. <div class="overview-item">
  19. <span class="overview-label">最后更新:</span>
  20. <span class="overview-value">{{ lastUpdate || '--' }}</span>
  21. </div>
  22. <div class="overview-actions">
  23. <a-button type="primary" danger @click="clearEvents" :loading="clearing">
  24. <template #icon><delete-outlined /></template>
  25. 清除所有事件
  26. </a-button>
  27. <a-button @click="refreshData" :loading="loading">
  28. <template #icon><reload-outlined /></template>
  29. 刷新
  30. </a-button>
  31. </div>
  32. </div>
  33. </a-card>
  34. <!-- 告警事件列表 -->
  35. <a-card :bordered="false" class="events-card">
  36. <a-table
  37. :columns="columns"
  38. :data-source="events"
  39. :loading="loading"
  40. :pagination="{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }"
  41. :scroll="{ x: 800 }"
  42. row-key="key"
  43. size="middle"
  44. >
  45. <template #bodyCell="{ column, record }">
  46. <template v-if="column.key === 'event_type'">
  47. <a-tag :color="record.event_type === 'ALARM' ? 'red' : 'blue'">
  48. {{ record.event_type === 'ALARM' ? '告警' : '事件' }}
  49. </a-tag>
  50. </template>
  51. <template v-else-if="column.key === 'alarm_type'">
  52. <span v-if="record.alarm_type">{{ record.alarm_type }}</span>
  53. <span v-else class="text-muted">-</span>
  54. </template>
  55. <template v-else-if="column.key === 'expected_jumper'">
  56. <span>{{ record.expected_jumper_uid || '-' }}</span>
  57. </template>
  58. <template v-else-if="column.key === 'actual_jumper'">
  59. <span :class="{ 'text-danger': record.actual_jumper_uid !== record.expected_jumper_uid }">
  60. {{ record.actual_jumper_uid || '未读到' }}
  61. </span>
  62. </template>
  63. <template v-else-if="column.key === 'timestamp'">
  64. <span class="timestamp">{{ formatTime(record.timestamp) }}</span>
  65. </template>
  66. </template>
  67. <template #emptyText>
  68. <a-empty description="暂无告警事件" />
  69. </template>
  70. </a-table>
  71. </a-card>
  72. </div>
  73. </template>
  74. <script setup>
  75. import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
  76. import { message } from 'ant-design-vue'
  77. import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons-vue'
  78. import apiService from '../api/apiService'
  79. import { useDataStore } from '../stores/dataStore'
  80. const dataStore = useDataStore()
  81. // 状态
  82. const loading = ref(false)
  83. const clearing = ref(false)
  84. const events = ref([])
  85. const lastUpdate = ref('')
  86. const pollTimer = ref(null)
  87. // 表格列定义
  88. const columns = [
  89. {
  90. title: '时间',
  91. key: 'timestamp',
  92. dataIndex: 'timestamp',
  93. width: 180,
  94. sorter: (a, b) => new Date(b.timestamp) - new Date(a.timestamp)
  95. },
  96. {
  97. title: '面板',
  98. key: 'panel_id',
  99. dataIndex: 'panel_id',
  100. width: 80
  101. },
  102. {
  103. title: '端口',
  104. key: 'port_id',
  105. dataIndex: 'port_id',
  106. width: 80
  107. },
  108. {
  109. title: '类型',
  110. key: 'event_type',
  111. dataIndex: 'event_type',
  112. width: 100
  113. },
  114. {
  115. title: '告警类型',
  116. key: 'alarm_type',
  117. dataIndex: 'alarm_type',
  118. width: 150
  119. },
  120. {
  121. title: '期望跳线UID',
  122. key: 'expected_jumper',
  123. dataIndex: 'expected_jumper_uid',
  124. width: 130
  125. },
  126. {
  127. title: '实际跳线UID',
  128. key: 'actual_jumper',
  129. dataIndex: 'actual_jumper_uid',
  130. width: 130
  131. }
  132. ]
  133. // 计算告警数量
  134. const alarmCount = computed(() => {
  135. return events.value.filter(e => e.event_type === 'ALARM').length
  136. })
  137. // 格式化时间
  138. function formatTime(timestamp) {
  139. if (!timestamp) return '-'
  140. try {
  141. const date = new Date(timestamp)
  142. return date.toLocaleString('zh-CN', {
  143. year: 'numeric',
  144. month: '2-digit',
  145. day: '2-digit',
  146. hour: '2-digit',
  147. minute: '2-digit',
  148. second: '2-digit'
  149. })
  150. } catch {
  151. return timestamp
  152. }
  153. }
  154. // 加载事件数据
  155. async function loadEvents() {
  156. try {
  157. const res = await apiService.port.getEvents(100)
  158. if (res.data.success) {
  159. // 添加唯一key
  160. events.value = (res.data.events || []).map((e, i) => ({
  161. ...e,
  162. key: `${e.timestamp}-${i}`
  163. }))
  164. }
  165. } catch (e) {
  166. console.error('load events error:', e)
  167. }
  168. }
  169. // 刷新数据
  170. async function refreshData() {
  171. loading.value = true
  172. try {
  173. await loadEvents()
  174. lastUpdate.value = new Date().toLocaleTimeString()
  175. } finally {
  176. loading.value = false
  177. }
  178. }
  179. // 清除所有事件
  180. async function clearEvents() {
  181. clearing.value = true
  182. try {
  183. const res = await apiService.port.clearEvents()
  184. if (res.data.success) {
  185. message.success('事件已清除')
  186. events.value = []
  187. }
  188. } catch (e) {
  189. console.error('clear events error:', e)
  190. message.error('清除失败')
  191. } finally {
  192. clearing.value = false
  193. }
  194. }
  195. // 生命周期
  196. onMounted(async () => {
  197. await refreshData()
  198. pollTimer.value = setInterval(refreshData, 5000)
  199. })
  200. onUnmounted(() => {
  201. if (pollTimer.value) clearInterval(pollTimer.value)
  202. })
  203. // 监听 dataStore.mqttData 实时推送 event/alarm topic 到表格顶部
  204. watch(
  205. () => dataStore.mqttData.length,
  206. (newLen, oldLen) => {
  207. if (newLen <= (oldLen || 0)) return
  208. const added = dataStore.mqttData.slice(oldLen || 0, newLen)
  209. for (const item of added) {
  210. if (!item || typeof item !== 'object') continue
  211. const topic = item.topic || ''
  212. const isAlarm = topic.endsWith('/alarm')
  213. const isEvent = topic.endsWith('/event')
  214. if (!isAlarm && !isEvent) continue
  215. try {
  216. const parsed = typeof item.payload === 'string' ? JSON.parse(item.payload) : item.payload
  217. const p = parsed?.payload || parsed || {}
  218. const rec = {
  219. key: `rt-${parsed?.msg_id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
  220. timestamp: parsed?.timestamp
  221. ? new Date(parsed.timestamp).toISOString()
  222. : item.timestamp || new Date().toISOString(),
  223. panel_id: p.panel_id,
  224. port_id: p.port_id,
  225. event_type: isAlarm ? 'ALARM' : 'EVENT',
  226. alarm_type: p.alarm_type || (isEvent ? p.event_type : null),
  227. expected_jumper_uid: p.expected_jumper_uid || null,
  228. actual_jumper_uid: p.actual_jumper_uid ?? p.jumper_uid ?? null,
  229. severity: p.severity || null
  230. }
  231. // 去重(同 msg_id 只插一次):按 key 前缀检测
  232. if (!events.value.find(e => e.key === rec.key)) {
  233. events.value = [rec, ...events.value].slice(0, 500)
  234. lastUpdate.value = new Date().toLocaleTimeString()
  235. }
  236. } catch (err) {
  237. console.warn('实时告警解析失败:', err, item)
  238. }
  239. }
  240. }
  241. )
  242. </script>
  243. <style scoped>
  244. .alarm-status {
  245. display: flex;
  246. flex-direction: column;
  247. gap: 16px;
  248. }
  249. .page-header {
  250. margin-bottom: 8px;
  251. }
  252. .page-title {
  253. margin: 0;
  254. font-size: 20px;
  255. font-weight: 600;
  256. color: #333;
  257. }
  258. .overview-card {
  259. flex-shrink: 0;
  260. }
  261. .overview-bar {
  262. display: flex;
  263. align-items: center;
  264. gap: 24px;
  265. flex-wrap: wrap;
  266. }
  267. .overview-item {
  268. display: flex;
  269. align-items: center;
  270. gap: 8px;
  271. }
  272. .overview-label {
  273. font-size: 14px;
  274. color: #666;
  275. }
  276. .overview-value {
  277. font-size: 14px;
  278. color: #333;
  279. }
  280. .overview-actions {
  281. margin-left: auto;
  282. display: flex;
  283. gap: 12px;
  284. }
  285. .events-card {
  286. flex: 1;
  287. min-height: 400px;
  288. }
  289. .timestamp {
  290. font-family: 'Monaco', 'Menlo', monospace;
  291. font-size: 13px;
  292. }
  293. .text-muted {
  294. color: #999;
  295. }
  296. .text-danger {
  297. color: #ff4d4f;
  298. font-weight: 500;
  299. }
  300. @media (max-width: 768px) {
  301. .overview-bar {
  302. flex-direction: column;
  303. align-items: flex-start;
  304. }
  305. .overview-actions {
  306. margin-left: 0;
  307. margin-top: 12px;
  308. width: 100%;
  309. }
  310. .overview-actions button {
  311. flex: 1;
  312. }
  313. }
  314. </style>