feat(findings): 发现页支持导出(Markdown 单文件/分文件zip、CSV、JSON,按筛选/全部/勾选)

- 后端新增 GET /exploration/findings/export:按 scope(filtered|all|selected) ×
  format(md-single|md-zip|csv|json) 导出,md-zip 用 archive/zip 后端打包
- db.ListFindingsForExport:带完整 report、不分页,按严重等级+时间排序
- report/findings.go:汇总/单条 Markdown、安全文件名、带 BOM 的 CSV 渲染
- 前端发现页加行勾选 + 导出弹窗(范围×格式);复用 lib/utils.copyText
- 顺带修复报告复制在 HTTP 非安全上下文失效(execCommand 降级)+ 复用 CopyButton

Claude-Session: https://claude.ai/code/session_01Bs2rrsVHUBTxRusm9espwB
This commit is contained in:
Autumn-27 committed 2026-08-20 12:06:46 -04:00
1 parent ed96f256a4
commit 43f40dd74f
9 files changed
+658 -37

No files matched your search

@@ -20,6 +20,7 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { StatusBadge } from "@/components/status-badge";
import { CopyButton } from "@/components/copy-button";
import { Markdown } from "@/components/markdown";
import { FindingLineageView } from "./lineage";
import { statusMeta } from "@/lib/status";
@@ -208,8 +209,11 @@ function FindingDetailInner() {
</Card>
{/* 证据下方:详细报告(Markdown 渲染) */}
<Card>
<CardHeader>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">详细报告</CardTitle>
{finding.report && (
<CopyButton text={finding.report} successMessage="已复制详细报告" />
)}
</CardHeader>
<CardContent>
{finding.report ? (
+196 -8
View File
@@ -8,10 +8,22 @@ import {
ArrowUpRightIcon,
FileTextIcon,
Trash2Icon,
DownloadIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { CopyButton } from "@/components/copy-button";
import { Markdown } from "@/components/markdown";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
AlertDialog,
AlertDialogAction,
@@ -101,6 +113,73 @@ export default function FindingsPage() {
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
// 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
// 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。
const [exportOpen, setExportOpen] = React.useState(false);
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
const [exporting, setExporting] = React.useState(false);
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}, []);
// 当前页可勾选的 finding_id(排除无 finding_id 的继承行)。
const pageSelectableIds = React.useMemo(
() => findings.map((f) => f.finding_id).filter((id): id is string => !!id),
[findings],
);
const pageSelectedCount = pageSelectableIds.filter((id) => selectedIds.has(id)).length;
let headerChecked: boolean | "indeterminate" = false;
if (pageSelectableIds.length > 0 && pageSelectedCount === pageSelectableIds.length) {
headerChecked = true;
} else if (pageSelectedCount > 0) {
headerChecked = "indeterminate";
}
const toggleSelectedPage = React.useCallback(
(checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of pageSelectableIds) {
if (checked) next.add(id);
else next.delete(id);
}
return next;
});
},
[pageSelectableIds],
);
// 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。
function openExport() {
setExportScope(selectedIds.size > 0 ? "selected" : "filtered");
setExportOpen(true);
}
async function doExport() {
setExporting(true);
try {
await api.exportFindings({
format: exportFormat,
scope: exportScope,
filters: { severity, status, vulnclass, task, sort },
ids: [...selectedIds],
});
setExportOpen(false);
toast.success("已开始下载导出文件");
} catch (e) {
toast.error("导出失败:" + (e as Error).message);
} finally {
setExporting(false);
}
}
// reset to page 1 whenever filters change
React.useEffect(() => {
setPage(1);
@@ -386,9 +465,19 @@ export default function FindingsPage() {
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
共 {total} 条
</span>
<div className="ml-auto flex items-center gap-3">
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">
已选 {selectedIds.size} 条
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
共 {total} 条
</span>
<Button size="sm" variant="outline" onClick={openExport}>
<DownloadIcon /> 导出
</Button>
</div>
</div>
<Card className="py-0">
@@ -398,6 +487,13 @@ export default function FindingsPage() {
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-8">
<Checkbox
checked={headerChecked}
onCheckedChange={(c) => toggleSelectedPage(c === true)}
aria-label="选择本页全部"
/>
</TableHead>
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead>漏洞名称</TableHead>
@@ -417,6 +513,17 @@ export default function FindingsPage() {
className="cursor-pointer"
onClick={() => toggleRow(f)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
{f.finding_id && (
<Checkbox
checked={selectedIds.has(f.finding_id)}
onCheckedChange={(c) =>
toggleSelected(f.finding_id as string, c === true)
}
aria-label="选择该漏洞"
/>
)}
</TableCell>
<TableCell>
<ChevronRightIcon
className={cn(
@@ -555,7 +662,7 @@ export default function FindingsPage() {
<TableRow className="hover:bg-transparent">
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
被强制单行、直接溢出单元格。 */}
<TableCell colSpan={8} className="bg-muted/30 whitespace-normal">
<TableCell colSpan={9} className="bg-muted/30 whitespace-normal">
<div className="flex flex-col gap-2 px-2 py-1">
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
{f.finding_id && edit && (
@@ -643,9 +750,20 @@ export default function FindingsPage() {
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
{f.finding_id && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileTextIcon className="size-3.5" />
详细报告
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<FileTextIcon className="size-3.5" />
详细报告
</span>
{reports[f.id]?.status === "done" &&
reports[f.id]?.text.trim() && (
<CopyButton
text={reports[f.id]?.text}
successMessage="已复制详细报告"
variant="ghost"
className="h-6 px-2 text-xs"
/>
)}
</div>
{(() => {
const rep = reports[f.id];
@@ -686,7 +804,7 @@ export default function FindingsPage() {
{findings.length === 0 && (
<TableRow>
<TableCell
colSpan={8}
colSpan={9}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
@@ -705,6 +823,76 @@ export default function FindingsPage() {
</CardContent>
</Card>
</div>
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>导出发现</DialogTitle>
<DialogDescription>
选择导出范围与格式,生成后浏览器会自动下载。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5 py-1">
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">导出范围</Label>
<RadioGroup
value={exportScope}
onValueChange={(v) => setExportScope(v as typeof exportScope)}
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="filtered" /> 导出当前筛选结果(共 {total} 条)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="all" /> 导出全部
</label>
<label
className={cn(
"flex items-center gap-2 text-sm",
selectedIds.size === 0 && "text-muted-foreground",
)}
>
<RadioGroupItem value="selected" disabled={selectedIds.size === 0} />
导出勾选的 {selectedIds.size} 条
</label>
</RadioGroup>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">导出格式</Label>
<RadioGroup
value={exportFormat}
onValueChange={(v) => setExportFormat(v as typeof exportFormat)}
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="md-single" /> Markdown 汇总报告(单个 .md 文件)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="md-zip" /> Markdown 分文件(一漏洞一 .md,打包 .zip)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="csv" /> CSV 表格(.csv)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="json" /> JSON(.json)
</label>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExportOpen(false)} disabled={exporting}>
取消
</Button>
<Button
onClick={doExport}
disabled={exporting || (exportScope === "selected" && selectedIds.size === 0)}
>
<DownloadIcon /> {exporting ? "导出中…" : "导出"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -9,34 +9,7 @@ import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
async function copyText(text: string): Promise<boolean> {
// 优先使用异步剪贴板 API(仅在 HTTPS / localhost 等安全上下文可用)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 继续走降级方案
}
}
// 降级:HTTP 等非安全上下文下用 execCommand
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
import { copyText } from "@/lib/utils";
export function ReportTab({ taskId }: { taskId: string }) {
const [report, setReport] = React.useState<string>("");
+67
View File
@@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn, copyText } from "@/lib/utils";
type CopyButtonProps = {
// 要复制的文本;为空则按钮禁用。
text: string | null | undefined;
// 复制成功后的 toast 文案,默认「已复制」。
successMessage?: string;
label?: React.ReactNode;
size?: React.ComponentProps<typeof Button>["size"];
variant?: React.ComponentProps<typeof Button>["variant"];
className?: string;
};
// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文
// 下自动降级(见 copyText)。
export function CopyButton({
text,
successMessage = "已复制",
label = "复制",
size = "sm",
variant = "outline",
className,
}: CopyButtonProps) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
async function handleCopy() {
if (!text) return;
const ok = await copyText(text);
if (ok) {
setCopied(true);
toast.success(successMessage);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
} else {
toast.error("复制失败,请手动选择文本复制");
}
}
return (
<Button
type="button"
size={size}
variant={variant}
className={cn(className)}
disabled={!text}
onClick={handleCopy}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{label}
</Button>
);
}
+38
View File
@@ -360,6 +360,44 @@ export const api = {
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
},
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
exportFindings: async (opts: {
format: "md-single" | "md-zip" | "csv" | "json";
scope: "filtered" | "all" | "selected";
filters?: Omit<FindingQuery, "page" | "pageSize">;
ids?: string[];
}) => {
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
if (opts.scope === "selected") {
p.set("ids", (opts.ids ?? []).join(","));
} else if (opts.scope === "filtered" && opts.filters) {
const f = opts.filters;
if (f.severity && f.severity !== "all") p.set("severity", f.severity);
if (f.status && f.status !== "all") p.set("status", f.status);
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
if (f.task && f.task !== "all") p.set("task_id", f.task);
if (f.sort) p.set("sort", f.sort);
}
const token = getToken();
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!r.ok) throw new Error(`export: ${r.status}`);
const blob = await r.blob();
// 文件名优先取后端 Content-Disposition,取不到则用默认名。
const disp = r.headers.get("Content-Disposition") ?? "";
const m = disp.match(/filename="?([^"]+)"?/);
const filename = m?.[1] ?? `findings-export`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
},
getFinding: (id: string, contextTaskId?: string) =>
get<Finding>(
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
+29
View File
@@ -45,6 +45,35 @@ export function radixOverlayWasOpenAtPointerDown(): boolean {
return overlayOpenAtLastPointerDown;
}
// copyText 把文本写入剪贴板,返回是否成功。
// 背景:navigator.clipboard 仅在安全上下文(HTTPS / localhost)可用;通过 IP + HTTP
// 访问时它为 undefined,此时降级到 execCommand("copy")。
export async function copyText(text: string): Promise<boolean> {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 继续走降级方案
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
export const getInitials = (str: string): string => {
if (typeof str !== "string" || !str.trim()) return "?";