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

+51
View File
@@ -210,6 +210,57 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
return out, total, err
}
// ListFindingsForExport returns findings for the 发现 page 导出功能,携带完整
// report 字段、不分页。ids 非空时按这批 finding id 精确导出(勾选导出),忽略
// filter;ids 为空时按 filter 导出(导出当前筛选/全部)。结果按严重等级降序、
// 再按时间倒序,与「导出汇总报告」的分组顺序一致。
func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding, error) {
const order = `ORDER BY CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC`
cols := findingSelectCols + `, COALESCE(f.report, '')`
var q string
var args []any
if len(ids) > 0 {
ph := make([]string, len(ids))
for i, id := range ids {
ph[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
q = `SELECT ` + cols + `
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.id IN (` + strings.Join(ph, ",") + `)
` + order
} else {
where, wargs := f.where()
q = `SELECT ` + cols + `
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id` + where + `
` + order
args = wargs
}
rows, err := d.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*DBFinding
for rows.Next() {
f := &DBFinding{}
var aidsJSON string
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
&f.TaskDescription, &f.Report); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
out = append(out, f)
}
return out, rows.Err()
}
// FindingStats is the whole-table aggregate powering the 发现 page's stat cards
// and vuln-class filter — computed server-side so it stays exact regardless of
// pagination.
+165
View File
@@ -0,0 +1,165 @@
package report
import (
"bytes"
"encoding/csv"
"fmt"
"path"
"regexp"
"sort"
"strings"
"time"
"github.com/Autumn-27/artex/db"
)
// 发现页「导出」用的渲染:把一批 findings 表行渲染成汇总 Markdown、单条 Markdown、
// 或 CSV。JSON 由 server 层直接用 DTO 序列化,不在此处。
// sortFindingsForExport 按严重等级降序、再按时间倒序排,与汇总报告的分组一致。
func sortFindingsForExport(fs []*db.DBFinding) {
sort.SliceStable(fs, func(i, j int) bool {
ri, rj := sevRank[fs[i].Severity], sevRank[fs[j].Severity]
if ri != rj {
return ri < rj // sevRank 越小越严重
}
return fs[i].CreatedAt.After(fs[j].CreatedAt)
})
}
// findingTitle 取漏洞可读标题:名称 → 类别 → 「未分类」。
func findingTitle(f *db.DBFinding) string {
return nz(f.Name, nz(f.VulnClass, "未分类"))
}
// FindingsMarkdown 把一批 findings 整合成一份汇总报告(摘要 + 按严重等级分组,
// 每条含类别/状态/所属任务/证据/详细报告)。
func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
items := append([]*db.DBFinding(nil), fs...)
sortFindingsForExport(items)
var b strings.Builder
b.WriteString("# 漏洞发现汇总报告\n\n")
fmt.Fprintf(&b, "- **生成时间**:%s\n", generatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(&b, "- **发现总数**:%d 个\n\n", len(items))
// 摘要:各严重等级计数。
counts := map[string]int{}
for _, f := range items {
counts[f.Severity]++
}
b.WriteString("## 摘要\n\n")
b.WriteString("| 严重等级 | 数量 |\n| --- | --- |\n")
for _, s := range []struct{ key, label string }{
{"critical", "严重"}, {"high", "高危"}, {"medium", "中危"}, {"low", "低危"},
} {
fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key])
}
b.WriteString("\n")
if len(items) == 0 {
b.WriteString("_无匹配的漏洞。_\n")
return b.String()
}
b.WriteString("## 漏洞明细\n\n")
for i, f := range items {
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
if f.VulnClass != "" {
fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass)
}
fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending"))
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
fmt.Fprintf(&b, "- **所属任务**:%s\n", desc)
}
fmt.Fprintf(&b, "- **发现时间**:%s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
if s := strings.TrimSpace(f.Summary); s != "" {
fmt.Fprintf(&b, "%s\n\n", s)
}
if e := strings.TrimSpace(f.Evidence); e != "" {
fmt.Fprintf(&b, "**证据:**\n\n```\n%s\n```\n\n", e)
}
if rep := strings.TrimSpace(f.Report); rep != "" {
b.WriteString("**详细报告:**\n\n")
b.WriteString(rep)
b.WriteString("\n\n")
}
b.WriteString("---\n\n")
}
return b.String()
}
// SingleFindingMarkdown 渲染单条漏洞为一份独立 Markdown(用于「一漏洞一文件」打包)。
func SingleFindingMarkdown(f *db.DBFinding, generatedAt time.Time) string {
var b strings.Builder
fmt.Fprintf(&b, "# [%s] %s\n\n", strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
if f.VulnClass != "" {
fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass)
}
fmt.Fprintf(&b, "- **严重等级**:%s\n", nz(f.Severity, "info"))
fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending"))
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
fmt.Fprintf(&b, "- **所属任务**:%s\n", desc)
}
fmt.Fprintf(&b, "- **发现时间**:%s\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(&b, "- **生成时间**:%s\n\n", generatedAt.Format("2006-01-02 15:04:05"))
if s := strings.TrimSpace(f.Summary); s != "" {
fmt.Fprintf(&b, "## 概述\n\n%s\n\n", s)
}
if e := strings.TrimSpace(f.Evidence); e != "" {
fmt.Fprintf(&b, "## 证据\n\n```\n%s\n```\n\n", e)
}
if rep := strings.TrimSpace(f.Report); rep != "" {
b.WriteString("## 详细报告\n\n")
b.WriteString(rep)
b.WriteString("\n")
}
return b.String()
}
var unsafeFilenameChars = regexp.MustCompile(`[^\p{Han}\p{L}\p{N}._-]+`)
// FindingFilename 为「一漏洞一文件」生成安全的 .md 文件名,形如
// `critical_SQL注入_#123.md`。去掉路径分隔符与控制字符,避免 zip 内非法路径。
func FindingFilename(f *db.DBFinding) string {
sev := nz(f.Severity, "info")
title := findingTitle(f)
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
name = unsafeFilenameChars.ReplaceAllString(name, "_")
name = strings.Trim(name, "._")
if name == "" {
name = fmt.Sprintf("finding_%d", f.ID)
}
// 防御性:再剥一层路径,杜绝 zip slip。
name = path.Base(name)
if len(name) > 120 {
name = name[:120]
}
return name + ".md"
}
// FindingsCSV 把一批 findings 渲染成 CSV(带 UTF-8 BOM,便于 Excel 正确识别中文)。
// 不含大段 report/evidence 全文,只放摘要类字段;需要全文用 Markdown/JSON 导出。
func FindingsCSV(fs []*db.DBFinding) []byte {
items := append([]*db.DBFinding(nil), fs...)
sortFindingsForExport(items)
var buf bytes.Buffer
buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM
w := csv.NewWriter(&buf)
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述"})
for _, f := range items {
_ = w.Write([]string{
fmt.Sprintf("%d", f.ID),
findingTitle(f),
f.VulnClass,
nz(f.Severity, "info"),
nz(f.Status, "pending"),
f.TaskDescription,
f.CreatedAt.Format("2006-01-02 15:04:05"),
strings.TrimSpace(f.Summary),
})
}
w.Flush()
return buf.Bytes()
}
+106
View File
@@ -1,6 +1,7 @@
package server
import (
"archive/zip"
"context"
"encoding/json"
@@ -677,6 +678,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
mux.HandleFunc("GET /api/exploration/findings", s.findings)
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
@@ -1895,6 +1897,110 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, dto)
}
// findingsExport 导出发现页的漏洞。
//
// scope = filtered(沿用页面筛选)| all(全部)| selected(勾选的 ids)
// format = md-single(整合一份 .md)| md-zip(一漏洞一 .md,打包 zip)
// | csv | json
// ids = 逗号分隔的 finding id(scope=selected 时必填)
// 筛选参数 severity/status/vulnclass/task_id/sort 与列表接口一致(scope=filtered 用)。
func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
scope := q.Get("scope")
format := q.Get("format")
var ids []int64
var filter db.FindingFilter
switch scope {
case "selected":
for _, part := range strings.Split(q.Get("ids"), ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseInt(part, 10, 64)
if err != nil || id <= 0 {
writeErr(w, 400, "bad finding id: "+part)
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
writeErr(w, 400, "no findings selected")
return
}
case "all":
// 空 filter = 不加任何条件。
case "filtered", "":
filter = db.FindingFilter{
Severity: normFilter(q.Get("severity")),
Status: normFilter(q.Get("status")),
VulnClass: normFilter(q.Get("vulnclass")),
TaskID: normFilter(q.Get("task_id")),
Sort: q.Get("sort"),
}
default:
writeErr(w, 400, "bad scope: "+scope)
return
}
fs, err := s.m.pg.ListFindingsForExport(filter, ids)
if err != nil {
writeErr(w, 500, err.Error())
return
}
now := time.Now()
stamp := now.Format("20060102-150405")
setDownload := func(contentType, filename string) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
}
switch format {
case "md-single":
setDownload("text/markdown; charset=utf-8", "findings-"+stamp+".md")
_, _ = w.Write([]byte(report.FindingsMarkdown(fs, now)))
case "md-zip":
setDownload("application/zip", "findings-"+stamp+".zip")
zw := zip.NewWriter(w)
used := map[string]int{}
for _, f := range fs {
base := report.FindingFilename(f)
name := base
// 去重:同名文件追加 -2、-3……
if n := used[base]; n > 0 {
name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1)
}
used[base]++
fw, werr := zw.Create(name)
if werr != nil {
log.Printf("[findings-export] zip create %s: %v", name, werr)
continue
}
_, _ = fw.Write([]byte(report.SingleFindingMarkdown(f, now)))
}
if cerr := zw.Close(); cerr != nil {
log.Printf("[findings-export] zip close: %v", cerr)
}
case "csv":
setDownload("text/csv; charset=utf-8", "findings-"+stamp+".csv")
_, _ = w.Write(report.FindingsCSV(fs))
case "json":
assets := s.resolveFindingAssets(fs)
out := make([]FindingDTO, 0, len(fs))
for _, f := range fs {
out = append(out, findingFromDB(f, assets))
}
setDownload("application/json; charset=utf-8", "findings-"+stamp+".json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(out)
default:
writeErr(w, 400, "bad format: "+format)
}
}
func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) {
if contextTask == nil || findingTaskID == nil {
return "", false, false
@@ -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 "?";