Files
oci-portal-dash/src/components/settings/NotifyChannelCard.vue
T
Wang Defa 5464d2dfea
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
发布 0.1.0:通知渠道、告警规则与多项体验修复
2026-07-10 17:31:19 +08:00

283 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NButton, NCollapseTransition, NInput, NSwitch } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { testNotifyChannel, updateNotifyChannel } from '@/api/settings'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import { useToast } from '@/composables/useToast'
import type { NotifyChannelItem, NotifyChannelType } from '@/types/api'
const props = defineProps<{ item: NotifyChannelItem }>()
const emit = defineEmits<{ saved: [NotifyChannelItem[]] }>()
const message = useToast()
/** 渠道展示元数据:图标字符 + 名称 + 一句话说明 + 密文字段文案 */
const CHANNEL_META: Record<
NotifyChannelType,
{ icon: string; name: string; hint: string; secretLabel?: string; secretHint?: string }
> = {
webhook: {
icon: 'W',
name: 'Webhook',
hint: '通用 HTTP POST,可对接飞书 / 钉钉 / Slack / 企业微信机器人',
},
ntfy: {
icon: 'N',
name: 'ntfy',
hint: '开源推送服务,官方 ntfy.sh 或自建服务端',
secretLabel: 'Access Token',
secretHint: '服务端开启鉴权时填写;留空表示沿用已保存值',
},
bark: {
icon: 'B',
name: 'Bark',
hint: 'iOS 推送,官方 api.day.app 或自建服务端',
secretLabel: 'Device Key',
secretHint: 'Bark App 内的设备 key;留空表示沿用已保存值',
},
smtp: {
icon: 'M',
name: 'SMTP 邮件',
hint: '465 端口走隐式 TLS,其余端口自动 STARTTLS',
secretLabel: '密码',
secretHint: 'SMTP 授权码或密码;留空表示沿用已保存值',
},
}
/** Webhook body 模板预设(占位符 {{title}} / {{text}} 发送时替换并做 JSON 转义) */
const WEBHOOK_PRESETS = [
{ label: 'Slack', tpl: '{"text": "{{title}}\\n{{text}}"}' },
{ label: '飞书', tpl: '{"msg_type": "text", "content": {"text": "{{title}}\\n{{text}}"}}' },
{ label: '钉钉', tpl: '{"msgtype": "text", "text": {"content": "{{title}}\\n{{text}}"}}' },
{ label: '企业微信', tpl: '{"msgtype": "text", "text": {"content": "{{title}}\\n{{text}}"}}' },
]
const meta = computed(() => CHANNEL_META[props.item.type])
const expanded = ref(false)
const saving = ref(false)
const testing = ref(false)
const form = reactive({
enabled: false,
url: '',
bodyTemplate: '',
server: '',
topic: '',
host: '',
port: 587 as number | null,
username: '',
from: '',
to: '',
secret: '',
})
// 服务端数据到达/保存后回填;密文字段不回明文,输入框留空表示沿用
watch(
() => props.item,
(it) => {
form.enabled = it.enabled
form.url = it.url ?? ''
form.bodyTemplate = it.bodyTemplate ?? ''
form.server = it.server ?? ''
form.topic = it.topic ?? ''
form.host = it.host ?? ''
form.port = it.port || 587
form.username = it.username ?? ''
form.from = it.from ?? ''
form.to = it.to ?? ''
form.secret = ''
},
{ immediate: true },
)
const secretPlaceholder = computed(() =>
props.item.secretSet
? `已配置${props.item.secretTail ? `(尾号 ${props.item.secretTail}` : ''},留空沿用`
: '',
)
async function save() {
saving.value = true
try {
const secret = form.secret.trim()
const items = await updateNotifyChannel(props.item.type, {
enabled: form.enabled,
url: form.url.trim(),
bodyTemplate: form.bodyTemplate,
server: form.server.trim(),
topic: form.topic.trim(),
host: form.host.trim(),
port: form.port ?? 0,
username: form.username.trim(),
from: form.from.trim(),
to: form.to.trim(),
// 留空表示沿用已保存的密文字段
...(secret ? { secret } : {}),
})
message.success(`${meta.value.name} 配置已保存`)
form.secret = ''
emit('saved', items)
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function sendTest() {
testing.value = true
try {
await testNotifyChannel(props.item.type)
message.success(`测试消息已发送,请在 ${meta.value.name} 侧查收`)
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
testing.value = false
}
}
</script>
<template>
<div class="border-b border-line-soft last:border-b-0">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-3 px-4.5 py-3.5 text-left"
@click="expanded = !expanded"
>
<span
class="flex h-8.5 w-8.5 flex-none items-center justify-center rounded-full bg-wash text-[15px] font-bold text-ink-2"
>
{{ meta.icon }}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-[13px] font-medium">{{ meta.name }}</span>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="item.enabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
{{ item.enabled ? '已启用' : '未启用' }}
</span>
</div>
<div class="mt-0.5 text-xs text-ink-3">{{ meta.hint }}</div>
</div>
<span class="text-xs text-ink-3">{{ expanded ? '收起' : '配置' }}</span>
</button>
<NCollapseTransition :show="expanded">
<div class="px-4.5 pb-3.5">
<div class="mb-3 flex items-center justify-between rounded-lg bg-wash px-3 py-2">
<span class="text-[12.5px]">启用该渠道</span>
<NSwitch v-model:value="form.enabled" size="small" />
</div>
<!-- Webhook -->
<template v-if="item.type === 'webhook'">
<FormField label="Webhook URL" hint="机器人/集成的完整回调地址,含鉴权参数">
<NInput v-model:value="form.url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…" />
</FormField>
<FormField
label="Body 模板"
hint="发送时 {{title}} / {{text}} 按 JSON 字符串转义后替换;留空按 Slack 形状发送"
>
<div class="mb-1.5 flex flex-wrap gap-1.5">
<NButton
v-for="p in WEBHOOK_PRESETS"
:key="p.label"
size="tiny"
quaternary
type="primary"
@click="form.bodyTemplate = p.tpl"
>
{{ p.label }}
</NButton>
</div>
<NInput
v-model:value="form.bodyTemplate"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
class="mono"
:placeholder="WEBHOOK_PRESETS[0].tpl"
/>
</FormField>
</template>
<!-- ntfy -->
<template v-else-if="item.type === 'ntfy'">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="服务端" hint="留空用官方 ntfy.sh">
<NInput v-model:value="form.server" placeholder="https://ntfy.sh" />
</FormField>
<FormField label="Topic" hint="订阅的主题名">
<NInput v-model:value="form.topic" placeholder="oci-portal" />
</FormField>
</div>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'tk_…(可选)'"
/>
</FormField>
</template>
<!-- Bark -->
<template v-else-if="item.type === 'bark'">
<FormField label="服务端" hint="留空用官方 api.day.app">
<NInput v-model:value="form.server" placeholder="https://api.day.app" />
</FormField>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'Bark App 内复制'"
/>
</FormField>
</template>
<!-- SMTP -->
<template v-else>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="主机">
<NInput v-model:value="form.host" placeholder="smtp.example.com" />
</FormField>
<FormField label="端口" hint="465 隐式 TLS,587 STARTTLS">
<AppInputNumber v-model:value="form.port" :min="1" :max="65535" class="!w-full" />
</FormField>
<FormField label="用户名" hint="留空表示免认证">
<NInput v-model:value="form.username" placeholder="user@example.com" />
</FormField>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'SMTP 授权码'"
/>
</FormField>
<FormField label="发件人">
<NInput v-model:value="form.from" placeholder="noreply@example.com" />
</FormField>
<FormField label="收件人">
<NInput v-model:value="form.to" placeholder="me@example.com" />
</FormField>
</div>
</template>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
<span class="text-xs text-ink-3">测试使用已保存的配置,修改后请先保存</span>
</div>
</div>
</NCollapseTransition>
</div>
</template>