fix(ui): align merged folders with existing columns and omit duplicate headers

This commit is contained in:
ji ruo committed 2026-10-09 13:20:53 +08:00
1 parent ada09f6c1a
commit 012030b41d
2 files changed
+219 -99

No files matched your search

@@ -9,13 +9,15 @@ import {
ChevronRightIcon,
FileTextIcon,
FlaskConicalIcon,
FolderIcon,
FolderOpenIcon,
RotateCcwIcon,
ShieldAlertIcon,
Trash2Icon,
} from "lucide-react";
import { CopyButton } from "@/components/copy-button";
import { FindingCaseFolder } from "@/components/finding-case-list";
import { FindingCaseMembers, FindingSeverityCounts } from "@/components/finding-case-list";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
import {
@@ -92,6 +94,7 @@ const COLUMN_COUNT = 9;
interface FindingsTableProps {
items: Finding[];
caseRows?: FindingCaseListRow[];
hideHeader?: boolean;
matchedIds?: number[];
presentation?: "records" | "task" | "asset";
selectedIds: Set<string>;
@@ -119,6 +122,7 @@ interface FindingsTableProps {
export function FindingsTable({
items,
caseRows,
hideHeader = false,
matchedIds,
presentation = "records",
selectedIds,
@@ -149,67 +153,65 @@ export function FindingsTable({
const rows: FindingCaseListRow[] = caseRows ?? items.map((finding) => ({ finding, matched_ids: [] }));
const shared: Omit<FindingsTableProps, "items" | "caseRows"> = {
presentation,
selectedIds,
onToggleSelected,
onToggleSelectedPage,
expandedKey,
onToggleRow,
reports,
edit,
onEditChange,
saving,
onSave,
onStatusChange,
onRetest,
activeRetests,
onDeepen,
onDelete,
};
return (
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
<Table className="min-w-[60rem] 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-44">资产</TableHead>
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-32">所属任务</TableHead>
<TableHead className="w-24">时间</TableHead>
<TableHead className="w-48">操作</TableHead>
</TableRow>
</TableHeader>
{hideHeader ? (
<colgroup>
<col className="w-8" />
<col className="w-8" />
<col className="w-20" />
<col />
<col className="w-44" />
<col className="w-28" />
<col className="w-32" />
<col className="w-24" />
<col className="w-48" />
</colgroup>
) : null}
{!hideHeader ? (
<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-44">资产</TableHead>
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-32">所属任务</TableHead>
<TableHead className="w-24">时间</TableHead>
<TableHead className="w-48">操作</TableHead>
</TableRow>
</TableHeader>
) : null}
<TableBody>
{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>
);
if (row.case) return <FindingFolderTableRows key={`case:${row.case.id}`} row={row} shared={shared} />;
const f = row.finding;
if (!f) return null;
const rowKey = findingRowKey(f);
@@ -508,3 +510,120 @@ export function FindingsTable({
</Table>
);
}
// Folder summaries occupy the same columns as the current view's ordinary rows.
// Expanded members reuse those rows without a second heading or column header.
function FindingFolderTableRows({
row,
shared,
}: {
row: FindingCaseListRow;
shared: Omit<FindingsTableProps, "items" | "caseRows">;
}) {
const [open, setOpen] = React.useState(false);
const group = row.case;
if (!group) return null;
const taskLabel = row.task_name?.trim() ? row.task_name : row.task_description;
const assets = row.assets ?? [];
const assetCount = row.asset_count ?? assets.length;
return (
<>
<TableRow className={cn(open && "bg-muted/60")}>
<TableCell />
<TableCell>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={`finding-table-members-${group.id}`}
aria-label={`${open ? "收起" : "展开"}${group.title}`}
>
<ChevronRightIcon className={cn("size-4 transition-transform", open && "rotate-90")} />
</Button>
</TableCell>
<TableCell>
<FindingSeverityCounts counts={group} />
</TableCell>
<TableCell>
<div className="flex min-w-0 items-start gap-2">
{open ? (
<FolderOpenIcon className="mt-0.5 size-4 shrink-0 text-primary" />
) : (
<FolderIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
)}
<div className="flex min-w-0 flex-col gap-0.5">
<Link
className="truncate font-medium hover:text-primary hover:underline"
title={group.title}
href={`/function/findings/case?id=${group.id}`}
>
{group.title}
</Link>
<span className="truncate text-muted-foreground text-xs">
{group.count} 条上报 · {group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</span>
</div>
</div>
</TableCell>
<TableCell>
{assets.length ? (
<div className="flex flex-col gap-0.5">
{assets.slice(0, 3).map((a) => (
<code key={a.id} className="max-w-full truncate text-xs" title={`${a.type} · ${a.label}`}>
{a.label}
</code>
))}
{assetCount > 3 ? <span className="text-muted-foreground text-xs">+{assetCount - 3}</span> : null}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<span className="text-muted-foreground" title="请查看成员各自的处置状态">
—
</span>
</TableCell>
<TableCell>
{group.task_id ? (
<Link
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
title={taskLabel}
href={`/function/tasks/detail?id=${group.task_id}`}
>
<span className="truncate">{taskLabel || `任务 #${group.task_id}`}</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-xs tabular-nums">
{row.last_found_at ? fmtTime(row.last_found_at) : "—"}
</TableCell>
<TableCell>
<Button asChild size="sm" variant="ghost">
<Link href={`/function/findings/case?id=${group.id}`}>统一报告</Link>
</Button>
</TableCell>
</TableRow>
{open ? (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="whitespace-normal bg-muted/20 px-4 py-4">
<section id={`finding-table-members-${group.id}`} aria-label={`${group.title}的原始上报`}>
<FindingCaseMembers
caseId={group.id}
version={group.version}
nested
renderRecords={(members) => (
<FindingsTable {...shared} items={members} matchedIds={row.matched_ids} hideHeader />
)}
/>
</section>
</TableCell>
</TableRow>
) : null}
</>
);
}
+43 -42
View File
@@ -463,7 +463,7 @@ export function FindingCaseFolder({
onSelect,
contextTask,
readOnly = false,
presentation = "records",
presentation = "compact",
className,
renderRecords,
}: {
@@ -512,46 +512,48 @@ export function FindingCaseFolder({
漏洞文件夹 · {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>
{presentation !== "compact" ? (
<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>
) : null}
</div>
<div className="flex shrink-0 flex-col gap-1">
<span className="text-muted-foreground text-xs">严 / 高 / 中 / 低</span>
@@ -566,7 +568,6 @@ export function FindingCaseFolder({
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}