Spaces:
Running
Running
taboola
label image questions in report, warn on incomplete download, changes to report aesthetics
8bbeab6 | 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> | |
| ); | |
| } | |