feat(报告): 添加任务报告导出功能并优化样式

- 在 AuditTasks 页面添加快速扫描和 Agent 任务的报告导出功能
- 在 ReportExportDialog 中优化颜色样式以支持亮色/暗色模式
- 修复报告生成器中字段为空时的处理逻辑
This commit is contained in:
lintsinghua 2025-12-18 23:58:56 +08:00
parent 87c501b55c
commit c0ac7d0544
3 changed files with 132 additions and 38 deletions

View File

@ -344,7 +344,9 @@ class ReportGenerator:
</div>
{% endif %}
{% if issue.description %}
<div class="issue-desc">{{ issue.description }}</div>
{% endif %}
{% if issue.code_snippet %}
<div class="code-snippet mono">{{ issue.code_snippet }}</div>
@ -413,13 +415,24 @@ class ReportGenerator:
item['severity'] = item.get('severity', 'low')
item['severity_label'] = sev_labels.get(item['severity'], 'UNKNOWN')
item['line'] = item.get('line_number') or item.get('line')
# 确保代码片段存在 (处理可能的字段名差异)
code = item.get('code_snippet') or item.get('code') or item.get('context')
if isinstance(code, list):
code = '\n'.join(code)
item['code_snippet'] = code
item['code_snippet'] = code if code else None
# 确保 description 不为 None
desc = item.get('description')
if not desc or desc == 'None':
desc = item.get('title', '') # 如果没有描述,使用标题
item['description'] = desc
# 确保 suggestion 不为 None
suggestion = item.get('suggestion')
if suggestion == 'None' or suggestion is None:
item['suggestion'] = None
processed.append(item)
return processed

View File

