搭建消息告警前端页面
This commit is contained in:
@@ -2,13 +2,757 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>消息告警</span>
|
||||
<div class="card-header">
|
||||
<span>消息告警</span>
|
||||
<el-button @click="loadAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
|
||||
</div>
|
||||
</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-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>
|
||||
// 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>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user