Spaces:
Paused
Paused
| import { useEffect, useRef, useState } from "react"; | |
| import { apiGet, apiUpload } from "../../lib/api"; | |
| import { ModelSelector } from "../ModelSelector"; | |
| interface ParserOption { | |
| id: string; | |
| name: string; | |
| description: string; | |
| } | |
| interface FileUploadPanelProps { | |
| sessionId: number; | |
| parsedData: Record<string, unknown>; | |
| onParsedDataUpdate: (dataType: string, data: unknown) => void; | |
| onGoToStep2: () => void; | |
| } | |
| interface UploadZone { | |
| type: "questions" | "answers"; | |
| label: string; | |
| description: string; | |
| placeholder: string; | |
| icon: string; | |
| } | |
| const zones: UploadZone[] = [ | |
| { | |
| type: "questions", | |
| label: "考試題目", | |
| description: "上傳考試試卷(任意格式)", | |
| placeholder: "例如:國中三年級數學期中考,共 20 題選擇題和 5 題計算題", | |
| icon: "📝", | |
| }, | |
| { | |
| type: "answers", | |
| label: "答案", | |
| description: "上傳作答一覽表(PDF / CSV / 圖片)", | |
| placeholder: | |
| "例如:A 卷的考生作答一覽表(CSV 第 1 列為題號標題,第 2 列為標準答案,第 3 列起為學生)", | |
| icon: "✅", | |
| }, | |
| ]; | |
| export function FileUploadPanel({ | |
| sessionId, | |
| parsedData, | |
| onParsedDataUpdate, | |
| onGoToStep2, | |
| }: FileUploadPanelProps) { | |
| const [files, setFiles] = useState<Record<string, File[]>>({ | |
| questions: [], | |
| answers: [], | |
| }); | |
| const [descriptions, setDescriptions] = useState<Record<string, string>>({ | |
| questions: "", | |
| answers: "", | |
| }); | |
| const [analyzing, setAnalyzing] = useState<Record<string, boolean>>({}); | |
| const [errors, setErrors] = useState<Record<string, string>>({}); | |
| const [model, setModel] = useState("gpt-5.4"); | |
| const [parsers, setParsers] = useState<ParserOption[]>([]); | |
| const [parserByZone, setParserByZone] = useState<Record<string, string>>({ | |
| questions: "auto", | |
| answers: "auto", | |
| }); | |
| const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({}); | |
| useEffect(() => { | |
| apiGet<{ parsers: ParserOption[] }>("/api/parsers") | |
| .then((res) => setParsers(res.parsers)) | |
| .catch(() => {}); | |
| }, []); | |
| const handleFileSelect = (type: string, selected: FileList | null) => { | |
| if (!selected) return; | |
| setFiles((prev) => ({ ...prev, [type]: Array.from(selected) })); | |
| setErrors((prev) => ({ ...prev, [type]: "" })); | |
| }; | |
| const handleDrop = (type: string, e: React.DragEvent) => { | |
| e.preventDefault(); | |
| handleFileSelect(type, e.dataTransfer.files); | |
| }; | |
| const handleAnalyze = async (type: string) => { | |
| const typeFiles = files[type]; | |
| if (!typeFiles.length) return; | |
| setAnalyzing((prev) => ({ ...prev, [type]: true })); | |
| setErrors((prev) => ({ ...prev, [type]: "" })); | |
| try { | |
| const formData = new FormData(); | |
| formData.append("data_type", type); | |
| formData.append("description", descriptions[type] || ""); | |
| formData.append("model", model); | |
| formData.append("parser", parserByZone[type] || "auto"); | |
| for (const f of typeFiles) { | |
| formData.append("files", f); | |
| } | |
| const res = await apiUpload<{ data: unknown }>(`/api/sessions/${sessionId}/upload`, formData); | |
| if (type === "answers") { | |
| const data = res.data as { | |
| student_answers?: unknown; | |
| teacher_answers?: unknown; | |
| }; | |
| if (data.student_answers) onParsedDataUpdate("student_answers", data.student_answers); | |
| if (data.teacher_answers) onParsedDataUpdate("teacher_answers", data.teacher_answers); | |
| onParsedDataUpdate("answers", res.data); | |
| } else { | |
| onParsedDataUpdate(type, res.data); | |
| } | |
| } catch (err: unknown) { | |
| setErrors((prev) => ({ | |
| ...prev, | |
| [type]: err instanceof Error ? err.message : "Analysis failed", | |
| })); | |
| } finally { | |
| setAnalyzing((prev) => ({ ...prev, [type]: false })); | |
| } | |
| }; | |
| const hasAnyParsedData = Object.keys(parsedData).length > 0; | |
| const isAnyAnalyzing = Object.values(analyzing).some(Boolean); | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Model selector */} | |
| <div className="flex justify-end"> | |
| <ModelSelector value={model} onChange={setModel} label="解析模型:" /> | |
| </div> | |
| {/* Info note */} | |
| <p className="text-xs text-[var(--color-text-muted)] text-center"> | |
| 支援任意格式檔案(PDF、圖片、文字檔等)。若檔案中包含圖片、圖表或示意圖,AI 會自動將其轉換為文字描述,以確保後續分析完整準確。 | |
| </p> | |
| {/* Upload zones */} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {zones.map((zone) => { | |
| const zoneData = parsedData[zone.type] as Record<string, unknown> | undefined; | |
| const isAnalyzing = analyzing[zone.type]; | |
| const hasFiles = files[zone.type].length > 0; | |
| const hasParsed = !!zoneData; | |
| return ( | |
| <div | |
| key={zone.type} | |
| className={`card p-6 transition-all ${hasParsed ? "border-[var(--color-success)]/50" : ""}`} | |
| onDragOver={(e) => e.preventDefault()} | |
| onDrop={(e) => handleDrop(zone.type, e)} | |
| > | |
| <div className="text-center mb-3"> | |
| <span className="text-3xl">{zone.icon}</span> | |
| <h3 className="font-display text-lg font-semibold text-[var(--color-text)] mt-2"> | |
| {zone.label} | |
| </h3> | |
| <p className="text-sm text-[var(--color-text-muted)]">{zone.description}</p> | |
| </div> | |
| {/* Per-zone description */} | |
| <textarea | |
| value={descriptions[zone.type] || ""} | |
| onChange={(e) => | |
| setDescriptions((prev) => ({ ...prev, [zone.type]: e.target.value })) | |
| } | |
| placeholder={zone.placeholder} | |
| className="w-full mb-3 px-3 py-2 text-xs bg-[var(--color-surface)] border border-[var(--color-border)] rounded-lg text-[var(--color-text)] placeholder-[var(--color-text-muted)] resize-none focus:outline-none focus:border-[var(--color-primary)] transition-colors" | |
| rows={2} | |
| /> | |
| {zone.type !== "questions" && parsers.length > 0 && ( | |
| <div className="mb-3"> | |
| <label className="block text-xs text-[var(--color-text-muted)] mb-1"> | |
| 解析工具 | |
| </label> | |
| <select | |
| value={parserByZone[zone.type] || "auto"} | |
| onChange={(e) => | |
| setParserByZone((prev) => ({ ...prev, [zone.type]: e.target.value })) | |
| } | |
| className="input !py-1.5 text-xs w-full" | |
| title={ | |
| parsers.find((p) => p.id === (parserByZone[zone.type] || "auto")) | |
| ?.description ?? "" | |
| } | |
| > | |
| {parsers.map((p) => ( | |
| <option key={p.id} value={p.id}> | |
| {p.name} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| )} | |
| <input | |
| ref={(el) => { fileInputRefs.current[zone.type] = el; }} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={(e) => handleFileSelect(zone.type, e.target.files)} | |
| /> | |
| <button | |
| onClick={() => fileInputRefs.current[zone.type]?.click()} | |
| className={`w-full py-6 border-2 border-dashed rounded-xl transition-all text-sm ${ | |
| hasFiles | |
| ? "border-[var(--color-primary)] bg-[var(--color-primary)]/5" | |
| : "border-[var(--color-border)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5" | |
| }`} | |
| > | |
| {hasFiles ? ( | |
| <div className="space-y-1"> | |
| {files[zone.type].map((f, i) => ( | |
| <p key={i} className="text-[var(--color-text)] truncate px-2"> | |
| {f.name} | |
| </p> | |
| ))} | |
| </div> | |
| ) : ( | |
| <p className="text-[var(--color-text-muted)]"> | |
| 點擊或拖放檔案至此 | |
| </p> | |
| )} | |
| </button> | |
| {/* Analyze button per zone */} | |
| <button | |
| onClick={() => handleAnalyze(zone.type)} | |
| disabled={!hasFiles || isAnalyzing} | |
| className="w-full mt-3 btn btn-primary py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {isAnalyzing ? ( | |
| <span className="flex items-center justify-center gap-2"> | |
| <span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| 解析中... | |
| </span> | |
| ) : hasParsed ? ( | |
| "重新解析" | |
| ) : ( | |
| "解析資料" | |
| )} | |
| </button> | |
| {/* Parsed status + summary */} | |
| {hasParsed && ( | |
| <div className="mt-3 p-3 bg-[var(--color-background)] rounded-lg"> | |
| <p className="text-xs text-[var(--color-success)] font-semibold mb-1"> | |
| ✓ 解析完成 | |
| {(() => { | |
| const directMeta = (zoneData as { _meta?: { parser?: string } })._meta; | |
| const nestedMeta = (zoneData as { | |
| student_answers?: { _meta?: { parser?: string } }; | |
| }).student_answers?._meta; | |
| const parserName = directMeta?.parser ?? nestedMeta?.parser; | |
| return parserName ? ( | |
| <span className="ml-2 text-[var(--color-text-muted)] font-normal"> | |
| (by {parserName}) | |
| </span> | |
| ) : null; | |
| })()} | |
| </p> | |
| <ZoneSummary type={zone.type} data={zoneData} /> | |
| </div> | |
| )} | |
| {errors[zone.type] && ( | |
| <p className="text-sm text-red-400 mt-2 text-center">{errors[zone.type]}</p> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Full parsed data display */} | |
| {hasAnyParsedData && ( | |
| <div className="space-y-4"> | |
| <QuestionsTable data={parsedData.questions as Record<string, unknown> | undefined} /> | |
| <StudentAnswersTable data={parsedData.student_answers as Record<string, unknown> | undefined} /> | |
| <TeacherAnswersTable data={parsedData.teacher_answers as Record<string, unknown> | undefined} /> | |
| </div> | |
| )} | |
| {/* Next step button */} | |
| {hasAnyParsedData && !isAnyAnalyzing && ( | |
| <div className="text-center"> | |
| <button | |
| onClick={onGoToStep2} | |
| className="btn btn-primary text-lg px-8 py-4" | |
| > | |
| 確認資料,前往下一步 → | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /** Compact summary shown inside each zone card */ | |
| function ZoneSummary({ type, data }: { type: string; data: Record<string, unknown> }) { | |
| if (type === "questions") { | |
| const questions = (data as { questions?: unknown[] }).questions; | |
| if (!questions) return null; | |
| return ( | |
| <p className="text-xs text-[var(--color-text-muted)]"> | |
| 共 {questions.length} 題 | |
| </p> | |
| ); | |
| } | |
| if (type === "answers") { | |
| const sa = (data as { student_answers?: { students?: unknown[] } }).student_answers; | |
| const ta = (data as { teacher_answers?: { answers?: unknown[] } }).teacher_answers; | |
| const studentCount = sa?.students?.length ?? 0; | |
| const keyCount = ta?.answers?.length ?? 0; | |
| return ( | |
| <p className="text-xs text-[var(--color-text-muted)]"> | |
| 共 {studentCount} 位學生 / 標準答案 {keyCount} 題 | |
| </p> | |
| ); | |
| } | |
| return null; | |
| } | |
| /* ── Parsed data tables ── */ | |
| function QuestionsTable({ data }: { data?: Record<string, unknown> }) { | |
| if (!data) return null; | |
| const questions = (data as { questions?: Array<{ number: number; text: string; options?: string[] | null }> }).questions; | |
| if (!questions?.length) return null; | |
| return ( | |
| <div className="card p-5"> | |
| <h3 className="font-display text-base font-semibold text-[var(--color-text)] mb-3"> | |
| 📝 考試題目({questions.length} 題) | |
| </h3> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="border-b border-[var(--color-border)]"> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-12">#</th> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)]">題目</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {questions.map((q, i) => ( | |
| <tr key={i} className="border-b border-[var(--color-border)]/50"> | |
| <td className="py-2 px-3 text-[var(--color-primary)] font-bold">{q.number}</td> | |
| <td className="py-2 px-3 text-[var(--color-text)]"> | |
| <p>{q.text}</p> | |
| {q.options && ( | |
| <div className="mt-1 text-xs text-[var(--color-text-muted)]"> | |
| {q.options.join(" | ")} | |
| </div> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function StudentAnswersTable({ data }: { data?: Record<string, unknown> }) { | |
| if (!data) return null; | |
| const students = (data as { students?: Array<{ name: string; id?: string; answers: Array<{ question_number: number; answer: string | null }> }> }).students; | |
| if (!students?.length) return null; | |
| // Collect all question numbers across students | |
| const allQNums = Array.from( | |
| new Set(students.flatMap((s) => s.answers.map((a) => a.question_number))) | |
| ).sort((a, b) => a - b); | |
| return ( | |
| <div className="card p-5"> | |
| <h3 className="font-display text-base font-semibold text-[var(--color-text)] mb-3"> | |
| 👨🎓 學生答案({students.length} 位學生) | |
| </h3> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="border-b border-[var(--color-border)]"> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)] sticky left-0 bg-[var(--color-surface)]">學生</th> | |
| {allQNums.map((n) => ( | |
| <th key={n} className="text-center py-2 px-3 text-[var(--color-text-muted)] min-w-[60px]">Q{n}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {students.map((s, i) => ( | |
| <tr key={i} className="border-b border-[var(--color-border)]/50"> | |
| <td className="py-2 px-3 text-[var(--color-text)] font-medium sticky left-0 bg-[var(--color-surface)]"> | |
| {s.name || s.id || `學生 ${i + 1}`} | |
| </td> | |
| {allQNums.map((n) => { | |
| const ans = s.answers?.find((a) => a.question_number === n); | |
| return ( | |
| <td key={n} className="text-center py-2 px-3 text-[var(--color-text-muted)]"> | |
| {ans?.answer ?? "-"} | |
| </td> | |
| ); | |
| })} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function TeacherAnswersTable({ data }: { data?: Record<string, unknown> }) { | |
| if (!data) return null; | |
| const answers = (data as { answers?: Array<{ question_number: number; correct_answer: string; explanation?: string | null }> }).answers; | |
| if (!answers?.length) return null; | |
| return ( | |
| <div className="card p-5"> | |
| <h3 className="font-display text-base font-semibold text-[var(--color-text)] mb-3"> | |
| ✅ 標準答案({answers.length} 題) | |
| </h3> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="border-b border-[var(--color-border)]"> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-12">#</th> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)]">正確答案</th> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)]">解釋</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {answers.map((a, i) => ( | |
| <tr key={i} className="border-b border-[var(--color-border)]/50"> | |
| <td className="py-2 px-3 text-[var(--color-primary)] font-bold">Q{a.question_number}</td> | |
| <td className="py-2 px-3 text-[var(--color-success)] font-semibold">{a.correct_answer}</td> | |
| <td className="py-2 px-3 text-[var(--color-text-muted)]">{a.explanation || "-"}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |