搭建消息告警前端页面

This commit is contained in:
2026-09-03 14:03:35 +08:00
parent 6d888ddaee
commit eb0ddafc64
10 changed files with 766 additions and 22 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>设备管理系统</title> <title>设备管理系统</title>
<script type="module" crossorigin src="/assets/index-B0bC4XlZ.js"></script> <script type="module" crossorigin src="/assets/index-BaJKRrhL.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js"> <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-DZ3WZ9y9.js"> <link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-DZ3WZ9y9.js">
<link rel="modulepreload" crossorigin href="/assets/message-ddUl4JIs.js"> <link rel="modulepreload" crossorigin href="/assets/message-ddUl4JIs.js">
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -1,49 +1,49 @@
{ {
"hash": "a6f0f194", "hash": "b6c330c5",
"configHash": "147f2811", "configHash": "e57f8d4c",
"lockfileHash": "77198382", "lockfileHash": "74076ca5",
"browserHash": "ee71f75c", "browserHash": "ed3d8378",
"optimized": { "optimized": {
"@element-plus/icons-vue": { "@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js", "src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js", "file": "@element-plus_icons-vue.js",
"fileHash": "9553abbf", "fileHash": "de153aba",
"needsInterop": false "needsInterop": false
}, },
"axios": { "axios": {
"src": "../../axios/index.js", "src": "../../axios/index.js",
"file": "axios.js", "file": "axios.js",
"fileHash": "3e018dd2", "fileHash": "c19fdb3e",
"needsInterop": false "needsInterop": false
}, },
"echarts": { "echarts": {
"src": "../../echarts/index.js", "src": "../../echarts/index.js",
"file": "echarts.js", "file": "echarts.js",
"fileHash": "275a5510", "fileHash": "a8d7111b",
"needsInterop": false "needsInterop": false
}, },
"element-plus": { "element-plus": {
"src": "../../element-plus/es/index.mjs", "src": "../../element-plus/es/index.mjs",
"file": "element-plus.js", "file": "element-plus.js",
"fileHash": "cc57a904", "fileHash": "10cb780f",
"needsInterop": false "needsInterop": false
}, },
"pinia": { "pinia": {
"src": "../../pinia/dist/pinia.js", "src": "../../pinia/dist/pinia.js",
"file": "pinia.js", "file": "pinia.js",
"fileHash": "0a5304b8", "fileHash": "82d3c424",
"needsInterop": false "needsInterop": false
}, },
"vue-router": { "vue-router": {
"src": "../../vue-router/dist/vue-router.js", "src": "../../vue-router/dist/vue-router.js",
"file": "vue-router.js", "file": "vue-router.js",
"fileHash": "0ef5b6fe", "fileHash": "1b68fa4e",
"needsInterop": false "needsInterop": false
}, },
"vue": { "vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js", "src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js", "file": "vue.js",
"fileHash": "52e89e85", "fileHash": "7e296374",
"needsInterop": false "needsInterop": false
} }
}, },
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+746 -2
View File
@@ -2,13 +2,757 @@
<div class="page-container"> <div class="page-container">
<el-card> <el-card>
<template #header> <template #header>
<div class="card-header">
<span>消息告警</span> <span>消息告警</span>
<el-button @click="loadAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
</div>
</template> </template>
<el-empty description="消息告警 - 功能开发中" />
<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-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> </div>
</template> </template>
<script setup> <script setup>
// TODO: 实现消息告警功能 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> </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>