feat: 重建 Bell 预警处置生命周期 (#133)

This commit is contained in:
QiuSW
2026-08-29 11:56:41 +08:00
parent afc58f7bf4
commit f09a61e5fb
19 changed files with 796 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getAlertLifecycle(id) { return request({ url: `/api/v1/bell/alerts/${id}/lifecycle`, method: 'get' }) }
export function acknowledgeAlert(id) { return request({ url: `/api/v1/bell/alerts/${id}/ack`, method: 'post' }) }
export function closeAlert(id, data) { return request({ url: `/api/v1/bell/alerts/${id}/close`, method: 'post', data }) }
@@ -0,0 +1,28 @@
<template>
<div class="lifecycle-actions">
<el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" />
<el-button v-if="lifecycle.canAck" v-permisaction="['bell:alert:ack']" type="primary" :loading="loading" @click="ack">我已看到并开始处理</el-button>
<el-button v-if="lifecycle.canClose" v-permisaction="['bell:alert:close']" type="primary" :loading="loading" @click="dialog=true">记录现场结果并完成</el-button>
<el-dialog v-model="dialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" @closed="reset">
<el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false" class="form-alert" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="form.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item>
<el-form-item label="补充说明(可选)"><el-input v-model="form.note" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="loading" @click="finish">确认结果并完成</el-button></template>
</el-dialog>
</div>
</template>
<script>
import { acknowledgeAlert, closeAlert } from '@/api/bell/alert-lifecycle'
export default {
name: 'BellLifecycleActions', props: { alertId: { type: String, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed'],
data() { return { loading: false, error: '', dialog: false, form: { outcome: '', note: '' }, rules: { outcome: [{ required: true, message: '请选择现场结果', trigger: 'change' }] }} },
methods: {
async ack() { this.loading = true; this.error = ''; try { const r = await acknowledgeAlert(this.alertId); this.msgSuccess(r.data.idempotent ? '您已在处理此预警' : '已记录由您开始处理'); this.$emit('changed') } catch (e) { this.error = e.message || '开始处理失败'; this.$emit('changed') } finally { this.loading = false } },
async finish() { try { await this.$refs.formRef.validate(); this.loading = true; this.error = ''; const r = await closeAlert(this.alertId, this.form); this.msgSuccess(r.data.idempotent ? '该结果已记录' : '预警已完成'); this.dialog = false; this.$emit('changed') } catch (e) { if (e && e.message) this.error = e.message } finally { this.loading = false } },
reset() { this.form = { outcome: '', note: '' }; this.$refs.formRef && this.$refs.formRef.clearValidate() }
}
}
</script>
<style scoped>.lifecycle-actions{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}.lifecycle-actions .el-alert{flex-basis:100%}.form-alert{margin-bottom:16px}.outcome-group{display:grid;gap:10px}</style>
@@ -0,0 +1,2 @@
<template><el-timeline><el-timeline-item v-for="item in items" :key="item.id" :timestamp="parseTime(item.occurredAt)" :type="item.transition==='closed'?'success':'primary'"><strong>{{ item.transition === 'closed' ? '处理完成' : '开始处理' }}</strong> · {{ item.actorName }}<div v-if="item.outcome">现场结果:{{ outcomeName(item.outcome) }}<span v-if="item.note">;{{ item.note }}</span></div></el-timeline-item><el-empty v-if="!items.length" description="尚无处理记录" /></el-timeline></template>
<script>export default { name: 'BellLifecycleTimeline', props: { items: { type: Array, default: () => [] }}, methods: { outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
+3
View File
@@ -0,0 +1,3 @@
<template><div><el-descriptions :column="1" border><el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item><el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item><el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusName(lifecycle.projection.status || detail.alert.status) }}</el-descriptions-item><el-descriptions-item label="处理人">{{ lifecycle.projection.acknowledgedByName || '尚未开始处理' }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeOutcome" label="现场结果">{{ outcomeName(lifecycle.projection.closeOutcome) }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeNote" label="处理说明">{{ lifecycle.projection.closeNote }}</el-descriptions-item><el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item><el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item></el-descriptions><LifecycleActions :alert-id="detail.alert.id" :lifecycle="lifecycle" @changed="$emit('changed')" /><h3>关联事件</h3><el-table :data="detail.events" border row-key="id"><el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column><el-table-column prop="eventType" label="事件类型" min-width="150" /><el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="$emit('go-event',scope.row.id)">查看</el-button></template></el-table-column></el-table><h3>处理时间线</h3><LifecycleTimeline :items="lifecycle.timeline" /><h3>命中说明</h3><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">规则 v{{ match.ruleVersion }}:{{ match.explanation }}</el-timeline-item></el-timeline></div></template>
<script>import LifecycleActions from './components/LifecycleActions.vue'; import LifecycleTimeline from './components/LifecycleTimeline.vue'; export default { name: 'BellAlertDetail', components: { LifecycleActions, LifecycleTimeline }, props: { detail: { type: Object, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed', 'go-event'], methods: { statusName(v) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[v] || v }, severityName(v) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[v] || v }, outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
<style scoped>h3{font-size:16px;margin:22px 0 10px}</style>
+14 -29
View File
@@ -2,9 +2,9 @@
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><h2>预警管理</h2><p>查看待处理预警及其关联事件;开始处理和完成操作将在下一阶段提供。</p></div></template>
<template #header><div class="page-heading"><h2>预警管理</h2><p>先开始处理,再记录现场结果完成预警。</p></div></template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="状态" prop="status"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:130px"><el-option label="待处理" value="open" /></el-select></el-form-item>
<el-form-item label="状态" prop="status"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:130px"><el-option label="待处理" value="open" /><el-option label="处理中" value="acknowledged" /><el-option label="已完成" value="closed" /></el-select></el-form-item>
<el-form-item label="风险" prop="severity"><el-select v-model="query.severity" clearable placeholder="全部风险" style="width:130px"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
@@ -15,7 +15,7 @@
<el-table-column prop="summary" label="预警事项" min-width="200" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="140" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default><el-tag type="danger">待处理</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusName(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="eventCount" label="关联事件" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
<template #empty><el-empty description="暂无预警" /></template>
@@ -26,28 +26,7 @@
<el-drawer v-model="drawer" title="预警详情" size="min(720px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<template v-if="detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item>
<el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item>
<el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item>
<el-descriptions-item label="状态">待处理</el-descriptions-item>
<el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item>
<el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item>
</el-descriptions>
<h3>关联事件</h3>
<el-table :data="detail.events" border row-key="id">
<el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column>
<el-table-column prop="eventType" label="事件类型" min-width="150" />
<el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="goEvent(scope.row.id)">查看</el-button></template></el-table-column>
</el-table>
<h3>命中说明</h3>
<el-timeline>
<el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">
规则 v{{ match.ruleVersion }}:{{ match.explanation }}
</el-timeline-item>
</el-timeline>
</template>
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
</div>
</el-drawer>
</template>
@@ -56,13 +35,16 @@
<script>
import { getAlert, listAlerts } from '@/api/bell/alert'
import { getAlertLifecycle } from '@/api/bell/alert-lifecycle'
import BellAlertDetail from './detail.vue'
export default {
name: 'BellAlerts',
components: { BellAlertDetail },
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, detail: null,
drawer: false, detail: null, lifecycle: null, activeId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, status: '', severity: '', location: '' }
}
@@ -76,12 +58,15 @@ export default {
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.detail = null
try { const response = await getAlert(row.id); this.detail = response.data } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.detail = null; this.lifecycle = null; this.activeId = row.id
try { const [detailResponse, lifecycleResponse] = await Promise.all([getAlert(row.id), getAlertLifecycle(row.id)]); this.detail = detailResponse.data; this.lifecycle = lifecycleResponse.data.detail } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
},
async reloadDetail() { await this.openDetail({ id: this.activeId }); await this.load() },
goEvent(id) { this.drawer = false; this.$router.push({ path: '/bell/events', query: { eventId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' },
statusName(value) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[value] || value },
statusType(value) { return { open: 'danger', acknowledged: 'warning', closed: 'success' }[value] || 'info' }
}
}
</script>