feat: finding 增加处置状态(待处理/误报/忽略/已处理)+ 发现列表页改版

- findings 表新增 status 列(默认 pending),schema.sql 同步 ALTER TABLE 补旧库
- 新增 PATCH /api/exploration/findings/{id} 更新处置状态,server 侧白名单校验
- FindingDTO 增加 status/finding_id/assets(后端预渲染资产 label)
- 任务内发现节点通过 node_id 关联独立 findings 行,复用同一处置状态
- 发现列表页:漏洞名称(类型主 + 摘要小字)、新增资产列与状态列(可下拉修改)、状态筛选与待处理统计
- 任务详情发现 Tab 同步展示/编辑处置状态与绑定资产
This commit is contained in:
Autumn-27 committed 2026-08-18 09:46:46 -04:00
1 parent c588dd67ae
commit 05f0b69469
10 files changed
+508 -83

No files matched your search

+145 -21
View File
@@ -34,10 +34,19 @@ import {
import { TablePagination } from "@/components/table-pagination";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Finding, Severity } from "@/lib/types";
import { toast } from "sonner";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
const SEVERITY_ORDER: Record<Severity, number> = { high: 3, medium: 2, low: 1 };
const FINDING_STATUSES: FindingStatus[] = [
"pending",
"false_positive",
"ignored",
"resolved",
];
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
@@ -49,6 +58,7 @@ function fmtTime(ts: string) {
export default function FindingsPage() {
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [expanded, setExpanded] = React.useState<string | null>(null);
@@ -74,6 +84,27 @@ export default function FindingsPage() {
};
}, []);
// updateStatus optimistically flips one finding's triage state, reverting on error.
const updateStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
if (!f.finding_id || next === f.status) return;
const prev = f.status;
setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)),
);
try {
await api.setFindingStatus(f.finding_id, next);
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
} catch (e) {
setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)),
);
toast.error("更新失败:" + (e as Error).message);
}
},
[],
);
const vulnclasses = React.useMemo(
() => Array.from(new Set(findings.map((f) => f.vulnclass))).sort(),
[findings],
@@ -82,13 +113,14 @@ export default function FindingsPage() {
const counts = React.useMemo(() => {
const c = { high: 0, medium: 0, low: 0 };
for (const f of findings) c[f.severity]++;
const tasks = new Set(findings.map((f) => f.task_id)).size;
return { ...c, total: findings.length, tasks };
const pending = findings.filter((f) => f.status === "pending").length;
return { ...c, total: findings.length, pending };
}, [findings]);
const rows = React.useMemo(() => {
let list = findings.slice();
if (severity !== "all") list = list.filter((f) => f.severity === severity);
if (status !== "all") list = list.filter((f) => f.status === status);
if (vulnclass !== "all")
list = list.filter((f) => f.vulnclass === vulnclass);
list.sort((a, b) => {
@@ -99,10 +131,12 @@ export default function FindingsPage() {
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
});
return list;
}, [findings, severity, vulnclass, sort]);
}, [findings, severity, status, vulnclass, sort]);
// reset to page 1 whenever filters change
React.useEffect(() => { setPage(1); }, [severity, vulnclass, sort]);
React.useEffect(() => {
setPage(1);
}, [severity, status, vulnclass, sort]);
const paginated = React.useMemo(
() => rows.slice((page - 1) * pageSize, page * pageSize),
@@ -111,10 +145,10 @@ export default function FindingsPage() {
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: counts.total },
{ label: "待处理", value: counts.pending, tone: "text-amber-500" },
{ label: "高危", value: counts.high, tone: "text-red-500" },
{ label: "中危", value: counts.medium, tone: "text-amber-500" },
{ label: "低危", value: counts.low, tone: "text-slate-500" },
{ label: "涉及任务", value: counts.tasks },
];
return (
@@ -129,9 +163,7 @@ export default function FindingsPage() {
<Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{s.label}</CardDescription>
<CardTitle
className={cn("text-2xl tabular-nums", s.tone)}
>
<CardTitle className={cn("text-2xl tabular-nums", s.tone)}>
{s.value}
</CardTitle>
</CardHeader>
@@ -164,6 +196,23 @@ export default function FindingsPage() {
))}
</div>
<Select
value={status}
onValueChange={(v) => setStatus(v as "all" | FindingStatus)}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="漏洞类型" />
@@ -203,8 +252,9 @@ export default function FindingsPage() {
<TableRow>
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead className="w-28">漏洞类型</TableHead>
<TableHead>摘要</TableHead>
<TableHead>漏洞名称</TableHead>
<TableHead className="w-52">资产</TableHead>
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
<TableHead className="w-32">时间</TableHead>
</TableRow>
@@ -229,13 +279,71 @@ export default function FindingsPage() {
<TableCell>
<StatusBadge domain="severity" value={f.severity} dot />
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{f.vulnclass}
</code>
</TableCell>
<TableCell className="max-w-md">
<span className="line-clamp-1">{f.summary}</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">
{f.vulnclass || "未分类"}
</span>
<span className="truncate text-xs text-muted-foreground">
{f.summary}
</span>
</div>
</TableCell>
<TableCell className="w-52">
{f.assets && f.assets.length > 0 ? (
<div className="flex flex-wrap gap-1">
{f.assets.slice(0, 3).map((a) => (
<code
key={a.id}
className="max-w-[12rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{f.assets.length > 3 && (
<span className="text-xs text-muted-foreground">
+{f.assets.length - 3}
</span>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{f.finding_id ? (
<Select
value={f.status}
onValueChange={(v) =>
updateStatus(f, v as FindingStatus)
}
>
<SelectTrigger
size="sm"
className="h-7 w-full border-none px-1 shadow-none focus-visible:ring-0"
>
<StatusBadge
domain="finding"
value={f.status}
dot
/>
</SelectTrigger>
<SelectContent>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge
domain="finding"
value={f.status}
dot
/>
)}
</TableCell>
<TableCell className="w-36 max-w-[9rem]">
{f.task_id ? (
@@ -245,7 +353,9 @@ export default function FindingsPage() {
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
title={f.task_description}
>
<span className="truncate">{f.task_description}</span>
<span className="truncate">
{f.task_description}
</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
@@ -258,9 +368,9 @@ export default function FindingsPage() {
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-muted/30">
<TableCell colSpan={7} className="bg-muted/30">
<div className="flex flex-col gap-2 px-2 py-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ShieldAlertIcon className="size-3.5" />
证据 · {f.id}
{f.param_id && (
@@ -268,6 +378,20 @@ export default function FindingsPage() {
{f.param_id}
</code>
)}
{f.assets && f.assets.length > 0 && (
<span className="flex flex-wrap items-center gap-1">
· 资产:
{f.assets.map((a) => (
<code
key={a.id}
className="rounded bg-muted px-1.5 py-0.5 font-mono"
title={a.type}
>
{a.label}
</code>
))}
</span>
)}
</div>
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
@@ -282,7 +406,7 @@ export default function FindingsPage() {
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
colSpan={7}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
@@ -5,32 +5,99 @@ import { ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/status-badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { api } from "@/lib/api";
import type { Finding } from "@/lib/types";
import { toast } from "sonner";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus } from "@/lib/types";
function Row({ f }: { f: Finding }) {
const FINDING_STATUSES: FindingStatus[] = [
"pending",
"false_positive",
"ignored",
"resolved",
];
function Row({
f,
onStatus,
}: {
f: Finding;
onStatus: (f: Finding, next: FindingStatus) => void;
}) {
const [open, setOpen] = React.useState(false);
return (
<div className="border-b last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-accent/40"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<span className="w-20 shrink-0 font-medium">{f.vulnclass}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{f.summary}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
<div className="flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent/40">
<button
onClick={() => setOpen((o) => !o)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">
{f.vulnclass || "未分类"}
</span>
<span className="truncate text-xs text-muted-foreground">
{f.summary}
</span>
</div>
</button>
{f.assets && f.assets.length > 0 && (
<div className="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
{f.assets.slice(0, 2).map((a) => (
<code
key={a.id}
className="max-w-[10rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{f.assets.length > 2 && (
<span className="text-xs text-muted-foreground">
+{f.assets.length - 2}
</span>
)}
</div>
)}
{f.finding_id ? (
<Select
value={f.status}
onValueChange={(v) => onStatus(f, v as FindingStatus)}
>
<SelectTrigger
size="sm"
className="h-7 w-28 shrink-0 border-none px-1 shadow-none focus-visible:ring-0"
>
<StatusBadge domain="finding" value={f.status} dot />
</SelectTrigger>
<SelectContent>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge domain="finding" value={f.status} dot />
)}
<span className="hidden shrink-0 text-xs text-muted-foreground md:block">
{new Date(f.ts).toLocaleString("zh-CN")}
</span>
</button>
</div>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 text-xs font-medium text-muted-foreground">
@@ -66,6 +133,26 @@ export function FindingsTab({ taskId }: { taskId: string }) {
};
}, [taskId]);
const onStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
if (!f.finding_id || next === f.status) return;
const prev = f.status;
setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)),
);
try {
await api.setFindingStatus(f.finding_id, next);
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
} catch (e) {
setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)),
);
toast.error("更新失败:" + (e as Error).message);
}
},
[],
);
const items = findings
.filter((f) => f.task_id === taskId)
.sort((a, b) => {
@@ -77,7 +164,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
{items.map((f) => (
<Row key={f.id} f={f} />
<Row key={f.id} f={f} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">