feat:添加报警统计页面

master
黄伟杰 1 month ago
parent d348383a74
commit ae5de803a2

@ -0,0 +1,67 @@
import request from '@/config/axios'
// 报警统计 Count 响应
export interface DeviceWarningCountRespVO {
totalCount: number
normalCount: number
tipCount: number
seriousCount: number
}
// 报警趋势 响应
export interface DeviceWarningTrendRespVO {
timePoints: string[]
counts: number[]
}
// 报警记录 响应
export interface DeviceWarningRecordRespVO {
id: number
deviceId: number
modelId: number
rule: string
alarmLevel: string
addressValue: string
ruleId: number
deviceName: string
modelName: string
ruleName: string
createTime: string
customerName: string
}
// 查询参数
export interface DeviceWarningQueryParams {
startTime?: string
endTime?: string
alarmLevel?: string
pageNo?: number
pageSize?: number
}
// 报警统计与记录 API
export const DeviceWarningRecordApi = {
// 获取报警统计数量
getWarningCount: async (params: { startTime?: string; endTime?: string }) => {
return await request.get<DeviceWarningCountRespVO>({
url: `/iot/device-warinning-record/count`,
params
})
},
// 获取报警趋势
getWarningTrend: async (params: { startTime?: string; endTime?: string }) => {
return await request.get<DeviceWarningTrendRespVO>({
url: `/iot/device-warinning-record/trend`,
params
})
},
// 获取报警记录分页
getWarningRecordList: async (params: DeviceWarningQueryParams) => {
return await request.get<PageResult<DeviceWarningRecordRespVO[]>>({
url: `/iot/device-warinning-record/page`,
params
})
}
}

