feat(findings): 发现页新增平铺大表视图并设为默认
分组视图要逐个任务展开才看得到漏洞,跨任务扫列表时反而绕路。新增 「全部发现」平铺视图作为默认视图,与「按任务分组」通过页头 Tab 切换。 - 表格主体抽成 FindingsTable,两个视图共用同一份行渲染,勾选/行内展开/ 行内改名与改状态/深入/删除的行为完全一致 - 平铺视图走 findingsPage(后端本就支持不带 task_id 的跨任务分页), 页码与每页条数独立 state,筛选变更即重置回第一页 - 两视图共用统计卡片与筛选条,当前视图随筛选项一起持久化到 localStorage - 轮询只打当前视图;行内改动同步写两边缓存,切换视图不会看到过期数据 - 首个请求推迟到偏好水合之后,避免按默认视图白拉一次
This commit is contained in:
1 parent
3ddb0cb808
commit
5fa2ff0b46
3 files changed
+610
-432
No files matched your search
@@ -10,6 +10,12 @@
|
||||
|
||||
- 任务 LLM 配置链改为任何状态都可修改,不再限制「运行中 / 暂停 / 配置链耗尽」:任务结束(done / failed / timeout)后主 Agent 对话仍走这条链,链上模型出问题时原先既不能改也就没法继续交互。后端 HTTP 与 DB 事务两处终态拦截一并去掉,前端弹窗对终态任务也开放编辑与保存。终态任务保存时不再重开额度阻塞意图(那些意图会被挪到 open,既没有 worker 执行、也不再满足「重跑意图」的条件),想继续跑仍走重跑意图 / 新增目标,由它们把任务重新拉回运行态。
|
||||
|
||||
### UI
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
- 发现页新增「全部发现」平铺视图并设为默认,与原「按任务分组」视图通过页头 Tab 切换:分组视图要逐个任务展开才看得到漏洞,只想跨任务扫一眼列表时反而绕路。平铺视图是一张跨任务大表(每页 10/20/50/100 条),行为与分组视图完全一致——勾选导出、行内展开看证据与详细报告、行内改名称/类别/严重度、改处置状态、深入、删除。两个视图共用统计卡片与筛选条,切换不丢筛选,当前视图与筛选项一并记在本地;轮询只打当前视图,行内改动会同步写两边缓存,切过去不会看到过期数据。
|
||||
|
||||
## [0.3.6] - 2026-08-27
|
||||
|
||||
### LLM
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChevronRightIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
|
||||
export const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
"risk_accepted",
|
||||
];
|
||||
|
||||
export const UNASSIGNED_TASK = "__unassigned__";
|
||||
|
||||
// 行内编辑缓冲:当前展开行的名称/类别/严重等级。
|
||||
export interface FindingEdit {
|
||||
name: string;
|
||||
vulnclass: string;
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
export type FindingReport = { status: "loading" | "done" | "error"; text: string };
|
||||
|
||||
// Exploration node ids are only unique inside a task. Prefer the persisted
|
||||
// finding id and otherwise namespace the node id by task so editing one group
|
||||
// cannot update a similarly-named node in another expanded group.
|
||||
export function findingRowKey(finding: Finding): string {
|
||||
if (finding.finding_id) return `finding:${finding.finding_id}`;
|
||||
return `node:${finding.task_id ?? UNASSIGNED_TASK}:${finding.id}`;
|
||||
}
|
||||
|
||||
export function isSameFinding(left: Finding, right: Finding): boolean {
|
||||
return findingRowKey(left) === findingRowKey(right);
|
||||
}
|
||||
|
||||
export function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const COLUMN_COUNT = 9;
|
||||
|
||||
interface FindingsTableProps {
|
||||
items: Finding[];
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelected: (id: string, checked: boolean) => void;
|
||||
onToggleSelectedPage: (ids: string[], checked: boolean) => void;
|
||||
/** 当前展开行的 findingRowKey;null = 全部收起。 */
|
||||
expandedKey: string | null;
|
||||
onToggleRow: (finding: Finding) => void;
|
||||
reports: Record<string, FindingReport>;
|
||||
edit: FindingEdit | null;
|
||||
onEditChange: React.Dispatch<React.SetStateAction<FindingEdit | null>>;
|
||||
saving: boolean;
|
||||
onSave: (finding: Finding) => void;
|
||||
onStatusChange: (finding: Finding, next: FindingStatus) => void;
|
||||
onDeepen: (finding: Finding) => void;
|
||||
onDelete: (finding: Finding) => void;
|
||||
/** 全选框的无障碍标签,平铺视图与分组视图措辞不同。 */
|
||||
selectAllLabel?: string;
|
||||
}
|
||||
|
||||
// FindingsTable 是发现列表的表格主体,平铺视图与按任务分组视图共用同一份行渲染
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 深入 / 删除),差异只在外层容器与分页。
|
||||
export function FindingsTable({
|
||||
items,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onToggleSelectedPage,
|
||||
expandedKey,
|
||||
onToggleRow,
|
||||
reports,
|
||||
edit,
|
||||
onEditChange,
|
||||
saving,
|
||||
onSave,
|
||||
onStatusChange,
|
||||
onDeepen,
|
||||
onDelete,
|
||||
selectAllLabel = "选择当前页全部",
|
||||
}: FindingsTableProps) {
|
||||
const selectableIds = items.map((finding) => finding.finding_id).filter((id): id is string => Boolean(id));
|
||||
const selectedCount = selectableIds.filter((id) => selectedIds.has(id)).length;
|
||||
let headerChecked: boolean | "indeterminate" = false;
|
||||
if (selectableIds.length > 0 && selectedCount === selectableIds.length) {
|
||||
headerChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
headerChecked = "indeterminate";
|
||||
}
|
||||
|
||||
return (
|
||||
/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
|
||||
换行/内部滚动,不会把整张表撑出横向滚动条。 */
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) => onToggleSelectedPage(selectableIds, checked === true)}
|
||||
aria-label={selectAllLabel}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</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>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((f) => {
|
||||
const rowKey = findingRowKey(f);
|
||||
const open = expandedKey === rowKey;
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={() => onToggleRow(f)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
onToggleRow(f);
|
||||
}}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{f.finding_id && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(f.finding_id)}
|
||||
onCheckedChange={(c) => onToggleSelected(f.finding_id as string, c === true)}
|
||||
aria-label="选择该漏洞"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{f.finding_id ? (
|
||||
<Link
|
||||
href={`/function/findings/detail?id=${f.finding_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate font-medium hover:text-primary hover:underline"
|
||||
title="查看发现详情"
|
||||
>
|
||||
{f.name || f.vulnclass || "未分类"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium">{f.name || 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) => onStatusChange(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 position="popper" align="end">
|
||||
{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 ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${f.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(f.ts)}</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{f.finding_id && f.task_id && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onDeepen(f)}>
|
||||
<FlaskConicalIcon data-icon="inline-start" />
|
||||
深入
|
||||
</Button>
|
||||
)}
|
||||
{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 className="break-words">
|
||||
「
|
||||
<span className="break-all">
|
||||
{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}
|
||||
</span>
|
||||
」将被永久删除, 同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onDelete(f)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
|
||||
被强制单行、直接溢出单元格。 */}
|
||||
<TableCell colSpan={COLUMN_COUNT} className="bg-muted/30 whitespace-normal">
|
||||
<div className="flex flex-col gap-2 px-2 py-1">
|
||||
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
|
||||
{f.finding_id && edit && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">漏洞名称</Label>
|
||||
<Input
|
||||
value={edit.name}
|
||||
onChange={(e) => onEditChange((s) => (s ? { ...s, name: e.target.value } : s))}
|
||||
placeholder="可读标题,留空回退类别"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-[10rem] flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">类别</Label>
|
||||
<Input
|
||||
value={edit.vulnclass}
|
||||
onChange={(e) => onEditChange((s) => (s ? { ...s, vulnclass: e.target.value } : s))}
|
||||
placeholder="如 SQL Injection"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">严重等级</Label>
|
||||
<Select
|
||||
value={edit.severity}
|
||||
onValueChange={(v) => onEditChange((s) => (s ? { ...s, severity: v as Severity } : s))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={() => onSave(f)}>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldAlertIcon className="size-3.5" />
|
||||
证据
|
||||
{f.vulnclass && (
|
||||
<span>
|
||||
· 类型:
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{f.vulnclass}</code>
|
||||
</span>
|
||||
)}
|
||||
{f.param_id && <code className="rounded bg-muted px-1.5 py-0.5 font-mono">{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}
|
||||
</pre>
|
||||
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
详细报告
|
||||
</span>
|
||||
{reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && (
|
||||
<CopyButton
|
||||
text={reports[rowKey]?.text}
|
||||
successMessage="已复制详细报告"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const rep = reports[rowKey];
|
||||
if (!rep || rep.status === "loading")
|
||||
return <p className="text-xs text-muted-foreground">加载中…</p>;
|
||||
if (rep.status === "error")
|
||||
return <p className="text-xs text-muted-foreground">报告加载失败。</p>;
|
||||
if (!rep.text.trim())
|
||||
return <p className="text-xs text-muted-foreground">暂无详细报告。</p>;
|
||||
return (
|
||||
// break-words 会继承到段落/列表,pre 另加
|
||||
// whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL
|
||||
// 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。
|
||||
<div className="min-w-0 break-words rounded-md border bg-background px-3 py-2 [&_pre]:whitespace-pre-wrap">
|
||||
<Markdown text={rep.text} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="py-12 text-center text-sm text-muted-foreground">
|
||||
没有匹配的发现。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -10,34 +10,17 @@ import {
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
InfoIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -47,13 +30,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -62,22 +43,26 @@ import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
import {
|
||||
FINDING_STATUSES,
|
||||
type FindingEdit,
|
||||
type FindingReport,
|
||||
FindingsTable,
|
||||
findingRowKey,
|
||||
fmtTime,
|
||||
isSameFinding,
|
||||
SEVERITIES,
|
||||
UNASSIGNED_TASK,
|
||||
} from "./_components/findings-table";
|
||||
|
||||
const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
"risk_accepted",
|
||||
];
|
||||
|
||||
const UNASSIGNED_TASK = "__unassigned__";
|
||||
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
|
||||
|
||||
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠。
|
||||
type FindingView = "flat" | "grouped";
|
||||
|
||||
const FINDING_VIEWS: FindingView[] = ["flat", "grouped"];
|
||||
|
||||
// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。
|
||||
interface GroupFindingsState {
|
||||
items: Finding[];
|
||||
total: number;
|
||||
@@ -87,22 +72,20 @@ interface GroupFindingsState {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// 平铺视图的页码单独放 state(而非塞进快照),筛选一变就能连带重置并触发重新加载。
|
||||
interface FlatFindingsState {
|
||||
items: Finding[];
|
||||
total: number;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FLAT_STATE: FlatFindingsState = { items: [], total: 0, loaded: false, loading: false };
|
||||
|
||||
function findingGroupKey(group: FindingGroup) {
|
||||
return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id);
|
||||
}
|
||||
|
||||
// Exploration node ids are only unique inside a task. Prefer the persisted
|
||||
// finding id and otherwise namespace the node id by task so editing one group
|
||||
// cannot update a similarly-named node in another expanded group.
|
||||
function findingRowKey(finding: Finding): string {
|
||||
if (finding.finding_id) return `finding:${finding.finding_id}`;
|
||||
return `node:${finding.task_id ?? UNASSIGNED_TASK}:${finding.id}`;
|
||||
}
|
||||
|
||||
function isSameFinding(left: Finding, right: Finding): boolean {
|
||||
return findingRowKey(left) === findingRowKey(right);
|
||||
}
|
||||
|
||||
const EMPTY_STATS: FindingStats = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
@@ -114,16 +97,8 @@ const EMPTY_STATS: FindingStats = {
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function FindingsPage() {
|
||||
const [view, setView] = React.useState<FindingView>("flat");
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
|
||||
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
||||
@@ -132,6 +107,9 @@ export default function FindingsPage() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const [flat, setFlat] = React.useState<FlatFindingsState>(EMPTY_FLAT_STATE);
|
||||
const [flatPage, setFlatPage] = React.useState(1);
|
||||
const [flatPageSize, setFlatPageSize] = React.useState(20);
|
||||
const [groups, setGroups] = React.useState<FindingGroup[]>([]);
|
||||
const [groupTotal, setGroupTotal] = React.useState(0);
|
||||
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(() => new Set());
|
||||
@@ -154,12 +132,14 @@ export default function FindingsPage() {
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
view?: unknown;
|
||||
severity?: unknown;
|
||||
status?: unknown;
|
||||
vulnclass?: unknown;
|
||||
task?: unknown;
|
||||
sort?: unknown;
|
||||
};
|
||||
if (FINDING_VIEWS.includes(parsed.view as FindingView)) setView(parsed.view as FindingView);
|
||||
if (parsed.severity === "all" || SEVERITIES.includes(parsed.severity as Severity)) {
|
||||
setSeverity(parsed.severity as "all" | Severity);
|
||||
}
|
||||
@@ -178,15 +158,20 @@ export default function FindingsPage() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated) return;
|
||||
setLocalStorageValue(FINDING_LIST_PREFERENCE_KEY, JSON.stringify({ severity, status, vulnclass, task, sort }));
|
||||
}, [preferencesHydrated, severity, sort, status, task, vulnclass]);
|
||||
setLocalStorageValue(
|
||||
FINDING_LIST_PREFERENCE_KEY,
|
||||
JSON.stringify({ view, severity, status, vulnclass, task, sort }),
|
||||
);
|
||||
}, [preferencesHydrated, severity, sort, status, task, view, vulnclass]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setQuery(search.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// setFindings 同时改写两个视图缓存里的同一条发现,切换视图不会看到过期状态。
|
||||
const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => {
|
||||
setFlat((current) => ({ ...current, items: update(current.items) }));
|
||||
setGroupFindings((current) => {
|
||||
const next: Record<string, GroupFindingsState> = {};
|
||||
for (const [key, state] of Object.entries(current)) {
|
||||
@@ -248,15 +233,44 @@ export default function FindingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const flatRequest = React.useRef(0);
|
||||
const groupRequests = React.useRef<Record<string, number>>({});
|
||||
const groupsRequest = React.useRef(0);
|
||||
const flatStateRef = React.useRef(flat);
|
||||
const expandedGroupsRef = React.useRef(expandedGroups);
|
||||
const groupFindingsRef = React.useRef(groupFindings);
|
||||
const visibleGroupKeysRef = React.useRef<Set<string>>(new Set());
|
||||
flatStateRef.current = flat;
|
||||
expandedGroupsRef.current = expandedGroups;
|
||||
groupFindingsRef.current = groupFindings;
|
||||
visibleGroupKeysRef.current = new Set(groups.map(findingGroupKey));
|
||||
|
||||
// loadFlat 拉取平铺视图的当前页;task 筛选交给后端,与分组视图共用同一批筛选条件。
|
||||
const loadFlat = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
if (activeFilterFingerprint.current !== requestFilter) return;
|
||||
const request = ++flatRequest.current;
|
||||
setFlat((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await api.findingsPage({
|
||||
page: flatPage,
|
||||
pageSize: flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
});
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setFlat({ items: result.items, total: result.total, loaded: true, loading: false });
|
||||
} catch {
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
setFlat((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
|
||||
|
||||
const refreshGroups = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
if (activeFilterFingerprint.current !== requestFilter) return;
|
||||
@@ -364,31 +378,46 @@ export default function FindingsPage() {
|
||||
[expandedGroups, groupFindings, loadGroup],
|
||||
);
|
||||
|
||||
const reloadGroupForFinding = React.useCallback(
|
||||
// 行内改动后刷新当前视图:平铺视图重拉当前页,分组视图刷组头 + 该发现所在的组。
|
||||
const refreshAfterMutation = React.useCallback(
|
||||
(finding: Finding, removed = false) => {
|
||||
if (view === "flat") {
|
||||
// 删空最后一页时,页码由越界修正 effect 回退并连带重新加载。
|
||||
void loadFlat();
|
||||
return;
|
||||
}
|
||||
void refreshGroups();
|
||||
const key = finding.task_id ?? UNASSIGNED_TASK;
|
||||
const state = groupFindings[key];
|
||||
const state = groupFindingsRef.current[key];
|
||||
if (state?.loaded) {
|
||||
const nextTotal = Math.max(0, state.total - (removed ? 1 : 0));
|
||||
const lastPage = Math.max(1, Math.ceil(nextTotal / state.pageSize));
|
||||
void loadGroup(key, Math.min(state.page, lastPage), state.pageSize);
|
||||
}
|
||||
},
|
||||
[groupFindings, loadGroup],
|
||||
[loadFlat, loadGroup, refreshGroups, view],
|
||||
);
|
||||
|
||||
// Reset both pagination levels when a shared finding filter changes.
|
||||
// Reset every view's pagination and expansion when a shared finding filter changes.
|
||||
React.useEffect(() => {
|
||||
void filterFingerprint;
|
||||
setPage(1);
|
||||
setExpanded(null);
|
||||
setExpandedGroups(new Set());
|
||||
setGroupFindings({});
|
||||
setFlatPage(1);
|
||||
setFlat(EMPTY_FLAT_STATE);
|
||||
}, [filterFingerprint]);
|
||||
|
||||
// Task-group paging is independent from every expanded group's finding page.
|
||||
// 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。
|
||||
// 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated) return;
|
||||
const refresh = () => {
|
||||
if (view === "flat") {
|
||||
if (!flatStateRef.current.loading) void loadFlat();
|
||||
return;
|
||||
}
|
||||
void refreshGroups();
|
||||
for (const key of expandedGroupsRef.current) {
|
||||
if (!visibleGroupKeysRef.current.has(key)) continue;
|
||||
@@ -399,13 +428,19 @@ export default function FindingsPage() {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadGroup, refreshGroups]);
|
||||
}, [loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const lastPage = Math.max(1, Math.ceil(groupTotal / pageSize));
|
||||
if (page > lastPage) setPage(lastPage);
|
||||
}, [groupTotal, page, pageSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!flat.loaded) return;
|
||||
const lastPage = Math.max(1, Math.ceil(flat.total / flatPageSize));
|
||||
if (flatPage > lastPage) setFlatPage(lastPage);
|
||||
}, [flat.loaded, flat.total, flatPage, flatPageSize]);
|
||||
|
||||
// Whole-table aggregates (stat cards + vuln-class options) — independent of the
|
||||
// current page, so they stay exact.
|
||||
React.useEffect(() => {
|
||||
@@ -462,26 +497,24 @@ export default function FindingsPage() {
|
||||
if (status !== "all" && next !== status) {
|
||||
setFindings((cur) => cur.filter((x) => !isSameFinding(x, f)));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) }));
|
||||
}
|
||||
void refreshGroups();
|
||||
reloadGroupForFinding(f);
|
||||
refreshAfterMutation(f);
|
||||
} catch (e) {
|
||||
setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: prev } : x)));
|
||||
toast.error(`更新失败:${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
[reloadGroupForFinding, refreshGroups, setFindings, status],
|
||||
[refreshAfterMutation, setFindings, status],
|
||||
);
|
||||
|
||||
// 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它,
|
||||
// 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。
|
||||
const [reports, setReports] = React.useState<Record<string, { status: "loading" | "done" | "error"; text: string }>>(
|
||||
{},
|
||||
);
|
||||
const [reports, setReports] = React.useState<Record<string, FindingReport>>({});
|
||||
|
||||
// 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。
|
||||
// 单行展开,故一份缓冲即可。
|
||||
const [edit, setEdit] = React.useState<{ name: string; vulnclass: string; severity: Severity } | null>(null);
|
||||
const [edit, setEdit] = React.useState<FindingEdit | null>(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
// toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。
|
||||
@@ -531,15 +564,14 @@ export default function FindingsPage() {
|
||||
.catch(() => {
|
||||
// The edit remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
void refreshGroups();
|
||||
reloadGroupForFinding(f);
|
||||
refreshAfterMutation(f);
|
||||
} catch (e) {
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[edit, reloadGroupForFinding, refreshGroups, setFindings],
|
||||
[edit, refreshAfterMutation, setFindings],
|
||||
);
|
||||
|
||||
// deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。
|
||||
@@ -555,6 +587,7 @@ export default function FindingsPage() {
|
||||
return next;
|
||||
});
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) }));
|
||||
const rowKey = findingRowKey(f);
|
||||
setExpanded((cur) => (cur === rowKey ? null : cur));
|
||||
toast.success("已删除漏洞");
|
||||
@@ -564,15 +597,19 @@ export default function FindingsPage() {
|
||||
.catch(() => {
|
||||
// The deletion remains valid even if the aggregate refresh fails.
|
||||
});
|
||||
void refreshGroups();
|
||||
reloadGroupForFinding(f, true);
|
||||
refreshAfterMutation(f, true);
|
||||
} catch (e) {
|
||||
toast.error(`删除失败:${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
[reloadGroupForFinding, refreshGroups, setFindings],
|
||||
[refreshAfterMutation, setFindings],
|
||||
);
|
||||
|
||||
const openDeepen = React.useCallback((f: Finding) => {
|
||||
setDeepenFinding(f);
|
||||
setDeepenDescription("");
|
||||
}, []);
|
||||
|
||||
async function submitDeepen() {
|
||||
if (!deepenFinding?.finding_id || !deepenDescription.trim() || deepening) return;
|
||||
setDeepening(true);
|
||||
@@ -583,10 +620,9 @@ export default function FindingsPage() {
|
||||
? `深入意图 #${result.intent_id} 已进入任务队列`
|
||||
: `已创建高优先级 Worker 意图 #${result.intent_id}`,
|
||||
);
|
||||
refreshAfterMutation(deepenFinding);
|
||||
setDeepenFinding(null);
|
||||
setDeepenDescription("");
|
||||
reloadGroupForFinding(deepenFinding);
|
||||
void refreshGroups();
|
||||
} catch (error) {
|
||||
toast.error(`提交失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
@@ -603,12 +639,39 @@ export default function FindingsPage() {
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
];
|
||||
|
||||
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
||||
const filteredTotal = view === "flat" ? flat.total : total;
|
||||
|
||||
const rowProps = {
|
||||
selectedIds,
|
||||
onToggleSelected: toggleSelected,
|
||||
onToggleSelectedPage: toggleSelectedPage,
|
||||
expandedKey: expanded,
|
||||
onToggleRow: toggleRow,
|
||||
reports,
|
||||
edit,
|
||||
onEditChange: setEdit,
|
||||
saving,
|
||||
onSave: saveEdit,
|
||||
onStatusChange: updateStatus,
|
||||
onDeepen: openDeepen,
|
||||
onDelete: deleteFinding,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">发现</h1>
|
||||
<p className="text-muted-foreground text-sm">跨任务漏洞汇总</p>
|
||||
</div>
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="flat">全部发现</TabsTrigger>
|
||||
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{statCards.map((stat) => {
|
||||
@@ -739,6 +802,32 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "flat" ? (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
{flat.loading && !flat.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
total={flat.total}
|
||||
onPageChange={setFlatPage}
|
||||
onPageSizeChange={(nextSize) => {
|
||||
setFlatPageSize(nextSize);
|
||||
setFlatPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group) => {
|
||||
const key = findingGroupKey(group);
|
||||
@@ -751,16 +840,6 @@ export default function FindingsPage() {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
};
|
||||
const selectableIds = state.items
|
||||
.map((finding) => finding.finding_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const selectedCount = selectableIds.filter((id) => selectedIds.has(id)).length;
|
||||
let groupChecked: boolean | "indeterminate" = false;
|
||||
if (selectableIds.length > 0 && selectedCount === selectableIds.length) {
|
||||
groupChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
groupChecked = "indeterminate";
|
||||
}
|
||||
return (
|
||||
<Card key={key} className="gap-0 py-0">
|
||||
<CardHeader className="px-4 py-3">
|
||||
@@ -802,7 +881,9 @@ export default function FindingsPage() {
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{fmtTime(group.last_found_at)}
|
||||
</span>
|
||||
{group.task_id !== null && (
|
||||
<Button size="icon-sm" variant="ghost" asChild>
|
||||
<Link
|
||||
@@ -824,349 +905,7 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
|
||||
换行/内部滚动,不会把整张表撑出横向滚动条。 */}
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={groupChecked}
|
||||
onCheckedChange={(checked) => toggleSelectedPage(selectableIds, checked === true)}
|
||||
aria-label="选择本组当前页全部"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</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>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{state.items.map((f) => {
|
||||
const rowKey = findingRowKey(f);
|
||||
const open = expanded === rowKey;
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleRow(f)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.target !== event.currentTarget ||
|
||||
(event.key !== "Enter" && event.key !== " ")
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
toggleRow(f);
|
||||
}}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{f.finding_id && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(f.finding_id)}
|
||||
onCheckedChange={(c) => toggleSelected(f.finding_id as string, c === true)}
|
||||
aria-label="选择该漏洞"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{f.finding_id ? (
|
||||
<Link
|
||||
href={`/function/findings/detail?id=${f.finding_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate font-medium hover:text-primary hover:underline"
|
||||
title="查看发现详情"
|
||||
>
|
||||
{f.name || f.vulnclass || "未分类"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium">
|
||||
{f.name || 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 position="popper" align="end">
|
||||
{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 ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${f.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(f.ts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{f.finding_id && f.task_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDeepenFinding(f);
|
||||
setDeepenDescription("");
|
||||
}}
|
||||
>
|
||||
<FlaskConicalIcon data-icon="inline-start" />
|
||||
深入
|
||||
</Button>
|
||||
)}
|
||||
{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 className="break-words">
|
||||
「
|
||||
<span className="break-all">
|
||||
{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}
|
||||
</span>
|
||||
」将被永久删除, 同时从发现列表、任务发现 Tab
|
||||
与探索图中移除,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteFinding(f)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
|
||||
被强制单行、直接溢出单元格。 */}
|
||||
<TableCell colSpan={9} className="bg-muted/30 whitespace-normal">
|
||||
<div className="flex flex-col gap-2 px-2 py-1">
|
||||
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
|
||||
{f.finding_id && edit && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">漏洞名称</Label>
|
||||
<Input
|
||||
value={edit.name}
|
||||
onChange={(e) =>
|
||||
setEdit((s) => (s ? { ...s, name: e.target.value } : s))
|
||||
}
|
||||
placeholder="可读标题,留空回退类别"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-[10rem] flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">类别</Label>
|
||||
<Input
|
||||
value={edit.vulnclass}
|
||||
onChange={(e) =>
|
||||
setEdit((s) => (s ? { ...s, vulnclass: e.target.value } : s))
|
||||
}
|
||||
placeholder="如 SQL Injection"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">严重等级</Label>
|
||||
<Select
|
||||
value={edit.severity}
|
||||
onValueChange={(v) =>
|
||||
setEdit((s) => (s ? { ...s, severity: v as Severity } : s))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{statusMeta("severity", sv).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={() => saveEdit(f)}>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldAlertIcon className="size-3.5" />
|
||||
证据
|
||||
{f.vulnclass && (
|
||||
<span>
|
||||
· 类型:
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{f.vulnclass}
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
{f.param_id && (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{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}
|
||||
</pre>
|
||||
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
详细报告
|
||||
</span>
|
||||
{reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && (
|
||||
<CopyButton
|
||||
text={reports[rowKey]?.text}
|
||||
successMessage="已复制详细报告"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const rep = reports[rowKey];
|
||||
if (!rep || rep.status === "loading")
|
||||
return <p className="text-xs text-muted-foreground">加载中…</p>;
|
||||
if (rep.status === "error")
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">报告加载失败。</p>
|
||||
);
|
||||
if (!rep.text.trim())
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">暂无详细报告。</p>
|
||||
);
|
||||
return (
|
||||
// break-words 会继承到段落/列表,pre 另加
|
||||
// whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL
|
||||
// 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。
|
||||
<div className="min-w-0 break-words rounded-md border bg-background px-3 py-2 [&_pre]:whitespace-pre-wrap">
|
||||
<Markdown text={rep.text} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{state.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="py-12 text-center text-sm text-muted-foreground">
|
||||
没有匹配的发现。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<FindingsTable items={state.items} selectAllLabel="选择本组当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
@@ -1198,6 +937,7 @@ export default function FindingsPage() {
|
||||
pageSizeOptions={[5, 10, 20]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
@@ -1264,7 +1004,8 @@ export default function FindingsPage() {
|
||||
<span className="text-xs text-muted-foreground">导出范围</span>
|
||||
<RadioGroup value={exportScope} onValueChange={(v) => setExportScope(v as typeof exportScope)}>
|
||||
<label htmlFor="export-scope-filtered" className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem id="export-scope-filtered" value="filtered" /> 导出当前筛选结果(共 {total} 条)
|
||||
<RadioGroupItem id="export-scope-filtered" value="filtered" /> 导出当前筛选结果(共 {filteredTotal}{" "}
|
||||
条)
|
||||
</label>
|
||||
<label htmlFor="export-scope-all" className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem id="export-scope-all" value="all" /> 导出全部
|
||||
|
||||
Reference in new issue
Block a user