"use client"; import * as React from "react"; import { BellIcon, PlusIcon, SendIcon, Trash2Icon } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; import type { NotificationChannel, NotificationFilter, NotificationMeta } from "@/lib/types"; import { CHANNEL_FIELDS, type ChannelForm, emptyForm, parseIDs, parseKeywords, parseKV, SEVERITY_OPTIONS, } from "./_components/channel-fields"; import { ConfigField, FilterSummary } from "./_components/channel-form"; import { DeliveryList } from "./_components/delivery-list"; import { formatBacklog, StatTile } from "./_components/stat-tile"; // 本页只负责编排:加载数据、维护表单状态、调用接口。 // 字段定义与解析在 _components/channel-fields.ts,控件与过滤摘要在 // _components/channel-form.tsx,投递记录在 _components/delivery-list.tsx—— // 拆开是因为它们各自能被单独读懂,而挤在一个文件里时这个页面接近 1100 行。 export default function NotifyPage() { const t = useTranslations("notifyPage"); // 渠道类型展示名:已知的 6 种走 i18n(notifyPage.kind.), // 后端若回传未知类型则原样显示该 key(保留原来的 `?? ch.kind` 语义)。 const kindLabel = (k: string) => (k in CHANNEL_FIELDS ? t(`kind.${k}`) : k); const [meta, setMeta] = React.useState(null); const [channels, setChannels] = React.useState([]); const [tab, setTab] = React.useState<"channels" | "deliveries">("channels"); const [open, setOpen] = React.useState(false); const [editing, setEditing] = React.useState(null); const [form, setForm] = React.useState(emptyForm("dingtalk")); const [saving, setSaving] = React.useState(false); const [testing, setTesting] = React.useState(false); const [globalSaving, setGlobalSaving] = React.useState(false); const [baseURL, setBaseURL] = React.useState(""); const [digestMin, setDigestMin] = React.useState(""); const load = React.useCallback(() => { api .notifyMeta() .then((m) => { setMeta(m); setBaseURL(m.public_base_url); setDigestMin(m.digest_interval_min); }) .catch((e) => toast.error(t("toast.loadConfigFailed", { msg: (e as Error).message }))); // 渠道列表加载失败要报出来:静默失败会显示成「一个渠道都没有」, // 用户会以为配置丢了,比直接报错更让人慌。 api .notifyChannels() .then(setChannels) .catch((e) => toast.error(t("toast.loadChannelsFailed", { msg: (e as Error).message }))); }, [t]); React.useEffect(() => { load(); }, [load]); function setF(patch: Partial) { setForm((f) => ({ ...f, ...patch })); } function setCfg(key: string, value: unknown) { setForm((f) => ({ ...f, config: { ...f.config, [key]: value } })); } function openAdd() { setEditing(null); setForm(emptyForm(meta?.kinds[0]?.kind ?? "dingtalk")); setOpen(true); } function openEdit(ch: NotificationChannel) { setEditing(ch); // filter 在后端是 Go 结构体,永远序列化成对象(不会是 null),所以不需要兜底。 const f = ch.filter; setForm({ name: ch.name, kind: ch.kind, mode: ch.mode, enabled: ch.enabled, ratePerMin: String(ch.rate_per_min), // 后端回显的 config 里凭据是掩码值;原样放进表单,提交时原样送回, // 后端据此保留库中原值。 config: { ...ch.config }, minSeverity: f.min_severity ?? "", includeText: (f.vulnclass_include ?? []).join("\n"), excludeText: (f.vulnclass_exclude ?? []).join("\n"), taskIDsText: (f.task_ids ?? []).join(","), assetIDsText: (f.asset_ids ?? []).join(","), onStatusChange: f.on_status_change ?? false, }); setOpen(true); } // buildConfig 把表单状态转成渠道 config。 // // 唯一的规则,两类值: // - 掩码值("__masked__...")原样送回 → 后端解读为「这个字段没改,保留库中原值」 // - 其余一律按用户输入提交,空串即「清空该字段」 // // 之所以不特殊照顾凭据字段(比如「凭据留空就跳过」),是因为那会让用户**无法清除** // 一个设错的密钥——界面上没有任何操作能表达「我要把它删掉」。现在的规则下, // 清空输入框就等于清空该字段,语义唯一且用户可控。 // 掩码值不会出现在输入框里(见 ConfigField),所以「框里有字」永远等于 // 「用户主动填的」。 function buildConfig(): Record { const defs = CHANNEL_FIELDS[form.kind] ?? []; const out: Record = {}; for (const d of defs) { const raw = form.config[d.key]; if (d.kind === "switch") { out[d.key] = raw === true; continue; } if (typeof raw === "string" && raw.startsWith("__masked__")) { out[d.key] = raw; continue; } if (d.kind === "number") { const n = Number(raw); out[d.key] = Number.isFinite(n) && n > 0 ? n : 0; continue; } if (d.kind === "kv") { out[d.key] = parseKV(String(raw ?? "")); continue; } if (d.kind === "list") { out[d.key] = String(raw ?? "") .split(/[\s,,]+/) .map((s) => s.trim()) .filter(Boolean); continue; } out[d.key] = String(raw ?? "").trim(); } return out; } function buildFilter(): NotificationFilter { return { min_severity: form.minSeverity || undefined, vulnclass_include: parseKeywords(form.includeText), vulnclass_exclude: parseKeywords(form.excludeText), task_ids: parseIDs(form.taskIDsText), asset_ids: parseIDs(form.assetIDsText), on_status_change: form.onStatusChange, }; } async function saveForm() { if (!form.name.trim()) { toast.error(t("toast.nameRequired")); return; } setSaving(true); try { const payload = { name: form.name.trim(), kind: form.kind, mode: form.mode, enabled: form.enabled, config: buildConfig(), filter: buildFilter(), rate_per_min: form.ratePerMin.trim() === "" ? undefined : Number(form.ratePerMin), }; if (editing) { await api.notifyUpdateChannel(editing.id, payload); toast.success(t("toast.saved")); setOpen(false); } else { await api.notifyCreateChannel(payload); toast.success(t("toast.channelAdded")); setOpen(false); } load(); } catch (e) { toast.error(t("toast.saveFailed", { msg: (e as Error).message })); } finally { setSaving(false); } } async function testChannel() { if (!editing) return; setTesting(true); try { const r = await api.notifyTestChannel(editing.id); toast.success(t("toast.testSent", { ms: r.latency_ms })); } catch (e) { // 后端把渠道返回的原始错误如实回传,这是排查配置的唯一线索,原样展示。 toast.error(t("toast.testFailed", { msg: (e as Error).message }), { duration: 12000 }); } finally { setTesting(false); } } async function removeChannel(ch: NotificationChannel) { try { await api.notifyDeleteChannel(ch.id); toast.success(t("toast.deleted", { name: ch.name })); setOpen(false); load(); } catch (e) { toast.error(t("toast.deleteFailed", { msg: (e as Error).message })); } } async function toggleEnabled(ch: NotificationChannel) { try { await api.notifyUpdateChannel(ch.id, { enabled: !ch.enabled }); load(); } catch (e) { toast.error(t("toast.actionFailed", { msg: (e as Error).message })); } } async function toggleGlobal(on: boolean) { setGlobalSaving(true); try { await api.setSettings({ notify_enabled: on }); setMeta((m) => (m ? { ...m, enabled: on } : m)); toast.success(on ? t("toast.globalOn") : t("toast.globalOff")); } catch (e) { toast.error(t("toast.actionFailed", { msg: (e as Error).message })); } finally { setGlobalSaving(false); } } async function saveGlobal() { setGlobalSaving(true); try { const patch: Record = { notify_public_base_url: baseURL.trim() }; const n = Number(digestMin); if (Number.isFinite(n) && n > 0) patch.notify_digest_interval_min = n; await api.setSettings(patch); toast.success(t("toast.saved")); load(); } catch (e) { toast.error(t("toast.saveFailed", { msg: (e as Error).message })); } finally { setGlobalSaving(false); } } const fields = CHANNEL_FIELDS[form.kind] ?? []; const secretKeys = new Set(meta?.kinds.find((k) => k.kind === form.kind)?.secret_keys ?? []); const defaultRate = meta?.kinds.find((k) => k.kind === form.kind)?.default_rate_per_min ?? 0; return (

