實現測驗資料匯出、查看詳細功能

This commit is contained in:
2025-09-29 20:01:50 +08:00
parent afc7580259
commit 18123d6f56
4 changed files with 664 additions and 17 deletions

View File

@@ -7,8 +7,9 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Brain, Lightbulb, BarChart3, ArrowLeft, Search, Download, Filter, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import { Brain, Lightbulb, BarChart3, ArrowLeft, Search, Download, Filter, ChevronLeft, ChevronRight, Loader2, Eye } from "lucide-react"
import Link from "next/link"
import { useAuth } from "@/lib/hooks/use-auth"
@@ -81,6 +82,10 @@ function AdminResultsContent() {
const [testTypeFilter, setTestTypeFilter] = useState("all")
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedResult, setSelectedResult] = useState<TestResult | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [detailData, setDetailData] = useState<any>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
useEffect(() => {
loadData()
@@ -232,9 +237,9 @@ function AdminResultsContent() {
const link = document.createElement('a')
link.href = url
link.download = data.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} else {
console.error('匯出失敗:', data.message)
@@ -246,6 +251,29 @@ function AdminResultsContent() {
}
}
const handleViewDetail = async (result: TestResult) => {
setSelectedResult(result)
setShowDetailModal(true)
setIsLoadingDetail(true)
try {
const response = await fetch(`/api/admin/test-results/detail?testResultId=${result.id}&testType=${result.type}`)
const data = await response.json()
if (data.success) {
setDetailData(data.data)
} else {
console.error('獲取詳細結果失敗:', data.message)
alert('獲取詳細結果失敗,請稍後再試')
}
} catch (error) {
console.error('獲取詳細結果錯誤:', error)
alert('獲取詳細結果時發生錯誤,請稍後再試')
} finally {
setIsLoadingDetail(false)
}
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
@@ -401,16 +429,17 @@ function AdminResultsContent() {
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((result) => {
const testTypeInfo = getTestTypeInfo(result.type)
@@ -444,10 +473,21 @@ function AdminResultsContent() {
{scoreLevel.level}
</Badge>
</TableCell>
<TableCell>
<TableCell>
<div className="text-sm">{formatDate(result.completedAt)}</div>
</TableCell>
</TableRow>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleViewDetail(result)}
className="flex items-center gap-2"
>
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
@@ -599,6 +639,273 @@ function AdminResultsContent() {
</Card>
</div>
</div>
{/* 詳細結果模態框 */}
<Dialog open={showDetailModal} onOpenChange={setShowDetailModal}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedResult && `${selectedResult.userName} - ${getTestTypeInfo(selectedResult.type).name}`}
</DialogDescription>
</DialogHeader>
{isLoadingDetail ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : detailData ? (
<div className="space-y-6">
{/* 基本資訊 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm">{detailData.user.name}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm">{detailData.user.email}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm">{detailData.user.department}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm">{formatDate(detailData.result.completedAt)}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm font-bold text-lg">{detailData.result.score}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<Badge className={`${getScoreLevel(detailData.result.score).color} text-white`}>
{getScoreLevel(detailData.result.score).level}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* 題目詳情 */}
{detailData.questions && detailData.questions.length > 0 && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* 邏輯思維題目 */}
{detailData.questions.filter((q: any) => q.type === 'logic').length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Brain className="w-5 h-5 text-blue-600" />
</h3>
<div className="space-y-4">
{detailData.questions
.filter((q: any) => q.type === 'logic')
.map((question: any, index: number) => (
<div key={index} className="border rounded-lg p-4 bg-blue-50/30">
<div className="flex items-start justify-between mb-3">
<h4 className="font-medium"> {index + 1} </h4>
<Badge variant={question.isCorrect ? "default" : "destructive"}>
{question.isCorrect ? "正確" : "錯誤"}
</Badge>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.question}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="space-y-1 mt-1">
{question.option_a && <p className="text-sm">A. {question.option_a}</p>}
{question.option_b && <p className="text-sm">B. {question.option_b}</p>}
{question.option_c && <p className="text-sm">C. {question.option_c}</p>}
{question.option_d && <p className="text-sm">D. {question.option_d}</p>}
{question.option_e && <p className="text-sm">E. {question.option_e}</p>}
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="space-y-1 mt-1">
<p className="text-sm">: <span className="font-bold">{question.userAnswer}</span></p>
<p className="text-sm">: <span className="font-bold text-green-600">{question.correctAnswer}</span></p>
</div>
</div>
</div>
{question.explanation && (
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.explanation}</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* 創意能力題目 */}
{detailData.questions.filter((q: any) => q.type === 'creative').length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-green-600" />
</h3>
<div className="space-y-4">
{detailData.questions
.filter((q: any) => q.type === 'creative')
.map((question: any, index: number) => (
<div key={index} className="border rounded-lg p-4 bg-green-50/30">
<div className="flex items-start justify-between mb-3">
<h4 className="font-medium"> {index + 1} </h4>
<Badge variant="outline" className="text-green-600 border-green-600">
{question.score}
</Badge>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.statement}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.userAnswer}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1 font-bold">{question.score} </p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 單一測試類型的題目(邏輯或創意) */}
{detailData.result.type !== 'combined' && detailData.questions.map((question: any, index: number) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<h4 className="font-medium"> {index + 1} </h4>
{question.isCorrect !== undefined && (
<Badge variant={question.isCorrect ? "default" : "destructive"}>
{question.isCorrect ? "正確" : "錯誤"}
</Badge>
)}
{question.score !== undefined && (
<Badge variant="outline" className="text-green-600 border-green-600">
{question.score}
</Badge>
)}
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.question || question.statement}</p>
</div>
{question.type === 'logic' && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="space-y-1 mt-1">
{question.option_a && <p className="text-sm">A. {question.option_a}</p>}
{question.option_b && <p className="text-sm">B. {question.option_b}</p>}
{question.option_c && <p className="text-sm">C. {question.option_c}</p>}
{question.option_d && <p className="text-sm">D. {question.option_d}</p>}
{question.option_e && <p className="text-sm">E. {question.option_e}</p>}
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="space-y-1 mt-1">
<p className="text-sm">: <span className="font-bold">{question.userAnswer}</span></p>
<p className="text-sm">: <span className="font-bold text-green-600">{question.correctAnswer}</span></p>
</div>
</div>
</div>
{question.explanation && (
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.explanation}</p>
</div>
)}
</>
)}
{question.type === 'creative' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1">{question.userAnswer}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<p className="text-sm mt-1 font-bold">{question.score} </p>
</div>
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 綜合測試詳細分析 */}
{detailData.result.type === 'combined' && detailData.result.details && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-800"></h4>
<p className="text-2xl font-bold text-blue-600">{detailData.result.details.logicScore}</p>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg">
<h4 className="font-medium text-green-800"></h4>
<p className="text-2xl font-bold text-green-600">{detailData.result.details.creativeScore}</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<h4 className="font-medium text-purple-800"></h4>
<p className="text-2xl font-bold text-purple-600">{detailData.result.details.abilityBalance}</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from "next/server"
import { getTestResultById } from "@/lib/database/models/test_result"
import { getLogicTestAnswersByTestResultId } from "@/lib/database/models/logic_test_answer"
import { getCreativeTestAnswersByTestResultId } from "@/lib/database/models/creative_test_answer"
import { getCombinedTestResultById } from "@/lib/database/models/combined_test_result"
import { findUserById } from "@/lib/database/models/user"
import { findLogicQuestionById } from "@/lib/database/models/logic_question"
import { findCreativeQuestionById } from "@/lib/database/models/creative_question"
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const testResultId = searchParams.get("testResultId")
const testType = searchParams.get("testType") as "logic" | "creative" | "combined"
if (!testResultId || !testType) {
return NextResponse.json(
{ success: false, message: "缺少必要參數" },
{ status: 400 }
)
}
let result: any = null
let user: any = null
let questions: any[] = []
let answers: any[] = []
// 獲取用戶資訊
if (testType === "combined") {
const combinedResult = await getCombinedTestResultById(testResultId)
if (combinedResult) {
user = await findUserById(combinedResult.user_id)
result = {
id: combinedResult.id,
userId: combinedResult.user_id,
type: "combined",
score: combinedResult.overall_score,
completedAt: combinedResult.completed_at,
details: {
logicScore: combinedResult.logic_score,
creativeScore: combinedResult.creativity_score,
abilityBalance: combinedResult.balance_score,
breakdown: combinedResult.logic_breakdown
}
}
// 獲取綜合測試的詳細答題資料
// 從 logic_breakdown 中獲取邏輯題答案
if (combinedResult.logic_breakdown && typeof combinedResult.logic_breakdown === 'object') {
const logicBreakdown = combinedResult.logic_breakdown as any
if (logicBreakdown.answers && Array.isArray(logicBreakdown.answers)) {
for (const answer of logicBreakdown.answers) {
const question = await findLogicQuestionById(answer.questionId)
if (question) {
questions.push({
...question,
type: 'logic',
userAnswer: answer.userAnswer,
isCorrect: answer.isCorrect,
correctAnswer: answer.correctAnswer,
explanation: answer.explanation
})
}
}
}
}
// 從 creativity_breakdown 中獲取創意題答案
if (combinedResult.creativity_breakdown && typeof combinedResult.creativity_breakdown === 'object') {
const creativityBreakdown = combinedResult.creativity_breakdown as any
if (creativityBreakdown.answers && Array.isArray(creativityBreakdown.answers)) {
for (const answer of creativityBreakdown.answers) {
const question = await findCreativeQuestionById(answer.questionId)
if (question) {
questions.push({
...question,
type: 'creative',
userAnswer: answer.userAnswer,
score: answer.score,
isReverse: answer.isReverse
})
}
}
}
}
}
} else {
const testResult = await getTestResultById(testResultId)
if (testResult) {
user = await findUserById(testResult.user_id)
result = {
id: testResult.id,
userId: testResult.user_id,
type: testResult.test_type,
score: testResult.score,
completedAt: testResult.completed_at
}
// 獲取詳細答案
if (testType === "logic") {
answers = await getLogicTestAnswersByTestResultId(testResultId)
// 獲取對應的題目
for (const answer of answers) {
const question = await findLogicQuestionById(answer.question_id)
if (question) {
questions.push({
...question,
userAnswer: answer.user_answer,
isCorrect: answer.is_correct,
correctAnswer: answer.correct_answer,
explanation: answer.explanation
})
}
}
} else if (testType === "creative") {
answers = await getCreativeTestAnswersByTestResultId(testResultId)
// 獲取對應的題目
for (const answer of answers) {
const question = await findCreativeQuestionById(answer.question_id)
if (question) {
questions.push({
...question,
userAnswer: answer.user_answer,
score: answer.score,
isReverse: answer.is_reverse
})
}
}
}
}
}
if (!result || !user) {
return NextResponse.json(
{ success: false, message: "找不到測試結果" },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: {
result,
user,
questions,
answers
}
})
} catch (error) {
console.error("獲取詳細測試結果失敗:", error)
return NextResponse.json(
{ success: false, message: "獲取詳細結果失敗", error: error instanceof Error ? error.message : String(error) },
{ status: 500 }
)
}
}