refactor(web): 通知页拆分为组件,1095 行降到 605 行

页面此前接近 1100 行,超过 CLAUDE.md 的 800 行硬上限。按项目的 _components/
约定拆开,每个文件都能单独读懂:

  _components/channel-fields.ts   渠道字段表与配置值解析(纯数据,无视图)
  _components/channel-form.tsx    按字段定义渲染控件的表单
  _components/delivery-list.tsx   投递记录表与筛选
  _components/stat-tile.tsx       概览统计卡片
  page.tsx                        只负责编排:加载、表单状态、调接口

拆分是纯模块搬移,未改行为;tsc 与 next build 均通过,/system/notify 仍正常
预渲染。

Co-Authored-By: Claude Code <[email protected]>
This commit is contained in:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent a049f09b97
commit ee8929115e
5 files changed
+558 -509

No files matched your search

@@ -0,0 +1,181 @@
// 渠道字段表与配置值的解析工具。
//
// 与页面拆开是因为这一份是**数据**而不是视图:它描述每种渠道有哪些字段、
// 各自该用什么控件,以及表单文本到配置值(JSON)的双向转换。
// 单独放一个文件后,新增渠道只需要动这里,页面本身不必改。
// 渠道类型的展示名与简介。放在前端是因为它只影响文案,后端不需要知道。
export const KIND_LABEL: Record<string, string> = {
dingtalk: "钉钉",
feishu: "飞书",
wecom: "企业微信",
webhook: "通用 Webhook",
telegram: "Telegram",
email: "邮件",
};
// 各渠道的配置字段定义。
//
// 这里刻意保留一份前端字段表,而不是让后端下发 schema:后端只负责
// Validate(必填/格式),UI 需要的是布局与控件类型,两者关注的不是同一件事。
// 唯一的耦合点是 secret_keys —— 哪些字段该渲染成密码框由后端给出,
// 因为只有渠道实现自己清楚哪些值算凭据(企业微信的整个 Webhook 就是凭据,
// 而钉钉的只是其中一个 secret)。新增渠道时这里少一个条目只会让表单变空白,
// 不会静默出错(下面的 hasFields 会提示)。
export type FieldKind = "text" | "password" | "number" | "select" | "textarea" | "switch" | "kv" | "list";
export interface FieldDef {
key: string;
label: string;
kind: FieldKind;
placeholder?: string;
help?: string;
options?: { value: string; label: string }[];
}
export const CHANNEL_FIELDS: Record<string, FieldDef[]> = {
dingtalk: [
{
key: "webhook",
label: "Webhook 地址",
kind: "text",
placeholder: "https://oapi.dingtalk.com/robot/send?access_token=...",
},
{
key: "secret",
label: "加签密钥",
kind: "password",
help: "机器人安全设置选「加签」时填写;选「自定义关键词」或未开启安全设置则留空",
},
],
feishu: [
{
key: "webhook",
label: "Webhook 地址",
kind: "text",
placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/...",
},
{ key: "secret", label: "签名校验密钥", kind: "password", help: "机器人开启「签名校验」时填写,否则留空" },
],
wecom: [
{
key: "webhook",
label: "Webhook 地址",
kind: "text",
placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
},
],
webhook: [
{ key: "url", label: "目标 URL", kind: "text", placeholder: "https://your-endpoint.example.com/hook" },
{
key: "method",
label: "请求方法",
kind: "select",
options: [
{ value: "POST", label: "POST(带请求体)" },
{ value: "PUT", label: "PUT(带请求体)" },
{ value: "PATCH", label: "PATCH(带请求体)" },
{ value: "GET", label: "GET(不带请求体)" },
],
},
{ key: "headers", label: "自定义请求头", kind: "kv", help: "每行 KEY=VALUE,例如 Authorization=Bearer xxx" },
{
key: "body_template",
label: "请求体模板",
kind: "textarea",
help:
"留空用内置默认模板。变量:{{.Title}} {{.Batch}} {{.Count}} {{.HomeURL}} {{.SentAt}}," +
"以及 range .Items 下的 .Name/.VulnClass/.Severity/.Summary/.Assets/.DetailURL/.StatusLabel。" +
"插入字符串请用 {{json .Xxx}} 而不是 {{.Xxx}},否则标题里的引号会破坏 JSON。",
},
],
telegram: [
{ key: "bot_token", label: "Bot Token", kind: "password", placeholder: "123456:ABC-DEF..." },
{ key: "chat_id", label: "Chat ID", kind: "text", placeholder: "-1001234567890" },
{
key: "base_url",
label: "API 地址",
kind: "text",
placeholder: "https://api.telegram.org",
help: "留空用官方地址;自建 Bot API 反代时填写",
},
],
email: [
{ key: "host", label: "SMTP 服务器", kind: "text", placeholder: "smtp.example.com" },
{
key: "port",
label: "端口",
kind: "number",
placeholder: "587",
help: "587 走 STARTTLS;465 请把「隐式 TLS」打开",
},
{ key: "username", label: "账号", kind: "text" },
{ key: "password", label: "密码 / 授权码", kind: "password" },
{ key: "from", label: "发件人", kind: "text", placeholder: "[email protected]" },
{ key: "to", label: "收件人", kind: "list", help: "多个地址用逗号分隔" },
{ key: "tls", label: "隐式 TLS", kind: "switch", help: "465 端口打开;587 保持关闭(会自动 STARTTLS)" },
],
};
export const SEVERITY_OPTIONS = [
{ value: "", label: "不限" },
{ value: "low", label: "低危及以上" },
{ value: "medium", label: "中危及以上" },
{ value: "high", label: "高危及以上" },
{ value: "critical", label: "仅严重" },
];
export type ChannelForm = {
name: string;
kind: string;
mode: "realtime" | "digest";
enabled: boolean;
ratePerMin: string;
config: Record<string, unknown>;
minSeverity: string;
includeText: string;
excludeText: string;
taskIDsText: string;
assetIDsText: string;
onStatusChange: boolean;
};
export const emptyForm = (kind: string): ChannelForm => ({
name: "",
kind,
mode: "realtime",
enabled: true,
ratePerMin: "",
config: {},
minSeverity: "",
includeText: "",
excludeText: "",
taskIDsText: "",
assetIDsText: "",
onStatusChange: false,
});
// parseKV 解析「每行 KEY=VALUE」的文本域。
export function parseKV(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
const i = t.indexOf("=");
if (i > 0) out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
return out;
}
// parseIDs 解析逗号/空白分隔的 id 列表。
export function parseIDs(text: string): number[] {
return text
.split(/[\s,,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => Number(s))
.filter((n) => Number.isFinite(n) && n > 0);
}
// parseKeywords 解析行/逗号分隔的关键词列表(漏洞类型名可能含空格,所以按行或逗号切)。
export function parseKeywords(text: string): string[] {
return text
.split(/[\n,,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
@@ -0,0 +1,153 @@
"use client";
import { CheckIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { NotificationFilter } from "@/lib/types";
// asText / inputType 是本文件内的取值辅助(与控件渲染强相关),不放在 channel-fields。
import { type FieldDef, type FieldKind, SEVERITY_OPTIONS } from "./channel-fields";
// asText 把任意配置值渲染成输入框可用的字符串。
// config 来自 JSON,值可能是 string / number / boolean / array / null,
// 这里只关心「能不能塞进文本框」,具体序列化由 buildConfig 负责。
function asText(v: unknown): string {
if (typeof v === "string") return v;
if (v === null || v === undefined) return "";
return String(v);
}
// inputType 把字段类型映射到 input 的 type 属性。
function inputType(kind: FieldKind): "text" | "password" | "number" {
if (kind === "password") return "password";
if (kind === "number") return "number";
return "text";
}
// ConfigField 按字段定义渲染对应的控件。
//
// 掩码字段的处理是这里唯一的讲究:输入框**不显示**掩码值本身,只显示一行
// 「已保存」提示。这样界面上就只有一个规则——框里有字就是用户填的,
// 空框就是空值。若把 "__masked__:…abc123" 塞进输入框,用户会以为那是要自己
// 删掉的占位文本,反而更容易误清凭据。
export function ConfigField({
def,
value,
isSecret,
onChange,
}: {
def: FieldDef;
value: unknown;
isSecret: boolean;
onChange: (v: unknown) => void;
}) {
const id = `n-cfg-${def.key}`;
const raw = asText(value);
// 后端回显的掩码值:形如 "__masked__:…abc123",尾部是原值的可辨识片段。
const masked = isSecret && raw.startsWith("__masked__");
const maskedTail = masked ? (raw.split("…")[1] ?? "") : "";
if (def.kind === "switch") {
return (
<div className="flex items-center gap-2 text-sm">
<Switch checked={value === true} onCheckedChange={onChange} aria-label={def.label} />
{def.label}
{def.help && <span className="text-muted-foreground">({def.help})</span>}
</div>
);
}
if (def.kind === "select") {
return (
<div className="grid gap-2">
<Label>{def.label}</Label>
<Select value={raw || def.options?.[0]?.value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(def.options ?? []).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件,
// 三层三元读起来已经要停下来数括号了。
function control() {
if (def.kind === "textarea" || def.kind === "kv") {
return (
<Textarea
id={id}
className="font-mono"
placeholder={def.placeholder}
value={masked ? "" : raw}
onChange={(e) => onChange(e.target.value)}
/>
);
}
if (def.kind === "list") {
return (
<Input
id={id}
value={Array.isArray(value) ? (value as string[]).join(", ") : raw}
onChange={(e) => onChange(e.target.value)}
placeholder={def.placeholder}
/>
);
}
return (
<Input
id={id}
className={def.kind === "text" ? "font-mono" : ""}
type={inputType(def.kind)}
placeholder={def.placeholder}
value={masked ? "" : raw}
onChange={(e) => onChange(e.target.value)}
/>
);
}
const hint = masked ? (
<p className="text-muted-foreground flex items-center gap-1 text-xs">
<CheckIcon className="size-3" />
已保存{maskedTail ? `(尾号 ${maskedTail})` : ""} · 填入新值即覆盖,清空则删除该项
</p>
) : (
def.help && <p className="text-muted-foreground text-xs">{def.help}</p>
);
return (
<div className="grid gap-2">
<Label htmlFor={id}>{def.label}</Label>
{control()}
{hint}
</div>
);
}
// FilterSummary 把过滤条件摘要成一行,让卡片不用展开就能看出这个渠道推什么。
export function FilterSummary({ filter }: { filter: NotificationFilter }) {
const parts: string[] = [];
if (filter.min_severity) {
parts.push(SEVERITY_OPTIONS.find((o) => o.value === filter.min_severity)?.label ?? filter.min_severity);
}
if (filter.vulnclass_include?.length) parts.push(`类型含 ${filter.vulnclass_include.length} 词`);
if (filter.vulnclass_exclude?.length) parts.push(`排除 ${filter.vulnclass_exclude.length} 词`);
if (filter.task_ids?.length) parts.push(`${filter.task_ids.length} 个任务`);
if (filter.asset_ids?.length) parts.push(`${filter.asset_ids.length} 个资产`);
if (filter.on_status_change) parts.push("含状态变更");
if (parts.length === 0) {
return <p className="text-muted-foreground text-sm">全部漏洞</p>;
}
return <p className="text-muted-foreground text-sm">{parts.join(" · ")}</p>;
}
@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { RefreshCwIcon, RotateCcwIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { statusMeta, toneClasses } from "@/lib/status";
import type { NotificationChannel, NotificationDelivery } from "@/lib/types";
// DeliveryList 是投递记录表:可按渠道与状态筛选,失败项可手动重发。
export function DeliveryList({ channels }: { channels: NotificationChannel[] }) {
const [rows, setRows] = React.useState<NotificationDelivery[]>([]);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(1);
const [channelID, setChannelID] = React.useState<number | undefined>(undefined);
const [state, setState] = React.useState<string | undefined>(undefined);
const [loading, setLoading] = React.useState(false);
const pageSize = 50;
const load = React.useCallback(() => {
setLoading(true);
api
.notifyDeliveries({ channelId: channelID, state, page, pageSize })
.then((r) => {
setRows(r.deliveries);
setTotal(r.total);
})
.catch((e) => toast.error("读取投递记录失败:" + (e as Error).message))
.finally(() => setLoading(false));
}, [channelID, state, page]);
React.useEffect(() => {
load();
}, [load]);
async function retry(id: number) {
try {
await api.notifyRetryDelivery(id);
toast.success("已重新入队");
load();
} catch (e) {
toast.error("重发失败:" + (e as Error).message);
}
}
const maxPage = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Select
value={channelID ? String(channelID) : "all"}
onValueChange={(v) => {
setPage(1);
setChannelID(v === "all" ? undefined : Number(v));
}}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue placeholder="全部渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部渠道</SelectItem>
{channels.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state ?? "all"}
onValueChange={(v) => {
setPage(1);
setState(v === "all" ? undefined : v);
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{["pending", "sending", "sent", "failed", "skipped"].map((s) => (
<SelectItem key={s} value={s}>
{statusMeta("delivery", s).label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> 刷新
</Button>
<span className="text-muted-foreground ml-auto text-xs">共 {total} 条</span>
</div>
<Card className="py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">时间</TableHead>
<TableHead>漏洞</TableHead>
<TableHead className="w-40">渠道</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-16">尝试</TableHead>
<TableHead>错误</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
{loading ? "加载中…" : "暂无投递记录"}
</TableCell>
</TableRow>
) : (
rows.map((d) => (
<TableRow key={d.id}>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{formatTime(d.created_at)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant="outline" className={toneClasses[statusMeta("severity", d.severity).tone]}>
{statusMeta("severity", d.severity).label}
</Badge>
<span className="truncate text-sm">{d.title || "(无标题)"}</span>
{d.event_kind === "finding_status_changed" && (
<Badge variant="outline" className="shrink-0">
状态变更
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm">{d.channel_name}</TableCell>
<TableCell>
<Badge variant="outline" className={toneClasses[statusMeta("delivery", d.state).tone]}>
{statusMeta("delivery", d.state).label}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">{d.attempts}</TableCell>
<TableCell className="text-muted-foreground max-w-md text-xs break-all">{d.last_error}</TableCell>
<TableCell>
{/* 只有失败/跳过的才给重发入口:已送达的重发会造成重复推送。 */}
{(d.state === "failed" || d.state === "skipped") && (
<Button size="sm" variant="outline" onClick={() => retry(d.id)}>
<RotateCcwIcon /> 重发
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{maxPage > 1 && (
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
上一页
</Button>
<span className="text-muted-foreground text-sm">
{page} / {maxPage}
</span>
<Button size="sm" variant="outline" disabled={page >= maxPage} onClick={() => setPage((p) => p + 1)}>
下一页
</Button>
</div>
)}
</div>
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("zh-CN", { hour12: false });
}
@@ -0,0 +1,21 @@
import { Card, CardContent } from "@/components/ui/card";
export function StatTile({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: string }) {
return (
<Card size="sm" className="gap-1">
<CardContent>
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`text-lg font-semibold ${tone === "red" ? "text-rose-600" : ""}`}>{value}</p>
{hint && <p className={`text-xs ${tone === "red" ? "text-rose-600" : "text-muted-foreground"}`}>{hint}</p>}
</CardContent>
</Card>
);
}
// formatBacklog 把积压毫秒数渲染成人看得懂的量级。
export function formatBacklog(ms: number): string {
if (!ms) return "—";
if (ms < 60_000) return `${Math.round(ms / 1000)} 秒`;
if (ms < 3_600_000) return `${Math.round(ms / 60_000)} 分钟`;
return `${(ms / 3_600_000).toFixed(1)} 小时`;
}
+19 -509
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { BellIcon, CheckIcon, PlusIcon, RefreshCwIcon, RotateCcwIcon, SendIcon, Trash2Icon } from "lucide-react";
import { BellIcon, PlusIcon, SendIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -13,190 +13,29 @@ 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { statusMeta, toneClasses } from "@/lib/status";
import type { NotificationChannel, NotificationDelivery, NotificationFilter, NotificationMeta } from "@/lib/types";
import type { NotificationChannel, NotificationFilter, NotificationMeta } from "@/lib/types";
// 渠道类型的展示名与简介。放在前端是因为它只影响文案,后端不需要知道。
const KIND_LABEL: Record<string, string> = {
dingtalk: "钉钉",
feishu: "飞书",
wecom: "企业微信",
webhook: "通用 Webhook",
telegram: "Telegram",
email: "邮件",
};
// 各渠道的配置字段定义。
//
// 这里刻意保留一份前端字段表,而不是让后端下发 schema:后端只负责
// Validate(必填/格式),UI 需要的是布局与控件类型,两者关注的不是同一件事。
// 唯一的耦合点是 secret_keys —— 哪些字段该渲染成密码框由后端给出,
// 因为只有渠道实现自己清楚哪些值算凭据(企业微信的整个 Webhook 就是凭据,
// 而钉钉的只是其中一个 secret)。新增渠道时这里少一个条目只会让表单变空白,
// 不会静默出错(下面的 hasFields 会提示)。
type FieldKind = "text" | "password" | "number" | "select" | "textarea" | "switch" | "kv" | "list";
interface FieldDef {
key: string;
label: string;
kind: FieldKind;
placeholder?: string;
help?: string;
options?: { value: string; label: string }[];
}
const CHANNEL_FIELDS: Record<string, FieldDef[]> = {
dingtalk: [
{
key: "webhook",
label: "Webhook 地址",
kind: "text",
placeholder: "https://oapi.dingtalk.com/robot/send?access_token=...",
},
{
key: "secret",
label: "加签密钥",
kind: "password",
help: "机器人安全设置选「加签」时填写;选「自定义关键词」或未开启安全设置则留空",
},
],
feishu: [
{
key: "webhook",
label: "Webhook 地址",
kind: "text",
placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/...",
},
{ key: "secret", label: "签名校验密钥", kind: "password", help: "机器人开启「签名校验」时填写,否则留空" },
],
wecom: [
{
key: "webhook",
label: "Webhook 地址",
kind: "text",
placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
},
],
webhook: [
{ key: "url", label: "目标 URL", kind: "text", placeholder: "https://your-endpoint.example.com/hook" },
{
key: "method",
label: "请求方法",
kind: "select",
options: [
{ value: "POST", label: "POST(带请求体)" },
{ value: "PUT", label: "PUT(带请求体)" },
{ value: "PATCH", label: "PATCH(带请求体)" },
{ value: "GET", label: "GET(不带请求体)" },
],
},
{ key: "headers", label: "自定义请求头", kind: "kv", help: "每行 KEY=VALUE,例如 Authorization=Bearer xxx" },
{
key: "body_template",
label: "请求体模板",
kind: "textarea",
help:
"留空用内置默认模板。变量:{{.Title}} {{.Batch}} {{.Count}} {{.HomeURL}} {{.SentAt}}," +
"以及 range .Items 下的 .Name/.VulnClass/.Severity/.Summary/.Assets/.DetailURL/.StatusLabel。" +
"插入字符串请用 {{json .Xxx}} 而不是 {{.Xxx}},否则标题里的引号会破坏 JSON。",
},
],
telegram: [
{ key: "bot_token", label: "Bot Token", kind: "password", placeholder: "123456:ABC-DEF..." },
{ key: "chat_id", label: "Chat ID", kind: "text", placeholder: "-1001234567890" },
{
key: "base_url",
label: "API 地址",
kind: "text",
placeholder: "https://api.telegram.org",
help: "留空用官方地址;自建 Bot API 反代时填写",
},
],
email: [
{ key: "host", label: "SMTP 服务器", kind: "text", placeholder: "smtp.example.com" },
{
key: "port",
label: "端口",
kind: "number",
placeholder: "587",
help: "587 走 STARTTLS;465 请把「隐式 TLS」打开",
},
{ key: "username", label: "账号", kind: "text" },
{ key: "password", label: "密码 / 授权码", kind: "password" },
{ key: "from", label: "发件人", kind: "text", placeholder: "[email protected]" },
{ key: "to", label: "收件人", kind: "list", help: "多个地址用逗号分隔" },
{ key: "tls", label: "隐式 TLS", kind: "switch", help: "465 端口打开;587 保持关闭(会自动 STARTTLS)" },
],
};
const SEVERITY_OPTIONS = [
{ value: "", label: "不限" },
{ value: "low", label: "低危及以上" },
{ value: "medium", label: "中危及以上" },
{ value: "high", label: "高危及以上" },
{ value: "critical", label: "仅严重" },
];
type ChannelForm = {
name: string;
kind: string;
mode: "realtime" | "digest";
enabled: boolean;
ratePerMin: string;
config: Record<string, unknown>;
minSeverity: string;
includeText: string;
excludeText: string;
taskIDsText: string;
assetIDsText: string;
onStatusChange: boolean;
};
const emptyForm = (kind: string): ChannelForm => ({
name: "",
kind,
mode: "realtime",
enabled: true,
ratePerMin: "",
config: {},
minSeverity: "",
includeText: "",
excludeText: "",
taskIDsText: "",
assetIDsText: "",
onStatusChange: false,
});
// parseKV 解析「每行 KEY=VALUE」的文本域。
function parseKV(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
const i = t.indexOf("=");
if (i > 0) out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
return out;
}
// parseIDs 解析逗号/空白分隔的 id 列表。
function parseIDs(text: string): number[] {
return text
.split(/[\s,,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => Number(s))
.filter((n) => Number.isFinite(n) && n > 0);
}
// parseKeywords 解析行/逗号分隔的关键词列表(漏洞类型名可能含空格,所以按行或逗号切)。
function parseKeywords(text: string): string[] {
return text
.split(/[\n,,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
import {
CHANNEL_FIELDS,
type ChannelForm,
emptyForm,
KIND_LABEL,
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 [meta, setMeta] = React.useState<NotificationMeta | null>(null);
const [channels, setChannels] = React.useState<NotificationChannel[]>([]);
@@ -764,332 +603,3 @@ export default function NotifyPage() {
</div>
);
}
// asText 把任意配置值渲染成输入框可用的字符串。
// config 来自 JSON,值可能是 string / number / boolean / array / null,
// 这里只关心「能不能塞进文本框」,具体序列化由 buildConfig 负责。
function asText(v: unknown): string {
if (typeof v === "string") return v;
if (v === null || v === undefined) return "";
return String(v);
}
// inputType 把字段类型映射到 input 的 type 属性。
function inputType(kind: FieldKind): "text" | "password" | "number" {
if (kind === "password") return "password";
if (kind === "number") return "number";
return "text";
}
// ConfigField 按字段定义渲染对应的控件。
//
// 掩码字段的处理是这里唯一的讲究:输入框**不显示**掩码值本身,只显示一行
// 「已保存」提示。这样界面上就只有一个规则——框里有字就是用户填的,
// 空框就是空值。若把 "__masked__:…abc123" 塞进输入框,用户会以为那是要自己
// 删掉的占位文本,反而更容易误清凭据。
function ConfigField({
def,
value,
isSecret,
onChange,
}: {
def: FieldDef;
value: unknown;
isSecret: boolean;
onChange: (v: unknown) => void;
}) {
const id = `n-cfg-${def.key}`;
const raw = asText(value);
// 后端回显的掩码值:形如 "__masked__:…abc123",尾部是原值的可辨识片段。
const masked = isSecret && raw.startsWith("__masked__");
const maskedTail = masked ? (raw.split("…")[1] ?? "") : "";
if (def.kind === "switch") {
return (
<div className="flex items-center gap-2 text-sm">
<Switch checked={value === true} onCheckedChange={onChange} aria-label={def.label} />
{def.label}
{def.help && <span className="text-muted-foreground">({def.help})</span>}
</div>
);
}
if (def.kind === "select") {
return (
<div className="grid gap-2">
<Label>{def.label}</Label>
<Select value={raw || def.options?.[0]?.value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(def.options ?? []).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件,
// 三层三元读起来已经要停下来数括号了。
function control() {
if (def.kind === "textarea" || def.kind === "kv") {
return (
<Textarea
id={id}
className="font-mono"
placeholder={def.placeholder}
value={masked ? "" : raw}
onChange={(e) => onChange(e.target.value)}
/>
);
}
if (def.kind === "list") {
return (
<Input
id={id}
value={Array.isArray(value) ? (value as string[]).join(", ") : raw}
onChange={(e) => onChange(e.target.value)}
placeholder={def.placeholder}
/>
);
}
return (
<Input
id={id}
className={def.kind === "text" ? "font-mono" : ""}
type={inputType(def.kind)}
placeholder={def.placeholder}
value={masked ? "" : raw}
onChange={(e) => onChange(e.target.value)}
/>
);
}
const hint = masked ? (
<p className="text-muted-foreground flex items-center gap-1 text-xs">
<CheckIcon className="size-3" />
已保存{maskedTail ? `(尾号 ${maskedTail})` : ""} · 填入新值即覆盖,清空则删除该项
</p>
) : (
def.help && <p className="text-muted-foreground text-xs">{def.help}</p>
);
return (
<div className="grid gap-2">
<Label htmlFor={id}>{def.label}</Label>
{control()}
{hint}
</div>
);
}
// FilterSummary 把过滤条件摘要成一行,让卡片不用展开就能看出这个渠道推什么。
function FilterSummary({ filter }: { filter: NotificationFilter }) {
const parts: string[] = [];
if (filter.min_severity) {
parts.push(SEVERITY_OPTIONS.find((o) => o.value === filter.min_severity)?.label ?? filter.min_severity);
}
if (filter.vulnclass_include?.length) parts.push(`类型含 ${filter.vulnclass_include.length} 词`);
if (filter.vulnclass_exclude?.length) parts.push(`排除 ${filter.vulnclass_exclude.length} 词`);
if (filter.task_ids?.length) parts.push(`${filter.task_ids.length} 个任务`);
if (filter.asset_ids?.length) parts.push(`${filter.asset_ids.length} 个资产`);
if (filter.on_status_change) parts.push("含状态变更");
if (parts.length === 0) {
return <p className="text-muted-foreground text-sm">全部漏洞</p>;
}
return <p className="text-muted-foreground text-sm">{parts.join(" · ")}</p>;
}
function StatTile({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: string }) {
return (
<Card size="sm" className="gap-1">
<CardContent>
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`text-lg font-semibold ${tone === "red" ? "text-rose-600" : ""}`}>{value}</p>
{hint && <p className={`text-xs ${tone === "red" ? "text-rose-600" : "text-muted-foreground"}`}>{hint}</p>}
</CardContent>
</Card>
);
}
// formatBacklog 把积压毫秒数渲染成人看得懂的量级。
function formatBacklog(ms: number): string {
if (!ms) return "—";
if (ms < 60_000) return `${Math.round(ms / 1000)} 秒`;
if (ms < 3_600_000) return `${Math.round(ms / 60_000)} 分钟`;
return `${(ms / 3_600_000).toFixed(1)} 小时`;
}
// DeliveryList 是投递记录表:可按渠道与状态筛选,失败项可手动重发。
function DeliveryList({ channels }: { channels: NotificationChannel[] }) {
const [rows, setRows] = React.useState<NotificationDelivery[]>([]);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(1);
const [channelID, setChannelID] = React.useState<number | undefined>(undefined);
const [state, setState] = React.useState<string | undefined>(undefined);
const [loading, setLoading] = React.useState(false);
const pageSize = 50;
const load = React.useCallback(() => {
setLoading(true);
api
.notifyDeliveries({ channelId: channelID, state, page, pageSize })
.then((r) => {
setRows(r.deliveries);
setTotal(r.total);
})
.catch((e) => toast.error("读取投递记录失败:" + (e as Error).message))
.finally(() => setLoading(false));
}, [channelID, state, page]);
React.useEffect(() => {
load();
}, [load]);
async function retry(id: number) {
try {
await api.notifyRetryDelivery(id);
toast.success("已重新入队");
load();
} catch (e) {
toast.error("重发失败:" + (e as Error).message);
}
}
const maxPage = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Select
value={channelID ? String(channelID) : "all"}
onValueChange={(v) => {
setPage(1);
setChannelID(v === "all" ? undefined : Number(v));
}}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue placeholder="全部渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部渠道</SelectItem>
{channels.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state ?? "all"}
onValueChange={(v) => {
setPage(1);
setState(v === "all" ? undefined : v);
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{["pending", "sending", "sent", "failed", "skipped"].map((s) => (
<SelectItem key={s} value={s}>
{statusMeta("delivery", s).label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> 刷新
</Button>
<span className="text-muted-foreground ml-auto text-xs">共 {total} 条</span>
</div>
<Card className="py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">时间</TableHead>
<TableHead>漏洞</TableHead>
<TableHead className="w-40">渠道</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-16">尝试</TableHead>
<TableHead>错误</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
{loading ? "加载中…" : "暂无投递记录"}
</TableCell>
</TableRow>
) : (
rows.map((d) => (
<TableRow key={d.id}>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{formatTime(d.created_at)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant="outline" className={toneClasses[statusMeta("severity", d.severity).tone]}>
{statusMeta("severity", d.severity).label}
</Badge>
<span className="truncate text-sm">{d.title || "(无标题)"}</span>
{d.event_kind === "finding_status_changed" && (
<Badge variant="outline" className="shrink-0">
状态变更
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm">{d.channel_name}</TableCell>
<TableCell>
<Badge variant="outline" className={toneClasses[statusMeta("delivery", d.state).tone]}>
{statusMeta("delivery", d.state).label}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">{d.attempts}</TableCell>
<TableCell className="text-muted-foreground max-w-md text-xs break-all">{d.last_error}</TableCell>
<TableCell>
{/* 只有失败/跳过的才给重发入口:已送达的重发会造成重复推送。 */}
{(d.state === "failed" || d.state === "skipped") && (
<Button size="sm" variant="outline" onClick={() => retry(d.id)}>
<RotateCcwIcon /> 重发
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{maxPage > 1 && (
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
上一页
</Button>
<span className="text-muted-foreground text-sm">
{page} / {maxPage}
</span>
<Button size="sm" variant="outline" disabled={page >= maxPage} onClick={() => setPage((p) => p + 1)}>
下一页
</Button>
</div>
)}
</div>
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("zh-CN", { hour12: false });
}