實作 Excel 匯出匯入題目管理
This commit is contained in:
@@ -28,7 +28,7 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { parseExcelFile, type ImportResult } from "@/lib/utils/excel-parser"
|
||||
import { parseExcelFile, type ImportResult, exportLogicQuestionsToExcel, exportCreativeQuestionsToExcel } from "@/lib/utils/excel-parser"
|
||||
|
||||
// 定義題目類型
|
||||
interface LogicQuestion {
|
||||
@@ -73,6 +73,7 @@ function QuestionsManagementContent() {
|
||||
|
||||
// 分頁狀態
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [currentLogicPage, setCurrentLogicPage] = useState(1)
|
||||
const [itemsPerPage] = useState(10)
|
||||
|
||||
// 分頁計算
|
||||
@@ -81,6 +82,12 @@ function QuestionsManagementContent() {
|
||||
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)
|
||||
@@ -98,6 +105,23 @@ function QuestionsManagementContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// 邏輯題目分頁處理函數
|
||||
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 () => {
|
||||
@@ -168,7 +192,16 @@ function QuestionsManagementContent() {
|
||||
setIsImporting(true)
|
||||
|
||||
try {
|
||||
const result = await parseExcelFile(selectedFile, importType)
|
||||
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) {
|
||||
@@ -176,6 +209,34 @@ function QuestionsManagementContent() {
|
||||
// 重置檔案輸入
|
||||
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({
|
||||
@@ -188,46 +249,47 @@ function QuestionsManagementContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTemplate = (type: "logic" | "creative") => {
|
||||
let csvContent = ""
|
||||
const downloadTemplate = async (type: "logic" | "creative") => {
|
||||
try {
|
||||
const response = await fetch(`/api/questions/export?type=${type}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('下載失敗')
|
||||
}
|
||||
|
||||
if (type === "logic") {
|
||||
csvContent = [
|
||||
["題目ID", "題目內容", "選項A", "選項B", "選項C", "選項D", "正確答案", "解釋"],
|
||||
[
|
||||
"1",
|
||||
"範例題目:如果所有A都是B,所有B都是C,那麼?",
|
||||
"所有A都是C",
|
||||
"所有C都是A",
|
||||
"有些A不是C",
|
||||
"無法確定",
|
||||
"A",
|
||||
"根據邏輯推理...",
|
||||
],
|
||||
["2", "在序列 2, 4, 8, 16, ? 中,下一個數字是?", "24", "32", "30", "28", "B", "每個數字都是前一個數字的2倍"],
|
||||
]
|
||||
.map((row) => row.join(","))
|
||||
.join("\n")
|
||||
} else {
|
||||
csvContent = [
|
||||
["題目ID", "陳述內容", "類別", "是否反向計分"],
|
||||
["1", "我經常能想出創新的解決方案", "innovation", "否"],
|
||||
["2", "我更喜歡按照既定規則工作", "flexibility", "是"],
|
||||
["3", "我喜歡嘗試新的做事方法", "innovation", "否"],
|
||||
]
|
||||
.map((row) => row.join(","))
|
||||
.join("\n")
|
||||
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: "下載範本失敗,請稍後再試",
|
||||
})
|
||||
}
|
||||
|
||||
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", `${type === "logic" ? "邏輯思維" : "創意能力"}題目範本.csv`)
|
||||
link.style.visibility = "hidden"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -398,7 +460,7 @@ function QuestionsManagementContent() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logicQuestions.map((question) => (
|
||||
{currentLogicQuestions.map((question) => (
|
||||
<TableRow key={question.id}>
|
||||
<TableCell className="font-medium">{question.id}</TableCell>
|
||||
<TableCell className="max-w-md truncate">{question.question}</TableCell>
|
||||
@@ -413,6 +475,147 @@ function QuestionsManagementContent() {
|
||||
</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">
|
||||
|
133
app/api/questions/export/route.ts
Normal file
133
app/api/questions/export/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getAllLogicQuestions } from "@/lib/database/models/logic_question"
|
||||
import { getAllCreativeQuestions } from "@/lib/database/models/creative_question"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const type = searchParams.get("type") as "logic" | "creative"
|
||||
|
||||
if (!type) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "缺少題目類型參數" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "logic") {
|
||||
const questions = await getAllLogicQuestions()
|
||||
|
||||
// 生成 CSV 格式的資料
|
||||
const headers = [
|
||||
"題目ID",
|
||||
"題目內容",
|
||||
"選項A",
|
||||
"選項B",
|
||||
"選項C",
|
||||
"選項D",
|
||||
"選項E",
|
||||
"正確答案",
|
||||
"解釋"
|
||||
]
|
||||
|
||||
const data = questions.map(q => [
|
||||
q.id,
|
||||
q.question,
|
||||
q.option_a,
|
||||
q.option_b,
|
||||
q.option_c,
|
||||
q.option_d,
|
||||
q.option_e || "",
|
||||
q.correct_answer,
|
||||
q.explanation
|
||||
])
|
||||
|
||||
// 轉換為 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: "邏輯思維題目範本.csv",
|
||||
contentType: "text/csv; charset=utf-8"
|
||||
}), {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
|
||||
} else {
|
||||
const questions = await getAllCreativeQuestions()
|
||||
|
||||
// 生成 CSV 格式的資料
|
||||
const headers = [
|
||||
"題目ID",
|
||||
"陳述內容",
|
||||
"類別",
|
||||
"反向計分"
|
||||
]
|
||||
|
||||
const data = questions.map(q => [
|
||||
q.id,
|
||||
q.statement,
|
||||
q.category,
|
||||
q.is_reverse ? "是" : "否"
|
||||
])
|
||||
|
||||
// 轉換為 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: "創意能力題目範本.csv",
|
||||
contentType: "text/csv; charset=utf-8"
|
||||
}), {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("匯出題目失敗:", error)
|
||||
console.error("錯誤詳情:", error instanceof Error ? error.message : String(error))
|
||||
console.error("錯誤堆疊:", error instanceof Error ? error.stack : "無堆疊資訊")
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "匯出失敗", error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
269
app/api/questions/import/route.ts
Normal file
269
app/api/questions/import/route.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import * as XLSX from "xlsx"
|
||||
import {
|
||||
createLogicQuestion,
|
||||
updateLogicQuestion,
|
||||
getAllLogicQuestions,
|
||||
clearLogicQuestions
|
||||
} from "@/lib/database/models/logic_question"
|
||||
import {
|
||||
createCreativeQuestion,
|
||||
updateCreativeQuestion,
|
||||
getAllCreativeQuestions,
|
||||
clearCreativeQuestions
|
||||
} from "@/lib/database/models/creative_question"
|
||||
|
||||
// 定義解析結果介面
|
||||
interface ImportResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: any[]
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
// 解析邏輯題目
|
||||
function parseLogicQuestions(data: any[][]): ImportResult {
|
||||
const errors: string[] = []
|
||||
const questions: any[] = []
|
||||
|
||||
// 跳過標題行
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i]
|
||||
if (!row || row.length < 7) continue
|
||||
|
||||
try {
|
||||
const question = {
|
||||
id: Number.parseInt(row[0]) || i,
|
||||
question: row[1]?.toString() || "",
|
||||
option_a: row[2]?.toString() || "",
|
||||
option_b: row[3]?.toString() || "",
|
||||
option_c: row[4]?.toString() || "",
|
||||
option_d: row[5]?.toString() || "",
|
||||
option_e: row[6]?.toString() || undefined,
|
||||
correct_answer: row[7]?.toString() || "",
|
||||
explanation: row[8]?.toString() || "",
|
||||
}
|
||||
|
||||
// 驗證必填欄位
|
||||
if (
|
||||
!question.question ||
|
||||
!question.option_a ||
|
||||
!question.option_b ||
|
||||
!question.option_c ||
|
||||
!question.option_d ||
|
||||
!question.correct_answer
|
||||
) {
|
||||
errors.push(`第 ${i + 1} 行:缺少必填欄位`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 驗證正確答案格式
|
||||
const validAnswers = question.option_e ? ["A", "B", "C", "D", "E"] : ["A", "B", "C", "D"]
|
||||
if (!validAnswers.includes(question.correct_answer.toUpperCase())) {
|
||||
errors.push(`第 ${i + 1} 行:正確答案必須是 ${validAnswers.join("、")}`)
|
||||
continue
|
||||
}
|
||||
|
||||
questions.push(question)
|
||||
} catch (error) {
|
||||
errors.push(`第 ${i + 1} 行:資料格式錯誤`)
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "沒有找到有效的題目資料",
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `成功解析 ${questions.length} 道題目`,
|
||||
data: questions,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 解析創意題目
|
||||
function parseCreativeQuestions(data: any[][]): ImportResult {
|
||||
const errors: string[] = []
|
||||
const questions: any[] = []
|
||||
|
||||
// 跳過標題行
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i]
|
||||
if (!row || row.length < 4) continue
|
||||
|
||||
try {
|
||||
const question = {
|
||||
id: Number.parseInt(row[0]) || i,
|
||||
statement: row[1]?.toString() || "",
|
||||
category: (row[2]?.toString().toLowerCase() as any) || "innovation",
|
||||
is_reverse: row[3]?.toString().toLowerCase() === "是" || row[3]?.toString().toLowerCase() === "true",
|
||||
}
|
||||
|
||||
// 驗證必填欄位
|
||||
if (!question.statement) {
|
||||
errors.push(`第 ${i + 1} 行:缺少陳述內容`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 驗證類別
|
||||
const validCategories = ["innovation", "imagination", "flexibility", "originality"]
|
||||
if (!validCategories.includes(question.category)) {
|
||||
errors.push(`第 ${i + 1} 行:類別必須是 innovation、imagination、flexibility 或 originality`)
|
||||
continue
|
||||
}
|
||||
|
||||
questions.push(question)
|
||||
} catch (error) {
|
||||
errors.push(`第 ${i + 1} 行:資料格式錯誤`)
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "沒有找到有效的題目資料",
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `成功解析 ${questions.length} 道題目`,
|
||||
data: questions,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("file") as File
|
||||
const type = formData.get("type") as "logic" | "creative"
|
||||
|
||||
if (!file || !type) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "缺少檔案或題目類型" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`開始處理 ${type} 題目匯入,檔案大小: ${file.size} bytes`)
|
||||
|
||||
// 讀取檔案內容
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
console.log(`檔案讀取完成,大小: ${arrayBuffer.byteLength} bytes`)
|
||||
|
||||
// 根據檔案類型處理
|
||||
let jsonData
|
||||
if (file.name.endsWith('.csv')) {
|
||||
// 處理 CSV 檔案
|
||||
const text = new TextDecoder('utf-8').decode(arrayBuffer)
|
||||
const lines = text.split('\n').filter(line => line.trim())
|
||||
jsonData = lines.map(line => {
|
||||
// 簡單的 CSV 解析(處理引號包圍的欄位)
|
||||
const result = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim())
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
result.push(current.trim())
|
||||
return result
|
||||
})
|
||||
} else {
|
||||
// 處理 Excel 檔案
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[sheetName]
|
||||
jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 })
|
||||
}
|
||||
|
||||
console.log(`資料解析完成,共 ${jsonData.length} 行`)
|
||||
|
||||
// 解析資料
|
||||
let result
|
||||
if (type === "logic") {
|
||||
result = parseLogicQuestions(jsonData as any[][])
|
||||
} else {
|
||||
result = parseCreativeQuestions(jsonData as any[][])
|
||||
}
|
||||
|
||||
console.log(`解析結果: ${result.success ? '成功' : '失敗'}, ${result.message}`)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
errors: result.errors
|
||||
})
|
||||
}
|
||||
|
||||
if (type === "logic") {
|
||||
const questions = result.data as any[]
|
||||
|
||||
// 清空現有邏輯題目
|
||||
await clearLogicQuestions()
|
||||
|
||||
// 插入新題目
|
||||
for (const question of questions) {
|
||||
await createLogicQuestion({
|
||||
question: question.question,
|
||||
option_a: question.option_a,
|
||||
option_b: question.option_b,
|
||||
option_c: question.option_c,
|
||||
option_d: question.option_d,
|
||||
option_e: question.option_e || null,
|
||||
correct_answer: question.correct_answer,
|
||||
explanation: question.explanation
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `成功匯入 ${questions.length} 道邏輯思維題目`,
|
||||
count: questions.length
|
||||
})
|
||||
|
||||
} else {
|
||||
const questions = result.data as any[]
|
||||
|
||||
// 清空現有創意題目
|
||||
await clearCreativeQuestions()
|
||||
|
||||
// 插入新題目
|
||||
for (const question of questions) {
|
||||
await createCreativeQuestion({
|
||||
statement: question.statement,
|
||||
category: question.category,
|
||||
is_reverse: question.is_reverse
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `成功匯入 ${questions.length} 道創意能力題目`,
|
||||
count: questions.length
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("匯入題目失敗:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "匯入失敗,請檢查檔案格式" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
20
app/api/test-export/route.ts
Normal file
20
app/api/test-export/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const csvContent = "題目ID,題目內容,選項A,選項B,選項C,選項D,正確答案,解釋\n1,測試題目,選項A,選項B,選項C,選項D,A,測試解釋"
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": "attachment; filename=test.csv"
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("測試匯出失敗:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "測試匯出失敗" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user