實作所有測驗結果與資料庫整合

This commit is contained in:
2025-09-29 19:45:31 +08:00
parent 373036c003
commit afc7580259
12 changed files with 1460 additions and 207 deletions

View File

@@ -8,20 +8,45 @@ 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 { Badge } from "@/components/ui/badge"
import { Brain, Lightbulb, BarChart3, ArrowLeft, Search, Download, Filter } from "lucide-react"
import { Brain, Lightbulb, BarChart3, ArrowLeft, Search, Download, Filter, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import Link from "next/link"
import { useAuth, type User } from "@/lib/hooks/use-auth"
import { useAuth } from "@/lib/hooks/use-auth"
interface TestResult {
id: string
userId: string
userName: string
userDepartment: string
userEmail: string
type: "logic" | "creative" | "combined"
score: number
completedAt: string
details?: any
}
interface AdminTestResultsStats {
totalResults: number
filteredResults: number
averageScore: number
totalUsers: number
usersWithResults: number
participationRate: number
testTypeCounts: {
logic: number
creative: number
combined: number
}
}
interface PaginationInfo {
currentPage: number
totalPages: number
totalResults: number
limit: number
hasNextPage: boolean
hasPrevPage: boolean
}
export default function AdminResultsPage() {
return (
<ProtectedRoute adminOnly>
@@ -33,125 +58,99 @@ export default function AdminResultsPage() {
function AdminResultsContent() {
const { user } = useAuth()
const [results, setResults] = useState<TestResult[]>([])
const [filteredResults, setFilteredResults] = useState<TestResult[]>([])
const [users, setUsers] = useState<User[]>([])
const [stats, setStats] = useState<AdminTestResultsStats>({
totalResults: 0,
filteredResults: 0,
averageScore: 0,
totalUsers: 0,
usersWithResults: 0,
participationRate: 0,
testTypeCounts: { logic: 0, creative: 0, combined: 0 }
})
const [pagination, setPagination] = useState<PaginationInfo>({
currentPage: 1,
totalPages: 1,
totalResults: 0,
limit: 10,
hasNextPage: false,
hasPrevPage: false
})
const [departments, setDepartments] = useState<string[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [departmentFilter, setDepartmentFilter] = useState("all")
const [testTypeFilter, setTestTypeFilter] = useState("all")
const [stats, setStats] = useState({
totalResults: 0,
averageScore: 0,
totalUsers: 0,
completionRate: 0,
})
const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"]
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [])
useEffect(() => {
filterResults()
}, [results, searchTerm, departmentFilter, testTypeFilter])
loadData()
}, [searchTerm, departmentFilter, testTypeFilter, pagination.currentPage])
const loadData = () => {
// Load users
const usersData = JSON.parse(localStorage.getItem("hr_users") || "[]")
setUsers(usersData)
const loadData = async () => {
setIsLoading(true)
setError(null)
// Load all test results
const allResults: TestResult[] = []
try {
const params = new URLSearchParams({
search: searchTerm,
department: departmentFilter,
testType: testTypeFilter,
page: pagination.currentPage.toString(),
limit: pagination.limit.toString()
})
usersData.forEach((user: User) => {
// Check for logic test results
const logicKey = `logicTestResults_${user.id}`
const logicResults = localStorage.getItem(logicKey)
if (logicResults) {
const data = JSON.parse(logicResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "logic",
score: data.score,
completedAt: data.completedAt,
details: data,
})
const response = await fetch(`/api/admin/test-results?${params}`)
const data = await response.json()
if (data.success) {
setResults(data.data.results)
setStats(data.data.stats)
setPagination(data.data.pagination)
setDepartments(data.data.departments)
} else {
setError(data.message || "載入資料失敗")
}
// Check for creative test results
const creativeKey = `creativeTestResults_${user.id}`
const creativeResults = localStorage.getItem(creativeKey)
if (creativeResults) {
const data = JSON.parse(creativeResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "creative",
score: data.score,
completedAt: data.completedAt,
details: data,
})
}
// Check for combined test results
const combinedKey = `combinedTestResults_${user.id}`
const combinedResults = localStorage.getItem(combinedKey)
if (combinedResults) {
const data = JSON.parse(combinedResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "combined",
score: data.overallScore,
completedAt: data.completedAt,
details: data,
})
}
})
// Sort by completion date (newest first)
allResults.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
setResults(allResults)
// Calculate statistics
const totalResults = allResults.length
const averageScore =
totalResults > 0 ? Math.round(allResults.reduce((sum, r) => sum + r.score, 0) / totalResults) : 0
const totalUsers = usersData.length
const usersWithResults = new Set(allResults.map((r) => r.userId)).size
const completionRate = totalUsers > 0 ? Math.round((usersWithResults / totalUsers) * 100) : 0
setStats({
totalResults,
averageScore,
totalUsers,
completionRate,
})
} catch (error) {
console.error("載入測驗結果失敗:", error)
setError("載入資料時發生錯誤")
} finally {
setIsLoading(false)
}
}
const filterResults = () => {
let filtered = results
const handleSearch = (value: string) => {
setSearchTerm(value)
setPagination(prev => ({ ...prev, currentPage: 1 }))
}
// Filter by search term (user name)
if (searchTerm) {
filtered = filtered.filter((result) => result.userName.toLowerCase().includes(searchTerm.toLowerCase()))
const handleDepartmentChange = (value: string) => {
setDepartmentFilter(value)
setPagination(prev => ({ ...prev, currentPage: 1 }))
}
const handleTestTypeChange = (value: string) => {
setTestTypeFilter(value)
setPagination(prev => ({ ...prev, currentPage: 1 }))
}
const handlePageChange = (page: number) => {
setPagination(prev => ({ ...prev, currentPage: page }))
}
const handlePreviousPage = () => {
if (pagination.hasPrevPage) {
handlePageChange(pagination.currentPage - 1)
}
}
// Filter by department
if (departmentFilter !== "all") {
filtered = filtered.filter((result) => result.userDepartment === departmentFilter)
const handleNextPage = () => {
if (pagination.hasNextPage) {
handlePageChange(pagination.currentPage + 1)
}
// Filter by test type
if (testTypeFilter !== "all") {
filtered = filtered.filter((result) => result.type === testTypeFilter)
}
setFilteredResults(filtered)
}
const getTestTypeInfo = (type: string) => {
@@ -192,33 +191,59 @@ function AdminResultsContent() {
if (score >= 80) return { level: "良好", color: "bg-blue-500" }
if (score >= 70) return { level: "中等", color: "bg-yellow-500" }
if (score >= 60) return { level: "及格", color: "bg-orange-500" }
return { level: "不及格", color: "bg-red-500" }
return { level: "待加強", color: "bg-red-500" }
}
const exportResults = () => {
const csvContent = [
["姓名", "部門", "測試類型", "分數", "等級", "完成時間"],
...filteredResults.map((result) => [
result.userName,
result.userDepartment,
getTestTypeInfo(result.type).name,
result.score.toString(),
getScoreLevel(result.score).level,
new Date(result.completedAt).toLocaleString("zh-TW"),
]),
]
.map((row) => row.join(","))
.join("\n")
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("zh-TW", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
}
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" })
const link = document.createElement("a")
const url = URL.createObjectURL(blob)
link.setAttribute("href", url)
link.setAttribute("download", `測試結果_${new Date().toISOString().split("T")[0]}.csv`)
link.style.visibility = "hidden"
const handleExport = async () => {
try {
const params = new URLSearchParams({
search: searchTerm,
department: departmentFilter,
testType: testTypeFilter
})
const response = await fetch(`/api/admin/test-results/export?${params}`)
const data = await response.json()
if (data.success) {
// 解碼 Base64 資料,保留 UTF-8 BOM
const binaryString = atob(data.data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
// 創建 Blob保留原始字節資料
const blob = new Blob([bytes], {
type: 'text/csv;charset=utf-8'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = data.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} else {
console.error('匯出失敗:', data.message)
alert('匯出失敗,請稍後再試')
}
} catch (error) {
console.error('匯出錯誤:', error)
alert('匯出時發生錯誤,請稍後再試')
}
}
return (
@@ -243,7 +268,7 @@ function AdminResultsContent() {
<div className="container mx-auto px-4 py-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Statistics Overview */}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6 text-center">
@@ -277,16 +302,16 @@ function AdminResultsContent() {
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-6 h-6 text-accent" />
<div className="w-12 h-12 bg-purple-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-6 h-6 text-purple-500" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{stats.completionRate}%</div>
<div className="text-2xl font-bold text-foreground mb-1">{stats.participationRate}%</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
</div>
{/* Filters */}
{/* Filter Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -296,24 +321,24 @@ function AdminResultsContent() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="輸入用戶姓名"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Select value={departmentFilter} onValueChange={handleDepartmentChange}>
<SelectTrigger>
<SelectValue />
<SelectValue placeholder="所有部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -326,11 +351,11 @@ function AdminResultsContent() {
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={testTypeFilter} onValueChange={setTestTypeFilter}>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Select value={testTypeFilter} onValueChange={handleTestTypeChange}>
<SelectTrigger>
<SelectValue />
<SelectValue placeholder="所有類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -341,9 +366,8 @@ function AdminResultsContent() {
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Button onClick={exportResults} className="w-full">
<div className="flex items-end">
<Button onClick={handleExport} className="w-full">
<Download className="w-4 h-4 mr-2" />
</Button>
@@ -352,15 +376,30 @@ function AdminResultsContent() {
</CardContent>
</Card>
{/* Results Table */}
{/* Test Results List */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{filteredResults.length} {results.length}
{pagination.totalResults} ( {stats.totalResults} )
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : error ? (
<div className="text-center py-8 text-red-500">
{error}
</div>
) : results.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
@@ -373,33 +412,40 @@ function AdminResultsContent() {
</TableRow>
</TableHeader>
<TableBody>
{filteredResults.map((result, index) => {
const testInfo = getTestTypeInfo(result.type)
{results.map((result) => {
const testTypeInfo = getTestTypeInfo(result.type)
const scoreLevel = getScoreLevel(result.score)
const Icon = testInfo.icon
const IconComponent = testTypeInfo.icon
return (
<TableRow key={index}>
<TableRow key={result.id}>
<TableCell>
<div>
<div className="font-medium">{result.userName}</div>
<div className="text-sm text-muted-foreground">{result.userEmail}</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{result.userDepartment}</Badge>
</TableCell>
<TableCell>{result.userDepartment}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`w-8 h-8 ${testInfo.color} rounded-lg flex items-center justify-center`}>
<Icon className="w-4 h-4 text-white" />
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${testTypeInfo.color}`}>
<IconComponent className="w-4 h-4 text-white" />
</div>
<span className={testInfo.textColor}>{testInfo.name}</span>
<span className={testTypeInfo.textColor}>{testTypeInfo.name}</span>
</div>
</TableCell>
<TableCell>
<div className="text-lg font-bold">{result.score}</div>
</TableCell>
<TableCell>
<Badge className={`${scoreLevel.color} text-white`}>{scoreLevel.level}</Badge>
<Badge className={`${scoreLevel.color} text-white`}>
{scoreLevel.level}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm">{new Date(result.completedAt).toLocaleString("zh-TW")}</div>
<div className="text-sm">{formatDate(result.completedAt)}</div>
</TableCell>
</TableRow>
)
@@ -407,10 +453,147 @@ function AdminResultsContent() {
</TableBody>
</Table>
{filteredResults.length === 0 && (
<div className="text-center py-8">
<div className="text-muted-foreground"></div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between mt-6 gap-4">
<div className="text-sm text-muted-foreground text-center sm:text-left">
{(pagination.currentPage - 1) * pagination.limit + 1} - {Math.min(pagination.currentPage * pagination.limit, pagination.totalResults)} {pagination.totalResults}
</div>
{/* Desktop Pagination */}
<div className="hidden sm:flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={!pagination.hasPrevPage}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={pagination.currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={!pagination.hasNextPage}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Mobile Pagination */}
<div className="flex sm:hidden items-center space-x-2 w-full justify-center">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={!pagination.hasPrevPage}
className="flex-1 max-w-[80px]"
>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<div className="flex items-center space-x-1 px-2">
{(() => {
const maxVisiblePages = 3
const startPage = Math.max(1, pagination.currentPage - 1)
const endPage = Math.min(pagination.totalPages, startPage + maxVisiblePages - 1)
const pages = []
// 如果不在第一頁,顯示第一頁和省略號
if (startPage > 1) {
pages.push(
<Button
key={1}
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
className="w-8 h-8 p-0"
>
1
</Button>
)
if (startPage > 2) {
pages.push(
<span key="ellipsis1" className="text-muted-foreground px-1">
...
</span>
)
}
}
// 顯示當前頁附近的頁碼
for (let i = startPage; i <= endPage; i++) {
pages.push(
<Button
key={i}
variant={pagination.currentPage === i ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(i)}
className="w-8 h-8 p-0"
>
{i}
</Button>
)
}
// 如果不在最後一頁,顯示省略號和最後一頁
if (endPage < pagination.totalPages) {
if (endPage < pagination.totalPages - 1) {
pages.push(
<span key="ellipsis2" className="text-muted-foreground px-1">
...
</span>
)
}
pages.push(
<Button
key={pagination.totalPages}
variant="outline"
size="sm"
onClick={() => handlePageChange(pagination.totalPages)}
className="w-8 h-8 p-0"
>
{pagination.totalPages}
</Button>
)
}
return pages
})()}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={!pagination.hasNextPage}
className="flex-1 max-w-[80px]"
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
@@ -418,4 +601,4 @@ function AdminResultsContent() {
</div>
</div>
)
}
}