@ -91,8 +91,8 @@ const FORMAT_CONFIG: Record<ReportFormat, {
icon: <FileText className="w-5 h-5" />,
extension: ".md",
mime: "text/markdown",
color: "text-sky-400",
bgColor: "bg-sky-500/10 border-sky-500/30",
color: "text-sky-600 dark:text-sky-400",
bgColor: "bg-sky-100 dark:bg-sky-500/10 border-sky-300 dark:border-sky-500/30",
},
json: {
label: "JSON",
@ -100,8 +100,8 @@ const FORMAT_CONFIG: Record<ReportFormat, {
icon: <FileJson className="w-5 h-5" />,
extension: ".json",
mime: "application/json",
color: "text-amber-400",
bgColor: "bg-amber-500/10 border-amber-500/30",
color: "text-amber-600 dark:text-amber-400",
bgColor: "bg-amber-100 dark:bg-amber-500/10 border-amber-300 dark:border-amber-500/30",
},
html: {
label: "HTML",
@ -109,8 +109,8 @@ const FORMAT_CONFIG: Record<ReportFormat, {
icon: <FileCode className="w-5 h-5" />,
extension: ".html",
mime: "text/html",
color: "text-emerald-400",
bgColor: "bg-emerald-500/10 border-emerald-500/30",
color: "text-emerald-600 dark:text-emerald-400",
bgColor: "bg-emerald-100 dark:bg-emerald-500/10 border-emerald-300 dark:border-emerald-500/30",
},
};
@ -126,10 +126,10 @@ const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
function getSeverityColor(severity: string): string {
const colors: Record<string, string> = {
critical: "text-rose-400",
high: "text-orange-400",
medium: "text-amber-400",
low: "text-sky-400",
critical: "text-rose-600 dark:text-rose-400",
high: "text-orange-600 dark:text-orange-400",
medium: "text-amber-600 dark:text-amber-400",
low: "text-sky-600 dark:text-sky-400",
info: "text-muted-foreground",
};
return colors[severity.toLowerCase()] || colors.info;
@ -145,10 +145,10 @@ function formatBytes(bytes: number): string {
// 获取安全评分颜色
function getScoreColor(score: number): { text: string; bg: string; glow: string } {
if (score >= 80) return { text: "text-emerald-400", bg: "stroke-emerald-500", glow: "drop-shadow-[0_0_8px_rgba(16,185,129,0.5)]" };
if (score >= 60) return { text: "text-amber-400", bg: "stroke-amber-500", glow: "drop-shadow-[0_0_8px_rgba(245,158,11,0.5)]" };
if (score >= 40) return { text: "text-orange-400", bg: "stroke-orange-500", glow: "drop-shadow-[0_0_8px_rgba(249,115,22,0.5)]" };
return { text: "text-rose-400", bg: "stroke-rose-500", glow: "drop-shadow-[0_0_8px_rgba(244,63,94,0.5)]" };
if (score >= 80) return { text: "text-emerald-600 dark:text-emerald-400", bg: "stroke-emerald-500", glow: "" };
if (score >= 60) return { text: "text-amber-600 dark:text-amber-400", bg: "stroke-amber-500", glow: "" };
if (score >= 40) return { text: "text-orange-600 dark:text-orange-400", bg: "stroke-orange-500", glow: "" };
return { text: "text-rose-600 dark:text-rose-400", bg: "stroke-rose-500", glow: "" };
}
// ============ Sub Components ============
@ -181,7 +181,7 @@ const CircularProgress = memo(function CircularProgress({
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-foreground/50"
className="text-slate-300 dark:text-slate-700"
/>
{/* Progress circle */}
<circle
@ -224,23 +224,23 @@ const EnhancedStatsPanel = memo(function EnhancedStatsPanel({
label: "漏洞总数",
value: totalFindings,
color: "text-foreground",
iconColor: "text-rose-400",
iconColor: "text-rose-600 dark:text-rose-400",
trend: totalFindings > 0 ? "up" : null,
},
{
icon: <AlertTriangle className="w-4 h-4" />,
label: "高危问题",
value: criticalAndHigh,
color: criticalAndHigh > 0 ? "text-rose-400" : "text-muted-foreground",
iconColor: "text-orange-400",
color: criticalAndHigh > 0 ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground",
iconColor: "text-orange-600 dark:text-orange-400",
trend: criticalAndHigh > 0 ? "critical" : null,
},
{
icon: <CheckCircle2 className="w-4 h-4" />,
label: "已验证",
value: verified,
color: "text-emerald-400",
iconColor: "text-emerald-400",
color: "text-emerald-600 dark:text-emerald-400",
iconColor: "text-emerald-600 dark:text-emerald-400",
trend: null,
},
];
@ -272,7 +272,7 @@ const EnhancedStatsPanel = memo(function EnhancedStatsPanel({
{stat.value}
</span>
{stat.trend === "critical" && stat.value > 0 && (
<Zap className="w-3 h-3 text-rose-400 animate-pulse" />
<Zap className="w-3 h-3 text-rose-600 dark:text-rose-400" />
)}
</div>
@ -1622,21 +1622,14 @@ export const ReportExportDialog = memo(function ReportExportDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl h-[90vh] bg-gradient-to-b from-[#0a0a0f] to-[#0d0d14] border-border/50 p-0 gap-0 overflow-hidden shadow-2xl shadow-black/50">
{/* Header - 增强设计 */}
<div className="relative px-6 py-5 border-b border-border/50 bg-gradient-to-r from-[#0d0d12] via-[#0f0f16] to-[#0d0d12]">
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-20 -right-20 w-40 h-40 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute -bottom-10 -left-10 w-32 h-32 bg-sky-500/5 rounded-full blur-2xl" />
</div>
<DialogContent className="max-w-5xl h-[90vh] bg-background border-border p-0 gap-0 overflow-hidden shadow-2xl">
{/* Header */}
<div className="relative px-6 py-5 border-b border-border bg-card">
<DialogHeader className="relative">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="relative p-3 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/30 shadow-lg shadow-primary/10">
<div className="relative p-3 rounded-xl bg-primary/10 border border-primary/30">
<FileDown className="w-6 h-6 text-primary" />
<div className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-emerald-500 border-2 border-background animate-pulse" />
</div>
<div>
<DialogTitle className="text-xl font-bold text-foreground flex items-center gap-2">
@ -1824,7 +1817,7 @@ export const ReportExportDialog = memo(function ReportExportDialog({
</div>
{/* Footer - 增强设计 */}
<div className="px-6 py-4 border-t border-border/50 bg-gradient-to-r from-[#0d0d12] via-[#0f0f16] to-[#0d0d12]">
<div className="px-6 py-4 border-t border-border bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${FORMAT_CONFIG[activeFormat].bgColor}`}>

View File

@ -24,16 +24,20 @@ import {
Shield,
Terminal,
Bot,
Zap
Zap,
Download
} from "lucide-react";
import { api } from "@/shared/config/database";
import { apiClient } from "@/shared/api/serverClient";
import type { AuditTask } from "@/shared/types";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
import ExportReportDialog from "@/components/reports/ExportReportDialog";
import { calculateTaskProgress } from "@/shared/utils/utils";
import { getAgentTasks, cancelAgentTask, type AgentTask } from "@/shared/api/agentTasks";
import { getAgentTasks, cancelAgentTask, getAgentFindings, type AgentTask, type AgentFinding } from "@/shared/api/agentTasks";
import ReportExportDialog from "@/pages/AgentAudit/components/ReportExportDialog";
// Zombie task detection config
const ZOMBIE_TIMEOUT = 180000; // 3 minutes without progress is potentially stuck
@ -59,6 +63,14 @@ export default function AuditTasks() {
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
const [agentLoading, setAgentLoading] = useState(true);
const [cancellingAgentTaskId, setCancellingAgentTaskId] = useState<string | null>(null);
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportTask, setExportTask] = useState<AuditTask | null>(null);
const [exportIssues, setExportIssues] = useState<any[]>([]);
// Agent 任务导出对话框状态
const [showAgentExportDialog, setShowAgentExportDialog] = useState(false);
const [exportAgentTask, setExportAgentTask] = useState<AgentTask | null>(null);
const [exportAgentFindings, setExportAgentFindings] = useState<AgentFinding[]>([]);
// Zombie task detection: track progress and time for each task
const taskProgressRef = useRef<Map<string, { progress: number; time: number }>>(new Map());
@ -201,6 +213,40 @@ export default function AuditTasks() {
}
};
// 打开快速扫描任务导出对话框
const handleOpenExportDialog = async (task: AuditTask) => {
try {
setExportingTaskId(task.id);
// 获取任务的问题列表
const issuesResponse = await apiClient.get(`/tasks/${task.id}/issues`);
setExportTask(task);
setExportIssues(issuesResponse.data || []);
setShowExportDialog(true);
} catch (error: any) {
console.error('获取问题列表失败:', error);
toast.error("获取问题列表失败");
} finally {
setExportingTaskId(null);
}
};
// 打开 Agent 任务导出对话框
const handleOpenAgentExportDialog = async (task: AgentTask) => {
try {
setExportingTaskId(task.id);
// 获取任务的 findings 列表
const findings = await getAgentFindings(task.id);
setExportAgentTask(task);
setExportAgentFindings(findings);
setShowAgentExportDialog(true);
} catch (error: any) {
console.error('获取 findings 列表失败:', error);
toast.error("获取审计结果失败");
} finally {
setExportingTaskId(null);
}
};
const loadTasks = async () => {
try {
setLoading(true);
@ -711,6 +757,17 @@ export default function AuditTasks() {
</Button>
</>
)}
{(task.status === 'completed' || (task.findings_count != null && task.findings_count > 0)) && (
<Button
size="sm"
className="cyber-btn-outline h-9"
onClick={() => handleOpenAgentExportDialog(task)}
disabled={exportingTaskId === task.id}
>
<Download className="w-4 h-4 mr-2" />
{exportingTaskId === task.id ? '加载中...' : '导出报告'}
</Button>
)}
{/* 任务详情按钮 */}
<Link to={`/agent-audit/${task.id}`}>
<Button size="sm" className="cyber-btn-outline h-9">
@ -838,6 +895,17 @@ export default function AuditTasks() {
{cancellingTaskId === task.id ? '取消中...' : '取消'}
</Button>
)}
{(task.issues_count > 0 || task.status === 'completed') && (
<Button
size="sm"
className="cyber-btn-outline h-9"
onClick={() => handleOpenExportDialog(task)}
disabled={exportingTaskId === task.id}
>
<Download className="w-4 h-4 mr-2" />
{exportingTaskId === task.id ? '加载中...' : '导出报告'}
</Button>
)}
<Link to={`/tasks/${task.id}`}>
<Button size="sm" className="cyber-btn-outline h-9">
<FileText className="w-4 h-4 mr-2" />
@ -892,6 +960,26 @@ export default function AuditTasks() {
taskId={currentTaskId}
taskType="repository"
/>
{/* 快速扫描任务导出对话框 */}
{exportTask && (
<ExportReportDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
task={exportTask}
issues={exportIssues}
/>
)}
{/* Agent 任务导出对话框 */}
{exportAgentTask && (
<ReportExportDialog
open={showAgentExportDialog}
onOpenChange={setShowAgentExportDialog}
task={exportAgentTask}
findings={exportAgentFindings}
/>
)}
</div>
);
}