feat(reports): Add comprehensive report export functionality

- Implement export report dialog in task detail page
- Add support for JSON and PDF report export formats
- Create report export service with export logic
- Update README.md and README_EN.md with report export documentation
- Add package-lock.json to ensure consistent dependency versions
Enhances user experience by providing flexible and detailed audit report export options for better documentation and sharing of code review results.
This commit is contained in:
lintsinghua 2025-10-24 20:56:24 +08:00
parent 68477ebfd4
commit 9fc95f8daf
6 changed files with 8531 additions and 1 deletions

View File

@ -600,6 +600,20 @@ XCodeReviewer/
4. 监控任务执行状态
5. 查看详细的问题报告
### 审计报告导出
1. 在任务详情页点击"导出报告"按钮
2. 选择导出格式:
- **JSON 格式**:结构化数据,适合程序处理和集成
- **PDF 格式**:专业报告,适合打印和分享(通过浏览器打印功能)
3. JSON 报告包含完整的任务信息、问题详情和统计数据
4. PDF 报告提供美观的可视化展示,支持中文显示
5. 报告内容包括:项目信息、审计统计、问题详情(按严重程度分类)、修复建议等
**PDF 导出提示:**
- 点击"导出 PDF"后会弹出浏览器打印对话框
- 建议在打印设置中**取消勾选"页眉和页脚"选项**,以获得更干净的报告(避免显示 URL 等信息)
- 在打印对话框中选择"另存为 PDF"即可保存报告文件
### 构建和部署
```bash

View File

@ -546,6 +546,20 @@ XCodeReviewer/
4. Monitor task execution status
5. View detailed issue reports
### Audit Report Export
1. Click the "Export Report" button on the task detail page
2. Choose export format:
- **JSON Format**: Structured data, suitable for programmatic processing and integration
- **PDF Format**: Professional report, suitable for printing and sharing (via browser print function)
3. JSON reports contain complete task information, issue details, and statistical data
4. PDF reports provide beautiful visual presentation with full Chinese character support
5. Report contents include: project information, audit statistics, issue details (categorized by severity), fix suggestions, etc.
**PDF Export Tips:**
- After clicking "Export PDF", the browser print dialog will appear
- It's recommended to **uncheck the "Headers and footers" option** in print settings for a cleaner report (to avoid displaying URLs and other information)
- Select "Save as PDF" in the print dialog to save the report file
### Build and Deploy
```bash
# Development mode

