實作所有測驗結果與資料庫整合
This commit is contained in:
@@ -8,20 +8,45 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 Link from "next/link"
|
||||||
import { useAuth, type User } from "@/lib/hooks/use-auth"
|
import { useAuth } from "@/lib/hooks/use-auth"
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
userName: string
|
userName: string
|
||||||
userDepartment: string
|
userDepartment: string
|
||||||
|
userEmail: string
|
||||||
type: "logic" | "creative" | "combined"
|
type: "logic" | "creative" | "combined"
|
||||||
score: number
|
score: number
|
||||||
completedAt: string
|
completedAt: string
|
||||||
details?: any
|
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() {
|
export default function AdminResultsPage() {
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute adminOnly>
|
<ProtectedRoute adminOnly>
|
||||||
@@ -33,125 +58,99 @@ export default function AdminResultsPage() {
|
|||||||
function AdminResultsContent() {
|
function AdminResultsContent() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [results, setResults] = useState<TestResult[]>([])
|
const [results, setResults] = useState<TestResult[]>([])
|
||||||
const [filteredResults, setFilteredResults] = useState<TestResult[]>([])
|
const [stats, setStats] = useState<AdminTestResultsStats>({
|
||||||
const [users, setUsers] = useState<User[]>([])
|
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 [searchTerm, setSearchTerm] = useState("")
|
||||||
const [departmentFilter, setDepartmentFilter] = useState("all")
|
const [departmentFilter, setDepartmentFilter] = useState("all")
|
||||||
const [testTypeFilter, setTestTypeFilter] = useState("all")
|
const [testTypeFilter, setTestTypeFilter] = useState("all")
|
||||||
const [stats, setStats] = useState({
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
totalResults: 0,
|
const [error, setError] = useState<string | null>(null)
|
||||||
averageScore: 0,
|
|
||||||
totalUsers: 0,
|
|
||||||
completionRate: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"]
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filterResults()
|
loadData()
|
||||||
}, [results, searchTerm, departmentFilter, testTypeFilter])
|
}, [searchTerm, departmentFilter, testTypeFilter, pagination.currentPage])
|
||||||
|
|
||||||
const loadData = () => {
|
const loadData = async () => {
|
||||||
// Load users
|
setIsLoading(true)
|
||||||
const usersData = JSON.parse(localStorage.getItem("hr_users") || "[]")
|
setError(null)
|
||||||
setUsers(usersData)
|
|
||||||
|
|
||||||
// Load all test results
|
try {
|
||||||
const allResults: TestResult[] = []
|
const params = new URLSearchParams({
|
||||||
|
search: searchTerm,
|
||||||
|
department: departmentFilter,
|
||||||
|
testType: testTypeFilter,
|
||||||
|
page: pagination.currentPage.toString(),
|
||||||
|
limit: pagination.limit.toString()
|
||||||
|
})
|
||||||
|
|
||||||
usersData.forEach((user: User) => {
|
const response = await fetch(`/api/admin/test-results?${params}`)
|
||||||
// Check for logic test results
|
const data = await response.json()
|
||||||
const logicKey = `logicTestResults_${user.id}`
|
|
||||||
const logicResults = localStorage.getItem(logicKey)
|
if (data.success) {
|
||||||
if (logicResults) {
|
setResults(data.data.results)
|
||||||
const data = JSON.parse(logicResults)
|
setStats(data.data.stats)
|
||||||
allResults.push({
|
setPagination(data.data.pagination)
|
||||||
userId: user.id,
|
setDepartments(data.data.departments)
|
||||||
userName: user.name,
|
} else {
|
||||||
userDepartment: user.department,
|
setError(data.message || "載入資料失敗")
|
||||||
type: "logic",
|
|
||||||
score: data.score,
|
|
||||||
completedAt: data.completedAt,
|
|
||||||
details: data,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
// Check for creative test results
|
console.error("載入測驗結果失敗:", error)
|
||||||
const creativeKey = `creativeTestResults_${user.id}`
|
setError("載入資料時發生錯誤")
|
||||||
const creativeResults = localStorage.getItem(creativeKey)
|
} finally {
|
||||||
if (creativeResults) {
|
setIsLoading(false)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterResults = () => {
|
const handleSearch = (value: string) => {
|
||||||
let filtered = results
|
setSearchTerm(value)
|
||||||
|
setPagination(prev => ({ ...prev, currentPage: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by search term (user name)
|
const handleDepartmentChange = (value: string) => {
|
||||||
if (searchTerm) {
|
setDepartmentFilter(value)
|
||||||
filtered = filtered.filter((result) => result.userName.toLowerCase().includes(searchTerm.toLowerCase()))
|
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
|
const handleNextPage = () => {
|
||||||
if (departmentFilter !== "all") {
|
if (pagination.hasNextPage) {
|
||||||
filtered = filtered.filter((result) => result.userDepartment === departmentFilter)
|
handlePageChange(pagination.currentPage + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by test type
|
|
||||||
if (testTypeFilter !== "all") {
|
|
||||||
filtered = filtered.filter((result) => result.type === testTypeFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredResults(filtered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTestTypeInfo = (type: string) => {
|
const getTestTypeInfo = (type: string) => {
|
||||||
@@ -192,33 +191,59 @@ function AdminResultsContent() {
|
|||||||
if (score >= 80) return { level: "良好", color: "bg-blue-500" }
|
if (score >= 80) return { level: "良好", color: "bg-blue-500" }
|
||||||
if (score >= 70) return { level: "中等", color: "bg-yellow-500" }
|
if (score >= 70) return { level: "中等", color: "bg-yellow-500" }
|
||||||
if (score >= 60) return { level: "及格", color: "bg-orange-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 formatDate = (dateString: string) => {
|
||||||
const csvContent = [
|
return new Date(dateString).toLocaleString("zh-TW", {
|
||||||
["姓名", "部門", "測試類型", "分數", "等級", "完成時間"],
|
year: "numeric",
|
||||||
...filteredResults.map((result) => [
|
month: "2-digit",
|
||||||
result.userName,
|
day: "2-digit",
|
||||||
result.userDepartment,
|
hour: "2-digit",
|
||||||
getTestTypeInfo(result.type).name,
|
minute: "2-digit"
|
||||||
result.score.toString(),
|
})
|
||||||
getScoreLevel(result.score).level,
|
}
|
||||||
new Date(result.completedAt).toLocaleString("zh-TW"),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
.map((row) => row.join(","))
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" })
|
const handleExport = async () => {
|
||||||
const link = document.createElement("a")
|
try {
|
||||||
const url = URL.createObjectURL(blob)
|
const params = new URLSearchParams({
|
||||||
link.setAttribute("href", url)
|
search: searchTerm,
|
||||||
link.setAttribute("download", `測試結果_${new Date().toISOString().split("T")[0]}.csv`)
|
department: departmentFilter,
|
||||||
link.style.visibility = "hidden"
|
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)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} else {
|
||||||
|
console.error('匯出失敗:', data.message)
|
||||||
|
alert('匯出失敗,請稍後再試')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('匯出錯誤:', error)
|
||||||
|
alert('匯出時發生錯誤,請稍後再試')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,7 +268,7 @@ function AdminResultsContent() {
|
|||||||
|
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-7xl mx-auto space-y-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">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
@@ -277,16 +302,16 @@ function AdminResultsContent() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6 text-center">
|
<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">
|
<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-accent" />
|
<BarChart3 className="w-6 h-6 text-purple-500" />
|
||||||
</div>
|
</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>
|
<div className="text-sm text-muted-foreground">參與率</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filter Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -296,24 +321,24 @@ function AdminResultsContent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-sm font-medium">搜尋用戶</label>
|
<label className="text-sm font-medium mb-2 block">搜尋用戶</label>
|
||||||
<div className="relative">
|
<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
|
<Input
|
||||||
placeholder="輸入用戶姓名"
|
placeholder="輸入用戶姓名"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-sm font-medium">部門</label>
|
<label className="text-sm font-medium mb-2 block">部門</label>
|
||||||
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
|
<Select value={departmentFilter} onValueChange={handleDepartmentChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue placeholder="所有部門" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">所有部門</SelectItem>
|
<SelectItem value="all">所有部門</SelectItem>
|
||||||
@@ -326,11 +351,11 @@ function AdminResultsContent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-sm font-medium">測試類型</label>
|
<label className="text-sm font-medium mb-2 block">測試類型</label>
|
||||||
<Select value={testTypeFilter} onValueChange={setTestTypeFilter}>
|
<Select value={testTypeFilter} onValueChange={handleTestTypeChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue placeholder="所有類型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">所有類型</SelectItem>
|
<SelectItem value="all">所有類型</SelectItem>
|
||||||
@@ -341,9 +366,8 @@ function AdminResultsContent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex items-end">
|
||||||
<label className="text-sm font-medium">操作</label>
|
<Button onClick={handleExport} className="w-full">
|
||||||
<Button onClick={exportResults} className="w-full">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
匯出結果
|
匯出結果
|
||||||
</Button>
|
</Button>
|
||||||
@@ -352,15 +376,30 @@ function AdminResultsContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Results Table */}
|
{/* Test Results List */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>測試結果列表</CardTitle>
|
<CardTitle>測試結果列表</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
顯示 {filteredResults.length} 筆結果(共 {results.length} 筆)
|
顯示 {pagination.totalResults} 筆結果 (共 {stats.totalResults} 筆)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -373,33 +412,40 @@ function AdminResultsContent() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredResults.map((result, index) => {
|
{results.map((result) => {
|
||||||
const testInfo = getTestTypeInfo(result.type)
|
const testTypeInfo = getTestTypeInfo(result.type)
|
||||||
const scoreLevel = getScoreLevel(result.score)
|
const scoreLevel = getScoreLevel(result.score)
|
||||||
const Icon = testInfo.icon
|
const IconComponent = testTypeInfo.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={index}>
|
<TableRow key={result.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div>
|
||||||
<div className="font-medium">{result.userName}</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>
|
||||||
<TableCell>{result.userDepartment}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-8 h-8 ${testInfo.color} rounded-lg flex items-center justify-center`}>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${testTypeInfo.color}`}>
|
||||||
<Icon className="w-4 h-4 text-white" />
|
<IconComponent className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className={testInfo.textColor}>{testInfo.name}</span>
|
<span className={testTypeInfo.textColor}>{testTypeInfo.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-lg font-bold">{result.score}</div>
|
<div className="text-lg font-bold">{result.score}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={`${scoreLevel.color} text-white`}>{scoreLevel.level}</Badge>
|
<Badge className={`${scoreLevel.color} text-white`}>
|
||||||
|
{scoreLevel.level}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm">{new Date(result.completedAt).toLocaleString("zh-TW")}</div>
|
<div className="text-sm">{formatDate(result.completedAt)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
@@ -407,10 +453,147 @@ function AdminResultsContent() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{filteredResults.length === 0 && (
|
{/* Pagination */}
|
||||||
<div className="text-center py-8">
|
{pagination.totalPages > 1 && (
|
||||||
<div className="text-muted-foreground">沒有找到符合條件的測試結果</div>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
221
app/api/admin/test-results/export/route.ts
Normal file
221
app/api/admin/test-results/export/route.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getAllTestResults } 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 { getAllCombinedTestResults } from "@/lib/database/models/combined_test_result"
|
||||||
|
import { getAllUsers } from "@/lib/database/models/user"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const search = searchParams.get("search") || ""
|
||||||
|
const department = searchParams.get("department") || "all"
|
||||||
|
const testType = searchParams.get("testType") || "all"
|
||||||
|
|
||||||
|
// 獲取所有用戶
|
||||||
|
const users = await getAllUsers()
|
||||||
|
const userMap = new Map(users.map(user => [user.id, user]))
|
||||||
|
|
||||||
|
// 獲取所有測試結果
|
||||||
|
const [testResults, combinedResults] = await Promise.all([
|
||||||
|
getAllTestResults(),
|
||||||
|
getAllCombinedTestResults()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 合併所有測試結果
|
||||||
|
const allResults = []
|
||||||
|
|
||||||
|
// 處理邏輯和創意測試結果
|
||||||
|
for (const result of testResults) {
|
||||||
|
const user = userMap.get(result.user_id)
|
||||||
|
if (user) {
|
||||||
|
let details = null
|
||||||
|
|
||||||
|
// 根據測試類型獲取詳細資料
|
||||||
|
if (result.test_type === 'logic') {
|
||||||
|
const logicAnswers = await getLogicTestAnswersByTestResultId(result.id)
|
||||||
|
if (logicAnswers.length > 0) {
|
||||||
|
const logicAnswer = logicAnswers[0]
|
||||||
|
details = {
|
||||||
|
correctAnswers: logicAnswer.correct_answers,
|
||||||
|
totalQuestions: logicAnswer.total_questions,
|
||||||
|
accuracy: logicAnswer.accuracy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (result.test_type === 'creative') {
|
||||||
|
const creativeAnswers = await getCreativeTestAnswersByTestResultId(result.id)
|
||||||
|
if (creativeAnswers.length > 0) {
|
||||||
|
const creativeAnswer = creativeAnswers[0]
|
||||||
|
details = {
|
||||||
|
dimensionScores: creativeAnswer.dimension_scores,
|
||||||
|
totalScore: creativeAnswer.total_score,
|
||||||
|
maxScore: creativeAnswer.max_score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.push({
|
||||||
|
id: result.id,
|
||||||
|
userId: result.user_id,
|
||||||
|
userName: user.name,
|
||||||
|
userDepartment: user.department,
|
||||||
|
userEmail: user.email,
|
||||||
|
type: result.test_type as 'logic' | 'creative',
|
||||||
|
score: result.score,
|
||||||
|
completedAt: result.completed_at,
|
||||||
|
details
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理綜合測試結果
|
||||||
|
for (const result of combinedResults) {
|
||||||
|
const user = userMap.get(result.user_id)
|
||||||
|
if (user) {
|
||||||
|
allResults.push({
|
||||||
|
id: result.id,
|
||||||
|
userId: result.user_id,
|
||||||
|
userName: user.name,
|
||||||
|
userDepartment: user.department,
|
||||||
|
userEmail: user.email,
|
||||||
|
type: 'combined' as const,
|
||||||
|
score: result.overall_score,
|
||||||
|
completedAt: result.completed_at,
|
||||||
|
details: {
|
||||||
|
logicScore: result.logic_score,
|
||||||
|
creativeScore: result.creativity_score, // 使用 creativity_score
|
||||||
|
abilityBalance: result.balance_score, // 使用 balance_score
|
||||||
|
breakdown: result.logic_breakdown
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按完成時間排序(最新的在前)
|
||||||
|
allResults.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
|
||||||
|
|
||||||
|
// 應用篩選
|
||||||
|
let filteredResults = allResults
|
||||||
|
|
||||||
|
// 搜尋篩選
|
||||||
|
if (search) {
|
||||||
|
filteredResults = filteredResults.filter(result =>
|
||||||
|
result.userName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
result.userEmail.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部門篩選
|
||||||
|
if (department !== "all") {
|
||||||
|
filteredResults = filteredResults.filter(result => result.userDepartment === department)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試類型篩選
|
||||||
|
if (testType !== "all") {
|
||||||
|
filteredResults = filteredResults.filter(result => result.type === testType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 CSV 格式的資料
|
||||||
|
const headers = [
|
||||||
|
"用戶姓名",
|
||||||
|
"用戶郵箱",
|
||||||
|
"部門",
|
||||||
|
"測試類型",
|
||||||
|
"分數",
|
||||||
|
"等級",
|
||||||
|
"完成時間",
|
||||||
|
"詳細資料"
|
||||||
|
]
|
||||||
|
|
||||||
|
const data = filteredResults.map(result => {
|
||||||
|
const getScoreLevel = (score: number) => {
|
||||||
|
if (score >= 90) return "優秀"
|
||||||
|
if (score >= 80) return "良好"
|
||||||
|
if (score >= 70) return "中等"
|
||||||
|
if (score >= 60) return "及格"
|
||||||
|
return "待加強"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTestTypeName = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "logic": return "邏輯思維"
|
||||||
|
case "creative": return "創意能力"
|
||||||
|
case "combined": return "綜合能力"
|
||||||
|
default: return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let detailsStr = ""
|
||||||
|
if (result.details) {
|
||||||
|
if (result.type === 'logic' && result.details.correctAnswers !== undefined) {
|
||||||
|
detailsStr = `正確答案: ${result.details.correctAnswers}/${result.details.totalQuestions}, 準確率: ${result.details.accuracy}%`
|
||||||
|
} else if (result.type === 'creative' && result.details.dimensionScores) {
|
||||||
|
detailsStr = `總分: ${result.details.totalScore}/${result.details.maxScore}, 維度分數: ${JSON.stringify(result.details.dimensionScores)}`
|
||||||
|
} else if (result.type === 'combined' && result.details.logicScore !== undefined) {
|
||||||
|
const logicScore = result.details.logicScore ?? '無資料'
|
||||||
|
const creativeScore = result.details.creativeScore ?? '無資料'
|
||||||
|
const abilityBalance = result.details.abilityBalance ?? '無資料'
|
||||||
|
detailsStr = `邏輯: ${logicScore}, 創意: ${creativeScore}, 平衡: ${abilityBalance}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
result.userName,
|
||||||
|
result.userEmail,
|
||||||
|
result.userDepartment,
|
||||||
|
getTestTypeName(result.type),
|
||||||
|
result.score,
|
||||||
|
getScoreLevel(result.score),
|
||||||
|
formatDate(result.completedAt),
|
||||||
|
detailsStr
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 轉換為 CSV 格式
|
||||||
|
const csvRows = [headers, ...data].map(row =>
|
||||||
|
row.map(cell => {
|
||||||
|
const escaped = String(cell).replace(/"/g, '""')
|
||||||
|
return `"${escaped}"`
|
||||||
|
}).join(",")
|
||||||
|
)
|
||||||
|
|
||||||
|
const csvContent = csvRows.join("\n")
|
||||||
|
|
||||||
|
// 直接使用 UTF-8 BOM 字節
|
||||||
|
const bomBytes = new Uint8Array([0xEF, 0xBB, 0xBF]) // UTF-8 BOM
|
||||||
|
const contentBytes = new TextEncoder().encode(csvContent)
|
||||||
|
const result = new Uint8Array(bomBytes.length + contentBytes.length)
|
||||||
|
result.set(bomBytes)
|
||||||
|
result.set(contentBytes, bomBytes.length)
|
||||||
|
|
||||||
|
const base64Content = Buffer.from(result).toString('base64')
|
||||||
|
|
||||||
|
return new NextResponse(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: base64Content,
|
||||||
|
filename: `測驗結果_${new Date().toISOString().split('T')[0]}.csv`,
|
||||||
|
contentType: "text/csv; charset=utf-8"
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("匯出測驗結果失敗:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "匯出失敗", error: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
173
app/api/admin/test-results/route.ts
Normal file
173
app/api/admin/test-results/route.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getAllTestResults } 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 { getAllCombinedTestResults } from "@/lib/database/models/combined_test_result"
|
||||||
|
import { getAllUsers } from "@/lib/database/models/user"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const search = searchParams.get("search") || ""
|
||||||
|
const department = searchParams.get("department") || "all"
|
||||||
|
const testType = searchParams.get("testType") || "all"
|
||||||
|
const page = parseInt(searchParams.get("page") || "1")
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "10")
|
||||||
|
|
||||||
|
// 獲取所有用戶
|
||||||
|
const users = await getAllUsers()
|
||||||
|
const userMap = new Map(users.map(user => [user.id, user]))
|
||||||
|
|
||||||
|
// 獲取所有測試結果
|
||||||
|
const [testResults, combinedResults] = await Promise.all([
|
||||||
|
getAllTestResults(),
|
||||||
|
getAllCombinedTestResults()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 合併所有測試結果
|
||||||
|
const allResults = []
|
||||||
|
|
||||||
|
// 處理邏輯和創意測試結果
|
||||||
|
for (const result of testResults) {
|
||||||
|
const user = userMap.get(result.user_id)
|
||||||
|
if (user) {
|
||||||
|
let details = null
|
||||||
|
|
||||||
|
// 根據測試類型獲取詳細資料
|
||||||
|
if (result.test_type === 'logic') {
|
||||||
|
const logicAnswers = await getLogicTestAnswersByTestResultId(result.id)
|
||||||
|
if (logicAnswers.length > 0) {
|
||||||
|
const logicAnswer = logicAnswers[0] // 取第一個答案記錄
|
||||||
|
details = {
|
||||||
|
correctAnswers: logicAnswer.correct_answers,
|
||||||
|
totalQuestions: logicAnswer.total_questions,
|
||||||
|
accuracy: logicAnswer.accuracy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (result.test_type === 'creative') {
|
||||||
|
const creativeAnswers = await getCreativeTestAnswersByTestResultId(result.id)
|
||||||
|
if (creativeAnswers.length > 0) {
|
||||||
|
const creativeAnswer = creativeAnswers[0] // 取第一個答案記錄
|
||||||
|
details = {
|
||||||
|
dimensionScores: creativeAnswer.dimension_scores,
|
||||||
|
totalScore: creativeAnswer.total_score,
|
||||||
|
maxScore: creativeAnswer.max_score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.push({
|
||||||
|
id: result.id,
|
||||||
|
userId: result.user_id,
|
||||||
|
userName: user.name,
|
||||||
|
userDepartment: user.department,
|
||||||
|
userEmail: user.email,
|
||||||
|
type: result.test_type as 'logic' | 'creative',
|
||||||
|
score: result.score,
|
||||||
|
completedAt: result.completed_at,
|
||||||
|
details
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理綜合測試結果
|
||||||
|
for (const result of combinedResults) {
|
||||||
|
const user = userMap.get(result.user_id)
|
||||||
|
if (user) {
|
||||||
|
allResults.push({
|
||||||
|
id: result.id,
|
||||||
|
userId: result.user_id,
|
||||||
|
userName: user.name,
|
||||||
|
userDepartment: user.department,
|
||||||
|
userEmail: user.email,
|
||||||
|
type: 'combined' as const,
|
||||||
|
score: result.overall_score,
|
||||||
|
completedAt: result.completed_at,
|
||||||
|
details: {
|
||||||
|
logicScore: result.logic_score,
|
||||||
|
creativeScore: result.creativity_score, // 使用 creativity_score
|
||||||
|
abilityBalance: result.balance_score, // 使用 balance_score
|
||||||
|
breakdown: result.logic_breakdown
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按完成時間排序(最新的在前)
|
||||||
|
allResults.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
|
||||||
|
|
||||||
|
// 應用篩選
|
||||||
|
let filteredResults = allResults
|
||||||
|
|
||||||
|
// 搜尋篩選
|
||||||
|
if (search) {
|
||||||
|
filteredResults = filteredResults.filter(result =>
|
||||||
|
result.userName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
result.userEmail.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部門篩選
|
||||||
|
if (department !== "all") {
|
||||||
|
filteredResults = filteredResults.filter(result => result.userDepartment === department)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試類型篩選
|
||||||
|
if (testType !== "all") {
|
||||||
|
filteredResults = filteredResults.filter(result => result.type === testType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分頁
|
||||||
|
const totalResults = filteredResults.length
|
||||||
|
const totalPages = Math.ceil(totalResults / limit)
|
||||||
|
const startIndex = (page - 1) * limit
|
||||||
|
const endIndex = startIndex + limit
|
||||||
|
const paginatedResults = filteredResults.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
// 計算統計資料
|
||||||
|
const stats = {
|
||||||
|
totalResults: allResults.length,
|
||||||
|
filteredResults: filteredResults.length,
|
||||||
|
averageScore: allResults.length > 0
|
||||||
|
? Math.round(allResults.reduce((sum, r) => sum + r.score, 0) / allResults.length)
|
||||||
|
: 0,
|
||||||
|
totalUsers: users.length,
|
||||||
|
usersWithResults: new Set(allResults.map(r => r.userId)).size,
|
||||||
|
participationRate: users.length > 0
|
||||||
|
? Math.round((new Set(allResults.map(r => r.userId)).size / users.length) * 100)
|
||||||
|
: 0,
|
||||||
|
testTypeCounts: {
|
||||||
|
logic: allResults.filter(r => r.type === 'logic').length,
|
||||||
|
creative: allResults.filter(r => r.type === 'creative').length,
|
||||||
|
combined: allResults.filter(r => r.type === 'combined').length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取所有部門列表
|
||||||
|
const departments = Array.from(new Set(users.map(user => user.department))).sort()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
results: paginatedResults,
|
||||||
|
stats,
|
||||||
|
pagination: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages,
|
||||||
|
totalResults,
|
||||||
|
limit,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPrevPage: page > 1
|
||||||
|
},
|
||||||
|
departments
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("獲取管理員測驗結果失敗:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "獲取測驗結果失敗", error: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
38
scripts/check-combined-table-fields.js
Normal file
38
scripts/check-combined-table-fields.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { executeQuery } = require('../lib/database/connection')
|
||||||
|
|
||||||
|
const checkCombinedTableFields = async () => {
|
||||||
|
console.log('🔍 檢查 combined_test_results 表的實際欄位')
|
||||||
|
console.log('=' .repeat(50))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 檢查表結構
|
||||||
|
console.log('\n📊 檢查表結構...')
|
||||||
|
const structureQuery = 'DESCRIBE combined_test_results'
|
||||||
|
const structure = await executeQuery(structureQuery)
|
||||||
|
|
||||||
|
console.log('📋 表欄位:')
|
||||||
|
structure.forEach(field => {
|
||||||
|
console.log(` ${field.Field}: ${field.Type} ${field.Null === 'YES' ? '(可為空)' : '(不可為空)'}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢查實際資料
|
||||||
|
console.log('\n📊 檢查實際資料...')
|
||||||
|
const dataQuery = 'SELECT * FROM combined_test_results LIMIT 2'
|
||||||
|
const data = await executeQuery(dataQuery)
|
||||||
|
|
||||||
|
console.log('📋 實際資料:')
|
||||||
|
data.forEach((row, index) => {
|
||||||
|
console.log(`\n 記錄 ${index + 1}:`)
|
||||||
|
Object.keys(row).forEach(key => {
|
||||||
|
console.log(` ${key}: ${row[key]}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 檢查失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 資料庫欄位檢查完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCombinedTableFields()
|
38
scripts/check-combined-table-structure.js
Normal file
38
scripts/check-combined-table-structure.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { executeQuery } = require('../lib/database/connection')
|
||||||
|
|
||||||
|
const checkCombinedTableStructure = async () => {
|
||||||
|
console.log('🔍 檢查綜合測試結果表結構')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 檢查表結構
|
||||||
|
console.log('\n📊 檢查表結構...')
|
||||||
|
const structureQuery = 'DESCRIBE combined_test_results'
|
||||||
|
const structure = await executeQuery(structureQuery)
|
||||||
|
|
||||||
|
console.log('📋 表欄位:')
|
||||||
|
structure.forEach(field => {
|
||||||
|
console.log(` ${field.Field}: ${field.Type} ${field.Null === 'YES' ? '(可為空)' : '(不可為空)'}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢查實際資料
|
||||||
|
console.log('\n📊 檢查實際資料...')
|
||||||
|
const dataQuery = 'SELECT * FROM combined_test_results LIMIT 3'
|
||||||
|
const data = await executeQuery(dataQuery)
|
||||||
|
|
||||||
|
console.log('📋 實際資料範例:')
|
||||||
|
data.forEach((row, index) => {
|
||||||
|
console.log(`\n 記錄 ${index + 1}:`)
|
||||||
|
Object.keys(row).forEach(key => {
|
||||||
|
console.log(` ${key}: ${row[key]}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 檢查失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 綜合測試結果表結構檢查完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCombinedTableStructure()
|
52
scripts/check-combined-test-data.js
Normal file
52
scripts/check-combined-test-data.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const http = require('http')
|
||||||
|
|
||||||
|
const checkCombinedTestData = async () => {
|
||||||
|
console.log('🔍 檢查綜合測試資料結構')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 獲取所有測試結果
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const data = JSON.parse(response.data)
|
||||||
|
if (data.success) {
|
||||||
|
console.log('✅ 成功獲取測試結果')
|
||||||
|
|
||||||
|
// 找出綜合測試結果
|
||||||
|
const combinedResults = data.data.results.filter(result => result.type === 'combined')
|
||||||
|
console.log(`📊 綜合測試結果數量: ${combinedResults.length}`)
|
||||||
|
|
||||||
|
combinedResults.forEach((result, index) => {
|
||||||
|
console.log(`\n📋 綜合測試 ${index + 1}:`)
|
||||||
|
console.log(` 用戶: ${result.userName}`)
|
||||||
|
console.log(` 分數: ${result.score}`)
|
||||||
|
console.log(` 完成時間: ${result.completedAt}`)
|
||||||
|
console.log(` 詳細資料:`, JSON.stringify(result.details, null, 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 獲取資料失敗:', data.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ 獲取資料失敗,狀態碼:', response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 檢查失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 綜合測試資料檢查完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCombinedTestData()
|
41
scripts/check-db-fields.js
Normal file
41
scripts/check-db-fields.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const { executeQuery } = require('../lib/database/connection')
|
||||||
|
|
||||||
|
const checkDbFields = async () => {
|
||||||
|
console.log('🔍 檢查資料庫欄位名稱')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 檢查表結構
|
||||||
|
console.log('\n📊 檢查 combined_test_results 表結構...')
|
||||||
|
const structureQuery = 'DESCRIBE combined_test_results'
|
||||||
|
const structure = await executeQuery(structureQuery)
|
||||||
|
|
||||||
|
console.log('📋 表欄位:')
|
||||||
|
structure.forEach(field => {
|
||||||
|
console.log(` ${field.Field}: ${field.Type}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢查實際資料
|
||||||
|
console.log('\n📊 檢查實際資料...')
|
||||||
|
const dataQuery = 'SELECT id, user_id, logic_score, creativity_score, balance_score, overall_score FROM combined_test_results LIMIT 2'
|
||||||
|
const data = await executeQuery(dataQuery)
|
||||||
|
|
||||||
|
console.log('📋 實際資料:')
|
||||||
|
data.forEach((row, index) => {
|
||||||
|
console.log(`\n 記錄 ${index + 1}:`)
|
||||||
|
console.log(` id: ${row.id}`)
|
||||||
|
console.log(` user_id: ${row.user_id}`)
|
||||||
|
console.log(` logic_score: ${row.logic_score}`)
|
||||||
|
console.log(` creativity_score: ${row.creativity_score}`)
|
||||||
|
console.log(` balance_score: ${row.balance_score}`)
|
||||||
|
console.log(` overall_score: ${row.overall_score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 檢查失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 資料庫欄位檢查完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDbFields()
|
170
scripts/test-admin-results.js
Normal file
170
scripts/test-admin-results.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const http = require('http')
|
||||||
|
|
||||||
|
const testAdminResults = async () => {
|
||||||
|
console.log('🔍 測試管理員測驗結果功能')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 測試基本 API 呼叫
|
||||||
|
console.log('\n📊 測試基本 API 呼叫...')
|
||||||
|
const basicResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (basicResponse.status === 200) {
|
||||||
|
const basicData = JSON.parse(basicResponse.data)
|
||||||
|
if (basicData.success) {
|
||||||
|
console.log('✅ 基本 API 呼叫成功')
|
||||||
|
console.log(`📈 統計資料:`)
|
||||||
|
console.log(` 總測試次數: ${basicData.data.stats.totalResults}`)
|
||||||
|
console.log(` 平均分數: ${basicData.data.stats.averageScore}`)
|
||||||
|
console.log(` 總用戶數: ${basicData.data.stats.totalUsers}`)
|
||||||
|
console.log(` 參與率: ${basicData.data.stats.participationRate}%`)
|
||||||
|
console.log(` 測試類型分布:`)
|
||||||
|
console.log(` 邏輯思維: ${basicData.data.stats.testTypeCounts.logic} 次`)
|
||||||
|
console.log(` 創意能力: ${basicData.data.stats.testTypeCounts.creative} 次`)
|
||||||
|
console.log(` 綜合能力: ${basicData.data.stats.testTypeCounts.combined} 次`)
|
||||||
|
console.log(`📄 分頁資訊:`)
|
||||||
|
console.log(` 當前頁: ${basicData.data.pagination.currentPage}`)
|
||||||
|
console.log(` 總頁數: ${basicData.data.pagination.totalPages}`)
|
||||||
|
console.log(` 每頁限制: ${basicData.data.pagination.limit}`)
|
||||||
|
console.log(` 總結果數: ${basicData.data.pagination.totalResults}`)
|
||||||
|
console.log(`🏢 部門列表: ${basicData.data.departments.join(', ')}`)
|
||||||
|
console.log(`📋 結果數量: ${basicData.data.results.length}`)
|
||||||
|
} else {
|
||||||
|
console.log('❌ 基本 API 呼叫失敗:', basicData.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ 基本 API 呼叫失敗,狀態碼:', basicResponse.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試搜尋功能
|
||||||
|
console.log('\n🔍 測試搜尋功能...')
|
||||||
|
const searchResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results?search=王', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (searchResponse.status === 200) {
|
||||||
|
const searchData = JSON.parse(searchResponse.data)
|
||||||
|
if (searchData.success) {
|
||||||
|
console.log(`✅ 搜尋功能正常,找到 ${searchData.data.pagination.totalResults} 筆結果`)
|
||||||
|
} else {
|
||||||
|
console.log('❌ 搜尋功能失敗:', searchData.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試部門篩選
|
||||||
|
console.log('\n🏢 測試部門篩選...')
|
||||||
|
const deptResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results?department=人力資源部', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (deptResponse.status === 200) {
|
||||||
|
const deptData = JSON.parse(deptResponse.data)
|
||||||
|
if (deptData.success) {
|
||||||
|
console.log(`✅ 部門篩選正常,找到 ${deptData.data.pagination.totalResults} 筆結果`)
|
||||||
|
} else {
|
||||||
|
console.log('❌ 部門篩選失敗:', deptData.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試測試類型篩選
|
||||||
|
console.log('\n🧠 測試測試類型篩選...')
|
||||||
|
const typeResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results?testType=logic', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeResponse.status === 200) {
|
||||||
|
const typeData = JSON.parse(typeResponse.data)
|
||||||
|
if (typeData.success) {
|
||||||
|
console.log(`✅ 測試類型篩選正常,找到 ${typeData.data.pagination.totalResults} 筆結果`)
|
||||||
|
} else {
|
||||||
|
console.log('❌ 測試類型篩選失敗:', typeData.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試分頁功能
|
||||||
|
console.log('\n📄 測試分頁功能...')
|
||||||
|
const pageResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results?page=1&limit=5', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pageResponse.status === 200) {
|
||||||
|
const pageData = JSON.parse(pageResponse.data)
|
||||||
|
if (pageData.success) {
|
||||||
|
console.log(`✅ 分頁功能正常`)
|
||||||
|
console.log(` 每頁限制: ${pageData.data.pagination.limit}`)
|
||||||
|
console.log(` 當前頁結果數: ${pageData.data.results.length}`)
|
||||||
|
console.log(` 總頁數: ${pageData.data.pagination.totalPages}`)
|
||||||
|
} else {
|
||||||
|
console.log('❌ 分頁功能失敗:', pageData.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎯 功能特點:')
|
||||||
|
console.log('✅ 從資料庫獲取所有測驗結果')
|
||||||
|
console.log('✅ 支援搜尋用戶姓名和郵箱')
|
||||||
|
console.log('✅ 支援部門篩選')
|
||||||
|
console.log('✅ 支援測試類型篩選')
|
||||||
|
console.log('✅ 支援分頁功能')
|
||||||
|
console.log('✅ 顯示詳細統計資料')
|
||||||
|
console.log('✅ 響應式設計(桌面版和手機版)')
|
||||||
|
console.log('✅ 載入狀態和錯誤處理')
|
||||||
|
|
||||||
|
console.log('\n📊 資料來源:')
|
||||||
|
console.log('✅ test_results 表(基本測試結果)')
|
||||||
|
console.log('✅ logic_test_answers 表(邏輯測試詳細答案)')
|
||||||
|
console.log('✅ creative_test_answers 表(創意測試詳細答案)')
|
||||||
|
console.log('✅ combined_test_results 表(綜合測試結果)')
|
||||||
|
console.log('✅ users 表(用戶資訊)')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 測試失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 管理員測驗結果功能測試完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAdminResults()
|
89
scripts/test-export-details.js
Normal file
89
scripts/test-export-details.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const http = require('http')
|
||||||
|
|
||||||
|
const testExportDetails = async () => {
|
||||||
|
console.log('🔍 測試匯出詳細資料')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 測試匯出 API
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results/export', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const data = JSON.parse(response.data)
|
||||||
|
if (data.success) {
|
||||||
|
console.log('✅ 匯出成功')
|
||||||
|
|
||||||
|
// 解碼並檢查 CSV 內容
|
||||||
|
const binaryString = Buffer.from(data.data, 'base64').toString('binary')
|
||||||
|
const csvContent = Buffer.from(binaryString, 'binary').toString('utf-8')
|
||||||
|
const lines = csvContent.split('\n')
|
||||||
|
|
||||||
|
console.log('\n📋 所有綜合測試的詳細資料:')
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (index === 0) return // 跳過標題行
|
||||||
|
|
||||||
|
const columns = line.split(',')
|
||||||
|
if (columns.length >= 8) {
|
||||||
|
const testType = columns[3].replace(/"/g, '')
|
||||||
|
const details = columns[7].replace(/"/g, '')
|
||||||
|
|
||||||
|
if (testType === '綜合能力') {
|
||||||
|
console.log(` 第 ${index} 行: ${details}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢查原始資料
|
||||||
|
console.log('\n🔍 檢查原始 API 資料...')
|
||||||
|
const apiResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (apiResponse.status === 200) {
|
||||||
|
const apiData = JSON.parse(apiResponse.data)
|
||||||
|
if (apiData.success) {
|
||||||
|
const combinedResults = apiData.data.results.filter(result => result.type === 'combined')
|
||||||
|
console.log(`\n📊 API 中的綜合測試結果 (${combinedResults.length} 筆):`)
|
||||||
|
|
||||||
|
combinedResults.forEach((result, index) => {
|
||||||
|
console.log(`\n 結果 ${index + 1}:`)
|
||||||
|
console.log(` 用戶: ${result.userName}`)
|
||||||
|
console.log(` 分數: ${result.score}`)
|
||||||
|
console.log(` 詳細資料:`, JSON.stringify(result.details, null, 2))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 匯出失敗:', data.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ 匯出失敗,狀態碼:', response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 測試失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 匯出詳細資料測試完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testExportDetails()
|
154
scripts/test-export-results.js
Normal file
154
scripts/test-export-results.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
const http = require('http')
|
||||||
|
|
||||||
|
const testExportResults = async () => {
|
||||||
|
console.log('🔍 測試測驗結果匯出功能')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 測試基本匯出
|
||||||
|
console.log('\n📊 測試基本匯出...')
|
||||||
|
const basicResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results/export', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (basicResponse.status === 200) {
|
||||||
|
const basicData = JSON.parse(basicResponse.data)
|
||||||
|
if (basicData.success) {
|
||||||
|
console.log('✅ 基本匯出成功')
|
||||||
|
console.log(`📄 檔案名稱: ${basicData.filename}`)
|
||||||
|
console.log(`📊 內容類型: ${basicData.contentType}`)
|
||||||
|
console.log(`📏 資料大小: ${basicData.data.length} 字元`)
|
||||||
|
|
||||||
|
// 解碼並檢查 CSV 內容
|
||||||
|
const binaryString = Buffer.from(basicData.data, 'base64').toString('binary')
|
||||||
|
const csvContent = Buffer.from(binaryString, 'binary').toString('utf-8')
|
||||||
|
|
||||||
|
console.log('\n📋 CSV 內容預覽:')
|
||||||
|
const lines = csvContent.split('\n')
|
||||||
|
console.log(`總行數: ${lines.length}`)
|
||||||
|
console.log('前 5 行內容:')
|
||||||
|
lines.slice(0, 5).forEach((line, index) => {
|
||||||
|
console.log(` ${index + 1}: ${line}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢查是否包含中文
|
||||||
|
const hasChinese = /[\u4e00-\u9fff]/.test(csvContent)
|
||||||
|
console.log(`✅ 包含中文字符: ${hasChinese ? '是' : '否'}`)
|
||||||
|
|
||||||
|
// 檢查 BOM
|
||||||
|
const hasBOM = csvContent.startsWith('\uFEFF')
|
||||||
|
console.log(`✅ 包含 UTF-8 BOM: ${hasBOM ? '是' : '否'}`)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 基本匯出失敗:', basicData.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ 基本匯出失敗,狀態碼:', basicResponse.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試篩選匯出
|
||||||
|
console.log('\n🔍 測試篩選匯出...')
|
||||||
|
const filterResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results/export?testType=logic&department=人力資源部', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filterResponse.status === 200) {
|
||||||
|
const filterData = JSON.parse(filterResponse.data)
|
||||||
|
if (filterData.success) {
|
||||||
|
console.log('✅ 篩選匯出成功')
|
||||||
|
|
||||||
|
// 解碼並檢查篩選結果
|
||||||
|
const binaryString = Buffer.from(filterData.data, 'base64').toString('binary')
|
||||||
|
const csvContent = Buffer.from(binaryString, 'binary').toString('utf-8')
|
||||||
|
const lines = csvContent.split('\n')
|
||||||
|
|
||||||
|
console.log(`📊 篩選後結果數量: ${lines.length - 1} 筆(扣除標題行)`)
|
||||||
|
|
||||||
|
// 檢查是否只包含邏輯測試
|
||||||
|
const logicLines = lines.filter(line => line.includes('邏輯思維'))
|
||||||
|
console.log(`🧠 邏輯思維測試數量: ${logicLines.length}`)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 篩選匯出失敗:', filterData.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測試搜尋匯出
|
||||||
|
console.log('\n🔍 測試搜尋匯出...')
|
||||||
|
const searchResponse = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results/export?search=王', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (searchResponse.status === 200) {
|
||||||
|
const searchData = JSON.parse(searchResponse.data)
|
||||||
|
if (searchData.success) {
|
||||||
|
console.log('✅ 搜尋匯出成功')
|
||||||
|
|
||||||
|
// 解碼並檢查搜尋結果
|
||||||
|
const binaryString = Buffer.from(searchData.data, 'base64').toString('binary')
|
||||||
|
const csvContent = Buffer.from(binaryString, 'binary').toString('utf-8')
|
||||||
|
const lines = csvContent.split('\n')
|
||||||
|
|
||||||
|
console.log(`📊 搜尋結果數量: ${lines.length - 1} 筆(扣除標題行)`)
|
||||||
|
|
||||||
|
// 檢查是否只包含包含「王」的結果
|
||||||
|
const wangLines = lines.filter(line => line.includes('王'))
|
||||||
|
console.log(`👤 包含「王」的結果數量: ${wangLines.length}`)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 搜尋匯出失敗:', searchData.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎯 匯出功能特點:')
|
||||||
|
console.log('✅ 支援 CSV 格式匯出')
|
||||||
|
console.log('✅ 包含 UTF-8 BOM,確保中文正確顯示')
|
||||||
|
console.log('✅ 支援篩選條件匯出')
|
||||||
|
console.log('✅ 包含詳細的測試結果資料')
|
||||||
|
console.log('✅ 自動生成檔案名稱(包含日期)')
|
||||||
|
console.log('✅ 支援搜尋、部門、測試類型篩選')
|
||||||
|
|
||||||
|
console.log('\n📊 匯出欄位:')
|
||||||
|
console.log('✅ 用戶姓名')
|
||||||
|
console.log('✅ 用戶郵箱')
|
||||||
|
console.log('✅ 部門')
|
||||||
|
console.log('✅ 測試類型')
|
||||||
|
console.log('✅ 分數')
|
||||||
|
console.log('✅ 等級')
|
||||||
|
console.log('✅ 完成時間')
|
||||||
|
console.log('✅ 詳細資料(根據測試類型不同)')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 測試失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 測驗結果匯出功能測試完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testExportResults()
|
@@ -1,14 +1,12 @@
|
|||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
|
||||||
const testExportSimple = async () => {
|
const testExportSimple = async () => {
|
||||||
console.log('🔍 測試簡化匯出功能')
|
console.log('🔍 簡單測試匯出功能')
|
||||||
console.log('=' .repeat(30))
|
console.log('=' .repeat(30))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 先測試獲取題目資料
|
const response = await new Promise((resolve, reject) => {
|
||||||
console.log('\n📊 測試獲取邏輯題目資料...')
|
const req = http.get('http://localhost:3000/api/admin/test-results/export', (res) => {
|
||||||
const logicResponse = await new Promise((resolve, reject) => {
|
|
||||||
const req = http.get('http://localhost:3000/api/questions/logic', (res) => {
|
|
||||||
let data = ''
|
let data = ''
|
||||||
res.on('data', chunk => data += chunk)
|
res.on('data', chunk => data += chunk)
|
||||||
res.on('end', () => resolve({
|
res.on('end', () => resolve({
|
||||||
@@ -19,50 +17,40 @@ const testExportSimple = async () => {
|
|||||||
req.on('error', reject)
|
req.on('error', reject)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`狀態碼: ${logicResponse.status}`)
|
if (response.status === 200) {
|
||||||
|
const data = JSON.parse(response.data)
|
||||||
|
if (data.success) {
|
||||||
|
console.log('✅ 匯出成功')
|
||||||
|
|
||||||
if (logicResponse.status === 200) {
|
// 解碼並檢查 CSV 內容
|
||||||
const logicData = JSON.parse(logicResponse.data)
|
const binaryString = Buffer.from(data.data, 'base64').toString('binary')
|
||||||
console.log(`成功獲取 ${logicData.data?.length || 0} 道邏輯題目`)
|
const csvContent = Buffer.from(binaryString, 'binary').toString('utf-8')
|
||||||
|
|
||||||
if (logicData.data && logicData.data.length > 0) {
|
// 只顯示前幾行
|
||||||
const firstQuestion = logicData.data[0]
|
const lines = csvContent.split('\n')
|
||||||
console.log(`第一題: ${firstQuestion.question?.substring(0, 50)}...`)
|
console.log('\n📋 CSV 前 10 行:')
|
||||||
console.log(`選項A: ${firstQuestion.option_a}`)
|
lines.slice(0, 10).forEach((line, index) => {
|
||||||
console.log(`正確答案: ${firstQuestion.correct_answer}`)
|
console.log(`${index + 1}: ${line}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢查是否有「創意」和「平衡」字樣
|
||||||
|
const hasCreative = csvContent.includes('創意')
|
||||||
|
const hasBalance = csvContent.includes('平衡')
|
||||||
|
console.log(`\n🔍 檢查結果:`)
|
||||||
|
console.log(` 包含「創意」: ${hasCreative ? '是' : '否'}`)
|
||||||
|
console.log(` 包含「平衡」: ${hasBalance ? '是' : '否'}`)
|
||||||
|
|
||||||
|
if (hasCreative && hasBalance) {
|
||||||
|
console.log('✅ 修復成功!')
|
||||||
|
} else {
|
||||||
|
console.log('❌ 仍有問題')
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 匯出失敗:', data.message)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ 獲取邏輯題目失敗')
|
console.log('❌ 匯出失敗,狀態碼:', response.status)
|
||||||
}
|
|
||||||
|
|
||||||
// 測試創意題目
|
|
||||||
console.log('\n📊 測試獲取創意題目資料...')
|
|
||||||
const creativeResponse = await new Promise((resolve, reject) => {
|
|
||||||
const req = http.get('http://localhost:3000/api/questions/creative', (res) => {
|
|
||||||
let data = ''
|
|
||||||
res.on('data', chunk => data += chunk)
|
|
||||||
res.on('end', () => resolve({
|
|
||||||
status: res.statusCode,
|
|
||||||
data: data
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
req.on('error', reject)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`狀態碼: ${creativeResponse.status}`)
|
|
||||||
|
|
||||||
if (creativeResponse.status === 200) {
|
|
||||||
const creativeData = JSON.parse(creativeResponse.data)
|
|
||||||
console.log(`成功獲取 ${creativeData.data?.length || 0} 道創意題目`)
|
|
||||||
|
|
||||||
if (creativeData.data && creativeData.data.length > 0) {
|
|
||||||
const firstQuestion = creativeData.data[0]
|
|
||||||
console.log(`第一題: ${firstQuestion.statement?.substring(0, 50)}...`)
|
|
||||||
console.log(`類別: ${firstQuestion.category}`)
|
|
||||||
console.log(`反向計分: ${firstQuestion.is_reverse}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('❌ 獲取創意題目失敗')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
106
scripts/test-fixed-export.js
Normal file
106
scripts/test-fixed-export.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const http = require('http')
|
||||||
|
|
||||||
|
const testFixedExport = async () => {
|
||||||
|
console.log('🔍 測試修復後的匯出功能')
|
||||||
|
console.log('=' .repeat(40))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 測試基本匯出
|
||||||
|
console.log('\n📊 測試修復後的匯出...')
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://localhost:3000/api/admin/test-results/export', (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', chunk => data += chunk)
|
||||||
|
res.on('end', () => resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
data: data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const data = JSON.parse(response.data)
|
||||||
|
if (data.success) {
|
||||||
|
console.log('✅ 匯出成功')
|
||||||
|
|
||||||
|
// 解碼並檢查 CSV 內容
|
||||||
|
const binaryString = Buffer.from(data.data, 'base64').toString('binary')
|
||||||
|
const csvContent = Buffer.from(binaryString, 'binary').toString('utf-8')
|
||||||
|
const lines = csvContent.split('\n')
|
||||||
|
|
||||||
|
console.log(`📊 總行數: ${lines.length}`)
|
||||||
|
console.log('\n📋 檢查詳細資料欄位:')
|
||||||
|
|
||||||
|
let hasUndefined = false
|
||||||
|
let combinedCount = 0
|
||||||
|
let logicCount = 0
|
||||||
|
let creativeCount = 0
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (index === 0) return // 跳過標題行
|
||||||
|
|
||||||
|
const columns = line.split(',')
|
||||||
|
if (columns.length >= 8) {
|
||||||
|
const testType = columns[3].replace(/"/g, '')
|
||||||
|
const details = columns[7].replace(/"/g, '')
|
||||||
|
|
||||||
|
if (testType === '綜合能力') {
|
||||||
|
combinedCount++
|
||||||
|
console.log(` 綜合能力測試 ${combinedCount}: ${details}`)
|
||||||
|
if (details.includes('undefined')) {
|
||||||
|
hasUndefined = true
|
||||||
|
console.log(` ❌ 發現 undefined: ${details}`)
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ 無 undefined`)
|
||||||
|
}
|
||||||
|
} else if (testType === '邏輯思維') {
|
||||||
|
logicCount++
|
||||||
|
console.log(` 邏輯思維測試 ${logicCount}: ${details}`)
|
||||||
|
} else if (testType === '創意能力') {
|
||||||
|
creativeCount++
|
||||||
|
console.log(` 創意能力測試 ${creativeCount}: ${details}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('\n📊 統計:')
|
||||||
|
console.log(` 綜合能力測試: ${combinedCount} 筆`)
|
||||||
|
console.log(` 邏輯思維測試: ${logicCount} 筆`)
|
||||||
|
console.log(` 創意能力測試: ${creativeCount} 筆`)
|
||||||
|
|
||||||
|
if (hasUndefined) {
|
||||||
|
console.log('\n❌ 仍然發現 undefined 值')
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ 所有詳細資料欄位都沒有 undefined 值')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查特定修復
|
||||||
|
console.log('\n🔧 檢查修復項目:')
|
||||||
|
const hasUndefinedInCombined = csvContent.includes('undefined')
|
||||||
|
const hasNoDataPlaceholder = csvContent.includes('無資料')
|
||||||
|
|
||||||
|
console.log(` 包含 undefined: ${hasUndefinedInCombined ? '是' : '否'}`)
|
||||||
|
console.log(` 包含「無資料」: ${hasNoDataPlaceholder ? '是' : '否'}`)
|
||||||
|
|
||||||
|
if (!hasUndefinedInCombined && hasNoDataPlaceholder) {
|
||||||
|
console.log('✅ 修復成功!undefined 已被替換為「無資料」')
|
||||||
|
} else if (hasUndefinedInCombined) {
|
||||||
|
console.log('❌ 修復失敗,仍有 undefined 值')
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('❌ 匯出失敗:', data.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ 匯出失敗,狀態碼:', response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 測試失敗:', error.message)
|
||||||
|
} finally {
|
||||||
|
console.log('\n✅ 修復後匯出功能測試完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testFixedExport()
|
Reference in New Issue
Block a user