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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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