mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
fix(ui): align merged folders with existing columns and omit duplicate headers
This commit is contained in:
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user