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:
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)} 小时`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user