fix(ui): restore original controls and limit folders to merged findings

This commit is contained in:
ji ruo committed 2026-10-09 13:12:54 +08:00
1 parent d36812b7e1
commit ada09f6c1a
9 files changed
+750 -508

No files matched your search

+2 -2
View File
@@ -335,7 +335,7 @@ server {
### 同一漏洞归并与统一报告
发现页默认「原始上报」,原始上报、按漏洞、按任务及按资产视图均展示可展开的归并文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。
发现页默认「按漏洞」。原始上报、按任务及按资产保留原来的表格、状态和操作按钮,仅已归并记录改为可展开的漏洞文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。
点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。
@@ -347,7 +347,7 @@ server {
任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。
在漏洞列表原始记录的「更多操作」中点击「复测漏洞」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。
在漏洞列表每行操作区点击「复测」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。
新版后端首次启动会预置可编辑的「漏洞复测」(`retester`)Agent,可在 Agent 管理中配置提示词、LLM、运行预算和工具。默认使用其绑定的 LLM,未绑定则使用全局激活配置。复测会话成功完成且结论为「已修复」时,系统自动将漏洞处置状态改为「已修复」;执行中、失败、停止或其他结论保留原状态。原始证据和报告始终保留。也可在状态下拉菜单中手动选择「已修复」。同一漏洞正在复测时复用已有会话,停止、失败或服务重启后可重新发起。
+6 -2
View File
@@ -559,9 +559,13 @@ func (d *DB) DistinctFindingStatsByTask() (map[string]*FindingDistinctStats, err
return out, rows.Err()
}
// Folder rows and candidate searches never fetch full report/PoC bodies.
func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) {
// Folder rows and candidate searches omit PoC bodies. The original table can
// request evidence for ungrouped rows without fetching report bodies.
func (d *DB) FindingCaseSummary(id int64, originalRow ...bool) (*DBFinding, error) {
cols := strings.Replace(findingSelectCols, "f.evidence,", "''::text,", 1)
if len(originalRow) > 0 && originalRow[0] {
cols = findingSelectCols
}
rows, err := d.Query(`SELECT `+cols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id WHERE f.id=$1`, id)
if err != nil {
return nil, err
+1 -1
View File
@@ -87,7 +87,7 @@ func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) {
v["assets"] = append([]FindingAssetDTO{}, findingFromDB(&db.DBFinding{AssetIDs: c.AssetIDs}, assets).Assets...)
}
if row.FindingID > 0 {
f, err := s.m.pg.FindingCaseSummary(row.FindingID)
f, err := s.m.pg.FindingCaseSummary(row.FindingID, q.Get("original_rows") == "1")
if err != nil {
caseHTTPError(w, err)
return
+41
View File
@@ -352,3 +352,44 @@ func TestFindingCaseListIncludesContextAndFilteredCounts(t *testing.T) {
t.Fatalf("list payload %s", w.Body.String())
}
}
func TestOriginalFindingRowsKeepEvidenceAndOmitReportBody(t *testing.T) {
s, task, ids := testCaseServer(t)
if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, ids[0], "large original report"); err != nil {
t.Fatal(err)
}
for _, original := range []bool{false, true} {
suffix := ""
if original {
suffix = "&original_rows=1"
}
r := httptest.NewRequest("GET", fmt.Sprintf("/api/finding-cases?task_id=%d%s", task, suffix), nil)
w := httptest.NewRecorder()
s.listFindingCases(w, r)
if w.Code != 200 {
t.Fatalf("status %d %s", w.Code, w.Body)
}
var page struct {
Items []struct {
Finding FindingDTO `json:"finding"`
} `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 3 {
t.Fatalf("lost original rows %s", w.Body)
}
for _, row := range page.Items {
if row.Finding.Report != "" {
t.Fatal("list loaded report body")
}
if original && !strings.HasPrefix(row.Finding.Evidence, "proof ") {
t.Fatal("original table evidence missing")
}
if !original && row.Finding.Evidence != "" {
t.Fatal("compact list should omit evidence")
}
}
}
}
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { CopyButton } from "@/components/copy-button";
import { FindingCaseFolder } from "@/components/finding-case-list";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
import {
@@ -37,7 +38,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Spinner } from "@/components/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { statusMeta } from "@/lib/status";
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
import type { ActiveFindingRetest, Finding, FindingCaseListRow, FindingStatus, Severity } from "@/lib/types";
import { cn } from "@/lib/utils";
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
@@ -90,6 +91,9 @@ const COLUMN_COUNT = 9;
interface FindingsTableProps {
items: Finding[];
caseRows?: FindingCaseListRow[];
matchedIds?: number[];
presentation?: "records" | "task" | "asset";
selectedIds: Set<string>;
onToggleSelected: (id: string, checked: boolean) => void;
onToggleSelectedPage: (ids: string[], checked: boolean) => void;
@@ -114,6 +118,9 @@ interface FindingsTableProps {
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
export function FindingsTable({
items,
caseRows,
matchedIds,
presentation = "records",
selectedIds,
onToggleSelected,
onToggleSelectedPage,
@@ -140,6 +147,8 @@ export function FindingsTable({
headerChecked = "indeterminate";
}
const rows: FindingCaseListRow[] = caseRows ?? items.map((finding) => ({ finding, matched_ids: [] }));
return (
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
<Table className="min-w-[60rem] table-fixed">
@@ -163,14 +172,56 @@ export function FindingsTable({
</TableRow>
</TableHeader>
<TableBody>
{items.map((f) => {
{rows.map((row) => {
if (row.case)
return (
<TableRow key={`case:${row.case.id}`}>
<TableCell colSpan={COLUMN_COUNT} className="p-0 whitespace-normal">
<FindingCaseFolder
row={row}
presentation={presentation}
selectedIds={selectedIds}
onSelect={onToggleSelected}
className="rounded-none border-0"
renderRecords={(members) => (
<FindingsTable
items={members}
matchedIds={row.matched_ids}
presentation={presentation}
selectedIds={selectedIds}
onToggleSelected={onToggleSelected}
onToggleSelectedPage={onToggleSelectedPage}
expandedKey={expandedKey}
onToggleRow={onToggleRow}
reports={reports}
edit={edit}
onEditChange={onEditChange}
saving={saving}
onSave={onSave}
onStatusChange={onStatusChange}
onRetest={onRetest}
activeRetests={activeRetests}
onDeepen={onDeepen}
onDelete={onDelete}
selectAllLabel="选择文件夹当前页记录"
/>
)}
/>
</TableCell>
</TableRow>
);
const f = row.finding;
if (!f) return null;
const rowKey = findingRowKey(f);
const open = expandedKey === rowKey;
const retest = f.finding_id ? activeRetests[f.finding_id] : undefined;
return (
<React.Fragment key={rowKey}>
<TableRow
className="cursor-pointer"
className={cn(
"cursor-pointer",
matchedIds && !matchedIds.includes(Number(f.finding_id)) && "bg-muted/30",
)}
role="button"
tabIndex={0}
aria-expanded={open}
@@ -213,6 +264,11 @@ export function FindingsTable({
<span className="truncate font-medium">{f.name || f.vulnclass || "未分类"}</span>
)}
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
{matchedIds && !matchedIds.includes(Number(f.finding_id)) ? (
<Badge variant="outline" className="w-fit">
未命中当前筛选
</Badge>
) : null}
<Badge variant="outline">流量证据 {f.traffic_count ?? 0} 条</Badge>
</div>
</TableCell>
@@ -441,7 +497,7 @@ export function FindingsTable({
</React.Fragment>
);
})}
{items.length === 0 && (
{(caseRows ? caseRows.length : items.length) === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="py-12 text-center text-sm text-muted-foreground">
没有匹配的发现。
+467 -89
View File
@@ -17,7 +17,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { FindingCaseList, FindingCaseReviewPanel } from "@/components/finding-case-list";
import { FindingCaseList } from "@/components/finding-case-list";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
@@ -47,6 +47,7 @@ import type {
ActiveFindingRetest,
Finding,
FindingAssetNode,
FindingCaseListRow,
FindingGroup,
FindingStats,
FindingStatus,
@@ -55,11 +56,22 @@ import type {
import { cn } from "@/lib/utils";
import { AssetTree, assetPathOf } from "./_components/asset-tree";
import { FINDING_STATUSES, fmtTime, SEVERITIES, UNASSIGNED_TASK } from "./_components/findings-table";
import {
FINDING_STATUSES,
type FindingEdit,
type FindingReport,
FindingsTable,
findingRowKey,
fmtTime,
isSameFinding,
SEVERITIES,
UNASSIGNED_TASK,
} from "./_components/findings-table";
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
// 所有视图共用漏洞文件夹;原始上报展示更多来源信息,任务和资产视图增加外层导航。
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
// asset = 左侧资产树 + 右侧该子树下的发现。
type FindingView = "cases" | "flat" | "grouped" | "asset";
const FINDING_VIEWS: FindingView[] = ["cases", "flat", "grouped", "asset"];
@@ -84,6 +96,28 @@ const EMPTY_ASSET_TREE: AssetTreeState = {
loading: false,
};
// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。
interface GroupFindingsState {
caseRows: FindingCaseListRow[];
items: Finding[];
total: number;
page: number;
pageSize: number;
loaded: boolean;
loading: boolean;
}
// 平铺视图的页码单独放 state(而非塞进快照),筛选一变就能连带重置并触发重新加载。
interface FlatFindingsState {
caseRows: FindingCaseListRow[];
items: Finding[];
total: number;
loaded: boolean;
loading: boolean;
}
const EMPTY_FLAT_STATE: FlatFindingsState = { caseRows: [], items: [], total: 0, loaded: false, loading: false };
function findingGroupKey(group: FindingGroup) {
return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id);
}
@@ -100,7 +134,7 @@ const EMPTY_STATS: FindingStats = {
};
export default function FindingsPage() {
const [view, setView] = React.useState<FindingView>("flat");
const [view, setView] = React.useState<FindingView>("cases");
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
@@ -108,12 +142,16 @@ export default function FindingsPage() {
const [sort, setSort] = React.useState<"severity" | "time">("severity");
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 [assetTree, setAssetTree] = React.useState<AssetTreeState>(EMPTY_ASSET_TREE);
const [assetScope, setAssetScope] = React.useState<string | null>(null);
const [groups, setGroups] = React.useState<FindingGroup[]>([]);
const [groupTotal, setGroupTotal] = React.useState(0);
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(() => new Set());
const [listRefresh, setListRefresh] = React.useState(0);
const [groupFindings, setGroupFindings] = React.useState<Record<string, GroupFindingsState>>({});
const [total, setTotal] = React.useState(0);
const [stats, setStats] = React.useState<FindingStats>(EMPTY_STATS);
const [statsLoaded, setStatsLoaded] = React.useState(false);
@@ -201,6 +239,28 @@ export default function FindingsPage() {
return () => window.clearTimeout(timer);
}, [search]);
// setFindings 同时改写两个视图缓存里的同一条发现,切换视图不会看到过期状态。
const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => {
function applyUpdate<T extends { items: Finding[]; caseRows: FindingCaseListRow[] }>(current: T): T {
const items = update(current.items);
const byId = new Map(items.map((f) => [findingRowKey(f), f]));
const caseRows = current.caseRows.flatMap((row) => {
if (row.case) return [row];
const finding = row.finding ? byId.get(findingRowKey(row.finding)) : undefined;
return finding ? [{ ...row, finding }] : [];
});
return { ...current, items, caseRows };
}
setFlat(applyUpdate);
setGroupFindings((current) => {
const next: Record<string, GroupFindingsState> = {};
for (const [key, state] of Object.entries(current)) {
next[key] = applyUpdate(state);
}
return next;
});
}, []);
// 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
// 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。
@@ -220,6 +280,17 @@ export default function FindingsPage() {
});
}, []);
const toggleSelectedPage = React.useCallback((ids: string[], checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of ids) {
if (checked) next.add(id);
else next.delete(id);
}
return next;
});
}, []);
// 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。
function openExport() {
setExportScope(selectedIds.size > 0 ? "selected" : "filtered");
@@ -253,8 +324,57 @@ export default function FindingsPage() {
}
}
const flatRequest = React.useRef(0);
const assetTreeRequest = 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));
// 资产视图右侧列表 = 平铺列表 + 选中子树的筛选,所以两个视图共用一份列表状态。
const activeAssetScope = view === "asset" ? assetScope : null;
// 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.findingCases(
{
page: flatPage,
pageSize: flatPageSize,
severity,
status,
vulnclass,
task,
query,
sort,
assetScope: activeAssetScope ?? undefined,
},
true,
);
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
setFlat({
caseRows: result.items,
items: result.items.flatMap((row) => (row.finding ? [row.finding] : [])),
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 }));
}
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
// loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链),
// 所以这里不带 assetScope。
@@ -305,57 +425,184 @@ export default function FindingsPage() {
}
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
const toggleGroup = React.useCallback((key: string) => {
setExpandedGroups((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const refreshAfterMutation = React.useCallback(
(_finding?: Finding, _removed = false) => {
setListRefresh((v) => v + 1);
if (view === "asset") void loadAssetTree();
if (view === "grouped") void refreshGroups();
void api
.findingStats()
.then(setStats)
.catch(() => {
/* Keep aggregate snapshot until the next poll. */
});
const loadGroup = React.useCallback(
async (key: string, groupPage: number, groupPageSize: number) => {
const request = (groupRequests.current[key] ?? 0) + 1;
const requestFilter = filterFingerprint;
if (activeFilterFingerprint.current !== requestFilter) return;
groupRequests.current[key] = request;
setGroupFindings((current) => ({
...current,
[key]: {
caseRows: current[key]?.caseRows ?? [],
items: current[key]?.items ?? [],
total: current[key]?.total ?? 0,
page: groupPage,
pageSize: groupPageSize,
loaded: current[key]?.loaded ?? false,
loading: true,
},
}));
try {
const result = await api.findingCases(
{
page: groupPage,
pageSize: groupPageSize,
severity,
status,
vulnclass,
task: key,
query,
sort,
},
true,
);
if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return;
setGroupFindings((current) => ({
...current,
[key]: {
caseRows: result.items,
items: result.items.flatMap((row) => (row.finding ? [row.finding] : [])),
total: result.total,
page: result.page,
pageSize: result.page_size,
loaded: true,
loading: false,
},
}));
} catch {
if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return;
setGroupFindings((current) => ({
...current,
[key]: {
...(current[key] ?? {
caseRows: [],
items: [],
total: 0,
page: groupPage,
pageSize: groupPageSize,
loaded: false,
}),
loading: false,
},
}));
}
},
[loadAssetTree, refreshGroups, view],
[filterFingerprint, severity, status, vulnclass, query, sort],
);
React.useEffect(() => {
for (const [key, state] of Object.entries(groupFindings)) {
if (!state.loaded || state.loading) continue;
const lastPage = Math.max(1, Math.ceil(state.total / state.pageSize));
if (state.page > lastPage) void loadGroup(key, lastPage, state.pageSize);
}
}, [groupFindings, loadGroup]);
const toggleGroup = React.useCallback(
(key: string) => {
const opening = !expandedGroups.has(key);
const next = new Set(expandedGroups);
if (opening) next.add(key);
else next.delete(key);
setExpandedGroups(next);
const state = groupFindings[key];
if (opening && !state?.loaded && !state?.loading) {
void loadGroup(key, state?.page ?? 1, state?.pageSize ?? 10);
}
},
[expandedGroups, groupFindings, loadGroup],
);
// 行内改动后刷新当前视图:平铺视图重拉当前页,分组视图刷组头 + 该发现所在的组。
const refreshAfterMutation = React.useCallback(
(finding: Finding, removed = false) => {
if (view === "asset") {
// 资产视图不轮询,所以改完要顺带把树的计数也重新算一次。
void loadFlat();
void loadAssetTree();
return;
}
if (view === "flat") {
// 删空最后一页时,页码由越界修正 effect 回退并连带重新加载。
void loadFlat();
return;
}
void refreshGroups();
const key = finding.task_id ?? UNASSIGNED_TASK;
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);
}
},
[loadAssetTree, loadFlat, loadGroup, refreshGroups, view],
);
// 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);
// 筛选变了树也会变,原先选中的节点可能已经不在树里,退回「全部资产」。
setAssetScope(null);
setAssetTree(EMPTY_ASSET_TREE);
}, [filterFingerprint]);
// 换资产节点等于换了一份结果集,回到第一页。
React.useEffect(() => {
void assetScope;
setFlatPage(1);
}, [assetScope]);
// 资产树只在进入视图 / 筛选变化时查一次(以及本页改动发现后由
// refreshAfterMutation 主动重拉),不做轮询。
React.useEffect(() => {
if (!preferencesHydrated || view !== "asset") return;
void activeRetestFingerprint;
void activeRetestFingerprint; // 复测结束可能改变状态筛选下的资产计数。
void loadAssetTree();
}, [activeRetestFingerprint, loadAssetTree, preferencesHydrated, view]);
// 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。
// 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。
// 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。
React.useEffect(() => {
if (!preferencesHydrated || view !== "grouped") return;
void activeRetestFingerprint;
void refreshGroups();
const timer = setInterval(() => void refreshGroups(), 5000);
if (!preferencesHydrated) return;
void activeRetestFingerprint; // 包括不定时轮询的资产视图,也在复测结束后刷新处置状态。
const refresh = () => {
if (view === "flat" || view === "asset") {
if (!flatStateRef.current.loading) void loadFlat();
return;
}
void refreshGroups();
for (const key of expandedGroupsRef.current) {
if (!visibleGroupKeysRef.current.has(key)) continue;
const state = groupFindingsRef.current[key];
if (state?.loaded && !state.loading) void loadGroup(key, state.page, state.pageSize);
}
};
refresh();
if (view === "asset") return;
const timer = setInterval(refresh, 5000);
return () => clearInterval(timer);
}, [activeRetestFingerprint, preferencesHydrated, refreshGroups, view]);
}, [activeRetestFingerprint, 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(() => {
@@ -393,30 +640,118 @@ export default function FindingsPage() {
}
}, [stats, statsLoaded, task, vulnclass]);
// updateStatus optimistically flips one finding's triage state, reverting on error.
const updateStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
if (!f.finding_id || f.inherited || next === f.status) return;
if (!f.finding_id || next === f.status) return;
const prev = f.status;
setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: next } : x)));
try {
await api.setFindingStatus(f.finding_id, next);
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
// refresh stat cards (pending count) and drop the row if it no longer matches the status filter
api
.findingStats()
.then(setStats)
.catch(() => {
// The row update remains valid even if the aggregate refresh fails.
});
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) }));
}
refreshAfterMutation(f);
} catch (e) {
setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: prev } : x)));
toast.error(`更新失败:${(e as Error).message}`);
}
},
[refreshAfterMutation],
[refreshAfterMutation, setFindings, status],
);
// 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它,
// 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。
const [reports, setReports] = React.useState<Record<string, FindingReport>>({});
// 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。
// 单行展开,故一份缓冲即可。
const [edit, setEdit] = React.useState<FindingEdit | null>(null);
const [saving, setSaving] = React.useState(false);
// toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。
const toggleRow = React.useCallback(
(f: Finding) => {
const key = findingRowKey(f);
const willOpen = expanded !== key;
setExpanded(willOpen ? key : null);
if (!willOpen) {
setEdit(null);
return;
}
setEdit({ name: f.name ?? "", vulnclass: f.vulnclass, severity: f.severity });
if (!f.finding_id || reports[key]) return;
const fid = f.finding_id;
setReports((r) => ({ ...r, [key]: { status: "loading", text: "" } }));
api
.getFinding(fid)
.then((full) => setReports((r) => ({ ...r, [key]: { status: "done", text: full.report ?? "" } })))
.catch(() => setReports((r) => ({ ...r, [key]: { status: "error", text: "" } })));
},
[expanded, reports],
);
// saveEdit 保存当前展开行的名称/类别/严重等级,回写本地列表并刷新统计(类别下拉/严重计数可能变)。
const saveEdit = React.useCallback(
async (f: Finding) => {
if (!f.finding_id || !edit) return;
setSaving(true);
try {
const updated = await api.updateFinding(f.finding_id, {
name: edit.name.trim(),
vulnclass: edit.vulnclass.trim(),
severity: edit.severity,
});
setFindings((cur) =>
cur.map((x) =>
isSameFinding(x, f)
? { ...x, name: updated.name, vulnclass: updated.vulnclass, severity: updated.severity }
: x,
),
);
toast.success("已保存");
api
.findingStats()
.then(setStats)
.catch(() => {
// The edit remains valid even if the aggregate refresh fails.
});
refreshAfterMutation(f);
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
} finally {
setSaving(false);
}
},
[edit, refreshAfterMutation, setFindings],
);
// 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) => !isSameFinding(x, f)));
setSelectedIds((current) => {
const next = new Set(current);
next.delete(f.finding_id as string);
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("已删除漏洞");
api
.findingStats()
@@ -429,7 +764,7 @@ export default function FindingsPage() {
toast.error(`删除失败:${(e as Error).message}`);
}
},
[refreshAfterMutation],
[refreshAfterMutation, setFindings],
);
const openDeepen = React.useCallback((f: Finding) => {
@@ -460,59 +795,78 @@ export default function FindingsPage() {
const statCards = [
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重上报", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危上报", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危上报", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危上报", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
];
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
const [caseTotal, setCaseTotal] = React.useState(0);
const [caseReportTotal, setCaseReportTotal] = React.useState(0);
const filteredTotal = view === "grouped" ? total : caseReportTotal;
const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total;
const assetPath = React.useMemo(
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
[assetScope, assetTree.nodes, view],
);
const recordActions = {
onUpdated: refreshAfterMutation,
const rowProps = {
selectedIds,
onToggleSelected: toggleSelected,
onToggleSelectedPage: toggleSelectedPage,
expandedKey: expanded,
onToggleRow: toggleRow,
reports,
edit,
onEditChange: setEdit,
saving,
onSave: saveEdit,
onStatusChange: updateStatus,
onRetest: setRetestFinding,
activeRetests,
onDeepen: openDeepen,
onDelete: deleteFinding,
activeRetests,
};
const sharedListProps = {
selectedIds,
onSelect: toggleSelected,
actions: recordActions,
refreshVersion: listRefresh,
showReview: false,
};
// 平铺视图与资产视图右侧是同一张表 + 同一份分页,只是筛选条件不同。
const flatListCard = (
<FindingCaseList
key={`${view}:${assetScope ?? "all"}`}
{...sharedListProps}
presentation={view === "asset" ? "asset" : "records"}
query={{
severity,
status,
vulnclass,
task,
query,
sort,
assetScope: view === "asset" ? (assetScope ?? undefined) : undefined,
}}
onTotal={setCaseTotal}
onReportTotal={setCaseReportTotal}
/>
<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}
caseRows={flat.caseRows}
presentation={view === "asset" ? "asset" : "records"}
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>
);
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="font-semibold text-xl tracking-tight">发现</h1>
<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)}>
@@ -646,7 +1000,7 @@ export default function FindingsPage() {
<div className="ml-auto flex items-center gap-3">
{selectedIds.size > 0 && (
<span className="text-muted-foreground text-xs tabular-nums">已选 {selectedIds.size} 条</span>
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
)}
<Button
size="sm"
@@ -672,8 +1026,6 @@ export default function FindingsPage() {
</div>
</div>
<FindingCaseReviewPanel taskId={task === "all" ? undefined : task} onChange={() => refreshAfterMutation()} />
{view === "flat" && flatListCard}
{view === "asset" && (
@@ -699,7 +1051,7 @@ export default function FindingsPage() {
</CardContent>
</Card>
<div className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1 text-muted-foreground text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-1 text-sm text-muted-foreground">
<button
type="button"
className={cn("hover:text-foreground", assetScope === null && "font-medium text-foreground")}
@@ -723,9 +1075,7 @@ export default function FindingsPage() {
</button>
</React.Fragment>
))}
<span className="ml-auto shrink-0 text-xs tabular-nums">
{caseTotal} 个漏洞 · {caseReportTotal} 条上报
</span>
<span className="ml-auto shrink-0 text-xs tabular-nums">共 {flat.total} 条</span>
</div>
{flatListCard}
</div>
@@ -734,10 +1084,10 @@ export default function FindingsPage() {
{view === "cases" && (
<FindingCaseList
{...sharedListProps}
onTotal={setCaseTotal}
onReportTotal={setCaseReportTotal}
query={{ severity, status, vulnclass, task, query, sort }}
selectedIds={selectedIds}
onSelect={toggleSelected}
/>
)}
{view === "grouped" && (
@@ -745,6 +1095,15 @@ export default function FindingsPage() {
{groups.map((group) => {
const key = findingGroupKey(group);
const groupOpen = expandedGroups.has(key);
const state = groupFindings[key] ?? {
caseRows: [],
items: [],
total: group.count,
page: 1,
pageSize: 10,
loaded: false,
loading: false,
};
return (
<Card key={key} className="gap-0 py-0">
<CardHeader className="px-4 py-3">
@@ -765,7 +1124,9 @@ export default function FindingsPage() {
<CardTitle className="truncate text-sm">
{group.task_id === null
? "未关联 / 任务已删除"
: `${group.task_name || "任务"}(#${group.task_id})`}
: group.task_name
? `${group.task_name}(任务 #${group.task_id})`
: `任务 #${group.task_id}`}
</CardTitle>
<CardDescription className="truncate" title={group.task_description}>
{group.task_description || "来源任务不可用"}
@@ -780,11 +1141,11 @@ export default function FindingsPage() {
return (
<span key={level} className="inline-flex items-center gap-1">
<StatusBadge domain="severity" value={level} dot />
<span className="text-muted-foreground text-xs tabular-nums">{count}</span>
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</span>
);
})}
<span className="text-muted-foreground text-xs tabular-nums">
<span className="text-xs tabular-nums text-muted-foreground">
{fmtTime(group.last_found_at)}
</span>
{group.task_id !== null && (
@@ -801,12 +1162,29 @@ export default function FindingsPage() {
</div>
</CardHeader>
{groupOpen && (
<CardContent className="px-4 pb-4">
<FindingCaseList
{...sharedListProps}
presentation="task"
query={{ severity, status, vulnclass, task: key, query, sort }}
/>
<CardContent className="px-0">
{state.loading && !state.loaded ? (
<div className="flex min-h-36 items-center justify-center">
<Spinner />
</div>
) : (
<>
<FindingsTable
items={state.items}
caseRows={state.caseRows}
presentation="task"
selectAllLabel="选择本组当前页全部"
{...rowProps}
/>
<TablePagination
page={state.page}
pageSize={state.pageSize}
total={state.total}
onPageChange={(nextPage) => void loadGroup(key, nextPage, state.pageSize)}
onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)}
/>
</>
)}
</CardContent>
)}
</Card>
@@ -814,7 +1192,7 @@ export default function FindingsPage() {
})}
{groups.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground text-sm">没有匹配的发现。</CardContent>
<CardContent className="py-12 text-center text-sm text-muted-foreground">没有匹配的发现。</CardContent>
</Card>
)}
<TablePagination
@@ -914,7 +1292,7 @@ export default function FindingsPage() {
<div className="flex flex-col gap-5 py-1">
<div className="flex flex-col gap-2">
<span className="text-muted-foreground text-xs">导出范围</span>
<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" /> 导出当前筛选结果(共 {filteredTotal}{" "}
@@ -934,7 +1312,7 @@ export default function FindingsPage() {
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground text-xs">导出格式</span>
<span className="text-xs text-muted-foreground">导出格式</span>
<div className="flex items-center gap-2">
<Checkbox
id="include-original-reports"
@@ -72,7 +72,7 @@ function Row({
</Badge>
)}
</div>
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
</div>
</button>
<Badge variant="outline">流量证据 {f.traffic_count ?? 0} 条</Badge>
@@ -87,7 +87,7 @@ function Row({
{a.label}
</code>
))}
{f.assets.length > 2 && <span className="text-muted-foreground text-xs">+{f.assets.length - 2}</span>}
{f.assets.length > 2 && <span className="text-xs text-muted-foreground">+{f.assets.length - 2}</span>}
</div>
)}
{f.finding_id && !f.inherited ? (
@@ -108,7 +108,7 @@ function Row({
) : (
<StatusBadge domain="finding" value={f.status} dot />
)}
<span className="hidden shrink-0 text-muted-foreground text-xs md:block">
<span className="hidden shrink-0 text-xs text-muted-foreground md:block">
{new Date(f.ts).toLocaleString("zh-CN")}
</span>
{f.finding_id && (
@@ -118,7 +118,7 @@ function Row({
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
: `/function/findings/detail?id=${f.finding_id}`
}
className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground text-xs hover:text-primary"
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
title="查看漏洞详情"
>
详情
@@ -128,8 +128,8 @@ function Row({
</div>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 font-medium text-muted-foreground text-xs">证据 / PoC</div>
<pre className="overflow-auto whitespace-pre-wrap rounded-md border bg-background p-3 font-mono text-xs">
<div className="mb-1 text-xs font-medium text-muted-foreground">证据 / PoC</div>
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
</div>
@@ -190,11 +190,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
return (
<div className="flex flex-col gap-4">
<FindingCaseList
query={{ task: taskId, sort: "time" }}
presentation="task"
actions={{ onStatusChange: onStatus }}
/>
<FindingCaseList query={{ task: taskId, sort: "time" }} />
{[
...new Set(
items
@@ -204,15 +200,15 @@ export function FindingsTab({ taskId }: { taskId: string }) {
),
].map((source) => (
<div key={source}>
<p className="mb-2 text-muted-foreground text-sm">继承任务 #{source} · 只读</p>
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} presentation="task" readOnly />
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
</div>
))}
<details>
<summary className="cursor-pointer text-muted-foreground text-sm">查看原始上报列表</summary>
<summary className="cursor-pointer text-sm text-muted-foreground">查看原始上报列表</summary>
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-muted-foreground text-xs">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<button
type="button"
@@ -237,7 +233,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-muted-foreground text-sm">本任务及直接关联任务暂无确认发现。</p>
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
)}
</CardContent>
</Card>
+159 -393
View File
@@ -4,58 +4,27 @@ import * as React from "react";
import Link from "next/link";
import { ChevronRightIcon, FolderIcon, FolderOpenIcon, MoreHorizontalIcon } from "lucide-react";
import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import { statusMeta } from "@/lib/status";
import type {
ActiveFindingRetest,
Finding,
FindingAsset,
FindingCase,
FindingCaseListRow,
FindingCasePage,
FindingCaseReviewRun,
FindingCaseSuggestion,
FindingQuery,
FindingStatus,
Severity,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -82,205 +51,6 @@ export function FindingSeverityCounts({
);
}
export interface FindingRecordActions {
onUpdated?: () => void;
onStatusChange?: (finding: Finding, status: FindingStatus) => void;
onRetest?: (finding: Finding) => void;
onDeepen?: (finding: Finding) => void;
onDelete?: (finding: Finding) => void;
activeRetests?: Record<string, ActiveFindingRetest>;
}
export type FindingListPresentation = "compact" | "records" | "task" | "asset";
function FindingContext({
taskId,
taskLabel,
assets = [],
assetCount = assets.length,
ts,
showTask = true,
}: {
taskId?: string | null;
taskLabel?: string;
assets?: FindingAsset[];
assetCount?: number;
ts?: string;
showTask?: boolean;
}) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
{showTask && taskId ? (
<Link
className="max-w-full truncate hover:text-primary hover:underline"
title={taskLabel}
href={`/function/tasks/detail?id=${taskId}`}
>
所属任务 #{taskId}
{taskLabel ? ` · ${taskLabel}` : ""}
</Link>
) : null}
{showTask && !taskId ? <span>未关联任务 / 任务已删除</span> : null}
{assets.length ? (
<span className="inline-flex min-w-0 flex-wrap items-center gap-1">
资产:
{assets.slice(0, 4).map((a) => (
<code key={a.id} className="max-w-48 truncate rounded bg-muted px-1" title={`${a.type} · ${a.label}`}>
{a.label}
</code>
))}
{assetCount > 4 ? <span>+{assetCount - 4}</span> : null}
</span>
) : (
<span>未关联资产</span>
)}
{ts ? (
<time dateTime={ts} title={new Date(ts).toLocaleString("zh-CN")}>
最近上报{" "}
{new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</time>
) : null}
</div>
);
}
function FindingRecordMenu({ finding: f, actions }: { finding: Finding; actions: FindingRecordActions }) {
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [name, setName] = React.useState("");
const [vulnclass, setVulnclass] = React.useState("");
const [severity, setSeverity] = React.useState<Severity>(f.severity);
async function saveEdit() {
if (saving) return;
setSaving(true);
try {
await api.updateFinding(f.finding_id ?? f.id, { name: name.trim(), vulnclass: vulnclass.trim(), severity });
toast.success("已保存原始上报");
setEditing(false);
actions.onUpdated?.();
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
} finally {
setSaving(false);
}
}
const active = actions.activeRetests?.[f.finding_id ?? f.id];
return (
<>
{active ? (
<Button asChild variant="ghost" size="sm">
<Link href={`/chat?c=${active.conversation_id}`}>
<Spinner />
复测中
</Link>
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`上报 #${f.finding_id ?? f.id}的更多操作`}>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/function/findings/detail?id=${f.finding_id ?? f.id}`}>查看详情</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setName(f.name ?? "");
setVulnclass(f.vulnclass);
setSeverity(f.severity);
setEditing(true);
}}
>
编辑原始上报
</DropdownMenuItem>
{actions.onRetest ? (
<DropdownMenuItem disabled={!!active} onSelect={() => actions.onRetest?.(f)}>
复测漏洞
</DropdownMenuItem>
) : null}
{actions.onDeepen && f.task_id ? (
<DropdownMenuItem onSelect={() => actions.onDeepen?.(f)}>深入利用</DropdownMenuItem>
) : null}
{actions.onDelete ? (
<DropdownMenuItem variant="destructive" onSelect={() => setConfirmDelete(true)}>
删除原始上报
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Dialog
open={editing}
onOpenChange={(open) => {
if (!saving) setEditing(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑原始上报 #{f.finding_id ?? f.id}</DialogTitle>
<DialogDescription>修改这条记录的名称、类型和等级;所属文件夹的统一报告将标记待更新。</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label htmlFor={`finding-name-${f.finding_id ?? f.id}`}>漏洞名称</Label>
<Input
id={`finding-name-${f.finding_id ?? f.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={saving}
/>
<Label htmlFor={`finding-type-${f.finding_id ?? f.id}`}>漏洞类型</Label>
<Input
id={`finding-type-${f.finding_id ?? f.id}`}
value={vulnclass}
onChange={(e) => setVulnclass(e.target.value)}
disabled={saving}
/>
<Label>原始等级</Label>
<Select value={severity} onValueChange={(v) => setSeverity(v as Severity)} disabled={saving}>
<SelectTrigger aria-label="原始上报等级">
<StatusBadge domain="severity" value={severity} />
</SelectTrigger>
<SelectContent>
{(["critical", "high", "medium", "low"] as Severity[]).map((level) => (
<SelectItem key={level} value={level}>
{statusMeta("severity", level).label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(false)} disabled={saving}>
取消
</Button>
<Button onClick={saveEdit} disabled={saving}>
{saving ? <Spinner /> : null}保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除这条原始上报?</AlertDialogTitle>
<AlertDialogDescription>报告、证据绑定和文件夹成员关系会一同移除,此操作不可撤销。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => actions.onDelete?.(f)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export function FindingCaseMemberRow({
finding: f,
selected,
@@ -288,8 +58,6 @@ export function FindingCaseMemberRow({
contextTask,
matched = true,
nested = false,
presentation = "records",
actions,
}: {
finding: Finding;
selected?: boolean;
@@ -297,8 +65,6 @@ export function FindingCaseMemberRow({
contextTask?: string;
matched?: boolean;
nested?: boolean;
presentation?: FindingListPresentation;
actions?: FindingRecordActions;
}) {
const id = f.finding_id ?? f.id;
return (
@@ -323,45 +89,10 @@ export function FindingCaseMemberRow({
#{id} {[f.name, f.vulnclass].find((v) => v?.trim()) ?? "未分类"}
</Link>
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
<FindingContext
taskId={f.task_id}
taskLabel={f.task_description}
assets={f.assets}
ts={presentation === "compact" ? undefined : f.ts}
showTask={presentation !== "task" || !!f.inherited}
/>
</div>
{!matched ? <Badge variant="outline">未命中当前筛选</Badge> : null}
{f.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
{actions?.onStatusChange && !f.inherited ? (
<Select value={f.status} onValueChange={(v) => actions.onStatusChange?.(f, v as FindingStatus)}>
<SelectTrigger size="sm" className="w-28" aria-label={`上报 #${id}的状态`}>
<StatusBadge domain="finding" value={f.status} />
</SelectTrigger>
<SelectContent>
{(
[
"pending",
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
"risk_accepted",
] as FindingStatus[]
).map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge domain="finding" value={f.status} />
)}
{actions && !f.inherited ? <FindingRecordMenu finding={f} actions={actions} /> : null}
<StatusBadge domain="finding" value={f.status} />
<Button asChild variant="ghost" size="sm">
<Link href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}>
查看报告
@@ -379,8 +110,7 @@ export function FindingCaseMembers({
onSelect,
contextTask,
nested = false,
presentation = "records",
actions,
renderRecords,
}: {
caseId: string;
version?: number;
@@ -389,8 +119,7 @@ export function FindingCaseMembers({
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
nested?: boolean;
presentation?: FindingListPresentation;
actions?: FindingRecordActions;
renderRecords?: (items: Finding[]) => React.ReactNode;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
@@ -431,19 +160,19 @@ export function FindingCaseMembers({
);
return (
<div className={cn("flex min-w-0 flex-col", nested && "gap-2 border-primary/25 border-l-2 pl-4 sm:pl-5")}>
{data.items.map((f) => (
<FindingCaseMemberRow
key={f.finding_id}
finding={f}
nested={nested}
presentation={presentation}
actions={actions}
selected={selectedIds?.has(f.finding_id ?? f.id)}
onSelect={onSelect}
contextTask={contextTask}
matched={!matchedIds || matchedIds.includes(Number(f.finding_id))}
/>
))}
{renderRecords
? renderRecords(data.items)
: data.items.map((f) => (
<FindingCaseMemberRow
key={f.finding_id}
finding={f}
nested={nested}
selected={selectedIds?.has(f.finding_id ?? f.id)}
onSelect={onSelect}
contextTask={contextTask}
matched={!matchedIds || matchedIds.includes(Number(f.finding_id))}
/>
))}
{data.total > 20 ? (
<TablePagination
page={page}
@@ -612,11 +341,6 @@ export function FindingCaseList({
contextTask,
readOnly = false,
onTotal,
onReportTotal,
presentation = "compact",
showReview = true,
actions,
refreshVersion,
}: {
query: Omit<FindingQuery, "page" | "pageSize">;
selectedIds?: Set<string>;
@@ -624,23 +348,15 @@ export function FindingCaseList({
contextTask?: string;
readOnly?: boolean;
onTotal?: (total: number) => void;
onReportTotal?: (total: number) => void;
presentation?: FindingListPresentation;
showReview?: boolean;
actions?: FindingRecordActions;
refreshVersion?: number;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingCasePage | null>(null);
const [error, setError] = React.useState("");
const [open, setOpen] = React.useState<Set<string>>(() => new Set());
const [refresh, setRefresh] = React.useState(0);
const key = JSON.stringify(query);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when the filter fingerprint changes.
React.useEffect(() => {
setPage(1);
setOpen(new Set());
setData(null);
}, [key]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh is an explicit reload requested after grouping/retry.
React.useEffect(() => {
@@ -664,28 +380,13 @@ export function FindingCaseList({
active = false;
clearTimeout(timer);
};
}, [key, page, refresh, refreshVersion]);
}, [key, page, refresh]);
React.useEffect(() => {
if (data) {
onTotal?.(data.total);
onReportTotal?.(data.matching_reports ?? data.stats.reports);
}
}, [data, onTotal, onReportTotal]);
React.useEffect(() => {
if (!data) return;
const lastPage = Math.max(1, Math.ceil(data.total / 20));
if (page > lastPage) setPage(lastPage);
}, [data, page]);
const toggle = (id: string) =>
setOpen((v) => {
const next = new Set(v);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
if (data) onTotal?.(data.total);
}, [data, onTotal]);
return (
<div className="flex min-w-0 flex-col gap-3">
{!readOnly && showReview ? (
{!readOnly ? (
<FindingCaseReviewPanel
taskId={query.task === "all" ? undefined : query.task}
onChange={() => setRefresh((v) => v + 1)}
@@ -708,81 +409,20 @@ export function FindingCaseList({
) : null}
{data ? (
<p className="text-muted-foreground text-xs">
{data.total} 个漏洞(归并后) · {data.matching_reports ?? data.stats.reports} 条命中上报
{data.stats.total} 个独立漏洞 · {data.stats.reports} 条上报
</p>
) : null}
{data?.items.map((row) => {
if (row.case) {
const group = row.case;
return (
<Card key={`case:${group.id}`} className="gap-0 overflow-hidden py-0">
<CardHeader className={cn("px-4 py-3", open.has(group.id) && "bg-muted/60")}>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${open.has(group.id) ? "收起" : "展开"}${group.title}`}
aria-expanded={open.has(group.id)}
aria-controls={`finding-case-members-${group.id}`}
onClick={() => toggle(group.id)}
>
<ChevronRightIcon className={cn(open.has(group.id) && "rotate-90")} />
</Button>
{open.has(group.id) ? (
<FolderOpenIcon className="size-5 shrink-0 text-primary" />
) : (
<FolderIcon className="size-5 shrink-0 text-muted-foreground" />
)}
<div className="flex min-w-0 flex-1 basis-2/3 flex-col gap-1 sm:basis-auto">
<CardTitle className="truncate text-sm">
<Link
className="hover:underline"
href={`/function/findings/case?id=${group.id}${contextTask ? `&context_task=${contextTask}` : ""}`}
>
{group.title}
</Link>
</CardTitle>
<CardDescription>
漏洞文件夹 · {group.count} 条原始上报 ·{" "}
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</CardDescription>
<FindingContext
taskId={group.task_id}
taskLabel={row.task_name || row.task_description}
assets={row.assets ?? []}
assetCount={row.asset_count}
ts={presentation === "compact" ? undefined : row.last_found_at}
showTask={presentation !== "task"}
/>
</div>
<div className="flex shrink-0 flex-col gap-1">
<span className="text-muted-foreground text-xs">严 / 高 / 中 / 低</span>
<FindingSeverityCounts counts={group} />
</div>
</div>
</CardHeader>
{open.has(group.id) ? (
<CardContent
id={`finding-case-members-${group.id}`}
role="region"
aria-label={`${group.title}的原始子报告`}
className="border-t bg-muted/20 px-4 py-4 sm:px-6"
>
<p className="mb-3 text-muted-foreground text-xs">原始子报告 · 各自等级和证据保留</p>
<FindingCaseMembers
nested
presentation={presentation}
actions={readOnly ? undefined : actions}
caseId={group.id}
version={group.version}
matchedIds={row.matched_ids}
selectedIds={selectedIds}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
/>
</CardContent>
) : null}
</Card>
<FindingCaseFolder
key={`case:${row.case.id}`}
row={row}
selectedIds={selectedIds}
onSelect={onSelect}
contextTask={contextTask}
readOnly={readOnly}
/>
);
}
if (row.finding)
@@ -791,8 +431,6 @@ export function FindingCaseList({
<CardContent className="px-0">
<FindingCaseMemberRow
finding={row.finding}
presentation={presentation}
actions={readOnly ? undefined : actions}
selected={selectedIds?.has(row.finding.finding_id ?? row.finding.id)}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
@@ -816,3 +454,131 @@ export function FindingCaseList({
</div>
);
}
// Only consolidated findings use this folder surface. Ordinary findings keep
// the original table rows and controls in each surrounding view.
export function FindingCaseFolder({
row,
selectedIds,
onSelect,
contextTask,
readOnly = false,
presentation = "records",
className,
renderRecords,
}: {
row: FindingCaseListRow;
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
readOnly?: boolean;
presentation?: "compact" | "records" | "task" | "asset";
className?: string;
renderRecords?: (items: Finding[]) => React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const group = row.case;
const taskLabel = row.task_name?.trim() ? row.task_name : row.task_description;
if (!group) return null;
return (
<Card className={cn("gap-0 overflow-hidden py-0", className)}>
<CardHeader className={cn("px-4 py-3", open && "bg-muted/60")}>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${open ? "收起" : "展开"}${group.title}`}
aria-expanded={open}
aria-controls={`finding-case-members-${group.id}`}
onClick={() => setOpen((v) => !v)}
>
<ChevronRightIcon className={cn(open && "rotate-90")} />
</Button>
{open ? (
<FolderOpenIcon className="size-5 shrink-0 text-primary" />
) : (
<FolderIcon className="size-5 shrink-0 text-muted-foreground" />
)}
<div className="flex min-w-0 flex-1 basis-2/3 flex-col gap-1 sm:basis-auto">
<CardTitle className="truncate text-sm">
<Link
className="hover:underline"
href={`/function/findings/case?id=${group.id}${contextTask ? `&context_task=${contextTask}` : ""}`}
>
{group.title}
</Link>
</CardTitle>
<CardDescription>
漏洞文件夹 · {group.count} 条原始上报 ·{" "}
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</CardDescription>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
{presentation !== "task" && group.task_id ? (
<Link
className="max-w-full truncate hover:underline"
href={`/function/tasks/detail?id=${group.task_id}`}
title={row.task_description}
>
所属任务 #{group.task_id}
{taskLabel ? ` · ${taskLabel}` : ""}
</Link>
) : null}
{(row.assets ?? []).length ? (
<span className="inline-flex min-w-0 flex-wrap items-center gap-1">
资产:
{row.assets?.map((a) => (
<code
key={a.id}
className="max-w-48 truncate rounded bg-muted px-1"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{(row.asset_count ?? 0) > 4 ? <span>+{(row.asset_count ?? 0) - 4}</span> : null}
</span>
) : (
<span>未关联资产</span>
)}
{row.last_found_at ? (
<time dateTime={row.last_found_at}>
最近上报{" "}
{new Date(row.last_found_at).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</time>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col gap-1">
<span className="text-muted-foreground text-xs">严 / 高 / 中 / 低</span>
<FindingSeverityCounts counts={group} />
</div>
</div>
</CardHeader>
{open ? (
<CardContent
id={`finding-case-members-${group.id}`}
role="region"
aria-label={`${group.title}的原始子报告`}
className="border-t bg-muted/20 px-4 py-4 sm:px-6"
>
<p className="mb-3 text-muted-foreground text-xs">原始子报告 · 各自等级和证据保留</p>
<FindingCaseMembers
nested
renderRecords={renderRecords}
caseId={group.id}
version={group.version}
matchedIds={row.matched_ids}
selectedIds={selectedIds}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
/>
</CardContent>
) : null}
</Card>
);
}
+2 -1
View File
@@ -569,10 +569,11 @@ export const api = {
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
findingCases: (q: FindingQuery) => {
findingCases: (q: FindingQuery, originalRows = false) => {
const p = findingFilterParams(q);
p.set("page", String(q.page));
p.set("limit", String(q.pageSize));
if (originalRows) p.set("original_rows", "1");
return get<FindingCasePage>(`/exploration/finding-cases?${p}`);
},
getFindingCase: (id: string, contextTask?: string) =>