From 5fa2ff0b468de9277e9cc9c2e8b79166ecd5dd11 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:59:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(findings):=20=E5=8F=91=E7=8E=B0=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B9=B3=E9=93=BA=E5=A4=A7=E8=A1=A8=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=B9=B6=E8=AE=BE=E4=B8=BA=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组视图要逐个任务展开才看得到漏洞,跨任务扫列表时反而绕路。新增 「全部发现」平铺视图作为默认视图,与「按任务分组」通过页头 Tab 切换。 - 表格主体抽成 FindingsTable,两个视图共用同一份行渲染,勾选/行内展开/ 行内改名与改状态/深入/删除的行为完全一致 - 平铺视图走 findingsPage(后端本就支持不带 task_id 的跨任务分页), 页码与每页条数独立 state,筛选变更即重置回第一页 - 两视图共用统计卡片与筛选条,当前视图随筛选项一起持久化到 localStorage - 轮询只打当前视图;行内改动同步写两边缓存,切换视图不会看到过期数据 - 首个请求推迟到偏好水合之后,避免按默认视图白拉一次 --- CHANGELOG.md | 6 + .../findings/_components/findings-table.tsx | 431 +++++++++ web/src/app/(main)/function/findings/page.tsx | 817 ++++++------------ 3 files changed, 716 insertions(+), 538 deletions(-) create mode 100644 web/src/app/(main)/function/findings/_components/findings-table.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec30dc..27f22d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/web/src/app/(main)/function/findings/_components/findings-table.tsx b/web/src/app/(main)/function/findings/_components/findings-table.tsx new file mode 100644 index 0000000..570448b --- /dev/null +++ b/web/src/app/(main)/function/findings/_components/findings-table.tsx @@ -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; + onToggleSelected: (id: string, checked: boolean) => void; + onToggleSelectedPage: (ids: string[], checked: boolean) => void; + /** 当前展开行的 findingRowKey;null = 全部收起。 */ + expandedKey: string | null; + onToggleRow: (finding: Finding) => void; + reports: Record; + edit: FindingEdit | null; + onEditChange: React.Dispatch>; + 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 单元格再宽也只能在固定宽度内 + 换行/内部滚动,不会把整张表撑出横向滚动条。 */ + + + + + onToggleSelectedPage(selectableIds, checked === true)} + aria-label={selectAllLabel} + /> + + + 严重度 + 漏洞名称 + 资产 + 状态 + 所属任务 + 时间 + 操作 + + + + {items.map((f) => { + const rowKey = findingRowKey(f); + const open = expandedKey === rowKey; + return ( + + onToggleRow(f)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return; + event.preventDefault(); + onToggleRow(f); + }} + > + e.stopPropagation()}> + {f.finding_id && ( + onToggleSelected(f.finding_id as string, c === true)} + aria-label="选择该漏洞" + /> + )} + + + + + + + + +
+ {f.finding_id ? ( + e.stopPropagation()} + className="truncate font-medium hover:text-primary hover:underline" + title="查看发现详情" + > + {f.name || f.vulnclass || "未分类"} + + ) : ( + {f.name || f.vulnclass || "未分类"} + )} + {f.summary} +
+
+ + {f.assets && f.assets.length > 0 ? ( +
+ {f.assets.slice(0, 3).map((a) => ( + + {a.label} + + ))} + {f.assets.length > 3 && ( + +{f.assets.length - 3} + )} +
+ ) : ( + — + )} +
+ e.stopPropagation()}> + {f.finding_id ? ( + + ) : ( + + )} + + + {f.task_id ? ( + e.stopPropagation()} + className="inline-flex max-w-full items-center gap-1 text-primary hover:underline" + title={f.task_description} + > + {f.task_description} + + + ) : ( + — + )} + + {fmtTime(f.ts)} + e.stopPropagation()}> +
+ {f.finding_id && f.task_id && ( + + )} + {f.finding_id && ( + + + + + + + 确认删除该漏洞? + + 「 + + {f.name || f.vulnclass || f.summary || `#${f.finding_id}`} + + 」将被永久删除, 同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。 + + + + 取消 + onDelete(f)}>删除 + + + + )} +
+
+
+ {open && ( + + {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 + 被强制单行、直接溢出单元格。 */} + +
+ {/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} + {f.finding_id && edit && ( +
+
+ + onEditChange((s) => (s ? { ...s, name: e.target.value } : s))} + placeholder="可读标题,留空回退类别" + /> +
+
+ + onEditChange((s) => (s ? { ...s, vulnclass: e.target.value } : s))} + placeholder="如 SQL Injection" + /> +
+
+ + +
+ +
+ )} +
+ + 证据 + {f.vulnclass && ( + + · 类型: + {f.vulnclass} + + )} + {f.param_id && {f.param_id}} + {f.assets && f.assets.length > 0 && ( + + · 资产: + {f.assets.map((a) => ( + + {a.label} + + ))} + + )} +
+
+                        {f.evidence}
+                      
+ + {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} + {f.finding_id && ( +
+
+ + + 详细报告 + + {reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && ( + + )} +
+ {(() => { + const rep = reports[rowKey]; + if (!rep || rep.status === "loading") + return

加载中…

; + if (rep.status === "error") + return

报告加载失败。

; + if (!rep.text.trim()) + return

暂无详细报告。

; + return ( + // break-words 会继承到段落/列表,pre 另加 + // whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL + // 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。 +
+ +
+ ); + })()} +
+ )} +
+
+
+ )} +
+ ); + })} + {items.length === 0 && ( + + + 没有匹配的发现。 + + + )} +
+
+ ); +} diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 5ad51ca..1cb9f64 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -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("flat"); const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); @@ -132,6 +107,9 @@ export default function FindingsPage() { const [search, setSearch] = React.useState(""); const [query, setQuery] = React.useState(""); const [expanded, setExpanded] = React.useState(null); + const [flat, setFlat] = React.useState(EMPTY_FLAT_STATE); + const [flatPage, setFlatPage] = React.useState(1); + const [flatPageSize, setFlatPageSize] = React.useState(20); const [groups, setGroups] = React.useState([]); const [groupTotal, setGroupTotal] = React.useState(0); const [expandedGroups, setExpandedGroups] = React.useState>(() => 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 = {}; 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>({}); const groupsRequest = React.useRef(0); + const flatStateRef = React.useRef(flat); const expandedGroupsRef = React.useRef(expandedGroups); const groupFindingsRef = React.useRef(groupFindings); const visibleGroupKeysRef = React.useRef>(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>( - {}, - ); + const [reports, setReports] = React.useState>({}); // 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。 // 单行展开,故一份缓冲即可。 - const [edit, setEdit] = React.useState<{ name: string; vulnclass: string; severity: Severity } | null>(null); + const [edit, setEdit] = React.useState(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,11 +639,38 @@ 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 (
-
-

发现

-

跨任务漏洞汇总

+
+
+

发现

+

跨任务漏洞汇总

+
+ setView(v as FindingView)}> + + 全部发现 + 按任务分组 + +
@@ -739,465 +802,142 @@ export default function FindingsPage() {
-
- {groups.map((group) => { - const key = findingGroupKey(group); - const groupOpen = expandedGroups.has(key); - const state = groupFindings[key] ?? { - items: [], - total: group.count, - page: 1, - pageSize: 10, - 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 ( - - -
- -
- {group.task_status && } - {SEVERITIES.map((level) => { - const count = group[level]; - if (count === 0) return null; - return ( - - - {count} - - ); - })} - {fmtTime(group.last_found_at)} - {group.task_id !== null && ( - - )} -
-
-
- {groupOpen && ( - - {state.loading && !state.loaded ? ( -
- -
- ) : ( - <> - {/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内 - 换行/内部滚动,不会把整张表撑出横向滚动条。 */} - - - - - toggleSelectedPage(selectableIds, checked === true)} - aria-label="选择本组当前页全部" - /> - - - 严重度 - 漏洞名称 - 资产 - 状态 - 所属任务 - 时间 - 操作 - - - - {state.items.map((f) => { - const rowKey = findingRowKey(f); - const open = expanded === rowKey; - return ( - - toggleRow(f)} - onKeyDown={(event) => { - if ( - event.target !== event.currentTarget || - (event.key !== "Enter" && event.key !== " ") - ) - return; - event.preventDefault(); - toggleRow(f); - }} - > - e.stopPropagation()}> - {f.finding_id && ( - toggleSelected(f.finding_id as string, c === true)} - aria-label="选择该漏洞" - /> - )} - - - - - - - - -
- {f.finding_id ? ( - e.stopPropagation()} - className="truncate font-medium hover:text-primary hover:underline" - title="查看发现详情" - > - {f.name || f.vulnclass || "未分类"} - - ) : ( - - {f.name || f.vulnclass || "未分类"} - - )} - {f.summary} -
-
- - {f.assets && f.assets.length > 0 ? ( -
- {f.assets.slice(0, 3).map((a) => ( - - {a.label} - - ))} - {f.assets.length > 3 && ( - - +{f.assets.length - 3} - - )} -
- ) : ( - — - )} -
- e.stopPropagation()}> - {f.finding_id ? ( - - ) : ( - - )} - - - {f.task_id ? ( - e.stopPropagation()} - className="inline-flex max-w-full items-center gap-1 text-primary hover:underline" - title={f.task_description} - > - {f.task_description} - - - ) : ( - — - )} - - - {fmtTime(f.ts)} - - e.stopPropagation()}> -
- {f.finding_id && f.task_id && ( - - )} - {f.finding_id && ( - - - - - - - 确认删除该漏洞? - - 「 - - {f.name || f.vulnclass || f.summary || `#${f.finding_id}`} - - 」将被永久删除, 同时从发现列表、任务发现 Tab - 与探索图中移除,此操作不可撤销。 - - - - 取消 - deleteFinding(f)}> - 删除 - - - - - )} -
-
-
- {open && ( - - {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 - 被强制单行、直接溢出单元格。 */} - -
- {/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} - {f.finding_id && edit && ( -
-
- - - setEdit((s) => (s ? { ...s, name: e.target.value } : s)) - } - placeholder="可读标题,留空回退类别" - /> -
-
- - - setEdit((s) => (s ? { ...s, vulnclass: e.target.value } : s)) - } - placeholder="如 SQL Injection" - /> -
-
- - -
- -
- )} -
- - 证据 - {f.vulnclass && ( - - · 类型: - - {f.vulnclass} - - - )} - {f.param_id && ( - - {f.param_id} - - )} - {f.assets && f.assets.length > 0 && ( - - · 资产: - {f.assets.map((a) => ( - - {a.label} - - ))} - - )} -
-
-                                            {f.evidence}
-                                          
- - {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} - {f.finding_id && ( -
-
- - - 详细报告 - - {reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && ( - - )} -
- {(() => { - const rep = reports[rowKey]; - if (!rep || rep.status === "loading") - return

加载中…

; - if (rep.status === "error") - return ( -

报告加载失败。

- ); - if (!rep.text.trim()) - return ( -

暂无详细报告。

- ); - return ( - // break-words 会继承到段落/列表,pre 另加 - // whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL - // 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。 -
- -
- ); - })()} -
- )} -
-
-
- )} -
- ); - })} - {state.items.length === 0 && ( - - - 没有匹配的发现。 - - - )} -
-
- void loadGroup(key, nextPage, state.pageSize)} - onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)} + {view === "flat" ? ( + + + {flat.loading && !flat.loaded ? ( +
+ +
+ ) : ( + <> + + { + setFlatPageSize(nextSize); + setFlatPage(1); + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + + )} +
+
+ ) : ( +
+ {groups.map((group) => { + const key = findingGroupKey(group); + const groupOpen = expandedGroups.has(key); + const state = groupFindings[key] ?? { + items: [], + total: group.count, + page: 1, + pageSize: 10, + loaded: false, + loading: false, + }; + return ( + + +
+ +
+ {group.task_status && } + {SEVERITIES.map((level) => { + const count = group[level]; + if (count === 0) return null; + return ( + + + {count} + + ); + })} + + {fmtTime(group.last_found_at)} + + {group.task_id !== null && ( + + )} +
+
+
+ {groupOpen && ( + + {state.loading && !state.loaded ? ( +
+ +
+ ) : ( + <> + + void loadGroup(key, nextPage, state.pageSize)} + onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)} + /> + + )} +
+ )} +
+ ); + })} + {groups.length === 0 && ( + + 没有匹配的发现。 - ); - })} - {groups.length === 0 && ( - - 没有匹配的发现。 - - )} - { - setPageSize(nextPageSize); - setPage(1); - }} - pageSizeOptions={[5, 10, 20]} - /> -
+ )} + { + setPageSize(nextPageSize); + setPage(1); + }} + pageSizeOptions={[5, 10, 20]} + /> +
+ )}
导出范围 setExportScope(v as typeof exportScope)}>