fix(findings): protect historical reports and verify consolidation results

This commit is contained in:
ji ruo committed 2026-10-09 12:23:11 +08:00
1 parent 71ec0f0efd
commit 7db7b71cdb
15 files changed
+906 -74

No files matched your search

+148 -32
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import Link from "next/link";
import { ChevronRightIcon, FolderIcon } from "lucide-react";
import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
@@ -12,8 +12,9 @@ import { TablePagination } from "@/components/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type {
@@ -55,18 +56,23 @@ export function FindingCaseMemberRow({
onSelect,
contextTask,
matched = true,
nested = false,
}: {
finding: Finding;
selected?: boolean;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
matched?: boolean;
nested?: boolean;
}) {
const id = f.finding_id ?? f.id;
return (
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-3 border-b px-4 py-3 text-sm last:border-b-0",
"flex min-w-0 flex-wrap items-center gap-3 py-3 text-sm",
nested
? "relative rounded-lg border bg-background px-3 before:absolute before:top-1/2 before:-left-4 before:w-4 before:border-border before:border-t sm:before:-left-5 sm:before:w-5"
: "border-b px-4 last:border-b-0",
!matched && "bg-muted/30",
)}
>
@@ -102,6 +108,7 @@ export function FindingCaseMembers({
selectedIds,
onSelect,
contextTask,
nested = false,
}: {
caseId: string;
version?: number;
@@ -109,6 +116,7 @@ export function FindingCaseMembers({
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
nested?: boolean;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
@@ -148,11 +156,12 @@ export function FindingCaseMembers({
</div>
);
return (
<div className="flex min-w-0 flex-col">
<div className={cn("flex min-w-0 flex-col", nested && "gap-2 border-primary/25 border-l-2 pl-4 sm:pl-5")}>
{data.items.map((f) => (
<FindingCaseMemberRow
key={f.finding_id}
finding={f}
nested={nested}
selected={selectedIds?.has(f.finding_id ?? f.id)}
onSelect={onSelect}
contextTask={contextTask}
@@ -173,10 +182,91 @@ export function FindingCaseMembers({
);
}
function FindingMergeSuggestionCard({
suggestion: s,
onResolve,
}: {
suggestion: FindingCaseSuggestion;
onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise<void>;
}) {
const [expanded, setExpanded] = React.useState(false);
const [busy, setBusy] = React.useState(false);
async function resolve(accept: boolean) {
if (busy) return;
setBusy(true);
try {
await onResolve(s, accept);
} finally {
setBusy(false);
}
}
return (
<Card size="sm" className="min-w-0">
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">疑似重复 · 待确认</Badge>
<CardDescription>任务 #{s.task_id}</CardDescription>
</div>
<CardTitle className="break-words">{s.title}</CardTitle>
<CardDescription className="flex flex-wrap gap-3">
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.left_id}`}>
查看原报告 #{s.left_id}
</Link>
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.right_id}`}>
查看原报告 #{s.right_id}
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<Collapsible open={expanded} onOpenChange={setExpanded}>
{!expanded ? (
<p className="line-clamp-2 whitespace-pre-wrap break-words text-muted-foreground text-sm">{s.reason}</p>
) : null}
<CollapsibleContent className="whitespace-pre-wrap break-words text-muted-foreground text-sm">
{s.reason}
</CollapsibleContent>
<CollapsibleTrigger asChild>
<Button variant="link" size="sm">
{expanded ? "收起判断依据" : "展开判断依据"}
</Button>
</CollapsibleTrigger>
</Collapsible>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy} onClick={() => resolve(true)}>
{busy ? <Spinner /> : null}确认归并
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => resolve(false)}>
不是同一漏洞
</Button>
</CardFooter>
</Card>
);
}
function FindingReviewRunRow({ run: r }: { run: FindingCaseReviewRun }) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
{r.state === "running" ? <Spinner /> : null}
<span>
任务 #{r.task_id} · 整理 #{r.conversation_id}
</span>
<Badge variant={r.state === "failed" ? "destructive" : "secondary"}>
{{ queued: "排队中", running: "运行中", done: "已完成", failed: "失败" }[r.state]}
</Badge>
<Link className="underline underline-offset-4" href={`/chat?c=${r.conversation_id}`}>
查看执行记录
</Link>
{r.error ? <p className="w-full break-words text-destructive">{r.error}</p> : null}
</div>
);
}
export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; onChange?: () => void }) {
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
const [error, setError] = React.useState("");
const [historyOpen, setHistoryOpen] = React.useState(false);
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
@@ -205,7 +295,7 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
const result = await api.resolveFindingCaseSuggestion(s.id, accept);
setSuggestions((v) => v.filter((x) => x.id !== s.id));
if (accept && result.case_id !== "0") {
toast.success("已归并,统一报告待生成");
toast.success("已归并,请进入文件夹生成统一报告");
} else {
toast.success("已否决,不会自动再次归并");
}
@@ -214,6 +304,9 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
toast.error((e as Error).message);
}
}
const latestByTask = new Map<string, FindingCaseReviewRun>();
for (const run of runs) if (!latestByTask.has(run.task_id)) latestByTask.set(run.task_id, run);
const latestFailures = [...latestByTask.values()].filter((run) => run.state === "failed");
return (
<div className="flex flex-col gap-2">
{error ? (
@@ -221,34 +314,45 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
<AlertDescription>整理状态加载失败:{error}</AlertDescription>
</Alert>
) : null}
{suggestions.length > 0 ? (
<p className="text-muted-foreground text-sm">待确认归并建议({suggestions.length})· 确认前原始记录分别保留</p>
) : null}
{suggestions.map((s) => (
<Alert key={s.id}>
<FindingMergeSuggestionCard key={s.id} suggestion={s} onResolve={resolve} />
))}
{runs.some((r) => r.state === "queued" || r.state === "running") ? (
<div className="flex flex-col gap-2">
{runs
.filter((r) => r.state === "queued" || r.state === "running")
.map((r) => (
<FindingReviewRunRow key={r.conversation_id} run={r} />
))}
</div>
) : null}
{latestFailures.map((r) => (
<Alert key={r.conversation_id} variant="destructive">
<AlertDescription>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">
疑似重复:#{s.left_id} / #{s.right_id} · {s.title}。{s.reason}
</span>
<Button variant="outline" size="sm" onClick={() => resolve(s, true)}>
确认归并
</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(s, false)}>
不是同一漏洞
</Button>
</div>
<FindingReviewRunRow run={r} />
</AlertDescription>
</Alert>
))}
{runs.slice(0, 5).map((r) => (
<div key={r.conversation_id} className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<span>
任务 #{r.task_id} 整理:{{ queued: "排队中", running: "运行中", done: "已完成", failed: "失败" }[r.state]}
</span>
{r.error ? <span className="text-destructive">{r.error}</span> : null}
<Link className="underline" href={`/chat?c=${r.conversation_id}`}>
查看执行记录
</Link>
</div>
))}
{runs.some((r) => r.state === "done" || r.state === "failed") ? (
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
{historyOpen ? "收起整理历史" : "查看整理历史"}(
{runs.filter((r) => r.state === "done" || r.state === "failed").length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="flex flex-col gap-2 py-2">
{runs
.filter((r) => r.state === "done" || r.state === "failed")
.map((r) => (
<FindingReviewRunRow key={r.conversation_id} run={r} />
))}
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
@@ -345,18 +449,23 @@ export function FindingCaseList({
const group = row.case;
return (
<Card key={`case:${group.id}`} className="gap-0 overflow-hidden py-0">
<CardHeader className="px-4 py-3">
<CardHeader className={cn("px-4 py-3", open.has(group.id) && "bg-muted/60")}>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${open.has(group.id) ? "收起" : "展开"}${group.title}`}
aria-expanded={open.has(group.id)}
aria-controls={`finding-case-members-${group.id}`}
onClick={() => toggle(group.id)}
>
<ChevronRightIcon className={cn(open.has(group.id) && "rotate-90")} />
</Button>
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
{open.has(group.id) ? (
<FolderOpenIcon className="size-5 shrink-0 text-primary" />
) : (
<FolderIcon className="size-5 shrink-0 text-muted-foreground" />
)}
<div className="flex min-w-0 flex-1 basis-2/3 flex-col gap-1 sm:basis-auto">
<CardTitle className="truncate text-sm">
<Link
@@ -367,7 +476,7 @@ export function FindingCaseList({
</Link>
</CardTitle>
<CardDescription>
{group.count} 条上报 ·{" "}
漏洞文件夹 · {group.count} 条原始上报 ·{" "}
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</CardDescription>
</div>
@@ -380,8 +489,15 @@ export function FindingCaseList({
</div>
</CardHeader>
{open.has(group.id) ? (
<CardContent className="border-t px-0">
<CardContent
id={`finding-case-members-${group.id}`}
role="region"
aria-label={`${group.title}的原始子报告`}
className="border-t bg-muted/20 px-4 py-4 sm:px-6"
>
<p className="mb-3 text-muted-foreground text-xs">原始子报告 · 各自等级和证据保留</p>
<FindingCaseMembers
nested
caseId={group.id}
version={group.version}
matchedIds={row.matched_ids}