This commit is contained in:
zxr
2026-08-24 22:04:01 +08:00
parent 695755546f
commit fb9c5b9255
4 changed files with 249 additions and 26 deletions

View File

@@ -18,6 +18,10 @@ export interface MessageRecord {
incidentId?: number
alertRecordId?: number
nextRetryAt?: string
occurrenceCount?: number
firstOccurredAt?: string
lastOccurredAt?: string
lastDeliveryId?: number
}
export type MessageListType = MessageRecord[]

View File

@@ -32,7 +32,7 @@ export const createNoticeChannel = (data: {
email_to?: string[]
email_cc?: string[]
email_bcc?: string[]
email_use_tls?: boolean
email_security_mode?: 'none' | 'starttls' | 'tls'
sms_provider?: string
sms_access_key_id?: string
sms_access_key_secret?: string
@@ -40,6 +40,7 @@ export const createNoticeChannel = (data: {
sms_template_code?: string
sms_phones?: string[]
in_app_targets?: string[]
in_app_aggregation_window?: number
}) => {
return request.post('/Alert/v1/channel/create', data)
}
@@ -67,7 +68,7 @@ export const updateNoticeChannel = (data: {
email_to?: string[]
email_cc?: string[]
email_bcc?: string[]
email_use_tls?: boolean
email_security_mode?: 'none' | 'starttls' | 'tls'
sms_provider?: string
sms_access_key_id?: string
sms_access_key_secret?: string
@@ -75,6 +76,7 @@ export const updateNoticeChannel = (data: {
sms_template_code?: string
sms_phones?: string[]
in_app_targets?: string[]
in_app_aggregation_window?: number
}) => {
return request.post('/Alert/v1/channel/update', data)
}

View File

@@ -13,7 +13,8 @@
<a-tag v-if="item.deliveryStatus" :color="getDeliveryColor(item.deliveryStatus)">
{{ getDeliveryLabel(item.deliveryStatus) }}
</a-tag>
<a-tag v-else-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-if="(item.occurrenceCount || 1) > 1" color="arcoblue">发生 {{ item.occurrenceCount }} </a-tag>
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>

View File

@@ -58,8 +58,12 @@
<a-form-item label="密送" field="email_bcc">
<a-input-tag v-model="form.email_bcc" placeholder="输入密送邮箱后回车,可选" allow-clear />
</a-form-item>
<a-form-item label="启用 TLS" field="email_use_tls">
<a-switch v-model="form.email_use_tls" />
<a-form-item label="连接安全方式" field="email_security_mode">
<a-select v-model="form.email_security_mode" placeholder="请选择连接安全方式" @change="handleEmailSecurityModeChange">
<a-option value="none">普通 SMTP</a-option>
<a-option value="starttls">STARTTLS</a-option>
<a-option value="tls">TLS</a-option>
</a-select>
</a-form-item>
</template>
@@ -98,20 +102,66 @@
<template v-if="form.type === 'in_app'">
<a-form-item label="站内消息目标" field="in_app_targets">
<a-input-tag v-model="form.in_app_targets" placeholder="输入目标后回车,如 user:42" allow-clear />
<a-select
v-model="form.in_app_targets"
placeholder="请选择接收用户"
multiple
allow-clear
allow-search
:filter-option="false"
:loading="userLoading"
@search="handleUserSearch"
>
<a-option v-for="user in userOptions" :key="user.id" :value="`user:${user.id}`">
{{ user.name || user.username || `用户 ${user.id}` }}{{ user.username || user.id }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="同告警聚合窗口(秒)" field="in_app_aggregation_window">
<a-input-number v-model="form.in_app_aggregation_window" :min="10" :max="3600" style="width: 100%" />
</a-form-item>
</template>
<a-form-item label="消息模板" field="message_template">
<a-form-item field="message_template">
<template #label>
<span class="template-label">
<span>消息模板</span>
<a-popover position="right" trigger="hover">
<icon-question-circle class="template-help-icon" />
<template #content>
<div class="template-help-panel">
<div class="template-help-header">
<strong>可用变量</strong>
<span>变量区分大小写</span>
</div>
<table class="template-help-table">
<thead>
<tr>
<th>变量</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="item in messageTemplateVariables" :key="item.variable">
<td><code>{{ item.variable }}</code></td>
<td>{{ item.description }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</a-popover>
</span>
</template>
<a-textarea
v-model="form.message_template"
placeholder="请输入消息模板,支持变量:{{.RuleName}}、{{.Summary}}、{{.FiredAt}}、{{.Value}}、{{.Threshold}}等"
placeholder="请输入消息模板"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="1000"
/>
</a-form-item>
<a-row :gutter="16">
<a-row v-if="form.type !== 'in_app'" :gutter="16">
<a-col :span="12">
<a-form-item label="限流次数" field="rate_limit">
<a-input-number v-model="form.rate_limit" placeholder="0表示不限制" :min="0" :max="10000" style="width: 100%" />
@@ -169,7 +219,7 @@
</a-space>
</a-form-item>
<a-row :gutter="16">
<a-row v-if="form.type !== 'in_app'" :gutter="16">
<a-col :span="12">
<a-form-item label="重试次数" field="retry_times">
<a-input-number v-model="form.retry_times" placeholder="发送失败重试次数" :min="0" :max="10" style="width: 100%" />
@@ -199,8 +249,11 @@
<script lang="ts" setup>
import { ref, watch, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconQuestionCircle } from '@arco-design/web-vue/es/icon'
import { createNoticeChannel, updateNoticeChannel } from '@/api/ops/noticeChannel'
import { fetchAlertLevelOptions } from '@/api/ops/alertLevel'
import { fetchUserList } from '@/api/ops/rbac2'
import type { UserItem } from '@/api/ops/rbac2'
interface NoticeChannel {
id?: number
@@ -224,7 +277,7 @@ interface NoticeChannel {
email_to?: string[] | string
email_cc?: string[] | string
email_bcc?: string[] | string
email_use_tls?: boolean
email_security_mode?: 'none' | 'starttls' | 'tls'
sms_provider?: string
sms_access_key_id?: string
sms_access_key_secret?: string
@@ -232,6 +285,7 @@ interface NoticeChannel {
sms_template_code?: string
sms_phones?: string[] | string
in_app_targets?: string[] | string
in_app_aggregation_window?: number
}
interface Props {
@@ -239,6 +293,21 @@ interface Props {
channel: NoticeChannel | null
}
const messageTemplateVariables = [
{ variable: '{{.AlertName}}', description: '告警名称' },
{ variable: '{{.RuleName}}', description: '规则名称' },
{ variable: '{{.Severity}}', description: '告警级别' },
{ variable: '{{.Status}}', description: '告警状态' },
{ variable: '{{.Summary}}', description: '告警摘要' },
{ variable: '{{.Description}}', description: '详细描述' },
{ variable: '{{.Value}}', description: '当前值' },
{ variable: '{{.Threshold}}', description: '阈值' },
{ variable: '{{.FiredAt}} / {{.StartsAt}}', description: '触发时间' },
{ variable: '{{.ResolvedAt}}', description: '恢复时间' },
{ variable: '{{.Duration}}', description: '持续秒数' },
{ variable: '{{.Fingerprint}}', description: '告警指纹' },
] as const
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
@@ -271,14 +340,14 @@ const form = ref({
enabled: true,
description: '',
email_smtp_host: '',
email_smtp_port: 25,
email_smtp_port: 587,
email_username: '',
email_password: '',
email_from: '',
email_to: [] as string[],
email_cc: [] as string[],
email_bcc: [] as string[],
email_use_tls: false,
email_security_mode: 'starttls' as 'none' | 'starttls' | 'tls',
sms_provider: '',
sms_access_key_id: '',
sms_access_key_secret: '',
@@ -286,6 +355,7 @@ const form = ref({
sms_template_code: '',
sms_phones: [] as string[],
in_app_targets: [] as string[],
in_app_aggregation_window: 60,
})
// 是否为编辑模式
@@ -293,6 +363,8 @@ const isEdit = computed(() => !!props.channel?.id)
const severityLoading = ref(false)
const baseSeverityLevels = ref<any[]>([])
const userLoading = ref(false)
const userOptions = ref<UserItem[]>([])
// 获取告警级别列表(支持 keyword 搜索)
const fetchSeverityLevels = async (keyword?: string) => {
@@ -342,6 +414,55 @@ const handleSeveritySearch = (keyword: string) => {
}, 300)
}
const loadUserOptions = async (keyword = '') => {
userLoading.value = true
try {
const response = await fetchUserList({
page: 1,
size: 50,
keyword: keyword || undefined,
status: 1,
})
const raw = response.details ?? response.data
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : []
const selectedIDs = new Set(
form.value.in_app_targets
.map((target) => Number(target.replace(/^user:/, '')))
.filter((id) => Number.isInteger(id) && id > 0)
)
const merged = new Map<number, UserItem>()
selectedIDs.forEach((id) => merged.set(id, { id, name: `用户 ${id}` }))
list.forEach((user) => {
const id = Number(user.id)
if (Number.isInteger(id) && id > 0) merged.set(id, { ...user, id })
})
userOptions.value = Array.from(merged.values())
} catch (error) {
userOptions.value = []
console.error('获取用户列表失败:', error)
} finally {
userLoading.value = false
}
}
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
const handleUserSearch = (keyword: string) => {
if (userSearchTimer) clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(() => loadUserOptions(keyword), 300)
}
const handleEmailSecurityModeChange = (mode: string) => {
if (mode === 'tls') {
form.value.email_smtp_port = 465
return
}
if (mode === 'starttls') {
form.value.email_smtp_port = 587
return
}
form.value.email_smtp_port = 25
}
// 处理静默时间段变化
const handleQuietHoursChange = () => {
if (quietHoursEnabled.value) {
@@ -378,14 +499,14 @@ watch(
enabled: props.channel.enabled !== undefined ? props.channel.enabled : true,
description: props.channel.description || '',
email_smtp_host: props.channel.email_smtp_host || '',
email_smtp_port: props.channel.email_smtp_port ?? 25,
email_smtp_port: props.channel.email_smtp_port ?? 587,
email_username: props.channel.email_username || '',
email_password: props.channel.email_password || '',
email_from: props.channel.email_from || '',
email_to: parseStringArray(props.channel.email_to),
email_cc: parseStringArray(props.channel.email_cc),
email_bcc: parseStringArray(props.channel.email_bcc),
email_use_tls: props.channel.email_use_tls || false,
email_security_mode: props.channel.email_security_mode || 'starttls',
sms_provider: props.channel.sms_provider || '',
sms_access_key_id: props.channel.sms_access_key_id || '',
sms_access_key_secret: props.channel.sms_access_key_secret || '',
@@ -393,6 +514,7 @@ watch(
sms_template_code: props.channel.sms_template_code || '',
sms_phones: parseStringArray(props.channel.sms_phones),
in_app_targets: parseStringArray(props.channel.in_app_targets),
in_app_aggregation_window: props.channel.in_app_aggregation_window ?? 60,
}
// 解析静默时间段
@@ -424,14 +546,14 @@ watch(
enabled: true,
description: '',
email_smtp_host: '',
email_smtp_port: 25,
email_smtp_port: 587,
email_username: '',
email_password: '',
email_from: '',
email_to: [],
email_cc: [],
email_bcc: [],
email_use_tls: false,
email_security_mode: 'starttls',
sms_provider: '',
sms_access_key_id: '',
sms_access_key_secret: '',
@@ -439,6 +561,7 @@ watch(
sms_template_code: '',
sms_phones: [],
in_app_targets: [],
in_app_aggregation_window: 60,
}
quietHoursEnabled.value = false
quietHoursStart.value = '22:00'
@@ -467,15 +590,17 @@ const handleOk = async () => {
type: form.value.type,
config: form.value.config,
message_template: form.value.message_template,
rate_limit: form.value.rate_limit,
rate_limit_window: form.value.rate_limit_window,
severity_filter: Array.isArray(form.value.severity_filter) ? form.value.severity_filter.join(',') : form.value.severity_filter,
quiet_hours: quietHoursPayload,
retry_times: form.value.retry_times,
retry_interval: form.value.retry_interval,
enabled: form.value.enabled,
description: form.value.description,
}
if (form.value.type !== 'in_app') {
data.rate_limit = form.value.rate_limit
data.rate_limit_window = form.value.rate_limit_window
data.retry_times = form.value.retry_times
data.retry_interval = form.value.retry_interval
}
appendChannelPayload(data)
let res
@@ -523,7 +648,14 @@ const isMaskedSecret = (value?: string) => {
const validateChannelConfig = () => {
if (form.value.type === 'email') {
if (!form.value.email_smtp_host || !form.value.email_smtp_port || !form.value.email_username || !form.value.email_from || !form.value.email_to.length) {
if (
!form.value.email_smtp_host ||
!form.value.email_smtp_port ||
!form.value.email_username ||
!form.value.email_from ||
!form.value.email_to.length ||
!form.value.email_security_mode
) {
return '请补齐邮件渠道必填字段'
}
if (!form.value.email_password) return '请输入 SMTP 密码/授权码'
@@ -540,8 +672,11 @@ const validateChannelConfig = () => {
}
if (!form.value.sms_access_key_secret) return '请输入 AccessKey Secret'
}
if (form.value.type === 'in_app' && !form.value.in_app_targets.length) {
return '请至少配置一个站内消息目标'
if (form.value.type === 'in_app') {
if (!form.value.in_app_targets.length) return '请至少配置一个站内消息目标'
if (form.value.in_app_aggregation_window < 10 || form.value.in_app_aggregation_window > 3600) {
return '同告警聚合窗口须在 103600 秒之间'
}
}
return ''
}
@@ -558,7 +693,7 @@ const appendChannelPayload = (data: any) => {
data.email_to = form.value.email_to
data.email_cc = form.value.email_cc
data.email_bcc = form.value.email_bcc
data.email_use_tls = form.value.email_use_tls
data.email_security_mode = form.value.email_security_mode
}
if (form.value.type === 'sms') {
data.sms_provider = form.value.sms_provider
@@ -572,6 +707,7 @@ const appendChannelPayload = (data: any) => {
}
if (form.value.type === 'in_app') {
data.in_app_targets = form.value.in_app_targets
data.in_app_aggregation_window = form.value.in_app_aggregation_window
}
}
@@ -589,6 +725,13 @@ const handleVisibleChange = (visible: boolean) => {
onMounted(() => {
fetchSeverityLevels()
})
watch(
() => form.value.type,
(type) => {
if (type === 'in_app') loadUserOptions()
}
)
</script>
<script lang="ts">
@@ -597,4 +740,77 @@ export default {
}
</script>
<style scoped lang="less"></style>
<style scoped lang="less">
.template-label {
display: inline-flex;
gap: 6px;
align-items: center;
}
.template-help-icon {
font-size: 14px;
color: var(--color-text-3);
cursor: help;
&:hover {
color: rgb(var(--primary-6));
}
}
.template-help-panel {
width: 460px;
}
.template-help-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
span {
font-size: 12px;
color: var(--color-text-3);
}
}
.template-help-table {
width: 100%;
overflow: hidden;
border: 1px solid var(--color-border-2);
border-spacing: 0;
border-collapse: separate;
border-radius: 4px;
th,
td {
padding: 6px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border-2);
}
th {
font-weight: 500;
color: var(--color-text-2);
background: var(--color-fill-2);
}
tbody tr:nth-child(even) {
background: var(--color-fill-1);
}
tbody tr:last-child td {
border-bottom: 0;
}
th:first-child,
td:first-child {
width: 68%;
border-right: 1px solid var(--color-border-2);
}
code {
font-family: Consolas, 'Courier New', monospace;
color: rgb(var(--primary-6));
}
}
</style>