ClassLens-2 / chatkit /frontend /src /components /step1 /FileUploadPanel.tsx
chih.yikuan
Redesign ClassLens v2: 3-step exam analysis workflow
bc45e54
Raw
History Blame Contribute Delete
15.4 kB
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>
);
}