ClassLens / chatkit /frontend /src /components /step1 /FileUploadPanel.tsx
Yu Chen
custom csv upload logic
0641d27
Raw
History Blame Contribute Delete
17.2 kB
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>
);
}