From ee8929115e58f0a024d603689b3cf839851436c6 Mon Sep 17 00:00:00 2001 From: luckone <1139448596@qq.com> Date: Wed, 30 Sep 2026 16:11:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E9=80=9A=E7=9F=A5=E9=A1=B5?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=B8=BA=E7=BB=84=E4=BB=B6=EF=BC=8C1095=20?= =?UTF-8?q?=E8=A1=8C=E9=99=8D=E5=88=B0=20605=20=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面此前接近 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 --- .../notify/_components/channel-fields.ts | 181 ++++++ .../notify/_components/channel-form.tsx | 153 +++++ .../notify/_components/delivery-list.tsx | 184 ++++++ .../system/notify/_components/stat-tile.tsx | 21 + web/src/app/(main)/system/notify/page.tsx | 528 +----------------- 5 files changed, 558 insertions(+), 509 deletions(-) create mode 100644 web/src/app/(main)/system/notify/_components/channel-fields.ts create mode 100644 web/src/app/(main)/system/notify/_components/channel-form.tsx create mode 100644 web/src/app/(main)/system/notify/_components/delivery-list.tsx create mode 100644 web/src/app/(main)/system/notify/_components/stat-tile.tsx diff --git a/web/src/app/(main)/system/notify/_components/channel-fields.ts b/web/src/app/(main)/system/notify/_components/channel-fields.ts new file mode 100644 index 0000000..441ee3f --- /dev/null +++ b/web/src/app/(main)/system/notify/_components/channel-fields.ts @@ -0,0 +1,181 @@ +// 渠道字段表与配置值的解析工具。 +// +// 与页面拆开是因为这一份是**数据**而不是视图:它描述每种渠道有哪些字段、 +// 各自该用什么控件,以及表单文本到配置值(JSON)的双向转换。 +// 单独放一个文件后,新增渠道只需要动这里,页面本身不必改。 +// 渠道类型的展示名与简介。放在前端是因为它只影响文案,后端不需要知道。 +export const KIND_LABEL: Record = { + 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 = { + 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: "artex@example.com" }, + { 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; + 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 { + const out: Record = {}; + 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); +} diff --git a/web/src/app/(main)/system/notify/_components/channel-form.tsx b/web/src/app/(main)/system/notify/_components/channel-form.tsx new file mode 100644 index 0000000..bc16292 --- /dev/null +++ b/web/src/app/(main)/system/notify/_components/channel-form.tsx @@ -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 ( +
+ + {def.label} + {def.help && ({def.help})} +
+ ); + } + + if (def.kind === "select") { + return ( +
+ + +
+ ); + } + + // 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件, + // 三层三元读起来已经要停下来数括号了。 + function control() { + if (def.kind === "textarea" || def.kind === "kv") { + return ( +