@ -0,0 +1,576 @@
<template>
<div class="alarm-statistics-container">
<!-- Header -->
<div class="header-section">
<h2 class="page-title">报警统计</h2>
<el-radio-group v-model="timeRange" @change="handleTimeRangeChange">
<el-radio-button label="today">今日</el-radio-button>
<el-radio-button label="week">本周</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
</el-radio-group>
</div>
<!-- Top Stats Cards -->
<div class="stats-cards">
<el-card shadow="never" class="stat-card" v-loading="countLoading">
<div class="stat-content">
<div class="stat-icon bg-red-100 text-red-500">
<Icon icon="ep:bell-filled" :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ countData.totalCount }}</div>
<div class="stat-label">报警总数</div>
</div>
</div>
</el-card>
<el-card shadow="never" class="stat-card" v-loading="countLoading">
<div class="stat-content">
<div class="stat-icon bg-blue-100 text-blue-500">
<Icon icon="ep:info-filled" :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ countData.normalCount }}</div>
<div class="stat-label">一般报警</div>
</div>
</div>
</el-card>
<el-card shadow="never" class="stat-card" v-loading="countLoading">
<div class="stat-content">
<div class="stat-icon bg-yellow-100 text-yellow-500">
<Icon icon="ep:opportunity" :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ countData.tipCount }}</div>
<div class="stat-label">提示报警</div>
</div>
</div>
</el-card>
<el-card shadow="never" class="stat-card" v-loading="countLoading">
<div class="stat-content">
<div class="stat-icon bg-orange-100 text-orange-500">
<Icon icon="ep:warning-filled" :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ countData.seriousCount }}</div>
<div class="stat-label">严重报警</div>
</div>
</div>
</el-card>
</div>
<!-- Charts Section -->
<div class="charts-section">
<el-card shadow="never" class="chart-card flex-2" v-loading="trendLoading">
<template #header>
<div class="card-title">报警趋势</div>
</template>
<div ref="trendChartRef" class="chart-container"></div>
</el-card>
<el-card shadow="never" class="chart-card flex-1" v-loading="countLoading">
<template #header>
<div class="card-title">报警级别分布</div>
</template>
<div ref="pieChartRef" class="chart-container"></div>
</el-card>
</div>
<!-- Alarm Records Table -->
<el-card shadow="never" class="table-card">
<template #header>
<div class="table-header">
<div class="card-title">报警记录</div>
<div class="table-filters">
<el-select
v-model="queryParams.alarmLevel"
placeholder="全部级别"
clearable
@change="handleQuery"
class="filter-select"
>
<el-option
v-for="dict in alarmLevelOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
<!-- 状态字段后端未返回前端仅占位展示下拉框 -->
<el-select v-model="mockStatus" placeholder="全部状态" clearable class="filter-select">
<el-option label="活动" value="active" />
<el-option label="已处理" value="handled" />
</el-select>
</div>
</div>
</template>
<el-table v-loading="tableLoading" :data="tableData" stripe style="width: 100%">
<el-table-column prop="deviceName" label="设备名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="rule" label="报警代码" min-width="100" />
<el-table-column prop="ruleName" label="报警信息" min-width="150" show-overflow-tooltip />
<el-table-column prop="alarmLevel" label="级别" min-width="100">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_ALARM_REGISTRATION" :value="scope.row.alarmLevel" />
</template>
</el-table-column>
<el-table-column prop="createTime" label="报警时间" min-width="160" />
<el-table-column label="状态" min-width="100">
<template #default>
<!-- 模拟状态展示由于接口没有此字段统一显示为待处理或占位 -->
<el-tag type="danger" effect="light" round>活动</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="handleProcess(scope.row)"></el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive, nextTick } from 'vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import { DeviceWarningRecordApi, DeviceWarningRecordRespVO } from '@/api/iot/deviceWarningRecord'
import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
defineOptions({ name: 'AlarmStatistics' })
const timeRange = ref('today')
const mockStatus = ref('')
const countLoading = ref(false)
const countData = reactive({
totalCount: 0,
normalCount: 0,
tipCount: 0,
seriousCount: 0
})
const trendLoading = ref(false)
const trendChartRef = ref<HTMLElement | null>(null)
let trendChartInstance: echarts.ECharts | null = null
const pieChartRef = ref<HTMLElement | null>(null)
let pieChartInstance: echarts.ECharts | null = null
const tableLoading = ref(false)
const tableData = ref<DeviceWarningRecordRespVO[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
alarmLevel: undefined,
startTime: undefined as string | undefined,
endTime: undefined as string | undefined
})
const alarmLevelOptions = getStrDictOptions(DICT_TYPE.IOT_ALARM_REGISTRATION)
//
const getTimeRangeValues = () => {
const now = dayjs()
let startTime = ''
let endTime = ''
if (timeRange.value === 'today') {
startTime = now.startOf('day').format('YYYY-MM-DD HH:mm:ss')
endTime = now.endOf('day').format('YYYY-MM-DD HH:mm:ss')
} else if (timeRange.value === 'week') {
startTime = now.startOf('week').format('YYYY-MM-DD HH:mm:ss')
endTime = now.endOf('week').format('YYYY-MM-DD HH:mm:ss')
} else if (timeRange.value === 'month') {
startTime = now.startOf('month').format('YYYY-MM-DD HH:mm:ss')
endTime = now.endOf('month').format('YYYY-MM-DD HH:mm:ss')
}
return { startTime, endTime }
}
const handleTimeRangeChange = () => {
const { startTime, endTime } = getTimeRangeValues()
queryParams.startTime = startTime
queryParams.endTime = endTime
queryParams.pageNo = 1
loadAllData()
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const loadAllData = () => {
getCountData()
getTrendData()
getList()
}
const getCountData = async () => {
try {
countLoading.value = true
const { startTime, endTime } = getTimeRangeValues()
const res = await DeviceWarningRecordApi.getWarningCount({ startTime, endTime })
if (res) {
countData.totalCount = res.totalCount || 0
countData.normalCount = res.normalCount || 0
countData.tipCount = res.tipCount || 0
countData.seriousCount = res.seriousCount || 0
updatePieChart()
}
} catch (error) {
console.error('获取统计数据失败', error)
} finally {
countLoading.value = false
}
}
const getTrendData = async () => {
try {
trendLoading.value = true
const { startTime, endTime } = getTimeRangeValues()
const res = await DeviceWarningRecordApi.getWarningTrend({ startTime, endTime })
if (res) {
updateTrendChart(res.timePoints || [], res.counts || [])
}
} catch (error) {
console.error('获取趋势数据失败', error)
} finally {
trendLoading.value = false
}
}
const getList = async () => {
try {
tableLoading.value = true
const res = await DeviceWarningRecordApi.getWarningRecordList(queryParams)
tableData.value = res?.list || []
total.value = res?.total || 0
} catch (error) {
console.error('获取列表数据失败', error)
} finally {
tableLoading.value = false
}
}
const initCharts = () => {
if (trendChartRef.value) {
trendChartInstance = echarts.init(trendChartRef.value)
}
if (pieChartRef.value) {
pieChartInstance = echarts.init(pieChartRef.value)
}
}
const updateTrendChart = (xData: string[], yData: number[]) => {
if (!trendChartInstance) return
const option = {
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed',
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
}
},
series: [
{
name: '报警数',
type: 'line',
smooth: true,
data: yData,
itemStyle: {
color: '#F56C6C'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(245, 108, 108, 0.3)' },
{ offset: 1, color: 'rgba(245, 108, 108, 0.05)' }
])
}
}
]
}
trendChartInstance.setOption(option)
}
const updatePieChart = () => {
if (!pieChartInstance) return
const option = {
tooltip: {
trigger: 'item'
},
legend: {
bottom: '5%',
left: 'center'
},
color: ['#F56C6C', '#E6A23C', '#409EFF'],
series: [
{
name: '报警级别分布',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: countData.seriousCount, name: '严重' },
{ value: countData.tipCount, name: '提示' },
{ value: countData.normalCount, name: '一般' }
]
}
]
}
pieChartInstance.setOption(option)
}
const handleProcess = (row: any) => {
// TODO:
console.log('处理', row)
}
const handleResize = () => {
trendChartInstance?.resize()
pieChartInstance?.resize()
}
onMounted(() => {
const { startTime, endTime } = getTimeRangeValues()
queryParams.startTime = startTime
queryParams.endTime = endTime
nextTick(() => {
initCharts()
loadAllData()
window.addEventListener('resize', handleResize)
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
trendChartInstance?.dispose()
pieChartInstance?.dispose()
})
</script>
<style scoped lang="scss">
.alarm-statistics-container {
padding: 16px;
background-color: var(--el-bg-color-page);
min-height: calc(100vh - 84px);
display: flex;
flex-direction: column;
gap: 16px;
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--el-bg-color);
padding: 16px 20px;
border-radius: 8px;
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.stat-card {
border-radius: 8px;
:deep(.el-card__body) {
padding: 20px;
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 4px;
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1;
color: var(--el-text-color-primary);
}
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
}
}
}
.charts-section {
display: flex;
gap: 16px;
.chart-card {
border-radius: 8px;
&.flex-2 {
flex: 2;
}
&.flex-1 {
flex: 1;
}
.card-title {
font-size: 16px;
font-weight: 500;
}
.chart-container {
height: 300px;
width: 100%;
}
}
}
.table-card {
border-radius: 8px;
flex: 1;
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
font-size: 16px;
font-weight: 500;
}
.table-filters {
display: flex;
gap: 12px;
.filter-select {
width: 120px;
}
}
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
}
}
/* 颜色工具类 */
.bg-red-100 {
background-color: #fee2e2;
}
.text-red-500 {
color: #ef4444;
}
.bg-blue-100 {
background-color: #dbeafe;
}
.text-blue-500 {
color: #3b82f6;
}
.bg-yellow-100 {
background-color: #fef9c3;
}
.text-yellow-500 {
color: #eab308;
}
.bg-orange-100 {
background-color: #ffedd5;
}
.text-orange-500 {
color: #f97316;
}
</style>
Loading…
Cancel
Save