Spaces:
Paused
Paused
| import { useState, useRef } from "react"; | |
| import { apiUpload } from "../../lib/api"; | |
| import { ModelSelector } from "../ModelSelector"; | |
| interface FileUploadPanelProps { | |
| sessionId: number; | |
| parsedData: Record<string, unknown>; | |
| onParsedDataUpdate: (dataType: string, data: unknown) => void; | |
| onGoToStep2: () => void; | |
| } | |
| interface UploadZone { | |
| type: "questions" | "student_answers" | "teacher_answers"; | |
| label: string; | |
| description: string; | |
| placeholder: string; | |
| icon: string; | |
| } | |
| const zones: UploadZone[] = [ | |
| { | |
| type: "questions", | |
| label: "考試題目", | |
| description: "上傳考試試卷(任意格式)", | |
| placeholder: "例如:國中三年級數學期中考,共 20 題選擇題和 5 題計算題", | |
| icon: "📝", | |
| }, | |
| { | |
| type: "student_answers", | |
| label: "學生答案", | |
| description: "上傳學生作答資料(任意格式)", | |
| placeholder: "例如:35 位學生的答案卷,手寫掃描", | |
| icon: "👨🎓", | |
| }, | |
| { | |
| type: "teacher_answers", | |
| label: "標準答案", | |
| description: "上傳教師答案/解答(任意格式)", | |
| placeholder: "例如:教師提供的標準答案與解題步驟", | |
| icon: "✅", | |
| }, | |
| ]; | |
| export function FileUploadPanel({ | |
| sessionId, | |
| parsedData, | |
| onParsedDataUpdate, | |
| onGoToStep2, | |
| }: FileUploadPanelProps) { | |
| const [files, setFiles] = useState<Record<string, File[]>>({ | |
| questions: [], | |
| student_answers: [], | |
| teacher_answers: [], | |
| }); | |
| const [descriptions, setDescriptions] = useState<Record<string, string>>({ | |
| questions: "", | |
| student_answers: "", | |
| teacher_answers: "", | |
| }); | |
| const [analyzing, setAnalyzing] = useState<Record<string, boolean>>({}); | |
| const [errors, setErrors] = useState<Record<string, string>>({}); | |
| const [model, setModel] = useState("gpt-5.4"); | |
| const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({}); | |
| 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); | |
| for (const f of typeFiles) { | |
| formData.append("files", f); | |
| } | |
| const res = await apiUpload<{ data: unknown }>(`/api/sessions/${sessionId}/upload`, formData); | |
| 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-3 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} | |
| /> | |
| <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">✓ 解析完成</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 === "student_answers") { | |
| const students = (data as { students?: unknown[] }).students; | |
| if (!students) return null; | |
| return ( | |
| <p className="text-xs text-[var(--color-text-muted)]"> | |
| 共 {students.length} 位學生 | |
| </p> | |
| ); | |
| } | |
| if (type === "teacher_answers") { | |
| const answers = (data as { answers?: unknown[] }).answers; | |
| if (!answers) return null; | |
| return ( | |
| <p className="text-xs text-[var(--color-text-muted)]"> | |
| 共 {answers.length} 題答案 | |
| </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; type?: string; options?: string[] | null; points?: number | 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> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-28">類型</th> | |
| <th className="text-left py-2 px-3 text-[var(--color-text-muted)] w-16">分數</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> | |
| <td className="py-2 px-3"> | |
| {q.type && <span className="badge badge-success text-xs">{q.type}</span>} | |
| </td> | |
| <td className="py-2 px-3 text-[var(--color-text-muted)]">{q.points ?? "-"}</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> | |
| ); | |
| } | |