7887
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,204 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { FileJson, FileText, Download, Loader2 } from "lucide-react";
import type { AuditTask, AuditIssue } from "@/shared/types";
import { exportToJSON, exportToPDF } from "@/features/reports/services/reportExport";
import { toast } from "sonner";
interface ExportReportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
task: AuditTask;
issues: AuditIssue[];
}
type ExportFormat = "json" | "pdf";
export default function ExportReportDialog({
open,
onOpenChange,
task,
issues
}: ExportReportDialogProps) {
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>("pdf");
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
switch (selectedFormat) {
case "json":
await exportToJSON(task, issues);
toast.success("JSON 报告已导出");
break;
case "pdf":
await exportToPDF(task, issues);
toast.success("PDF 报告已导出");
break;
}
onOpenChange(false);
} catch (error) {
console.error("导出报告失败:", error);
toast.error("导出报告失败,请重试");
} finally {
setIsExporting(false);
}
};
const formats = [
{
value: "json" as ExportFormat,
label: "JSON 格式",
description: "结构化数据,适合程序处理和集成",
icon: FileJson,
color: "text-yellow-600",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200"
},
{
value: "pdf" as ExportFormat,
label: "PDF 格式",
description: "专业报告,适合打印和分享",
icon: FileText,
color: "text-red-600",
bgColor: "bg-red-50",
borderColor: "border-red-200"
}
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Download className="w-5 h-5 text-primary" />
<span></span>
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="py-6">
<RadioGroup
value={selectedFormat}
onValueChange={(value) => setSelectedFormat(value as ExportFormat)}
className="space-y-4"
>
{formats.map((format) => {
const Icon = format.icon;
const isSelected = selectedFormat === format.value;
return (
<div key={format.value} className="relative">
<RadioGroupItem
value={format.value}
id={format.value}
className="peer sr-only"
/>
<Label
htmlFor={format.value}
className={`flex items-start space-x-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${isSelected
? `${format.borderColor} ${format.bgColor} shadow-md`
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
}`}
>
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center ${isSelected ? format.bgColor : "bg-gray-50"
}`}
>
<Icon className={`w-6 h-6 ${isSelected ? format.color : "text-gray-400"}`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<h4 className={`font-semibold ${isSelected ? format.color : "text-gray-900"}`}>
{format.label}
</h4>
{isSelected && (
<div className={`w-5 h-5 rounded-full ${format.bgColor} flex items-center justify-center`}>
<div className={`w-2.5 h-2.5 rounded-full ${format.color.replace("text-", "bg-")}`} />
</div>
)}
</div>
<p className="text-sm text-gray-600">{format.description}</p>
</div>
</Label>
</div>
);
})}
</RadioGroup>
{/* 报告预览信息 */}
<div className="mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h4 className="font-medium text-gray-900 mb-3"></h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-gray-900">{task.project?.name || "未知"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-gray-900">{task.quality_score.toFixed(1)}/100</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-gray-900">{task.scanned_files}/{task.total_files}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-orange-600">{issues.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-gray-900">{task.total_lines.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">:</span>
<span className="font-medium text-red-600">
{issues.filter(i => i.severity === "critical").length}
</span>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isExporting}
>
</Button>
<Button
onClick={handleExport}
disabled={isExporting}
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,395 @@
import type { AuditTask, AuditIssue } from "@/shared/types";
// 导出 JSON 格式报告
export async function exportToJSON(task: AuditTask, issues: AuditIssue[]) {
const report = {
metadata: {
exportDate: new Date().toISOString(),
version: "1.0.0",
format: "JSON"
},
task: {
id: task.id,
projectName: task.project?.name || "未知项目",
taskType: task.task_type,
status: task.status,
branchName: task.branch_name,
createdAt: task.created_at,
completedAt: task.completed_at,
qualityScore: task.quality_score,
totalFiles: task.total_files,
scannedFiles: task.scanned_files,
totalLines: task.total_lines,
issuesCount: task.issues_count
},
issues: issues.map(issue => ({
id: issue.id,
title: issue.title,
description: issue.description,
severity: issue.severity,
issueType: issue.issue_type,
filePath: issue.file_path,
lineNumber: issue.line_number,
columnNumber: issue.column_number,
codeSnippet: issue.code_snippet,
suggestion: issue.suggestion,
aiExplanation: issue.ai_explanation
})),
summary: {
totalIssues: issues.length,
critical: issues.filter(i => i.severity === "critical").length,
high: issues.filter(i => i.severity === "high").length,
medium: issues.filter(i => i.severity === "medium").length,
low: issues.filter(i => i.severity === "low").length,
byType: {
security: issues.filter(i => i.issue_type === "security").length,
bug: issues.filter(i => i.issue_type === "bug").length,
performance: issues.filter(i => i.issue_type === "performance").length,
style: issues.filter(i => i.issue_type === "style").length,
maintainability: issues.filter(i => i.issue_type === "maintainability").length
}
}
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `audit-report-${task.id}-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// 导出 PDF 格式报告(使用隐藏 iframe 打印)
export async function exportToPDF(task: AuditTask, issues: AuditIssue[]) {
const criticalIssues = issues.filter(i => i.severity === "critical");
const highIssues = issues.filter(i => i.severity === "high");
const mediumIssues = issues.filter(i => i.severity === "medium");
const lowIssues = issues.filter(i => i.severity === "low");
const html = generateReportHTML(task, issues, criticalIssues, highIssues, mediumIssues, lowIssues);
// 创建隐藏的 iframe
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = 'none';
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow?.document;
if (iframeDoc) {
iframeDoc.open();
iframeDoc.write(html);
iframeDoc.close();
// 等待内容加载完成后打印
iframe.onload = () => {
setTimeout(() => {
iframe.contentWindow?.print();
// 打印对话框关闭后移除 iframe
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
}, 250);
};
}
}
// 生成报告 HTML简化版
function generateReportHTML(
task: AuditTask,
issues: AuditIssue[],
criticalIssues: AuditIssue[],
highIssues: AuditIssue[],
mediumIssues: AuditIssue[],
lowIssues: AuditIssue[]
): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title></title>
<style>
@page {
margin: 2cm;
size: A4;
}
@media print {
body {
margin: 0;
padding: 20px;
}
}
body {
font-family: "Microsoft YaHei", Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 40px;
}
h1 {
color: #dc2626;
border-bottom: 3px solid #dc2626;
padding-bottom: 10px;
margin-bottom: 30px;
}
h2 {
color: #dc2626;
margin-top: 30px;
margin-bottom: 15px;
}
h3 {
color: #374151;
margin-top: 20px;
margin-bottom: 10px;
}
.info-section {
background: #f9fafb;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.info-item {
margin-bottom: 10px;
}
.info-label {
font-weight: bold;
color: #6b7280;
display: inline-block;
width: 120px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
border: 1px solid #e5e7eb;
padding: 12px;
text-align: left;
}
th {
background: #dc2626;
color: white;
font-weight: bold;
}
tr:nth-child(even) {
background: #f9fafb;
}
.issue {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
page-break-inside: avoid;
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.issue-title {
font-size: 16px;
font-weight: bold;
color: #111827;
}
.severity {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.severity-critical {
background: #fee2e2;
color: #991b1b;
}
.severity-high {
background: #fed7aa;
color: #9a3412;
}
.severity-medium {
background: #fef3c7;
color: #92400e;
}
.severity-low {
background: #dbeafe;
color: #1e40af;
}
.issue-meta {
color: #6b7280;
font-size: 14px;
margin-bottom: 10px;
}
.code-block {
background: #1f2937;
color: #f3f4f6;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
margin: 10px 0;
font-family: "Courier New", monospace;
font-size: 13px;
}
.suggestion {
background: #dbeafe;
border-left: 4px solid #2563eb;
padding: 15px;
margin: 10px 0;
border-radius: 4px;
}
.footer {
margin-top: 50px;
padding-top: 20px;
border-top: 2px solid #e5e7eb;
text-align: center;
color: #6b7280;
font-size: 12px;
}
</style>
</head>
<body>
<h1></h1>
<div class="info-section">
<h2></h2>
<div class="info-item">
<span class="info-label">:</span>
<span>${task.project?.name || "未知项目"}</span>
</div>
<div class="info-item">
<span class="info-label">ID:</span>
<span>${task.id}</span>
</div>
<div class="info-item">
<span class="info-label">:</span>
<span>${task.branch_name || "默认分支"}</span>
</div>
<div class="info-item">
<span class="info-label">:</span>
<span>${new Date(task.created_at).toLocaleString("zh-CN")}</span>
</div>
${task.completed_at ? `
<div class="info-item">
<span class="info-label">:</span>
<span>${new Date(task.completed_at).toLocaleString("zh-CN")}</span>
</div>
` : ""}
</div>
<h2></h2>
<table>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
<tr>
<td></td>
<td>${task.quality_score.toFixed(1)}/100</td>
<td></td>
<td>${task.scanned_files}/${task.total_files}</td>
</tr>
<tr>
<td></td>
<td>${task.total_lines.toLocaleString()}</td>
<td></td>
<td>${task.issues_count}</td>
</tr>
<tr>
<td></td>
<td>${criticalIssues.length}</td>
<td></td>
<td>${highIssues.length}</td>
</tr>
<tr>
<td></td>
<td>${mediumIssues.length}</td>
<td></td>
<td>${lowIssues.length}</td>
</tr>
</table>
${issues.length > 0 ? `
<h2></h2>
${criticalIssues.length > 0 ? `
<h3> (${criticalIssues.length})</h3>
${criticalIssues.map(issue => generateIssueHTML(issue, "critical")).join("")}
` : ""}
${highIssues.length > 0 ? `
<h3> (${highIssues.length})</h3>
${highIssues.map(issue => generateIssueHTML(issue, "high")).join("")}
` : ""}
${mediumIssues.length > 0 ? `
<h3> (${mediumIssues.length})</h3>
${mediumIssues.map(issue => generateIssueHTML(issue, "medium")).join("")}
` : ""}
${lowIssues.length > 0 ? `
<h3> (${lowIssues.length})</h3>
${lowIssues.map(issue => generateIssueHTML(issue, "low")).join("")}
` : ""}
` : `
<div class="info-section">
<h3> </h3>
<p></p>
</div>
`}
<div class="footer">
<p><strong>:</strong> ${new Date().toLocaleString("zh-CN")}</p>
</div>
</body>
</html>
`;
}
// 生成问题的 HTML
function generateIssueHTML(issue: AuditIssue, severity: string): string {
return `
<div class="issue">
<div class="issue-header">
<div class="issue-title">${escapeHtml(issue.title)}</div>
<span class="severity severity-${severity}">
${severity === "critical" ? "严重" : severity === "high" ? "高" : severity === "medium" ? "中等" : "低"}
</span>
</div>
<div class="issue-meta">
📁 ${escapeHtml(issue.file_path)}
${issue.line_number ? ` | 📍 第 ${issue.line_number}` : ""}
${issue.column_number ? `,第 ${issue.column_number}` : ""}
</div>
${issue.description ? `
<p><strong>:</strong> ${escapeHtml(issue.description)}</p>
` : ""}
${issue.code_snippet ? `
<div class="code-block"><pre>${escapeHtml(issue.code_snippet)}</pre></div>
` : ""}
${issue.suggestion ? `
<div class="suggestion">
<strong>💡 :</strong><br>
${escapeHtml(issue.suggestion)}
</div>
` : ""}
</div>
`;
}
// HTML 转义
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}

View File

@ -26,6 +26,7 @@ import {
import { api } from "@/shared/config/database";
import type { AuditTask, AuditIssue } from "@/shared/types";
import { toast } from "sonner";
import ExportReportDialog from "@/components/reports/ExportReportDialog";
// AI解释解析函数
function parseAIExplanation(aiExplanation: string) {
@ -319,6 +320,7 @@ export default function TaskDetail() {
const [task, setTask] = useState<AuditTask | null>(null);
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [loading, setLoading] = useState(true);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
useEffect(() => {
if (id) {
@ -437,7 +439,11 @@ export default function TaskDetail() {
</span>
</Badge>
{task.status === 'completed' && (
<Button size="sm" className="btn-primary">
<Button
size="sm"
className="btn-primary"
onClick={() => setExportDialogOpen(true)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
@ -639,6 +645,16 @@ export default function TaskDetail() {
</CardContent>
</Card>
)}
{/* 导出报告对话框 */}
{task && (
<ExportReportDialog
open={exportDialogOpen}
onOpenChange={setExportDialogOpen}
task={task}
issues={issues}
/>
)}
</div>
);
}