新增 AI 解析、評分結果

This commit is contained in:
2025-09-22 17:35:22 +08:00
parent 9d4c586ad3
commit 1d6b1b61b7
16 changed files with 1954 additions and 404 deletions

139
app/api/evaluate/route.ts Normal file
View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { GeminiService } from '@/lib/services/gemini';
import { CriteriaItemService } from '@/lib/services/database';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const projectTitle = formData.get('projectTitle') as string;
const projectDescription = formData.get('projectDescription') as string;
const file = formData.get('file') as File;
const websiteUrl = formData.get('websiteUrl') as string;
console.log('🚀 開始處理評審請求...');
console.log('📝 專案標題:', projectTitle);
console.log('📋 專案描述:', projectDescription);
console.log('📁 上傳文件:', file ? file.name : '無');
console.log('🌐 網站連結:', websiteUrl || '無');
// 驗證必填欄位
if (!projectTitle?.trim()) {
return NextResponse.json(
{ success: false, error: '請填寫專案標題' },
{ status: 400 }
);
}
if (!file && !websiteUrl?.trim()) {
return NextResponse.json(
{ success: false, error: '請上傳文件或提供網站連結' },
{ status: 400 }
);
}
// 獲取評分標準
console.log('📊 載入評分標準...');
const templates = await CriteriaItemService.getAllTemplates();
console.log('📊 載入的模板:', templates);
if (!templates || templates.length === 0) {
return NextResponse.json(
{ success: false, error: '未找到評分標準,請先設定評分標準' },
{ status: 400 }
);
}
const criteria = templates[0].items || [];
console.log('📊 評分項目:', criteria);
console.log('✅ 評分標準載入完成,共', criteria.length, '個項目');
let content = '';
if (file) {
// 處理文件上傳
console.log('📄 開始處理上傳文件...');
// 檢查文件類型
const allowedTypes = [
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'video/mp4',
'video/avi',
'video/quicktime',
'application/pdf',
'text/plain' // 添加純文字格式用於測試
];
if (!allowedTypes.includes(file.type)) {
console.log('❌ 不支援的文件格式:', file.type);
return NextResponse.json(
{ success: false, error: `不支援的文件格式: ${file.type}` },
{ status: 400 }
);
}
// 檢查文件大小 (100MB)
const maxSize = 100 * 1024 * 1024;
if (file.size > maxSize) {
return NextResponse.json(
{ success: false, error: '文件大小超過 100MB 限制' },
{ status: 400 }
);
}
// 提取內容
content = await GeminiService.extractPPTContent(file);
} else if (websiteUrl) {
// 處理網站連結
console.log('🌐 開始處理網站連結...');
content = `網站內容分析: ${websiteUrl}
由於目前是簡化版本,無法直接抓取網站內容。
實際應用中應該使用網頁抓取技術來獲取網站內容進行分析。
專案描述: ${projectDescription || '無'}`;
}
// 使用 Gemini AI 進行評分
console.log('🤖 開始 AI 評分...');
const evaluation = await GeminiService.analyzePresentation(
content,
projectTitle,
projectDescription || '',
criteria
);
// 儲存評審結果到資料庫(可選)
// TODO: 實作結果儲存功能
console.log('🎉 評審完成!');
console.log('📊 最終評分結果:');
console.log(' 總分:', evaluation.totalScore, '/', evaluation.maxTotalScore);
console.log(' 各項目評分:');
evaluation.results.forEach(result => {
console.log(` - ${result.criteriaName}: ${result.score}/${result.maxScore}`);
});
return NextResponse.json({
success: true,
data: {
evaluation,
projectTitle,
projectDescription,
fileInfo: file ? {
name: file.name,
size: file.size,
type: file.type
} : null,
websiteUrl: websiteUrl || null
}
});
} catch (error) {
console.error('❌ 評審處理失敗:', error);
return NextResponse.json(
{ success: false, error: '評審處理失敗,請稍後再試' },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { Sidebar } from "@/components/sidebar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -88,31 +88,105 @@ const mockResults = {
],
}
const chartData = mockResults.criteria.map((item) => ({
name: item.name,
score: item.score,
maxScore: item.maxScore,
percentage: (item.score / item.maxScore) * 100,
}))
const pieData = mockResults.criteria.map((item) => ({
name: item.name,
value: item.weightedScore,
weight: item.weight,
}))
const radarData = mockResults.criteria.map((item) => ({
subject: item.name,
score: item.score,
fullMark: item.maxScore,
}))
// 圖表數據將在組件內部動態生成
const COLORS = ["#0891b2", "#6366f1", "#f59e0b", "#dc2626", "#10b981"]
export default function ResultsPage() {
const [activeTab, setActiveTab] = useState("overview")
const [evaluationData, setEvaluationData] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const { toast } = useToast()
useEffect(() => {
// 從 localStorage 獲取評審結果
const storedData = localStorage.getItem('evaluationResult')
if (storedData) {
try {
const data = JSON.parse(storedData)
setEvaluationData(data)
} catch (error) {
console.error('解析評審結果失敗:', error)
toast({
title: "數據錯誤",
description: "無法載入評審結果,請重新進行評審",
variant: "destructive",
})
}
} else {
toast({
title: "無評審結果",
description: "請先進行評審以查看結果",
variant: "destructive",
})
}
setIsLoading(false)
}, [toast])
// 如果正在載入,顯示載入狀態
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<main className="md:ml-64 p-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">...</p>
</div>
</div>
</div>
</main>
</div>
)
}
// 如果沒有數據,顯示錯誤狀態
if (!evaluationData) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<main className="md:ml-64 p-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<p className="text-muted-foreground mb-4"></p>
<Button onClick={() => window.location.href = '/upload'}>
</Button>
</div>
</div>
</div>
</main>
</div>
)
}
// 使用真實數據或回退到模擬數據
const results = evaluationData.evaluation?.fullData || mockResults
// 確保所有必要的數據結構存在
const safeResults = {
...results,
overview: results.overview || {
excellentItems: 0,
improvementItems: 0,
overallPerformance: 0
},
chartData: results.chartData || {
barChart: [],
pieChart: [],
radarChart: []
},
improvementSuggestions: results.improvementSuggestions || {
overallSuggestions: '暫無改進建議',
maintainStrengths: [],
keyImprovements: [],
actionPlan: []
}
}
const downloadReport = () => {
toast({
title: "報告下載中",
@@ -155,7 +229,7 @@ export default function ResultsPage() {
<div>
<h1 className="text-3xl font-bold text-foreground mb-2 font-[var(--font-playfair)]"></h1>
<p className="text-muted-foreground">
{mockResults.projectTitle} - {mockResults.analysisDate}
{safeResults.projectTitle} - {safeResults.analysisDate}
</p>
</div>
<div className="flex gap-2">
@@ -176,22 +250,24 @@ export default function ResultsPage() {
<CardContent className="pt-6">
<div className="grid md:grid-cols-4 gap-6">
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">{mockResults.overallScore}</div>
<div className="text-sm text-muted-foreground"> / {mockResults.totalPossible}</div>
<div className="text-4xl font-bold text-primary mb-2">{safeResults.overallScore}</div>
<div className="text-sm text-muted-foreground"> / {safeResults.totalPossible}</div>
</div>
<div className="text-center">
<Badge className={`text-lg px-4 py-2 ${getGradeColor(mockResults.grade)}`}>{mockResults.grade}</Badge>
<Badge className={`text-lg px-4 py-2 ${getGradeColor(safeResults.grade)}`}>{safeResults.grade}</Badge>
<div className="text-sm text-muted-foreground mt-2"></div>
</div>
<div className="text-center">
<div className="flex items-center justify-center mb-2">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{safeResults.performanceStatus}</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center mb-2">
<Star className="h-6 w-6 text-yellow-500" />
{Array.from({ length: safeResults.recommendedStars || 0 }, (_, i) => (
<Star key={i} className="h-6 w-6 text-yellow-500 fill-current" />
))}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
@@ -217,7 +293,7 @@ export default function ResultsPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{mockResults.criteria.map((item, index) => (
{safeResults.criteria.map((item, index) => (
<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">
@@ -243,21 +319,21 @@ export default function ResultsPage() {
<Card>
<CardContent className="pt-6 text-center">
<CheckCircle className="h-8 w-8 text-green-600 mx-auto mb-2" />
<div className="text-2xl font-bold text-green-600 mb-1">3</div>
<div className="text-2xl font-bold text-green-600 mb-1">{safeResults.overview.excellentItems}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<AlertCircle className="h-8 w-8 text-yellow-600 mx-auto mb-2" />
<div className="text-2xl font-bold text-yellow-600 mb-1">2</div>
<div className="text-2xl font-bold text-yellow-600 mb-1">{safeResults.overview.improvementItems}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<TrendingUp className="h-8 w-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold text-blue-600 mb-1">82%</div>
<div className="text-2xl font-bold text-blue-600 mb-1">{safeResults.overview.overallPerformance}%</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
@@ -265,7 +341,7 @@ export default function ResultsPage() {
</TabsContent>
<TabsContent value="detailed" className="space-y-6">
{mockResults.criteria.map((item, index) => (
{safeResults.criteria.map((item, index) => (
<Card key={index}>
<CardHeader>
<div className="flex items-center justify-between">
@@ -322,7 +398,7 @@ export default function ResultsPage() {
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<BarChart data={safeResults.chartData.barChart}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" angle={-45} textAnchor="end" height={80} />
<YAxis domain={[0, 10]} />
@@ -342,7 +418,7 @@ export default function ResultsPage() {
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={pieData}
data={safeResults.chartData.pieChart}
cx="50%"
cy="50%"
labelLine={false}
@@ -351,7 +427,7 @@ export default function ResultsPage() {
fill="#8884d8"
dataKey="value"
>
{pieData.map((entry, index) => (
{safeResults.chartData.pieChart.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
@@ -370,7 +446,7 @@ export default function ResultsPage() {
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<RadarChart data={radarData}>
<RadarChart data={safeResults.chartData.radarChart}>
<PolarGrid />
<PolarAngleAxis dataKey="subject" />
<PolarRadiusAxis domain={[0, 10]} />
@@ -385,86 +461,54 @@ export default function ResultsPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> AI </CardDescription>
<CardDescription>{safeResults.improvementSuggestions.overallSuggestions}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-medium mb-3 text-green-700"></h4>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 bg-green-50 rounded-lg">
<h5 className="font-medium mb-2"></h5>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<h5 className="font-medium mb-2"></h5>
<p className="text-sm text-muted-foreground">
</p>
</div>
{safeResults.improvementSuggestions.maintainStrengths.map((strength, index) => (
<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">
{strength.description}
</p>
</div>
))}
</div>
</div>
<div>
<h4 className="font-medium mb-3 text-orange-700"></h4>
<div className="space-y-4">
<div className="p-4 bg-orange-50 rounded-lg">
<h5 className="font-medium mb-2"></h5>
<p className="text-sm text-muted-foreground mb-3"></p>
<ul className="text-sm space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="p-4 bg-orange-50 rounded-lg">
<h5 className="font-medium mb-2"></h5>
<p className="text-sm text-muted-foreground mb-3"></p>
<ul className="text-sm space-y-1">
<li> </li>
<li> </li>
<li> 使</li>
<li> </li>
</ul>
</div>
{safeResults.improvementSuggestions.keyImprovements.map((improvement, index) => (
<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) => (
<li key={sIndex}> {suggestion}</li>
))}
</ul>
</div>
))}
</div>
</div>
<div>
<h4 className="font-medium mb-3 text-blue-700"></h4>
<div className="space-y-3">
<div 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">
1
{safeResults.improvementSuggestions.actionPlan.map((action, index) => (
<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}
</div>
<div>
<h5 className="font-medium">{action.phase}</h5>
<p className="text-sm text-muted-foreground">{action.description}</p>
</div>
</div>
<div>
<h5 className="font-medium">1-2</h5>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<div 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">
2
</div>
<div>
<h5 className="font-medium">1</h5>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
<div 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">
3
</div>
<div>
<h5 className="font-medium">3</h5>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
))}
</div>
</div>
</CardContent>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useCallback } from "react"
import { useState, useCallback, useEffect } from "react"
import { Sidebar } from "@/components/sidebar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -22,6 +22,7 @@ interface UploadedFile {
status: "uploading" | "completed" | "error"
progress: number
url?: string
file?: File // 儲存原始文件對象
}
export default function UploadPage() {
@@ -30,24 +31,52 @@ export default function UploadPage() {
const [projectTitle, setProjectTitle] = useState("")
const [projectDescription, setProjectDescription] = useState("")
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analysisProgress, setAnalysisProgress] = useState(0)
const { toast } = useToast()
// 動態進度條效果
useEffect(() => {
let progressInterval: NodeJS.Timeout | null = null
if (isAnalyzing) {
setAnalysisProgress(0)
progressInterval = setInterval(() => {
setAnalysisProgress((prev) => {
if (prev >= 90) {
// 在90%時停止,等待實際完成
return prev
}
// 模擬不規則的進度增長
const increment = Math.random() * 8 + 2 // 2-10之間的隨機增量
return Math.min(prev + increment, 90)
})
}, 500) // 每500ms更新一次
} else {
setAnalysisProgress(0)
}
return () => {
if (progressInterval) {
clearInterval(progressInterval)
}
}
}, [isAnalyzing])
const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
name: file.name,
size: file.size,
type: file.type,
status: "uploading",
progress: 0,
status: "completed", // 直接標記為完成,因為我們會在評審時處理文件
progress: 100,
file: file, // 儲存原始文件對象
}))
setFiles((prev) => [...prev, ...newFiles])
// 模擬上傳進度
newFiles.forEach((file) => {
simulateUpload(file.id)
})
console.log('📁 文件已準備就緒:', newFiles.map(f => f.name).join(', '))
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -61,27 +90,6 @@ export default function UploadPage() {
maxSize: 100 * 1024 * 1024, // 100MB
})
const simulateUpload = (fileId: string) => {
const interval = setInterval(() => {
setFiles((prev) =>
prev.map((file) => {
if (file.id === fileId) {
const newProgress = Math.min(file.progress + Math.random() * 30, 100)
const status = newProgress === 100 ? "completed" : "uploading"
return { ...file, progress: newProgress, status }
}
return file
}),
)
}, 500)
setTimeout(() => {
clearInterval(interval)
setFiles((prev) =>
prev.map((file) => (file.id === fileId ? { ...file, progress: 100, status: "completed" } : file)),
)
}, 3000)
}
const removeFile = (fileId: string) => {
setFiles((prev) => prev.filter((file) => file.id !== fileId))
@@ -105,7 +113,7 @@ export default function UploadPage() {
return <FileText className="h-8 w-8 text-gray-500" />
}
const startAnalysis = () => {
const startAnalysis = async () => {
if (files.length === 0 && !websiteUrl.trim()) {
toast({
title: "請上傳文件或提供網站連結",
@@ -126,16 +134,76 @@ export default function UploadPage() {
setIsAnalyzing(true)
// 模擬分析過程
setTimeout(() => {
setIsAnalyzing(false)
toast({
title: "分析完成",
description: "評審結果已生成,請查看結果頁面",
try {
console.log('🚀 開始 AI 評審流程...')
console.log('📝 專案標題:', projectTitle)
console.log('📋 專案描述:', projectDescription)
console.log('📁 上傳文件數量:', files.length)
console.log('🌐 網站連結:', websiteUrl)
// 準備表單數據
const formData = new FormData()
formData.append('projectTitle', projectTitle)
formData.append('projectDescription', projectDescription)
if (files.length > 0) {
// 只處理第一個文件(可以後續擴展支援多文件)
const firstFile = files[0]
if (firstFile.file) {
formData.append('file', firstFile.file)
console.log('📄 處理文件:', firstFile.name, '大小:', firstFile.size)
} else {
throw new Error('文件對象遺失,請重新上傳')
}
}
if (websiteUrl.trim()) {
formData.append('websiteUrl', websiteUrl)
console.log('🌐 處理網站連結:', websiteUrl)
}
// 發送評審請求
console.log('📤 發送評審請求到 API...')
const response = await fetch('/api/evaluate', {
method: 'POST',
body: formData,
})
// 這裡會導向到結果頁面
window.location.href = "/results"
}, 5000)
const result = await response.json()
if (result.success) {
console.log('✅ AI 評審完成!')
console.log('📊 評審結果:', result.data)
// 設置進度為100%
setAnalysisProgress(100)
// 等待一下讓用戶看到100%的進度
await new Promise(resolve => setTimeout(resolve, 500))
toast({
title: "評審完成",
description: `總分: ${result.data.evaluation.totalScore}/${result.data.evaluation.maxTotalScore}`,
})
// 儲存結果到 localStorage 以便結果頁面使用
localStorage.setItem('evaluationResult', JSON.stringify(result.data))
// 導向到結果頁面
window.location.href = "/results"
} else {
throw new Error(result.error || '評審失敗')
}
} catch (error) {
console.error('❌ AI 評審失敗:', error)
toast({
title: "評審失敗",
description: error instanceof Error ? error.message : "請稍後再試",
variant: "destructive",
})
} finally {
setIsAnalyzing(false)
}
}
return (
@@ -332,8 +400,11 @@ export default function UploadPage() {
<span>...</span>
<span> 3-5 </span>
</div>
<Progress value={33} className="h-2" />
<p className="text-xs text-muted-foreground mt-2">AI </p>
<Progress value={analysisProgress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground mt-2">
<span>AI </span>
<span>{Math.round(analysisProgress)}%</span>
</div>
</div>
)}
</div>