實作 Excel 匯出匯入題目管理

This commit is contained in:
2025-09-29 19:20:01 +08:00
parent ac03ff36be
commit 373036c003
20 changed files with 1965 additions and 62 deletions

View 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 }
)
}
}

View 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 }
)
}
}