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:
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 ? (
|
||||
|
||||
@@ -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>("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}` : ""}`,
|
||||
|
||||
@@ -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 "?";
|
||||
|
||||
|
||||
Reference in new issue
Block a user