// Modal editor for a notification channel — used for both create and edit, // mirroring NodeEditDialog. Holds a local draft until 保存 (cancel discards). // The channel id is the stable key per-account opt-ins reference, so it's // editable only on create and locked on edit. import { useState } from 'react'; import { DEFAULT_WEBHOOK_BODY, DEFAULT_EMAIL_BODY, DEFAULT_EMAIL_SUBJECT, } from '@snowluma/common/notification-templates'; import { Plus, Trash2 } from 'lucide-react'; import { Modal } from '@/components/interior/modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { PasswordVisibilityIcon } from '@/components/ui/password-visibility-icon'; import { Textarea } from '@/components/ui/textarea'; import { ToggleSwitch } from '@/components/ui/toggle-switch'; import { useTheme } from '@/contexts/ThemeContext'; import { cn } from '@/lib/utils'; import { notificationChannelType, type NotificationChannel, type NotificationChannelType, } from '@/types'; interface HeaderRow { name: string; value: string; } function headersToRows(headers?: Record): HeaderRow[] { const rows = Object.entries(headers ?? {}).map(([name, value]) => ({ name, value })); return rows.length > 0 ? rows : [{ name: '', value: '' }]; } function rowsToHeaders(rows: HeaderRow[]): Record | undefined { const out: Record = {}; for (const row of rows) { const name = row.name.trim(); const value = row.value.trim(); if (!name || !value) { continue; } out[name] = value; } return Object.keys(out).length > 0 ? out : undefined; } const CHANNEL_ID_RE = /^[\w.-]+$/; function isHttpUrl(u: string): boolean { try { const x = new URL(u.trim()); return x.protocol === 'http:' || x.protocol === 'https:'; } catch { return false; } } function looksLikeEmail(value: string): boolean { return /^[^\s<>@]+@[^\s<>@]+$/.test(value.trim()) || /<[^<>@\s]+@[^<>@\s]+>/.test(value); } function hasRecipient(value: string): boolean { return value.split(/[,;\n]+/).some((part) => looksLikeEmail(part)); } export function applyType( draft: NotificationChannel, type: NotificationChannelType, ): NotificationChannel { if (type === notificationChannelType(draft)) { return { ...draft, type }; } if (type === 'email') { const keepBody = draft.bodyTemplate && draft.bodyTemplate !== DEFAULT_WEBHOOK_BODY; return { ...draft, type, url: '', headers: undefined, smtpHost: draft.smtpHost ?? '', smtpPort: draft.smtpPort ?? 465, smtpSecure: draft.smtpSecure ?? true, smtpUser: draft.smtpUser ?? '', smtpPass: draft.smtpPass ?? '', from: draft.from ?? '', to: draft.to ?? '', subjectTemplate: draft.subjectTemplate || DEFAULT_EMAIL_SUBJECT, bodyTemplate: keepBody ? draft.bodyTemplate : DEFAULT_EMAIL_BODY, }; } const keepBody = draft.bodyTemplate && draft.bodyTemplate !== DEFAULT_EMAIL_BODY; return { id: draft.id, name: draft.name, type: 'webhook', url: draft.url, bodyTemplate: keepBody ? draft.bodyTemplate : DEFAULT_WEBHOOK_BODY, enabled: draft.enabled, }; } export function toChannel( draft: NotificationChannel, headerRows: HeaderRow[], ): NotificationChannel { const id = draft.id.trim(); const name = draft.name.trim(); const enabled = draft.enabled; if (notificationChannelType(draft) === 'email') { return { id, name, type: 'email', url: '', bodyTemplate: draft.bodyTemplate, enabled, smtpHost: draft.smtpHost?.trim() ?? '', smtpPort: draft.smtpPort ?? 465, smtpSecure: draft.smtpSecure ?? true, smtpUser: draft.smtpUser?.trim() || undefined, smtpPass: draft.smtpPass || undefined, from: draft.from?.trim() ?? '', to: draft.to?.trim() ?? '', subjectTemplate: draft.subjectTemplate?.trim() || DEFAULT_EMAIL_SUBJECT, }; } return { id, name, type: 'webhook', url: draft.url.trim(), bodyTemplate: draft.bodyTemplate, enabled, headers: rowsToHeaders(headerRows), }; } interface NotificationChannelDialogProps { open: boolean; onOpenChange: (open: boolean) => void; isEdit: boolean; /** Seed draft — a blank channel for create, the existing one for edit. */ initial: NotificationChannel; /** Ids of every other channel, for the duplicate check. */ otherIds: string[]; onSubmit: (channel: NotificationChannel) => void; } export function NotificationChannelDialog(props: NotificationChannelDialogProps) { const { open, onOpenChange, isEdit, initial, otherIds, onSubmit } = props; // Parent unmounts on close, so each open re-seeds via lazy init. const [draft, setDraft] = useState(() => ({ ...initial, type: notificationChannelType(initial), })); const [headerRows, setHeaderRows] = useState(() => headersToRows(initial.headers)); const [showPass, setShowPass] = useState(false); const reduce = useTheme().appearance.disableMotion; const patch = (p: Partial) => setDraft({ ...draft, ...p }); const patchHeader = (index: number, p: Partial) => { setHeaderRows(headerRows.map((row, i) => (i === index ? { ...row, ...p } : row))); }; const type = notificationChannelType(draft); const id = draft.id.trim(); const idBlank = id.length === 0; const idBad = !idBlank && (!CHANNEL_ID_RE.test(id) || id.length > 64); const idDup = !idBlank && otherIds.includes(id); const urlBlank = draft.url.trim().length === 0; const urlBad = !urlBlank && !isHttpUrl(draft.url); const hostBlank = !(draft.smtpHost ?? '').trim(); const fromBlank = !(draft.from ?? '').trim(); const fromBad = !fromBlank && !looksLikeEmail(draft.from ?? ''); const toBlank = !(draft.to ?? '').trim(); const toBad = !toBlank && !hasRecipient(draft.to ?? ''); const port = draft.smtpPort ?? 465; const portBad = !Number.isInteger(port) || port < 1 || port > 65535; const canSave = type === 'email' ? !idBlank && !idBad && !idDup && !hostBlank && !fromBlank && !fromBad && !toBlank && !toBad && !portBad : !idBlank && !idBad && !idDup && !urlBlank && !urlBad; const idError = idBlank ? '请填写渠道 ID' : idBad ? '只能用字母 / 数字 / . _ - ,≤64 字符' : idDup ? 'ID 与其它渠道重复' : undefined; const urlError = urlBlank ? '请填写 Webhook URL' : urlBad ? '必须是 http(s) 地址' : undefined; const hostError = hostBlank ? '请填写 SMTP 主机' : undefined; const fromError = fromBlank ? '请填写发件人' : fromBad ? '必须是邮箱地址' : undefined; const toError = toBlank ? '请填写收件人' : toBad ? '至少填写一个邮箱' : undefined; const portError = portBad ? '端口 1–65535' : undefined; return ( onOpenChange(false)} title={isEdit ? '编辑渠道' : '新建渠道'} closeLabel="关闭渠道编辑弹窗" maxWidth={576} maxHeight="min(86dvh, 780px)" footer={ <> } >
{( [ ['webhook', 'Webhook'], ['email', '邮件'], ] as const ).map(([value, label]) => ( ))}
patch({ id: v })} /> patch({ name: v })} />
{type === 'webhook' ? ( <> patch({ url: v })} />
{headerRows.map((row, i) => (
patchHeader(i, { name: e.target.value }) } className="font-mono text-xs" /> patchHeader(i, { value: e.target.value }) } className="font-mono text-xs" />
))}

可选,默认 Content-Type 为 application/json。

) : ( <>
patch({ smtpHost: v })} /> { const n = Math.trunc(Number(v)); if (!Number.isFinite(n)) { patch({ smtpPort: 465 }); return; } patch({ smtpPort: n, smtpSecure: n === 465 ? true : n === 587 ? false : draft.smtpSecure, }); }} />
patch({ smtpUser: v })} />
patch({ smtpPass: e.target.value })} className="pr-11 font-mono text-xs" />

465 开启,587 关闭(STARTTLS)。

patch({ smtpSecure: v })} ariaLabel="隐式 TLS" />
patch({ from: v })} /> patch({ to: v })} />
patch({ subjectTemplate: v })} /> )}