{t("title")}

{t("subtitle")}

{meta && ( // 用 div 而不是 label:Switch 自带 aria-label,外面再套一层 label // 既关联不到任何原生控件,又会让点击文字看起来应该能切换。
{t("globalSwitch")}
)}
{meta && (
0 ? "red" : undefined} /> 5 * 60_000 ? t("stats.backlogStuck") : undefined} tone={meta.stats.backlog_age_ms > 5 * 60_000 ? "red" : undefined} />
)} {t("global.title")}
setBaseURL(e.target.value)} />

{t("global.baseUrlHint")}

setDigestMin(e.target.value)} />

{t("global.digestHint")}

setTab(v as "channels" | "deliveries")} className="flex flex-col gap-4"> {t("tab.channels")} {t("tab.deliveries")}
{channels.map((ch) => ( openEdit(ch)} className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm" >
{ch.name} {/* 卡片整体可点(进入编辑),所以这两个控件必须各自吞掉冒泡, 否则开关/删除会顺带触发编辑。把 stopPropagation 挂在控件自己 身上,而不是套一层 div:套 div 会造出一个「看起来可交互但没有 角色」的静态元素,既触发 a11y 告警,语义上也说不通。 */}
toggleEnabled(ch)} onClick={(e) => e.stopPropagation()} aria-label={t("aria.enable")} />
{kindLabel(ch.kind)} {ch.mode === "digest" ? t("mode.digest") : t("mode.realtime")} {!ch.enabled && {t("disabled")}}
))}
{editing ? editing.name : t("sheet.addTitle")} {kindLabel(form.kind)} {defaultRate > 0 ? t("sheet.rateLimited", { rate: defaultRate }) : t("sheet.rateUnlimited")}
{editing &&

{t("sheet.kindLocked")}

}
setF({ name: e.target.value })} />
{fields.length === 0 ? (

{t("sheet.noFields")}

) : ( fields.map((d) => ( setCfg(d.key, v)} /> )) )}

{t("sheet.modeHint")}

0 ? String(defaultRate) : t("sheet.ratePlaceholder")} value={form.ratePerMin} onChange={(e) => setF({ ratePerMin: e.target.value })} />

{t("sheet.rateHint")}

{t("filter.title")}