mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 09:13:49 +08:00
fix(findings): protect historical reports and verify consolidation results
This commit is contained in:
1 parent
71ec0f0efd
commit
7db7b71cdb
15 files changed
+906
-74
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user