mOdataStatistics.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. <!-- 数据统计 - 井盖设备智能终端运维看板(报警运维看板) -->
  2. <template>
  3. <div class="alarm-dashboard">
  4. <header class="page-header">
  5. <div class="header-left">
  6. <el-icon><Monitor /></el-icon>
  7. <span class="title">井盖设备智能终端运维看板</span>
  8. </div>
  9. <el-button :icon="Refresh" @click="loadAll" :loading="loading">刷新</el-button>
  10. </header>
  11. <!-- 第一行指标 -->
  12. <div class="kpi-grid">
  13. <div class="kpi-card">
  14. <div class="kpi-head"><span class="kpi-icon"><el-icon><Calendar /></el-icon></span>今日新增报警</div>
  15. <div class="kpi-value green">{{ kpi.todayCount }}</div>
  16. <div class="kpi-foot">较昨日 <span :class="diffClass">{{ diffText }}</span></div>
  17. </div>
  18. <div class="kpi-card">
  19. <div class="kpi-head"><span class="kpi-icon"><el-icon><Document /></el-icon></span>已转工单</div>
  20. <div class="kpi-value">{{ kpi.transferredCount }}</div>
  21. <div class="kpi-foot">已派发处置的报警</div>
  22. </div>
  23. <div class="kpi-card">
  24. <div class="kpi-head"><span class="kpi-icon"><el-icon><Warning /></el-icon></span>未转工单</div>
  25. <div class="kpi-value orange">{{ kpi.untransferredCount }}</div>
  26. <div class="kpi-foot">待核实转派的报警</div>
  27. </div>
  28. </div>
  29. <!-- 第二行指标 -->
  30. <div class="kpi-grid">
  31. <div class="kpi-card">
  32. <div class="kpi-head"><span class="kpi-icon"><el-icon><CircleCheck /></el-icon></span>报警处置率</div>
  33. <div class="kpi-value green">{{ kpi.disposalRate }}<small>%</small></div>
  34. <div class="kpi-foot">已处置异常占比</div>
  35. </div>
  36. <div class="kpi-card">
  37. <div class="kpi-head"><span class="kpi-icon"><el-icon><Clock /></el-icon></span>平均响应时长</div>
  38. <div class="kpi-value">{{ formatDuration(kpi.avgResponseHours) }}</div>
  39. <div class="kpi-foot">报警处置响应</div>
  40. </div>
  41. <div class="kpi-card">
  42. <div class="kpi-head"><span class="kpi-icon"><el-icon><Timer /></el-icon></span>平均处置时长</div>
  43. <div class="kpi-value">{{ formatDuration(kpi.avgDisposalHours) }}</div>
  44. <div class="kpi-foot">报警处置完成</div>
  45. </div>
  46. </div>
  47. <!-- 已核实报警 · 分级 -->
  48. <div class="section-title"><span class="title-bar"></span>已核实报警 · 分级</div>
  49. <div class="level-grid">
  50. <div v-for="(item, index) in levels" :key="item.level" class="level-card" :class="'level-' + (index + 1)">
  51. <div class="level-value">{{ item.count ?? 0 }}</div>
  52. <div class="level-name">{{ item.name }}</div>
  53. </div>
  54. </div>
  55. <!-- 近 7 日报警数 -->
  56. <div class="section-title"><span class="title-bar"></span>近 7 日报警数</div>
  57. <div ref="trendRef" class="trend-chart" v-loading="loading"></div>
  58. <!-- 实时故障列表 -->
  59. <div class="section-title"><span class="title-bar"></span>实时故障列表<span class="title-count">共 {{ abnormalAlarms.length }} 条</span></div>
  60. <el-table :data="pagedAlarms" class="alarm-table" size="small" v-loading="loading">
  61. <el-table-column prop="deviceCode" label="设备编码" min-width="150" show-overflow-tooltip>
  62. <template #default="{ row }">{{ row.deviceCode || '-' }}</template>
  63. </el-table-column>
  64. <el-table-column prop="deviceName" label="设备名称" min-width="150" show-overflow-tooltip>
  65. <template #default="{ row }">{{ row.deviceName || '-' }}</template>
  66. </el-table-column>
  67. <el-table-column prop="installLocation" label="设备安装位置" min-width="180" show-overflow-tooltip>
  68. <template #default="{ row }">{{ row.installLocation || '-' }}</template>
  69. </el-table-column>
  70. <el-table-column prop="faultDesc" label="故障描述" min-width="200" show-overflow-tooltip>
  71. <template #default="{ row }">{{ row.faultDesc || '-' }}</template>
  72. </el-table-column>
  73. <el-table-column prop="faultTime" label="故障发生时间" min-width="160" show-overflow-tooltip>
  74. <template #default="{ row }">{{ row.faultTime || '-' }}</template>
  75. </el-table-column>
  76. </el-table>
  77. <div class="pagination-wrapper">
  78. <el-pagination
  79. background
  80. layout="total, sizes, prev, pager, next"
  81. :total="abnormalAlarms.length"
  82. v-model:current-page="currentPage"
  83. v-model:page-size="pageSize"
  84. :page-sizes="[10, 20, 50]"
  85. />
  86. </div>
  87. </div>
  88. </template>
  89. <script setup>
  90. import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
  91. import {
  92. Monitor, Refresh, Calendar, Document, Warning, CircleCheck, Clock, Timer
  93. } from '@element-plus/icons-vue'
  94. import { ElMessage } from 'element-plus'
  95. import * as echarts from 'echarts'
  96. import { getAlarmDashboard } from '@/api/pipeNetwork/basic'
  97. // ==================== 状态 ====================
  98. const loading = ref(false)
  99. const currentPage = ref(1)
  100. const pageSize = ref(10)
  101. const kpi = reactive({
  102. todayCount: 0,
  103. yesterdayCount: 0,
  104. todayDiff: 0,
  105. transferredCount: 0,
  106. untransferredCount: 0,
  107. disposalRate: 0,
  108. avgResponseHours: null,
  109. avgDisposalHours: null
  110. })
  111. // 四个等级卡片固定展示,接口无数据时全部为 0
  112. const FALLBACK_LEVELS = [
  113. { level: '1', name: '一级报警', count: 0 },
  114. { level: '2', name: '二级报警', count: 0 },
  115. { level: '3', name: '三级报警', count: 0 },
  116. { level: '4', name: '四级报警', count: 0 }
  117. ]
  118. const levels = ref(FALLBACK_LEVELS.map(item => ({ ...item })))
  119. const trend = ref([])
  120. const abnormalAlarms = ref([])
  121. const trendRef = ref(null)
  122. let trendChart = null
  123. // ==================== 展示格式化 ====================
  124. const diffText = computed(() => {
  125. const diff = Number(kpi.todayDiff) || 0
  126. return diff > 0 ? `+${diff}` : String(diff)
  127. })
  128. const diffClass = computed(() => {
  129. const diff = Number(kpi.todayDiff) || 0
  130. return diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'
  131. })
  132. // 后端返回小时:满 24 小时折算成天,否则展示小时;无有效样本显示占位符
  133. function formatDuration(hours) {
  134. if (hours == null || hours === '') return '—'
  135. const value = Number(hours)
  136. if (!Number.isFinite(value) || value < 0) return '—'
  137. if (value >= 24) {
  138. const days = value / 24
  139. return `${Number.isInteger(days) ? days : days.toFixed(1)}天`
  140. }
  141. return `${value.toFixed(1)}小时`
  142. }
  143. function fallbackTrend() {
  144. const result = []
  145. const today = new Date()
  146. for (let i = 6; i >= 0; i--) {
  147. const day = new Date(today.getFullYear(), today.getMonth(), today.getDate() - i)
  148. const mm = String(day.getMonth() + 1).padStart(2, '0')
  149. const dd = String(day.getDate()).padStart(2, '0')
  150. result.push({ label: `${mm}.${dd}`, count: 0 })
  151. }
  152. return result
  153. }
  154. const pagedAlarms = computed(() => {
  155. const start = (currentPage.value - 1) * pageSize.value
  156. return abnormalAlarms.value.slice(start, start + pageSize.value)
  157. })
  158. // ==================== 数据加载 ====================
  159. async function loadAll() {
  160. loading.value = true
  161. try {
  162. const { data } = await getAlarmDashboard()
  163. const d = data || {}
  164. kpi.todayCount = d.todayCount ?? 0
  165. kpi.yesterdayCount = d.yesterdayCount ?? 0
  166. kpi.todayDiff = d.todayDiff ?? 0
  167. kpi.transferredCount = d.transferredCount ?? 0
  168. kpi.untransferredCount = d.untransferredCount ?? 0
  169. kpi.disposalRate = d.disposalRate ?? 0
  170. kpi.avgResponseHours = d.avgResponseHours ?? null
  171. kpi.avgDisposalHours = d.avgDisposalHours ?? null
  172. levels.value = Array.isArray(d.levels) && d.levels.length
  173. ? d.levels.map(item => ({ ...item }))
  174. : FALLBACK_LEVELS.map(item => ({ ...item }))
  175. trend.value = Array.isArray(d.trend) && d.trend.length ? d.trend : fallbackTrend()
  176. abnormalAlarms.value = Array.isArray(d.abnormalAlarms) ? d.abnormalAlarms : []
  177. currentPage.value = 1
  178. await nextTick()
  179. renderTrendChart()
  180. } catch (error) {
  181. const msg = error?.message || error?.msg || '未知错误'
  182. ElMessage.error('统计数据加载失败: ' + msg)
  183. } finally {
  184. loading.value = false
  185. }
  186. }
  187. // ==================== 近 7 日趋势图 ====================
  188. function renderTrendChart() {
  189. if (!trendRef.value) return
  190. if (!trendChart) trendChart = echarts.init(trendRef.value)
  191. const rows = trend.value.length ? trend.value : fallbackTrend()
  192. trendChart.setOption({
  193. backgroundColor: 'transparent',
  194. grid: { left: 44, right: 24, top: 24, bottom: 32 },
  195. tooltip: { trigger: 'axis', backgroundColor: '#ffffff', borderColor: '#dce3ed', textStyle: { color: '#303133' } },
  196. xAxis: {
  197. type: 'category',
  198. boundaryGap: false,
  199. data: rows.map(item => item.label),
  200. axisLine: { lineStyle: { color: '#c9d4e2' } },
  201. axisTick: { show: false },
  202. axisLabel: { color: '#7b8a9e', fontSize: 11 }
  203. },
  204. yAxis: {
  205. type: 'value',
  206. min: 0,
  207. minInterval: 1,
  208. splitLine: { lineStyle: { color: '#e8edf4', type: 'dashed' } },
  209. axisLabel: { color: '#7b8a9e', fontSize: 11 }
  210. },
  211. series: [{
  212. name: '报警数',
  213. type: 'line',
  214. smooth: true,
  215. symbol: 'circle',
  216. symbolSize: 7,
  217. data: rows.map(item => Number(item.count) || 0),
  218. lineStyle: { width: 2, color: '#f59a23' },
  219. itemStyle: { color: '#f59a23', borderColor: '#ffffff', borderWidth: 2 },
  220. areaStyle: {
  221. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  222. { offset: 0, color: 'rgba(255,159,67,.32)' },
  223. { offset: 1, color: 'rgba(255,159,67,.02)' }
  224. ])
  225. }
  226. }]
  227. }, true)
  228. }
  229. // ==================== 生命周期 ====================
  230. const handleResize = () => { trendChart && trendChart.resize() }
  231. onMounted(async () => {
  232. await nextTick()
  233. window.addEventListener('resize', handleResize)
  234. loadAll()
  235. })
  236. onBeforeUnmount(() => {
  237. window.removeEventListener('resize', handleResize)
  238. trendChart && trendChart.dispose()
  239. trendChart = null
  240. })
  241. </script>
  242. <style scoped>
  243. .alarm-dashboard {
  244. min-height: 100vh;
  245. padding: 18px 22px 28px;
  246. background: #f0f2f6;
  247. color: #303133;
  248. box-sizing: border-box;
  249. }
  250. .page-header {
  251. display: flex;
  252. align-items: center;
  253. justify-content: space-between;
  254. margin-bottom: 16px;
  255. }
  256. .header-left { display: flex; align-items: center; gap: 10px; color: #409eff; }
  257. .header-left .title { font-size: 18px; font-weight: 600; letter-spacing: 1px; color: #1f2d3d; }
  258. .kpi-grid {
  259. display: grid;
  260. grid-template-columns: repeat(3, 1fr);
  261. gap: 14px;
  262. margin-bottom: 14px;
  263. }
  264. .kpi-card {
  265. position: relative;
  266. padding: 14px 18px 13px;
  267. border: 1px solid #e4e9f2;
  268. border-radius: 8px;
  269. background: #fff;
  270. box-shadow: 0 1px 4px rgba(31, 45, 61, .06);
  271. overflow: hidden;
  272. }
  273. .kpi-card::before {
  274. content: '';
  275. position: absolute;
  276. left: 0; top: 0; bottom: 0;
  277. width: 3px;
  278. background: linear-gradient(180deg, #7ec8ff, #2b7de9);
  279. }
  280. .kpi-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #6b7a90; }
  281. .kpi-icon {
  282. display: inline-flex;
  283. align-items: center;
  284. justify-content: center;
  285. width: 20px; height: 20px;
  286. border-radius: 4px;
  287. background: #eaf3ff;
  288. color: #409eff;
  289. font-size: 13px;
  290. }
  291. .kpi-value {
  292. margin-top: 8px;
  293. font-size: 30px;
  294. font-weight: 700;
  295. line-height: 1.15;
  296. color: #1f2d3d;
  297. font-variant-numeric: tabular-nums;
  298. }
  299. .kpi-value small { font-size: 16px; margin-left: 2px; font-weight: 600; }
  300. .kpi-value.green { color: #18a058; }
  301. .kpi-value.orange { color: #e8890c; }
  302. .kpi-foot { margin-top: 5px; font-size: 11px; color: #909399; }
  303. .kpi-foot .up { color: #f56c6c; font-weight: 600; }
  304. .kpi-foot .down { color: #18a058; font-weight: 600; }
  305. .kpi-foot .flat { color: #909399; }
  306. .section-title {
  307. display: flex;
  308. align-items: center;
  309. gap: 9px;
  310. margin: 20px 0 12px;
  311. font-size: 14px;
  312. font-weight: 600;
  313. color: #303133;
  314. }
  315. .title-bar {
  316. width: 3px; height: 14px;
  317. border-radius: 2px;
  318. background: linear-gradient(180deg, #7ec8ff, #2b7de9);
  319. }
  320. .title-count { font-size: 12px; font-weight: 400; color: #909399; }
  321. .level-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
  322. .level-card {
  323. padding: 16px 10px 14px;
  324. text-align: center;
  325. border: 1px solid #e4e9f2;
  326. border-radius: 8px;
  327. background: #fff;
  328. box-shadow: 0 1px 4px rgba(31, 45, 61, .06);
  329. }
  330. .level-value { font-size: 28px; font-weight: 700; line-height: 1.2; color: #1f2d3d; font-variant-numeric: tabular-nums; }
  331. .level-name { margin-top: 6px; font-size: 12px; color: #6b7a90; }
  332. .level-1 { border-color: #f7c4c4; }
  333. .level-1 .level-value { color: #f04c4c; }
  334. .level-2 { border-color: #f5d9b0; }
  335. .level-2 .level-value { color: #e8890c; }
  336. .level-3 { border-color: #ede0a8; }
  337. .level-3 .level-value { color: #c9a227; }
  338. .level-4 { border-color: #b9e2ee; }
  339. .level-4 .level-value { color: #1a9cb5; }
  340. .trend-chart {
  341. height: 210px;
  342. border: 1px solid #e4e9f2;
  343. border-radius: 8px;
  344. background: #fff;
  345. box-shadow: 0 1px 4px rgba(31, 45, 61, .06);
  346. }
  347. .alarm-table {
  348. width: 100%;
  349. border-radius: 8px;
  350. overflow: hidden;
  351. box-shadow: 0 1px 4px rgba(31, 45, 61, .06);
  352. }
  353. .alarm-table :deep(th.el-table__cell) {
  354. background: #f5f7fa;
  355. color: #606266;
  356. font-weight: 600;
  357. }
  358. .pagination-wrapper {
  359. display: flex;
  360. justify-content: flex-end;
  361. margin-top: 12px;
  362. padding-top: 12px;
  363. border-top: 1px solid #ebeef5;
  364. }
  365. @media (max-width: 1100px) {
  366. .kpi-grid { grid-template-columns: repeat(2, 1fr); }
  367. .level-grid { grid-template-columns: repeat(2, 1fr); }
  368. }
  369. </style>