ClassLensPortal / chatkit /frontend /src /components /step2 /AnswerGridEditor.tsx
taboola
label image questions in report, warn on incomplete download, changes to report aesthetics
8bbeab6
Raw
History Blame Contribute Delete
12.9 kB
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiGet, apiPost } from "../../lib/api";
export interface QuestionMeta {
number: number;
text: string;
options: string[];
}
export interface StudentRow {
name: string;
answers: (string | null)[];
}
export interface AnswerGrid {
total_questions: number;
official_answers: (string | null)[];
students: StudentRow[];
questions: QuestionMeta[];
}
interface AnswerGridResponse {
grid: AnswerGrid;
is_confirmed: boolean;
}
interface AnswerGridEditorProps {
sessionId: number;
onConfirmedChange: (grid: AnswerGrid | null) => void;
}
const CORRECT_MARKER = "=";
const DASH_MARKERS = new Set(["-", "–", "—"]);
const AUTO_SAVE_DELAY_MS = 900;
function normalizeCell(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed === CORRECT_MARKER || DASH_MARKERS.has(trimmed)) {
return CORRECT_MARKER;
}
const first = trimmed.charAt(0).toUpperCase();
return /^[A-Z]$/.test(first) ? first : null;
}
function resizeRow(row: (string | null)[], n: number): (string | null)[] {
if (row.length === n) return row;
if (row.length > n) return row.slice(0, n);
return [...row, ...Array<string | null>(n - row.length).fill(null)];
}
function resizeQuestions(qs: QuestionMeta[], n: number): QuestionMeta[] {
if (qs.length === n) return qs;
if (qs.length > n) return qs.slice(0, n);
const pad: QuestionMeta[] = [];
for (let i = qs.length; i < n; i++) {
pad.push({ number: i + 1, text: "", options: [] });
}
return [...qs, ...pad];
}
export function AnswerGridEditor({
sessionId,
onConfirmedChange,
}: AnswerGridEditorProps) {
const [grid, setGrid] = useState<AnswerGrid | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
apiGet<AnswerGridResponse>(`/api/sessions/${sessionId}/answer-grid`)
.then((res) => {
setGrid(res.grid);
setSaved(res.is_confirmed);
if (res.is_confirmed) {
onConfirmedChange(res.grid);
}
})
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load grid"))
.finally(() => setLoading(false));
}, [sessionId]);
const qNums = useMemo(() => {
if (!grid) return [];
return Array.from({ length: grid.total_questions }, (_, i) => i + 1);
}, [grid]);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSaveRef = useRef<() => void>(() => {});
const markDirty = useCallback(() => {
setSaved(false);
onConfirmedChange(null);
// Debounce so rapid edits (typing across cells) collapse into one save
// instead of firing a request per keystroke/blur.
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = null;
handleSaveRef.current();
}, AUTO_SAVE_DELAY_MS);
}, [onConfirmedChange]);
// Flush any pending auto-save immediately if the component unmounts
// (e.g. teacher navigates away right after an edit) so it isn't lost.
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
handleSaveRef.current();
}
};
}, []);
const updateN = useCallback(
(n: number) => {
if (!grid || n < 1) return;
setGrid({
...grid,
total_questions: n,
official_answers: resizeRow(grid.official_answers, n),
students: grid.students.map((s) => ({
...s,
answers: resizeRow(s.answers, n),
})),
questions: resizeQuestions(grid.questions, n),
});
markDirty();
},
[grid, markDirty]
);
const updateStudentCell = useCallback(
(studentIdx: number, qIdx: number, raw: string) => {
if (!grid) return;
const value = normalizeCell(raw);
if (value === (grid.students[studentIdx]?.answers[qIdx] ?? null)) return;
const nextStudents = grid.students.map((s, i) => {
if (i !== studentIdx) return s;
const nextAnswers = [...s.answers];
nextAnswers[qIdx] = value;
return { ...s, answers: nextAnswers };
});
setGrid({ ...grid, students: nextStudents });
markDirty();
},
[grid, markDirty]
);
const updateOfficialCell = useCallback(
(qIdx: number, raw: string) => {
if (!grid) return;
const value = normalizeCell(raw);
if (value === (grid.official_answers[qIdx] ?? null)) return;
const next = [...grid.official_answers];
next[qIdx] = value;
setGrid({ ...grid, official_answers: next });
markDirty();
},
[grid, markDirty]
);
const handleSave = useCallback(async () => {
if (!grid) return;
if (saving) {
// A save is already in flight — reschedule instead of dropping this
// edit or firing an overlapping request.
saveTimeoutRef.current = setTimeout(() => handleSaveRef.current(), AUTO_SAVE_DELAY_MS);
return;
}
setSaving(true);
setError("");
try {
const res = await apiPost<AnswerGridResponse>(
`/api/sessions/${sessionId}/answer-grid`,
grid
);
setGrid(res.grid);
setSaved(true);
onConfirmedChange(res.grid);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save grid");
} finally {
setSaving(false);
}
}, [grid, saving, sessionId, onConfirmedChange]);
useEffect(() => {
handleSaveRef.current = handleSave;
}, [handleSave]);
if (loading) {
return (
<div className="card p-8 text-center">
<div className="inline-block w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!grid) {
return (
<div className="card p-8 text-center">
<p className="text-[var(--color-text-muted)]">
{error || "尚未上傳任何資料,請回到上一步先解析檔案"}
</p>
</div>
);
}
return (
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h3 className="font-display text-lg font-semibold text-[var(--color-text)]">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" style={{ display: "inline", verticalAlign: "-2px", marginRight: 6 }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="1.3" fill="none" />
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" stroke="currentColor" strokeWidth="1.1" />
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" stroke="currentColor" strokeWidth="1.1" />
<line x1="5.5" y1="5.5" x2="5.5" y2="14.5" stroke="currentColor" strokeWidth="1.1" />
<line x1="10.5" y1="5.5" x2="10.5" y2="14.5" stroke="currentColor" strokeWidth="1.1" />
</svg>
確認答案表 (Answer Grid)
</h3>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
請核對學生作答與標準答案,確認後才能生成報告。支援單選題(A-Z)。
學生欄輸入 <code className="px-1 bg-[var(--color-background)] rounded">=</code> 代表該題答對。
</p>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-[var(--color-text-muted)]">題數:</label>
<input
type="number"
min={1}
max={200}
value={grid.total_questions}
onChange={(e) => updateN(Number(e.target.value))}
className="input !w-20 !py-1 text-sm"
/>
</div>
</div>
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-sm border-separate border-spacing-0">
<thead className="sticky top-0 bg-[var(--color-surface)] z-10">
<tr>
<th className="text-left py-2 px-3 border-b border-[var(--color-border)] sticky left-0 bg-[var(--color-surface)] z-20 min-w-[120px]">
學生
</th>
{qNums.map((n) => (
<th
key={n}
className="text-center py-2 px-2 border-b border-[var(--color-border)] text-[var(--color-text-muted)] min-w-[48px]"
>
Q{n}
</th>
))}
</tr>
</thead>
<tbody>
{grid.students.map((s, si) => (
<tr key={si} className={si % 2 === 0 ? "bg-[var(--color-background)]" : "bg-[var(--color-surface)]"}>
<td className={`py-1 px-3 border-b border-[var(--color-border)]/50 sticky left-0 z-10 font-medium text-[var(--color-text)] ${si % 2 === 0 ? "bg-[var(--color-background)]" : "bg-[var(--color-surface)]"}`}>
{s.name}
</td>
{qNums.map((n, qi) => {
const cell = s.answers[qi];
const isCorrect = cell === CORRECT_MARKER;
return (
<td
key={n}
className="border-b border-[var(--color-border)]/50 p-0.5"
>
<input
type="text"
maxLength={2}
defaultValue={cell ?? ""}
onBlur={(e) => updateStudentCell(si, qi, e.target.value)}
className={`w-full text-center py-1 bg-transparent border border-transparent hover:border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none rounded ${
isCorrect
? "text-[var(--color-success)] font-semibold"
: "text-[var(--color-text)]"
}`}
/>
</td>
);
})}
</tr>
))}
{/* Answer key row */}
<tr className="bg-[var(--color-primary)]/5">
<td className="py-2 px-3 border-t-2 border-[var(--color-primary)] sticky left-0 bg-[var(--color-surface)] z-10 font-bold text-[var(--color-primary)]">
標準答案
</td>
{qNums.map((n, qi) => (
<td
key={n}
className="border-t-2 border-[var(--color-primary)] p-0.5"
>
<input
type="text"
maxLength={2}
defaultValue={grid.official_answers[qi] ?? ""}
onBlur={(e) => updateOfficialCell(qi, e.target.value)}
className="w-full text-center py-1 bg-transparent text-[var(--color-success)] font-semibold border border-transparent hover:border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none rounded"
/>
</td>
))}
</tr>
</tbody>
</table>
</div>
{error && (
<p className="text-sm text-red-400 text-center">{error}</p>
)}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="text-xs">
{saving ? (
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-muted)]">
<span className="w-3 h-3 border border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
儲存中...
</span>
) : saved ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 font-semibold">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<circle cx="6" cy="6" r="5" stroke="currentColor" strokeWidth="1.3" fill="none" />
<path d="M3.5 6.5l1.8 1.8 3.2-3.6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
已自動儲存 — 可生成報告
</span>
) : (
<span className="text-amber-500">⚠ 尚未儲存 — 編輯後將自動儲存</span>
)}
</div>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-primary text-sm disabled:opacity-50"
>
{saving ? "儲存中..." : saved ? "立即重新儲存" : "立即儲存"}
</button>
</div>
</div>
);
}