feat(findings): 漏洞删除功能(二次确认)

- db: DeleteFinding(id) 删 findings 行 + 来源探索节点(kind='finding'),
  级联清理 edges/node_assets、置空 activity 引用;返回受影响行数。
- server: DELETE /api/exploration/findings/{id}。
- web: api.deleteFinding;三处均可删且带 AlertDialog 二次确认——
  发现列表页独立「操作」列(每行垃圾桶按钮)、漏洞详情页头部删除按钮、
  任务详情·发现 Tab 展开区删除按钮。两处列表同源,删一次全部消失。
- 测试 TestDeleteFinding:findings 行 + 来源节点均被删,重复删返回(0,nil)。

go build/vet、tsc 通过。
This commit is contained in:
Autumn-27 committed 2026-08-19 10:28:06 -04:00
1 parent 8047bb7aca
commit 90030051ce
7 files changed
+250 -8

No files matched your search

@@ -2,12 +2,23 @@
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon, Trash2Icon } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -61,6 +72,7 @@ function FieldRow({
}
function FindingDetailInner() {
const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const [finding, setFinding] = React.useState<Finding | null>(null);
@@ -116,6 +128,18 @@ function FindingDetailInner() {
[finding, id],
);
// deleteFinding 删除当前漏洞(需二次确认),成功后返回发现列表。
const deleteFinding = React.useCallback(async () => {
if (!id) return;
try {
await api.deleteFinding(id);
toast.success("已删除漏洞");
router.push("/function/findings");
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, [id, router]);
if (!finding) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
@@ -160,6 +184,25 @@ function FindingDetailInner() {
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto text-destructive">
<Trash2Icon /> 删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription>
「{title}」将被永久删除,同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={deleteFinding}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<TabsList>
<TabsTrigger value="overview">概览</TabsTrigger>
+62 -2
View File
@@ -7,10 +7,22 @@ import {
ShieldAlertIcon,
ArrowUpRightIcon,
FileTextIcon,
Trash2Icon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { Markdown } from "@/components/markdown";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -231,6 +243,24 @@ export default function FindingsPage() {
[edit],
);
// deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。
const deleteFinding = React.useCallback(
async (f: Finding) => {
if (!f.finding_id) return;
try {
await api.deleteFinding(f.finding_id);
setFindings((cur) => cur.filter((x) => x.id !== f.id));
setTotal((t) => Math.max(0, t - 1));
setExpanded((cur) => (cur === f.id ? null : cur));
toast.success("已删除漏洞");
api.findingStats().then(setStats).catch(() => {});
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
},
[],
);
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: stats.total },
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
@@ -375,6 +405,7 @@ export default function FindingsPage() {
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
<TableHead className="w-32">时间</TableHead>
<TableHead className="w-16 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -490,12 +521,41 @@ export default function FindingsPage() {
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(f.ts)}
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{f.finding_id && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-7 text-muted-foreground hover:text-destructive"
aria-label="删除漏洞"
>
<Trash2Icon className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription>
「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除,
同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteFinding(f)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</TableCell>
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
被强制单行、直接溢出单元格。 */}
<TableCell colSpan={7} className="bg-muted/30 whitespace-normal">
<TableCell colSpan={8} className="bg-muted/30 whitespace-normal">
<div className="flex flex-col gap-2 px-2 py-1">
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
{f.finding_id && edit && (
@@ -626,7 +686,7 @@ export default function FindingsPage() {
{findings.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
colSpan={8}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
@@ -2,10 +2,22 @@
import * as React from "react";
import Link from "next/link";
import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { ArrowUpRightIcon, ChevronRightIcon, Trash2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/status-badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
@@ -31,9 +43,11 @@ const FINDING_STATUSES: FindingStatus[] = [
function Row({
f,
onStatus,
onDelete,
}: {
f: Finding;
onStatus: (f: Finding, next: FindingStatus) => void;
onDelete: (f: Finding) => void;
}) {
const [open, setOpen] = React.useState(false);
return (
@@ -116,8 +130,30 @@ function Row({
</div>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 text-xs font-medium text-muted-foreground">
证据 / PoC
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">证据 / PoC</span>
{f.finding_id && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="h-7 text-destructive">
<Trash2Icon /> 删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription>
「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除,
同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(f)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
@@ -169,6 +205,17 @@ export function FindingsTab({ taskId }: { taskId: string }) {
[],
);
const onDelete = React.useCallback(async (f: Finding) => {
if (!f.finding_id) return;
try {
await api.deleteFinding(f.finding_id);
setFindings((cur) => cur.filter((x) => x.id !== f.id));
toast.success("已删除漏洞");
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, []);
const items = findings
.filter((f) => f.task_id === taskId)
.sort((a, b) => {
@@ -180,7 +227,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} onStatus={onStatus} />
<Row key={f.id} f={f} onStatus={onStatus} onDelete={onDelete} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
+2
View File
@@ -261,6 +261,8 @@ export const api = {
id: string,
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
) => patch<Finding>(`/exploration/findings/${id}`, fields),
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
tokenStats: (task?: string) =>
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({