Files
IOT_WEB/src/views/inspection/alert-message/Index.vue
T
2026-09-03 14:03:35 +08:00

759 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-container">
<el-card>
<template #header>
<div class="card-header">
<span>消息告警</span>
<el-button @click="loadAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
</div>
</template>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- ==================== 告警列表 ==================== -->
<el-tab-pane label="告警列表" name="list">
<!-- 概览卡片 -->
<el-row :gutter="16" class="summary-row">
<el-col :span="6">
<div class="summary-card danger">
<div class="summary-count">{{ overview.UnacknowledgedCount || 0 }}</div>
<div class="summary-label">未确认</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-card">
<div class="summary-count">{{ overview.TodayCount || 0 }}</div>
<div class="summary-label">今日新增</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-card danger">
<div class="summary-count">{{ overview.TodayUrgentCount || 0 }}</div>
<div class="summary-label">今日紧急</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-card">
<div class="summary-count">{{ overview.WeekCount || 0 }}</div>
<div class="summary-label">近7日总数</div>
</div>
</el-col>
</el-row>
<!-- 筛选栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="级别">
<el-select v-model="query.level" placeholder="全部" clearable style="width: 110px">
<el-option v-for="i in levelOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option v-for="i in statusOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="query.type" placeholder="全部" clearable style="width: 120px">
<el-option v-for="i in typeOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="来源">
<el-select v-model="query.source" placeholder="全部" clearable style="width: 120px">
<el-option v-for="i in sourceOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="关键字">
<el-input v-model="query.keyword" placeholder="设备/内容" clearable style="width: 160px" @keyup.enter="searchAlerts" />
</el-form-item>
<el-form-item label="时间">
<el-date-picker v-model="query.dateRange" type="datetimerange" value-format="YYYY-MM-DDTHH:mm:ss"
start-placeholder="开始" end-placeholder="结束" style="width: 320px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchAlerts">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 告警表格 -->
<el-table :data="alerts" border stripe v-loading="listLoading">
<el-table-column label="最近告警" width="160">
<template #default="{ row }">{{ fmtDateTime(row.LastAlertTime) }}</template>
</el-table-column>
<el-table-column label="设备" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.DeviceName || '-' }}{{ row.DeviceCode || '-' }}</template>
</el-table-column>
<el-table-column prop="Metric" label="测点" width="100" />
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="levelTag(row.AlertLevel)">{{ levelLabel(row.AlertLevel) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="当前值/阈值" width="110">
<template #default="{ row }">
{{ row.CurrentValue ?? '-' }} / {{ row.ThresholdValue ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="Content" label="告警内容" min-width="200" show-overflow-tooltip />
<el-table-column label="类型" width="95">
<template #default="{ row }">{{ typeLabel(row.AlertType) }}</template>
</el-table-column>
<el-table-column label="来源" width="95">
<template #default="{ row }">{{ sourceLabel(row.Source) }}</template>
</el-table-column>
<el-table-column prop="TriggerCount" label="触发" width="65" />
<el-table-column label="状态" width="95">
<template #default="{ row }">
<el-tag :type="statusTag(row.AlertStatus)">{{ statusLabel(row.AlertStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button v-if="row.AlertStatus === 1" link type="warning" size="small" @click="openOperate(row, 'ack')">确认</el-button>
<el-button v-if="row.AlertStatus <= 2" link type="success" size="small" @click="openOperate(row, 'handle')">处理</el-button>
<el-button v-if="row.AlertStatus === 3" link type="info" size="small" @click="openOperate(row, 'close')">关闭</el-button>
<el-button v-if="row.AlertStatus <= 2" link type="danger" size="small" @click="openOperate(row, 'ignore')">忽略</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@size-change="loadAlerts" @current-change="loadAlerts" />
</div>
</el-tab-pane>
<!-- ==================== 统计分析 ==================== -->
<el-tab-pane label="统计分析" name="stats">
<el-form :inline="true">
<el-form-item label="时间范围">
<el-date-picker v-model="statsRange" type="daterange" value-format="YYYY-MM-DD"
start-placeholder="开始日期" end-placeholder="结束日期" style="width: 260px" />
</el-form-item>
<el-form-item label="趋势粒度">
<el-radio-group v-model="granularity">
<el-radio-button value="day"></el-radio-button>
<el-radio-button value="week"></el-radio-button>
<el-radio-button value="month"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadStats">统计</el-button>
</el-form-item>
</el-form>
<!-- 及时率卡片 -->
<el-row :gutter="16" class="summary-row">
<el-col :span="6">
<div class="summary-card">
<div class="summary-count">{{ timeliness.TimelyRate ?? 0 }}%</div>
<div class="summary-label">确认及时率30分钟内</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-card">
<div class="summary-count">{{ timeliness.AvgAckMinutes ?? '-' }}</div>
<div class="summary-label">平均确认时长分钟</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-card">
<div class="summary-count">{{ timeliness.AvgHandleMinutes ?? '-' }}</div>
<div class="summary-label">平均处理时长分钟</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-card">
<div class="summary-count">{{ timeliness.UnhandledCount ?? 0 }}</div>
<div class="summary-label">未闭环数量</div>
</div>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24">
<el-card shadow="never" class="chart-card">
<template #header><span>告警趋势</span></template>
<div ref="trendChartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="mt-16">
<el-col :span="8">
<el-card shadow="never" class="chart-card">
<template #header><span>级别分布</span></template>
<div ref="levelChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="chart-card">
<template #header><span>来源分布</span></template>
<div ref="sourceChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="chart-card">
<template #header><span>高频设备 TOP10</span></template>
<div ref="topChartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<!-- ==================== 通知配置 ==================== -->
<el-tab-pane label="通知配置" name="notify">
<el-tabs v-model="notifyTab" type="border-card">
<!-- 渠道管理 -->
<el-tab-pane label="通知渠道" name="channel">
<div class="toolbar">
<el-button type="primary" size="small" @click="openChannelDialog()">新增渠道</el-button>
</div>
<el-table :data="channels" border stripe>
<el-table-column prop="Name" label="渠道名称" min-width="140" />
<el-table-column label="类型" width="100">
<template #default="{ row }"><el-tag>{{ channelLabel(row.ChannelType) }}</el-tag></template>
</el-table-column>
<el-table-column prop="WebhookUrl" label="Webhook" min-width="220" show-overflow-tooltip />
<el-table-column label="加签" width="80">
<template #default="{ row }">
<el-tag :type="row.HasSecret ? 'success' : 'info'" size="small">{{ row.HasSecret ? '已配置' : '未配置' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="推送模式" width="140">
<template #default="{ row }">{{ pushModeLabel(row.PushMode) }}</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-tag :type="row.IsEnabled ? 'success' : 'info'" size="small">{{ row.IsEnabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleTestChannel(row)">测试</el-button>
<el-button link type="primary" size="small" @click="openChannelDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteChannel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 通知规则 -->
<el-tab-pane label="通知规则" name="rule">
<div class="toolbar">
<el-button type="primary" size="small" @click="openRuleDialog()">新增规则</el-button>
</div>
<el-table :data="notifyRules" border stripe>
<el-table-column prop="RuleName" label="规则名称" min-width="140" />
<el-table-column label="告警级别" width="100">
<template #default="{ row }">
<el-tag :type="levelTag(row.AlertLevel)">{{ levelLabel(row.AlertLevel) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="通知渠道" min-width="140">
<template #default="{ row }">{{ channelNameOf(row.ChannelId) }}</template>
</el-table-column>
<el-table-column prop="Receivers" label="接收人(@手机号)" min-width="150" show-overflow-tooltip />
<el-table-column label="通知值班人" width="100">
<template #default="{ row }">{{ row.NotifyDutyPerson ? '是' : '否' }}</template>
</el-table-column>
<el-table-column prop="SilenceMinutes" label="静默期(分)" width="95" />
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-switch :model-value="row.IsEnabled" @change="(v) => handleToggleRule(row, v)" />
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openRuleDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 值班排班 -->
<el-tab-pane label="值班排班" name="duty">
<div class="toolbar">
<el-date-picker v-model="dutyRange" type="daterange" value-format="YYYY-MM-DD" size="small"
start-placeholder="开始" end-placeholder="结束" style="width: 240px" @change="loadDuties" />
<el-button type="primary" size="small" @click="openDutyDialog()">新增排班</el-button>
</div>
<el-table :data="duties" border stripe>
<el-table-column label="值班日期" width="120">
<template #default="{ row }">{{ fmtDate(row.DutyDate) }}</template>
</el-table-column>
<el-table-column prop="PersonName" label="值班人" width="120" />
<el-table-column prop="Phone" label="联系电话" width="140" />
<el-table-column prop="Remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDutyDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteDuty(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 推送日志 -->
<el-tab-pane label="推送日志" name="log">
<el-table :data="notifyLogs" border stripe>
<el-table-column label="推送时间" width="160">
<template #default="{ row }">{{ fmtDateTime(row.NotifyTime) }}</template>
</el-table-column>
<el-table-column label="渠道" width="90">
<template #default="{ row }">{{ channelLabel(row.ChannelType) }}</template>
</el-table-column>
<el-table-column prop="Target" label="目标" min-width="180" show-overflow-tooltip />
<el-table-column prop="Content" label="内容" min-width="220" show-overflow-tooltip />
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.Success ? 'success' : 'danger'" size="small">{{ row.Success ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ErrorMsg" label="错误信息" min-width="150" show-overflow-tooltip />
<el-table-column prop="RetryCount" label="重试" width="60" />
</el-table>
<div class="pagination">
<el-pagination v-model:current-page="logQuery.pageIndex" v-model:page-size="logQuery.pageSize"
:total="logTotal" layout="total, prev, pager, next" @current-change="loadLogs" />
</div>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 操作对话框确认/处理/关闭/忽略通用 -->
<el-dialog v-model="operateVisible" :title="operateTitle" width="440px">
<el-form label-width="80px">
<el-form-item label="操作人"><el-input v-model="operateForm.Operator" /></el-form-item>
<el-form-item label="备注"><el-input v-model="operateForm.Remark" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="operateVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitOperate">确定</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="告警详情" size="42%">
<el-descriptions v-if="detail" :column="2" border size="small">
<el-descriptions-item label="设备">{{ detail.DeviceName }}{{ detail.DeviceCode }}</el-descriptions-item>
<el-descriptions-item label="设备类型">{{ detail.DeviceType || '-' }}</el-descriptions-item>
<el-descriptions-item label="测点">{{ detail.Metric || '-' }}</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag :type="levelTag(detail.AlertLevel)">{{ levelLabel(detail.AlertLevel) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="当前值">{{ detail.CurrentValue ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="阈值">{{ detail.ThresholdValue ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ typeLabel(detail.AlertType) }}</el-descriptions-item>
<el-descriptions-item label="来源">{{ sourceLabel(detail.Source) }}</el-descriptions-item>
<el-descriptions-item label="首次告警">{{ fmtDateTime(detail.FirstAlertTime) }}</el-descriptions-item>
<el-descriptions-item label="最近告警">{{ fmtDateTime(detail.LastAlertTime) }}</el-descriptions-item>
<el-descriptions-item label="触发次数">{{ detail.TriggerCount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detail.AlertStatus)">{{ statusLabel(detail.AlertStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">{{ detail.Content }}</el-descriptions-item>
<el-descriptions-item label="确认人">{{ detail.AckBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="确认时间">{{ fmtDateTime(detail.AckTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="处理人">{{ detail.HandleBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="处理时间">{{ fmtDateTime(detail.HandleTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="处理备注" :span="2">{{ detail.HandleRemark || '-' }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
<!-- 渠道对话框 -->
<el-dialog v-model="channelVisible" :title="channelForm.Id ? '编辑渠道' : '新增渠道'" width="560px">
<el-form :model="channelForm" label-width="110px">
<el-form-item label="渠道名称" required><el-input v-model="channelForm.Name" /></el-form-item>
<el-form-item label="渠道类型" required>
<el-select v-model="channelForm.ChannelType" style="width: 100%">
<el-option v-for="i in channelTypeOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="Webhook地址" required><el-input v-model="channelForm.WebhookUrl" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="加签密钥">
<el-input v-model="channelForm.Secret" :placeholder="channelForm.Id ? '留空表示不修改' : '机器人开启加签时填写'" show-password />
</el-form-item>
<el-form-item label="推送模式">
<el-radio-group v-model="channelForm.PushMode">
<el-radio v-for="i in pushModeOptions" :key="i.value" :value="i.value">{{ i.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="启用"><el-switch v-model="channelForm.IsEnabled" /></el-form-item>
<el-form-item label="备注"><el-input v-model="channelForm.Remark" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="channelVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitChannel">保存</el-button>
</template>
</el-dialog>
<!-- 通知规则对话框 -->
<el-dialog v-model="ruleVisible" :title="ruleForm.Id ? '编辑规则' : '新增规则'" width="520px">
<el-form :model="ruleForm" label-width="110px">
<el-form-item label="规则名称" required><el-input v-model="ruleForm.RuleName" /></el-form-item>
<el-form-item label="告警级别" required>
<el-select v-model="ruleForm.AlertLevel" style="width: 100%">
<el-option v-for="i in levelOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="通知渠道" required>
<el-select v-model="ruleForm.ChannelId" style="width: 100%">
<el-option v-for="c in channels" :key="c.Id" :label="`${c.Name}${channelLabel(c.ChannelType)}`" :value="c.Id" />
</el-select>
</el-form-item>
<el-form-item label="接收人">
<el-input v-model="ruleForm.Receivers" :placeholder="receiversPlaceholder" />
</el-form-item>
<el-form-item label="通知值班人"><el-switch v-model="ruleForm.NotifyDutyPerson" /></el-form-item>
<el-form-item label="静默期(分钟)">
<el-input-number v-model="ruleForm.SilenceMinutes" :min="0" :max="1440" />
</el-form-item>
<el-form-item label="启用"><el-switch v-model="ruleForm.IsEnabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="ruleVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitRule">保存</el-button>
</template>
</el-dialog>
<!-- 排班对话框 -->
<el-dialog v-model="dutyVisible" :title="dutyForm.Id ? '编辑排班' : '新增排班'" width="440px">
<el-form :model="dutyForm" label-width="90px">
<el-form-item label="值班日期" required>
<el-date-picker v-model="dutyForm.DutyDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="值班人" required><el-input v-model="dutyForm.PersonName" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="dutyForm.Phone" placeholder="用于钉钉/企微@提醒" /></el-form-item>
<el-form-item label="备注"><el-input v-model="dutyForm.Remark" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dutyVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitDuty">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
import {
getAlertList, getAlertDetail, ackAlert, handleAlert, closeAlert, ignoreAlert,
getAlertOverview, getAlertTrend, getAlertDistribution, getAlertTop, getAlertTimeliness,
getChannels, addChannel, updateChannel, deleteChannel, testChannel,
getNotifyRules, addNotifyRule, updateNotifyRule, deleteNotifyRule, setRuleEnabled,
getNotifyLogs, getDuties, addDuty, updateDuty, deleteDuty,
levelOptions, statusOptions, typeOptions, sourceOptions, channelTypeOptions, pushModeOptions,
levelLabel, levelTag, statusLabel, statusTag, typeLabel, sourceLabel, channelLabel, pushModeLabel
} from '@/api/alert'
import { fmtDate, fmtDateTime } from '@/utils/format'
const activeTab = ref('list')
const saving = ref(false)
// ==================== 告警列表 ====================
const listLoading = ref(false)
const alerts = ref([])
const total = ref(0)
const overview = ref({})
const query = reactive({ pageIndex: 1, pageSize: 10, level: null, status: null, type: null, source: null, keyword: '', dateRange: null })
async function loadAlerts() {
listLoading.value = true
try {
const list = await getAlertList({
pageIndex: query.pageIndex, pageSize: query.pageSize,
level: query.level ?? undefined, status: query.status ?? undefined,
type: query.type ?? undefined, source: query.source ?? undefined,
keyword: query.keyword || undefined,
startDate: query.dateRange?.[0] || undefined,
endDate: query.dateRange?.[1] || undefined
})
alerts.value = list
total.value = list.total || 0
overview.value = await getAlertOverview()
} finally {
listLoading.value = false
}
}
function searchAlerts() { query.pageIndex = 1; loadAlerts() }
function resetQuery() {
Object.assign(query, { pageIndex: 1, level: null, status: null, type: null, source: null, keyword: '', dateRange: null })
loadAlerts()
}
// 详情
const detailVisible = ref(false)
const detail = ref(null)
async function openDetail(row) {
detail.value = await getAlertDetail(row.Id)
detailVisible.value = true
}
// 闭环操作
const operateVisible = ref(false)
const operateForm = reactive({ row: null, action: 'ack', Operator: '', Remark: '' })
const OPERATE_TITLES = { ack: '确认告警', handle: '处理告警', close: '关闭告警', ignore: '忽略告警' }
const OPERATE_APIS = { ack: ackAlert, handle: handleAlert, close: closeAlert, ignore: ignoreAlert }
const operateTitle = computed(() => OPERATE_TITLES[operateForm.action])
function openOperate(row, action) {
operateForm.row = row
operateForm.action = action
operateForm.Operator = ''
operateForm.Remark = ''
operateVisible.value = true
}
async function submitOperate() {
saving.value = true
try {
await OPERATE_APIS[operateForm.action](operateForm.row.Id, {
Operator: operateForm.Operator, Remark: operateForm.Remark
})
ElMessage.success('操作成功')
operateVisible.value = false
loadAlerts()
} finally {
saving.value = false
}
}
// ==================== 统计分析 ====================
const statsRange = ref(null)
const granularity = ref('day')
const timeliness = ref({})
const trendChartRef = ref()
const levelChartRef = ref()
const sourceChartRef = ref()
const topChartRef = ref()
let charts = []
function makeChart(domRef) {
let chart = charts.find(c => c.getDom() === domRef)
if (!chart) { chart = echarts.init(domRef); charts.push(chart) }
return chart
}
async function loadStats() {
const params = {
startDate: statsRange.value?.[0] || undefined,
endDate: statsRange.value?.[1] || undefined
}
const [trend, levels, sources, top, time] = await Promise.all([
getAlertTrend({ ...params, granularity: granularity.value }),
getAlertDistribution({ ...params, dimension: 'level' }),
getAlertDistribution({ ...params, dimension: 'source' }),
getAlertTop({ ...params, topN: 10 }),
getAlertTimeliness(params)
])
timeliness.value = time || {}
await nextTick()
makeChart(trendChartRef.value).setOption({
tooltip: { trigger: 'axis' },
legend: { bottom: 0 },
grid: { left: 45, right: 20, top: 20, bottom: 55 },
xAxis: { type: 'category', data: trend.map(t => t.Bucket) },
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '总数', type: 'line', smooth: true, data: trend.map(t => t.Total) },
{ name: '信息', type: 'line', stack: 'x', areaStyle: { opacity: 0.15 }, data: trend.map(t => t.InfoCount) },
{ name: '警告', type: 'line', stack: 'x', areaStyle: { opacity: 0.15 }, data: trend.map(t => t.WarningCount) },
{ name: '紧急', type: 'line', stack: 'x', areaStyle: { opacity: 0.15 }, data: trend.map(t => t.UrgentCount) }
]
}, true)
const pieOption = (data) => ({
tooltip: { trigger: 'item', formatter: '{b}: {c}{d}%' },
legend: { bottom: 0 },
series: [{ type: 'pie', radius: ['35%', '62%'], data, label: { formatter: '{b}\n{c}' } }]
})
makeChart(levelChartRef.value).setOption(pieOption(levels.map(l => ({ name: l.Name, value: l.Count }))), true)
makeChart(sourceChartRef.value).setOption(pieOption(sources.map(s => ({ name: s.Name, value: s.Count }))), true)
makeChart(topChartRef.value).setOption({
tooltip: { trigger: 'axis' },
grid: { left: 100, right: 30, top: 10, bottom: 30 },
xAxis: { type: 'value', minInterval: 1 },
yAxis: { type: 'category', data: top.map(t => t.DeviceName || t.DeviceCode).reverse(), axisLabel: { width: 90, overflow: 'truncate' } },
series: [{ type: 'bar', data: top.map(t => t.Count).reverse(), barMaxWidth: 18, itemStyle: { color: '#F56C6C' } }]
}, true)
}
function handleResize() { charts.forEach(c => c.resize()) }
function onTabChange(name) {
if (name === 'stats') loadStats()
if (name === 'notify') { loadChannels(); loadNotifyRules(); loadDuties(); loadLogs() }
}
// ==================== 通知配置:渠道 ====================
const notifyTab = ref('channel')
const channels = ref([])
const channelVisible = ref(false)
const channelForm = ref({})
async function loadChannels() { channels.value = await getChannels() }
function channelNameOf(id) { return channels.value.find(c => c.Id === id)?.Name || '-' }
function openChannelDialog(row) {
channelForm.value = row
? { ...row, Secret: '' } // 编辑时 Secret 留空表示不修改
: { Id: '', Name: '', ChannelType: 1, WebhookUrl: '', Secret: '', PushMode: 1, IsEnabled: true, Remark: '' }
channelVisible.value = true
}
async function submitChannel() {
if (!channelForm.value.Name || !channelForm.value.WebhookUrl) {
ElMessage.warning('请填写渠道名称与 Webhook 地址')
return
}
saving.value = true
try {
if (channelForm.value.Id) await updateChannel(channelForm.value.Id, channelForm.value)
else await addChannel(channelForm.value)
ElMessage.success('保存成功')
channelVisible.value = false
loadChannels()
} finally {
saving.value = false
}
}
async function handleTestChannel(row) {
const res = await testChannel(row.Id)
ElMessage.success(res ? '测试推送成功,请到群里查看' : '测试推送完成')
loadLogs()
}
async function handleDeleteChannel(row) {
await ElMessageBox.confirm(`确定删除渠道【${row.Name}】吗?`, '提示', { type: 'warning' })
await deleteChannel(row.Id)
ElMessage.success('删除成功')
loadChannels()
}
// ==================== 通知配置:规则 ====================
const notifyRules = ref([])
const ruleVisible = ref(false)
const ruleForm = ref({})
// 接收人提示随所选渠道类型变化:飞书可填手机号(系统自动转 open_id)或 open_id,企微/钉钉填手机号
const receiversPlaceholder = computed(() => {
const ch = channels.value.find(c => c.Id === ruleForm.value.ChannelId)
return ch && ch.ChannelType === 1
? '手机号或 open_id(ou_ 开头),逗号分隔;@所有人填 all(填手机号系统自动转 open_id'
: '手机号,逗号分隔(企微/钉钉需为群成员才能 @ 到)'
})
async function loadNotifyRules() { notifyRules.value = await getNotifyRules() }
function openRuleDialog(row) {
ruleForm.value = row
? { ...row }
: { Id: '', RuleName: '', AlertLevel: 3, ChannelId: '', Receivers: '', NotifyDutyPerson: false, SilenceMinutes: 5, IsEnabled: true }
ruleVisible.value = true
}
async function submitRule() {
if (!ruleForm.value.RuleName || !ruleForm.value.ChannelId) {
ElMessage.warning('请填写规则名称并选择通知渠道')
return
}
saving.value = true
try {
if (ruleForm.value.Id) await updateNotifyRule(ruleForm.value.Id, ruleForm.value)
else await addNotifyRule(ruleForm.value)
ElMessage.success('保存成功')
ruleVisible.value = false
loadNotifyRules()
} finally {
saving.value = false
}
}
async function handleToggleRule(row, enabled) {
await setRuleEnabled(row.Id, enabled)
row.IsEnabled = enabled
ElMessage.success(enabled ? '已启用' : '已停用')
}
async function handleDeleteRule(row) {
await ElMessageBox.confirm(`确定删除规则【${row.RuleName}】吗?`, '提示', { type: 'warning' })
await deleteNotifyRule(row.Id)
ElMessage.success('删除成功')
loadNotifyRules()
}
// ==================== 通知配置:值班 ====================
const duties = ref([])
const dutyRange = ref(null)
const dutyVisible = ref(false)
const dutyForm = ref({})
async function loadDuties() {
duties.value = await getDuties({
startDate: dutyRange.value?.[0] || undefined,
endDate: dutyRange.value?.[1] || undefined
})
}
function openDutyDialog(row) {
dutyForm.value = row ? { ...row } : { Id: '', DutyDate: '', PersonName: '', Phone: '', Remark: '' }
dutyVisible.value = true
}
async function submitDuty() {
if (!dutyForm.value.DutyDate || !dutyForm.value.PersonName) {
ElMessage.warning('请填写值班日期与值班人')
return
}
saving.value = true
try {
if (dutyForm.value.Id) await updateDuty(dutyForm.value.Id, dutyForm.value)
else await addDuty(dutyForm.value)
ElMessage.success('保存成功')
dutyVisible.value = false
loadDuties()
} finally {
saving.value = false
}
}
async function handleDeleteDuty(row) {
await ElMessageBox.confirm(`确定删除 ${fmtDate(row.DutyDate)} 的排班吗?`, '提示', { type: 'warning' })
await deleteDuty(row.Id)
ElMessage.success('删除成功')
loadDuties()
}
// ==================== 通知配置:日志 ====================
const notifyLogs = ref([])
const logTotal = ref(0)
const logQuery = reactive({ pageIndex: 1, pageSize: 10 })
async function loadLogs() {
const list = await getNotifyLogs({ pageIndex: logQuery.pageIndex, pageSize: logQuery.pageSize })
notifyLogs.value = list
logTotal.value = list.total || 0
}
onMounted(() => {
loadAlerts()
loadChannels()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
charts.forEach(c => c.dispose())
charts = []
})
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.summary-row { margin-bottom: 16px; }
.summary-card { border: 1px solid var(--el-border-color-light); border-radius: 6px; padding: 14px; text-align: center; }
.summary-card.danger .summary-count { color: var(--el-color-danger); }
.summary-count { font-size: 24px; font-weight: 600; }
.summary-label { color: var(--el-text-color-secondary); margin-top: 4px; font-size: 13px; }
.search-form { margin-bottom: 4px; }
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
.toolbar { display: flex; gap: 12px; margin-bottom: 12px; align-items: center; }
.chart-card :deep(.el-card__header) { padding: 10px 16px; font-weight: 600; }
.chart { height: 300px; }
.mt-16 { margin-top: 16px; }
</style>