Files
hr-assessment-system/app/admin/questions/page.tsx

817 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { ProtectedRoute } from "@/components/protected-route"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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 { Alert, AlertDescription } from "@/components/ui/alert"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
ArrowLeft,
Upload,
Download,
Brain,
Lightbulb,
FileSpreadsheet,
CheckCircle,
AlertCircle,
Info,
Loader2,
ChevronLeft,
ChevronRight,
} from "lucide-react"
import Link from "next/link"
import { parseExcelFile, type ImportResult, exportLogicQuestionsToExcel, exportCreativeQuestionsToExcel } from "@/lib/utils/excel-parser"
// 定義題目類型
interface LogicQuestion {
id: number
question: string
option_a: string
option_b: string
option_c: string
option_d: string
option_e?: string
correct_answer: string
explanation: string
}
interface CreativeQuestion {
id: number
statement: string
category: string
is_reverse: boolean
}
export default function QuestionsManagementPage() {
return (
<ProtectedRoute adminOnly>
<QuestionsManagementContent />
</ProtectedRoute>
)
}
function QuestionsManagementContent() {
const [activeTab, setActiveTab] = useState("logic")
const [isImporting, setIsImporting] = useState(false)
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [importType, setImportType] = useState<"logic" | "creative">("logic")
// 題目狀態
const [logicQuestions, setLogicQuestions] = useState<LogicQuestion[]>([])
const [creativeQuestions, setCreativeQuestions] = useState<CreativeQuestion[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 分頁狀態
const [currentPage, setCurrentPage] = useState(1)
const [currentLogicPage, setCurrentLogicPage] = useState(1)
const [itemsPerPage] = useState(10)
// 分頁計算
const totalPages = Math.ceil(creativeQuestions.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentCreativeQuestions = creativeQuestions.slice(startIndex, endIndex)
// 邏輯題目分頁計算
const totalLogicPages = Math.ceil(logicQuestions.length / itemsPerPage)
const logicStartIndex = (currentLogicPage - 1) * itemsPerPage
const logicEndIndex = logicStartIndex + itemsPerPage
const currentLogicQuestions = logicQuestions.slice(logicStartIndex, logicEndIndex)
// 分頁處理函數
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePreviousPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1)
}
}
// 邏輯題目分頁處理函數
const handleLogicPageChange = (page: number) => {
setCurrentLogicPage(page)
}
const handleLogicPreviousPage = () => {
if (currentLogicPage > 1) {
setCurrentLogicPage(currentLogicPage - 1)
}
}
const handleLogicNextPage = () => {
if (currentLogicPage < totalLogicPages) {
setCurrentLogicPage(currentLogicPage + 1)
}
}
// 從資料庫獲取題目
useEffect(() => {
const fetchQuestions = async () => {
try {
setIsLoading(true)
setError(null)
// 並行獲取兩種題目
const [logicResponse, creativeResponse] = await Promise.all([
fetch('/api/questions/logic'),
fetch('/api/questions/creative')
])
if (!logicResponse.ok || !creativeResponse.ok) {
throw new Error('獲取題目失敗')
}
const logicData = await logicResponse.json()
const creativeData = await creativeResponse.json()
if (logicData.success) {
setLogicQuestions(logicData.data)
}
if (creativeData.success) {
setCreativeQuestions(creativeData.data)
}
} catch (err) {
console.error('獲取題目失敗:', err)
setError(err instanceof Error ? err.message : '獲取題目失敗')
} finally {
setIsLoading(false)
}
}
fetchQuestions()
}, [])
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
if (
file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
file.type === "application/vnd.ms-excel" ||
file.name.endsWith(".xlsx") ||
file.name.endsWith(".xls")
) {
setSelectedFile(file)
setImportResult(null)
} else {
setImportResult({
success: false,
message: "請選擇 Excel 檔案 (.xlsx 或 .xls)",
})
}
}
}
const handleImport = async () => {
if (!selectedFile) {
setImportResult({
success: false,
message: "請先選擇要匯入的 Excel 檔案",
})
return
}
setIsImporting(true)
try {
const formData = new FormData()
formData.append("file", selectedFile)
formData.append("type", importType)
const response = await fetch("/api/questions/import", {
method: "POST",
body: formData,
})
const result = await response.json()
setImportResult(result)
if (result.success) {
setSelectedFile(null)
// 重置檔案輸入
const fileInput = document.getElementById("file-input") as HTMLInputElement
if (fileInput) fileInput.value = ""
// 重新載入題目資料
const fetchQuestions = async () => {
try {
const [logicResponse, creativeResponse] = await Promise.all([
fetch('/api/questions/logic'),
fetch('/api/questions/creative')
])
if (logicResponse.ok) {
const logicData = await logicResponse.json()
if (logicData.success) {
setLogicQuestions(logicData.data)
}
}
if (creativeResponse.ok) {
const creativeData = await creativeResponse.json()
if (creativeData.success) {
setCreativeQuestions(creativeData.data)
}
}
} catch (err) {
console.error('重新載入題目失敗:', err)
}
}
fetchQuestions()
}
} catch (error) {
setImportResult({
success: false,
message: "匯入失敗,請檢查檔案格式是否正確",
errors: [error instanceof Error ? error.message : "未知錯誤"],
})
} finally {
setIsImporting(false)
}
}
const downloadTemplate = async (type: "logic" | "creative") => {
try {
const response = await fetch(`/api/questions/export?type=${type}`)
if (!response.ok) {
throw new Error('下載失敗')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.message || '下載失敗')
}
// 解碼 Base64 資料,保留 UTF-8 BOM
const binaryString = atob(result.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 = result.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error("下載範本失敗:", error)
setImportResult({
success: false,
message: "下載範本失敗,請稍後再試",
})
}
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card/50 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="w-4 h-4 mr-2" />
<span className="hidden sm:inline"></span>
</Link>
</Button>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</div>
</header>
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto space-y-8">
{/* Import Section */}
<Card className="border-2 border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Excel
</CardTitle>
<CardDescription> Excel </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* File Upload */}
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="file-input"> Excel </Label>
<Input
id="file-input"
type="file"
accept=".xlsx,.xls"
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={importType} onValueChange={(value: "logic" | "creative") => setImportType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="logic"></SelectItem>
<SelectItem value="creative"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{selectedFile && (
<div className="flex items-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
<FileSpreadsheet className="w-4 h-4 text-blue-600" />
<span className="text-sm text-blue-800">{selectedFile.name}</span>
</div>
)}
<div className="flex gap-3">
<Button onClick={handleImport} disabled={!selectedFile || isImporting} className="flex-1">
{isImporting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Import Result */}
{importResult && (
<Alert variant={importResult.success ? "default" : "destructive"}>
{importResult.success ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
<AlertDescription>
{importResult.message}
{importResult.errors && (
<ul className="mt-2 list-disc list-inside">
{importResult.errors.map((error, index) => (
<li key={index} className="text-sm">
{error}
</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
{/* Template Download */}
<div className="border-t pt-4">
<div className="flex items-center gap-2 mb-3">
<Info className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => downloadTemplate("logic")} className="flex-1">
<Download className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => downloadTemplate("creative")} className="flex-1">
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Current Questions */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="logic" className="flex items-center gap-2">
<Brain className="w-4 h-4" />
({logicQuestions.length})
</TabsTrigger>
<TabsTrigger value="creative" className="flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
({creativeQuestions.length})
</TabsTrigger>
</TabsList>
<TabsContent value="logic" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold"></h3>
<Badge variant="outline">{logicQuestions.length} </Badge>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentLogicQuestions.map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium">{question.id}</TableCell>
<TableCell className="max-w-md truncate">{question.question}</TableCell>
<TableCell>
{question.option_e ? 5 : 4}
</TableCell>
<TableCell>
<Badge className="bg-green-500 text-white">{question.correct_answer}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* 邏輯題目分頁控制 */}
{!isLoading && !error && logicQuestions.length > itemsPerPage && (
<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">
{logicStartIndex + 1} - {Math.min(logicEndIndex, logicQuestions.length)} {logicQuestions.length}
</div>
{/* Desktop Pagination */}
<div className="hidden sm:flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleLogicPreviousPage}
disabled={currentLogicPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: totalLogicPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={currentLogicPage === page ? "default" : "outline"}
size="sm"
onClick={() => handleLogicPageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleLogicNextPage}
disabled={currentLogicPage === totalLogicPages}
>
<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={handleLogicPreviousPage}
disabled={currentLogicPage === 1}
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, currentLogicPage - 1)
const endPage = Math.min(totalLogicPages, startPage + maxVisiblePages - 1)
const pages = []
// 如果不在第一頁,顯示第一頁和省略號
if (startPage > 1) {
pages.push(
<Button
key={1}
variant="outline"
size="sm"
onClick={() => handleLogicPageChange(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={currentLogicPage === i ? "default" : "outline"}
size="sm"
onClick={() => handleLogicPageChange(i)}
className="w-8 h-8 p-0"
>
{i}
</Button>
)
}
// 如果不在最後一頁,顯示省略號和最後一頁
if (endPage < totalLogicPages) {
if (endPage < totalLogicPages - 1) {
pages.push(
<span key="ellipsis2" className="text-muted-foreground px-1">
...
</span>
)
}
pages.push(
<Button
key={totalLogicPages}
variant="outline"
size="sm"
onClick={() => handleLogicPageChange(totalLogicPages)}
className="w-8 h-8 p-0"
>
{totalLogicPages}
</Button>
)
}
return pages
})()}
</div>
<Button
variant="outline"
size="sm"
onClick={handleLogicNextPage}
disabled={currentLogicPage === totalLogicPages}
className="flex-1 max-w-[80px]"
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="creative" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold"></h3>
<Badge variant="outline">{creativeQuestions.length} </Badge>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentCreativeQuestions.map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium">{question.id}</TableCell>
<TableCell className="max-w-md truncate">{question.statement}</TableCell>
<TableCell>
<Badge variant="secondary">{question.category}</Badge>
</TableCell>
<TableCell>
{question.is_reverse ? (
<Badge className="bg-orange-500 text-white"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* 創意題目分頁控制 */}
{!isLoading && !error && creativeQuestions.length > itemsPerPage && (
<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">
{startIndex + 1} - {Math.min(endIndex, creativeQuestions.length)} {creativeQuestions.length}
</div>
{/* Desktop Pagination */}
<div className="hidden sm:flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={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={currentPage === totalPages}
>
<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={currentPage === 1}
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, currentPage - 1)
const endPage = Math.min(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={currentPage === i ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(i)}
className="w-8 h-8 p-0"
>
{i}
</Button>
)
}
// 如果不在最後一頁,顯示省略號和最後一頁
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push(
<span key="ellipsis2" className="text-muted-foreground px-1">
...
</span>
)
}
pages.push(
<Button
key={totalPages}
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
className="w-8 h-8 p-0"
>
{totalPages}
</Button>
)
}
return pages
})()}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="flex-1 max-w-[80px]"
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</div>
)
}