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