feat(findings): 发现页新增平铺大表视图并设为默认

分组视图要逐个任务展开才看得到漏洞,跨任务扫列表时反而绕路。新增
「全部发现」平铺视图作为默认视图,与「按任务分组」通过页头 Tab 切换。

- 表格主体抽成 FindingsTable,两个视图共用同一份行渲染,勾选/行内展开/
  行内改名与改状态/深入/删除的行为完全一致
- 平铺视图走 findingsPage(后端本就支持不带 task_id 的跨任务分页),
  页码与每页条数独立 state,筛选变更即重置回第一页
- 两视图共用统计卡片与筛选条,当前视图随筛选项一起持久化到 localStorage
- 轮询只打当前视图;行内改动同步写两边缓存,切换视图不会看到过期数据
- 首个请求推迟到偏好水合之后,避免按默认视图白拉一次
This commit is contained in:
Autumn-27 committed 2026-08-31 09:59:15 -04:00
1 parent 3ddb0cb808
commit 5fa2ff0b46
3 files changed
+716 -538

No files matched your search

+6
View File
@@ -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>
);
}
File diff suppressed because it is too large. Load diff