diff --git a/db/findings.go b/db/findings.go index 9dfe6e2..48f505c 100644 --- a/db/findings.go +++ b/db/findings.go @@ -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. diff --git a/report/findings.go b/report/findings.go new file mode 100644 index 0000000..265bfd3 --- /dev/null +++ b/report/findings.go @@ -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() +} diff --git a/server/server.go b/server/server.go index 1c462e0..64d8c01 100644 --- a/server/server.go +++ b/server/server.go @@ -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 diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d5590e0..7d36f5c 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -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() { {/* 证据下方:详细报告(Markdown 渲染) */} - + 详细报告 + {finding.report && ( + + )} {finding.report ? ( diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 38b9eda..03201b3 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -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>(() => 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() { - - 共 {total} 条 - +
+ {selectedIds.size > 0 && ( + + 已选 {selectedIds.size} 条 + + )} + + 共 {total} 条 + + +
@@ -398,6 +487,13 @@ export default function FindingsPage() { + + toggleSelectedPage(c === true)} + aria-label="选择本页全部" + /> + 严重度 漏洞名称 @@ -417,6 +513,17 @@ export default function FindingsPage() { className="cursor-pointer" onClick={() => toggleRow(f)} > + e.stopPropagation()}> + {f.finding_id && ( + + toggleSelected(f.finding_id as string, c === true) + } + aria-label="选择该漏洞" + /> + )} + {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 被强制单行、直接溢出单元格。 */} - +
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} {f.finding_id && edit && ( @@ -643,9 +750,20 @@ export default function FindingsPage() { {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} {f.finding_id && (
-
- - 详细报告 +
+ + + 详细报告 + + {reports[f.id]?.status === "done" && + reports[f.id]?.text.trim() && ( + + )}
{(() => { const rep = reports[f.id]; @@ -686,7 +804,7 @@ export default function FindingsPage() { {findings.length === 0 && ( 没有匹配的发现。 @@ -705,6 +823,76 @@ export default function FindingsPage() {
+ + + + + 导出发现 + + 选择导出范围与格式,生成后浏览器会自动下载。 + + + +
+
+ + setExportScope(v as typeof exportScope)} + > + + + + +
+ +
+ + setExportFormat(v as typeof exportFormat)} + > + + + + + +
+
+ + + + + +
+
); } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx index a9bb3e8..e671149 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx @@ -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 { - // 优先使用异步剪贴板 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(""); diff --git a/web/src/components/copy-button.tsx b/web/src/components/copy-button.tsx new file mode 100644 index 0000000..6949a9e --- /dev/null +++ b/web/src/components/copy-button.tsx @@ -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["size"]; + variant?: React.ComponentProps["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 | 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 ( + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 29e7455..2263247 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -360,6 +360,44 @@ export const api = { return get(`/exploration/findings?${p.toString()}`); }, findingStats: () => get("/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; + 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( `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index eb6d2c9..e5cbf1d 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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 { + 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 "?";