實作完整分享、刪除、下載報告功能
This commit is contained in:
114
app/api/evaluation/[id]/delete/route.ts
Normal file
114
app/api/evaluation/[id]/delete/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { EvaluationService, ProjectService, ProjectFileService, ProjectWebsiteService } from '@/lib/services/database';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const evaluationId = parseInt(params.id);
|
||||
|
||||
if (isNaN(evaluationId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '無效的評審ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🗑️ 開始刪除評審記錄: ID=${evaluationId}`);
|
||||
|
||||
// 獲取評審記錄以找到對應的專案
|
||||
const evaluation = await EvaluationService.findById(evaluationId);
|
||||
if (!evaluation) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '找不到指定的評審記錄' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectId = evaluation.project_id;
|
||||
console.log(`📋 找到對應專案: ID=${projectId}`);
|
||||
|
||||
// 獲取專案信息
|
||||
const project = await ProjectService.findById(projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '找不到對應的專案' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 獲取專案文件列表
|
||||
const projectFiles = await ProjectFileService.findByProjectId(projectId);
|
||||
console.log(`📁 找到 ${projectFiles.length} 個專案文件`);
|
||||
|
||||
// 開始事務性刪除
|
||||
try {
|
||||
// 1. 刪除評審相關數據(這些會因為外鍵約束自動刪除)
|
||||
// - evaluation_scores (CASCADE)
|
||||
// - evaluation_feedback (CASCADE)
|
||||
await EvaluationService.delete(evaluationId);
|
||||
console.log(`✅ 已刪除評審記錄: ID=${evaluationId}`);
|
||||
|
||||
// 2. 刪除專案網站記錄
|
||||
const projectWebsites = await ProjectWebsiteService.findByProjectId(projectId);
|
||||
for (const website of projectWebsites) {
|
||||
await ProjectWebsiteService.delete(website.id);
|
||||
}
|
||||
console.log(`✅ 已刪除 ${projectWebsites.length} 個專案網站記錄`);
|
||||
|
||||
// 3. 刪除專案文件記錄和實際文件
|
||||
for (const file of projectFiles) {
|
||||
try {
|
||||
// 刪除實際文件
|
||||
const filePath = join(process.cwd(), file.file_path);
|
||||
await unlink(filePath);
|
||||
console.log(`🗑️ 已刪除文件: ${file.original_name}`);
|
||||
} catch (fileError) {
|
||||
console.warn(`⚠️ 刪除文件失敗: ${file.original_name}`, fileError);
|
||||
// 繼續刪除其他文件,不中斷整個流程
|
||||
}
|
||||
|
||||
// 刪除文件記錄
|
||||
await ProjectFileService.delete(file.id);
|
||||
}
|
||||
console.log(`✅ 已刪除 ${projectFiles.length} 個專案文件記錄`);
|
||||
|
||||
// 4. 刪除專案記錄
|
||||
await ProjectService.delete(projectId);
|
||||
console.log(`✅ 已刪除專案記錄: ID=${projectId}`);
|
||||
|
||||
console.log(`🎉 成功刪除評審報告: 專案=${project.title}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '評審報告已成功刪除',
|
||||
data: {
|
||||
projectId: projectId,
|
||||
projectTitle: project.title,
|
||||
deletedFiles: projectFiles.length,
|
||||
deletedWebsites: projectWebsites.length
|
||||
}
|
||||
});
|
||||
|
||||
} catch (deleteError) {
|
||||
console.error('❌ 刪除過程中發生錯誤:', deleteError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '刪除過程中發生錯誤,請稍後再試'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 刪除評審記錄失敗:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '刪除評審記錄失敗' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
236
app/api/evaluation/[id]/download/route.ts
Normal file
236
app/api/evaluation/[id]/download/route.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { EvaluationService } from '@/lib/services/database';
|
||||
import { generateHTMLPDFReport, type PDFReportData } from '@/lib/utils/html-pdf-generator';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const evaluationId = parseInt(params.id);
|
||||
|
||||
if (isNaN(evaluationId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '無效的評審ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`📄 開始生成 PDF 報告: ID=${evaluationId}`);
|
||||
|
||||
// 獲取評審詳細數據
|
||||
const evaluationWithDetails = await EvaluationService.findWithDetails(evaluationId);
|
||||
|
||||
if (!evaluationWithDetails) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '找不到指定的評審記錄' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ 成功獲取評審數據: 專案=${evaluationWithDetails.project?.title}`);
|
||||
|
||||
// 處理反饋數據,按類型分組
|
||||
const feedbackByType = {
|
||||
criteria: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'criteria') || [],
|
||||
strength: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'strength') || [],
|
||||
improvement: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'improvement') || [],
|
||||
overall: evaluationWithDetails.feedback?.filter(f => f.feedback_type === 'overall') || []
|
||||
};
|
||||
|
||||
// 為每個評分標準獲取對應的 AI 評語、優點和改進建議
|
||||
const criteriaWithFeedback = evaluationWithDetails.scores?.map(score => {
|
||||
const criteriaId = score.criteria_item_id;
|
||||
const criteriaName = score.criteria_item_name || '未知項目';
|
||||
|
||||
// 獲取該評分標準的 AI 評語
|
||||
const criteriaFeedback = feedbackByType.criteria
|
||||
.filter(f => f.criteria_item_id === criteriaId)
|
||||
.map(f => f.content)
|
||||
.filter((content, index, arr) => arr.indexOf(content) === index)[0] || '無評語';
|
||||
|
||||
// 獲取該評分標準的優點
|
||||
const strengths = feedbackByType.strength
|
||||
.filter(f => f.criteria_item_id === criteriaId)
|
||||
.map(f => f.content)
|
||||
.filter((content, index, arr) => arr.indexOf(content) === index);
|
||||
|
||||
// 獲取該評分標準的改進建議
|
||||
const improvements = feedbackByType.improvement
|
||||
.filter(f => f.criteria_item_id === criteriaId)
|
||||
.map(f => f.content)
|
||||
.filter((content, index, arr) => arr.indexOf(content) === index);
|
||||
|
||||
return {
|
||||
name: criteriaName,
|
||||
score: Number(score.score) || 0,
|
||||
maxScore: Number(score.max_score) || 10,
|
||||
weight: Number(score.weight) || 1,
|
||||
weightedScore: Number(score.weighted_score) || 0,
|
||||
percentage: Number(score.percentage) || 0,
|
||||
feedback: criteriaFeedback,
|
||||
strengths: strengths,
|
||||
improvements: improvements
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// 構建 PDF 報告數據
|
||||
const pdfData: PDFReportData = {
|
||||
projectTitle: evaluationWithDetails.project?.title || '未知專案',
|
||||
overallScore: Number(evaluationWithDetails.overall_score) || 0,
|
||||
totalPossible: Number(evaluationWithDetails.max_possible_score) || 100,
|
||||
grade: evaluationWithDetails.grade || 'N/A',
|
||||
analysisDate: evaluationWithDetails.created_at ? new Date(evaluationWithDetails.created_at).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||||
criteria: criteriaWithFeedback,
|
||||
overview: {
|
||||
excellentItems: evaluationWithDetails.excellent_items || 0,
|
||||
improvementItems: evaluationWithDetails.improvement_items || 0,
|
||||
overallPerformance: Number(evaluationWithDetails.overall_score) || 0
|
||||
},
|
||||
improvementSuggestions: {
|
||||
overallSuggestions: feedbackByType.overall[0]?.content || '無詳細分析',
|
||||
maintainStrengths: feedbackByType.strength
|
||||
.filter(f => f.criteria_item_id === null)
|
||||
.map(f => {
|
||||
const content = f.content || '無描述';
|
||||
const colonIndex = content.indexOf(':');
|
||||
const title = colonIndex > -1 ? content.substring(0, colonIndex + 1) : '系統優勢:';
|
||||
const description = colonIndex > -1 ? content.substring(colonIndex + 1).trim() : content;
|
||||
|
||||
return {
|
||||
title: title,
|
||||
description: description
|
||||
};
|
||||
}),
|
||||
keyImprovements: (() => {
|
||||
const allImprovementData = feedbackByType.improvement
|
||||
.filter(f => f.criteria_item_id === null);
|
||||
|
||||
const improvementData = allImprovementData.length >= 5
|
||||
? allImprovementData.slice(1, -3)
|
||||
: allImprovementData;
|
||||
|
||||
const tempGroups = [];
|
||||
let currentGroup = null;
|
||||
|
||||
for (const item of improvementData) {
|
||||
const content = item.content || '';
|
||||
const colonIndex = content.indexOf(':');
|
||||
|
||||
if (colonIndex > -1) {
|
||||
const title = content.substring(0, colonIndex + 1);
|
||||
const remainingContent = content.substring(colonIndex + 1).trim();
|
||||
|
||||
const suggestionIndex = remainingContent.indexOf('建議:');
|
||||
|
||||
if (suggestionIndex > -1) {
|
||||
if (currentGroup) {
|
||||
tempGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
currentGroup = {
|
||||
title: title,
|
||||
subtitle: '建議:',
|
||||
suggestions: []
|
||||
};
|
||||
|
||||
const suggestionsText = remainingContent.substring(suggestionIndex + 3).trim();
|
||||
const suggestions = suggestionsText.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.startsWith('•'))
|
||||
.map(s => s.substring(1).trim());
|
||||
|
||||
currentGroup.suggestions = suggestions;
|
||||
} else if (remainingContent.startsWith('•')) {
|
||||
if (currentGroup) {
|
||||
const suggestion = remainingContent.substring(1).trim();
|
||||
currentGroup.suggestions.push(suggestion);
|
||||
}
|
||||
} else {
|
||||
if (currentGroup) {
|
||||
tempGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
currentGroup = {
|
||||
title: title,
|
||||
subtitle: '',
|
||||
suggestions: [remainingContent]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (currentGroup) {
|
||||
tempGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
if (content.startsWith('•')) {
|
||||
if (currentGroup) {
|
||||
const suggestion = content.substring(1).trim();
|
||||
currentGroup.suggestions.push(suggestion);
|
||||
} else {
|
||||
currentGroup = {
|
||||
title: '改進方向:',
|
||||
subtitle: '',
|
||||
suggestions: [content.substring(1).trim()]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
currentGroup = {
|
||||
title: '改進方向:',
|
||||
subtitle: '',
|
||||
description: content,
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup) {
|
||||
tempGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
return tempGroups;
|
||||
})(),
|
||||
actionPlan: feedbackByType.improvement
|
||||
.filter(f => f.criteria_item_id === null)
|
||||
.slice(-3)
|
||||
.map((f, index) => {
|
||||
const content = f.content || '無描述';
|
||||
const colonIndex = content.indexOf(':');
|
||||
const phase = colonIndex > -1 ? content.substring(0, colonIndex + 1) : `階段 ${index + 1}:`;
|
||||
const description = colonIndex > -1 ? content.substring(colonIndex + 1).trim() : content;
|
||||
|
||||
return {
|
||||
phase: phase,
|
||||
description: description
|
||||
};
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`📋 開始生成 PDF 報告: 專案=${pdfData.projectTitle}`);
|
||||
|
||||
// 生成 PDF
|
||||
const pdfBlob = await generateHTMLPDFReport(pdfData);
|
||||
|
||||
console.log(`✅ PDF 報告生成完成: 大小=${pdfBlob.size} bytes`);
|
||||
|
||||
// 返回 PDF 文件
|
||||
const fileName = `評審報告_${pdfData.projectTitle}_${pdfData.analysisDate}.pdf`;
|
||||
|
||||
return new NextResponse(pdfBlob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
'Content-Length': pdfBlob.size.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 生成 PDF 報告失敗:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '生成 PDF 報告失敗' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
@@ -5,6 +5,7 @@ import type React from "react"
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
|
||||
export default function ClientLayout({
|
||||
children,
|
||||
@@ -16,6 +17,7 @@ export default function ClientLayout({
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||
<Toaster />
|
||||
<Analytics />
|
||||
</>
|
||||
)
|
||||
|
@@ -7,8 +7,10 @@ import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { FileText, Calendar, Search, Eye, Download, Trash2, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
// 歷史記錄數據類型
|
||||
interface HistoryItem {
|
||||
@@ -46,6 +48,8 @@ export default function HistoryPage() {
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 載入歷史記錄數據
|
||||
useEffect(() => {
|
||||
@@ -111,6 +115,104 @@ export default function HistoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 下載評審報告
|
||||
const handleDownload = async (evaluationId: number, projectTitle: string) => {
|
||||
try {
|
||||
toast({
|
||||
title: "報告下載中",
|
||||
description: "評審報告 PDF 正在生成,請稍候...",
|
||||
});
|
||||
|
||||
// 調用下載 API
|
||||
const response = await fetch(`/api/evaluation/${evaluationId}/download`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '下載失敗');
|
||||
}
|
||||
|
||||
// 獲取 PDF Blob
|
||||
const pdfBlob = await response.blob();
|
||||
|
||||
// 創建下載連結
|
||||
const url = window.URL.createObjectURL(pdfBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 從 Content-Disposition 標頭獲取檔案名稱,或使用預設名稱
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let fileName = `評審報告_${projectTitle}_${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
|
||||
if (fileNameMatch) {
|
||||
fileName = decodeURIComponent(fileNameMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
// 顯示成功提示
|
||||
toast({
|
||||
title: "下載完成",
|
||||
description: "評審報告已成功下載",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('下載失敗:', error);
|
||||
toast({
|
||||
title: "下載失敗",
|
||||
description: error instanceof Error ? error.message : '下載過程中發生錯誤',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 刪除評審報告
|
||||
const handleDelete = async (evaluationId: number, projectTitle: string) => {
|
||||
try {
|
||||
setDeletingId(evaluationId.toString());
|
||||
|
||||
const response = await fetch(`/api/evaluation/${evaluationId}/delete`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 從本地狀態中移除已刪除的項目
|
||||
setHistoryData(prev => prev.filter(item => item.evaluation_id !== evaluationId));
|
||||
|
||||
// 重新載入統計數據
|
||||
const statsResponse = await fetch('/api/history/stats');
|
||||
const statsResult = await statsResponse.json();
|
||||
if (statsResult.success) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "刪除成功",
|
||||
description: `評審報告「${projectTitle}」已成功刪除`,
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.error || '刪除失敗');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刪除失敗:', error);
|
||||
toast({
|
||||
title: "刪除失敗",
|
||||
description: error instanceof Error ? error.message : '刪除過程中發生錯誤',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
@@ -264,7 +366,11 @@ export default function HistoryPage() {
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(item.evaluation_id!, item.title)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -273,9 +379,48 @@ export default function HistoryPage() {
|
||||
處理中...
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={deletingId === item.evaluation_id?.toString()}
|
||||
>
|
||||
{deletingId === item.evaluation_id?.toString() ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您確定要刪除評審報告「{item.title}」嗎?此操作將永久刪除:
|
||||
<br />
|
||||
• 評審結果和分數
|
||||
<br />
|
||||
• 專案文件和上傳的檔案
|
||||
<br />
|
||||
• 所有相關的評語和建議
|
||||
<br />
|
||||
<br />
|
||||
<strong>此操作無法復原!</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => item.evaluation_id && handleDelete(item.evaluation_id, item.title)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -27,6 +27,7 @@ import {
|
||||
} from "recharts"
|
||||
import { Download, Share2, TrendingUp, AlertCircle, CheckCircle, Star } from "lucide-react"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { ShareModal } from "@/components/share-modal"
|
||||
|
||||
// 模擬評分結果數據 - 使用您提到的例子:8, 7, 6, 8, 4,平均 = 6.6
|
||||
const mockCriteria = [
|
||||
@@ -129,7 +130,8 @@ export default function ResultsPage() {
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
const [evaluationData, setEvaluationData] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -168,10 +170,10 @@ export default function ResultsPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('載入評審結果失敗:', error)
|
||||
setError(error.message)
|
||||
setError((error as Error).message)
|
||||
toast({
|
||||
title: "載入失敗",
|
||||
description: error.message || "無法載入評審結果,請重新進行評審",
|
||||
description: (error as Error).message || "無法載入評審結果,請重新進行評審",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
@@ -230,10 +232,10 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
// 使用真實數據或回退到模擬數據
|
||||
const results = evaluationData.evaluation?.fullData || evaluationData || mockResults
|
||||
const results = (evaluationData as any)?.evaluation?.fullData || evaluationData || mockResults
|
||||
|
||||
// 計算統計數據 - 基於 criteria_items 的平均分作為閾值
|
||||
const calculateOverview = (criteria: any[]) => {
|
||||
const calculateOverview = (criteria: any[]): any => {
|
||||
if (!criteria || criteria.length === 0) {
|
||||
return {
|
||||
excellentItems: 0,
|
||||
@@ -278,18 +280,72 @@ export default function ResultsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadReport = () => {
|
||||
toast({
|
||||
title: "報告下載中",
|
||||
description: "評審報告 PDF 正在生成,請稍候...",
|
||||
})
|
||||
const downloadReport = async () => {
|
||||
try {
|
||||
// 顯示載入提示
|
||||
toast({
|
||||
title: "報告下載中",
|
||||
description: "評審報告 PDF 正在生成,請稍候...",
|
||||
});
|
||||
|
||||
// 獲取評審 ID
|
||||
const evaluationId = searchParams.get('id');
|
||||
|
||||
if (!evaluationId) {
|
||||
throw new Error('無法獲取評審 ID');
|
||||
}
|
||||
|
||||
// 調用下載 API
|
||||
const response = await fetch(`/api/evaluation/${evaluationId}/download`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '下載失敗');
|
||||
}
|
||||
|
||||
// 獲取 PDF Blob
|
||||
const pdfBlob = await response.blob();
|
||||
|
||||
// 創建下載連結
|
||||
const url = window.URL.createObjectURL(pdfBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 從 Content-Disposition 標頭獲取檔案名稱,或使用預設名稱
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let fileName = `評審報告_${safeResults.projectTitle}_${safeResults.analysisDate}.pdf`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
|
||||
if (fileNameMatch) {
|
||||
fileName = decodeURIComponent(fileNameMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
// 顯示成功提示
|
||||
toast({
|
||||
title: "下載完成",
|
||||
description: "評審報告已成功下載",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('下載失敗:', error);
|
||||
toast({
|
||||
title: "下載失敗",
|
||||
description: error instanceof Error ? error.message : '下載過程中發生錯誤',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shareResults = () => {
|
||||
toast({
|
||||
title: "分享連結已複製",
|
||||
description: "評審結果分享連結已複製到剪貼板",
|
||||
})
|
||||
setIsShareModalOpen(true)
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number, maxScore: number) => {
|
||||
@@ -384,7 +440,7 @@ export default function ResultsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{safeResults.criteria.map((item, index) => (
|
||||
{safeResults.criteria.map((item: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -432,7 +488,7 @@ export default function ResultsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="detailed" className="space-y-6">
|
||||
{safeResults.criteria.map((item, index) => (
|
||||
{safeResults.criteria.map((item: any, index: number) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -455,7 +511,7 @@ export default function ResultsPage() {
|
||||
<div>
|
||||
<h4 className="font-medium mb-2 text-green-700">優點</h4>
|
||||
<ul className="space-y-1">
|
||||
{item.strengths.map((strength, i) => (
|
||||
{item.strengths.map((strength: any, i: number) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
{strength}
|
||||
@@ -466,7 +522,7 @@ export default function ResultsPage() {
|
||||
<div>
|
||||
<h4 className="font-medium mb-2 text-orange-700">改進建議</h4>
|
||||
<ul className="space-y-1">
|
||||
{item.improvements.map((improvement, i) => (
|
||||
{item.improvements.map((improvement: any, i: number) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
{improvement}
|
||||
@@ -518,7 +574,7 @@ export default function ResultsPage() {
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{safeResults.chartData.pieChart.map((entry, index) => (
|
||||
{safeResults.chartData.pieChart.map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -558,7 +614,7 @@ export default function ResultsPage() {
|
||||
<div>
|
||||
<h4 className="font-medium mb-3 text-green-700">繼續保持的優勢</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{safeResults.improvementSuggestions.maintainStrengths.map((strength, index) => (
|
||||
{safeResults.improvementSuggestions.maintainStrengths.map((strength: any, index: number) => (
|
||||
<div key={index} className="p-4 bg-green-50 rounded-lg">
|
||||
<h5 className="font-medium mb-2">{strength.title}</h5>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -572,12 +628,12 @@ export default function ResultsPage() {
|
||||
<div>
|
||||
<h4 className="font-medium mb-3 text-orange-700">重點改進方向</h4>
|
||||
<div className="space-y-4">
|
||||
{safeResults.improvementSuggestions.keyImprovements.map((improvement, index) => (
|
||||
{safeResults.improvementSuggestions.keyImprovements.map((improvement: any, index: number) => (
|
||||
<div key={index} className="p-4 bg-orange-50 rounded-lg">
|
||||
<h5 className="font-medium mb-2">{improvement.title}</h5>
|
||||
<p className="text-sm text-muted-foreground mb-3">{improvement.description}</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{improvement.suggestions.map((suggestion, sIndex) => (
|
||||
{improvement.suggestions.map((suggestion: any, sIndex: number) => (
|
||||
<li key={sIndex}>• {suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -589,7 +645,7 @@ export default function ResultsPage() {
|
||||
<div>
|
||||
<h4 className="font-medium mb-3 text-blue-700">下一步行動計劃</h4>
|
||||
<div className="space-y-3">
|
||||
{safeResults.improvementSuggestions.actionPlan.map((action, index) => (
|
||||
{safeResults.improvementSuggestions.actionPlan.map((action: any, index: number) => (
|
||||
<div key={index} className="flex items-start gap-3 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
||||
{index + 1}
|
||||
@@ -608,6 +664,14 @@ export default function ResultsPage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Share Modal */}
|
||||
<ShareModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
evaluationId={searchParams.get('id') || undefined}
|
||||
projectTitle={safeResults.projectTitle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user