import { useEffect, useState } from "react"; import { apiGet, apiPut, apiDelete } from "../../lib/api"; type Tab = "overview" | "teachers" | "sessions" | "students"; interface Stats { teachers: number; students: number; quizzes: number; reports: number; student_results: number; prompts: number; schema_version: string | null; } interface TeacherRow { id: number; email: string; full_name: string; school: string; is_admin: boolean; created_at: string; quiz_count: number; student_count: number; } interface SessionRow { id: number; title: string; subject: string | null; status: string; teacher_email: string; report_count: number; created_at: string; } interface StudentRow { id: number; name: string; teacher_email: string; result_count: number; avg_score: number | null; } interface TimelineEntry { quiz_id: number; quiz_title: string; quiz_subject: string | null; quiz_created_at: string; total_questions: number; correct_count: number; score: number; answers: { answers?: (string | null)[] }; } interface QuestionMeta { number: number; text: string; options: string[]; } interface RawFile { id: number; data_type: string; file_name: string; content_type: string; size_bytes: number; storage_path: string; download_url: string | null; } interface QuizResult { student_id: number; student_name: string; total_questions: number; correct_count: number; score: number; answers: { answers?: (string | null)[] }; } interface QuizDetailData { quiz: { id: number; title: string; subject: string | null; status: string; created_at: string }; raw_files: RawFile[]; parsed_data: { questions?: { questions: QuestionMeta[] }; student_answers?: { students: { name: string; id?: string; answers: { question_number: number; answer: string }[] }[] }; teacher_answers?: { answers: { question_number: number; correct_answer: string; explanation?: string | null }[] }; answer_grid?: { total_questions: number; official_answers: (string | null)[]; students: { name: string; answers: (string | null)[] }[]; questions: QuestionMeta[] }; }; results: QuizResult[]; } const TABS: { key: Tab; label: string }[] = [ { key: "overview", label: "總覽" }, { key: "teachers", label: "教師" }, { key: "sessions", label: "測驗" }, { key: "students", label: "學生" }, ]; export function AdminDashboard({ isAdmin }: { isAdmin: boolean }) { const [tab, setTab] = useState("overview"); // The teachers tab + user management is admin-only; teachers see only their own data. const tabs = isAdmin ? TABS : TABS.filter((t) => t.key !== "teachers"); return (

{isAdmin ? "管理後台" : "我的資料中心"}

{isAdmin ? "資料庫管理與使用分析" : "您自己儲存的測驗、學生與成績"}

{tabs.map((t) => ( ))}
{tab === "overview" && } {tab === "teachers" && isAdmin && } {tab === "sessions" && } {tab === "students" && }
); } function useFetch(path: string, deps: unknown[] = []) { const [data, setData] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); const [nonce, setNonce] = useState(0); const reload = () => setNonce((n) => n + 1); useEffect(() => { let active = true; setLoading(true); apiGet(path) .then((d) => active && setData(d)) .catch((e) => active && setError(e instanceof Error ? e.message : "Error")) .finally(() => active && setLoading(false)); return () => { active = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, nonce, ...deps]); return { data, error, loading, reload }; } function StatCard({ label, value }: { label: string; value: number | string }) { return (
{value}
{label}
); } function Overview({ isAdmin }: { isAdmin: boolean }) { const { data, loading, error } = useFetch("/api/admin/stats"); if (loading) return ; if (error || !data) return ; return (
{isAdmin && }
{isAdmin && (

資料庫結構版本:{data.schema_version ?? "未知"}

)}
); } function Teachers() { const { data, loading, error, reload } = useFetch<{ users: TeacherRow[] }>("/api/admin/users"); if (loading) return ; if (error || !data) return ; const toggleAdmin = async (u: TeacherRow) => { await apiPut(`/api/admin/users/${u.id}/admin`, { is_admin: !u.is_admin }); reload(); }; const remove = async (u: TeacherRow) => { if (!confirm(`刪除教師 ${u.email}?此操作會一併刪除其所有測驗與報告。`)) return; await apiDelete(`/api/admin/users/${u.id}`); reload(); }; return ( {data.users.map((u) => ( ))}
{u.email} {u.full_name || "—"} {u.school || "—"} {u.quiz_count} {u.student_count}
); } function Sessions({ isAdmin }: { isAdmin: boolean }) { const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions"); const [detailId, setDetailId] = useState(null); if (detailId !== null) { return setDetailId(null)} />; } if (loading) return ; if (error || !data) return ; const remove = async (s: SessionRow) => { if (!confirm(`刪除測驗「${s.title}」?`)) return; await apiDelete(`/api/admin/sessions/${s.id}`); reload(); }; const headers = isAdmin ? ["標題", "科目", "教師", "狀態", "報告", "建立時間", ""] : ["標題", "科目", "狀態", "報告", "建立時間", ""]; return ( {data.sessions.map((s) => ( {isAdmin && } ))}
{s.title} {s.subject || "—"}{s.teacher_email}{s.status} {s.report_count} {new Date(s.created_at).toLocaleDateString()}
{isAdmin && ( )}
); } function Students() { const { data, loading, error } = useFetch<{ students: StudentRow[] }>("/api/admin/students"); const [selected, setSelected] = useState(null); if (selected !== null) { return setSelected(null)} />; } if (loading) return ; if (error || !data) return ; return ( {data.students.map((s) => ( ))}
{s.name} {s.teacher_email} {s.result_count} {s.avg_score ?? "—"}
); } function StudentDetail({ studentId, onBack }: { studentId: number; onBack: () => void }) { const { data, loading, error } = useFetch<{ student: { name: string }; timeline: TimelineEntry[]; }>(`/api/admin/students/${studentId}`); const [quizId, setQuizId] = useState(null); const [openRow, setOpenRow] = useState(null); if (quizId !== null) { return setQuizId(null)} />; } if (loading) return ; if (error || !data) return ; return (

{data.student.name} 的跨測驗歷程

{data.timeline.length === 0 ? (

尚無成績紀錄

) : (
{data.timeline.map((t, i) => (
{t.quiz_title} {t.quiz_subject || "—"} · {new Date(t.quiz_created_at).toLocaleDateString()}
{t.correct_count}/{t.total_questions} 答對 ·{" "} {t.score} 分
{openRow === i && (

原始作答資料(= 代表答對;字母為作答選項;空白為未作答)

)}
))}
)}
); } function QuizDetail({ quizId, onBack }: { quizId: number; onBack: () => void }) { const { data, loading, error } = useFetch(`/api/admin/sessions/${quizId}`); const [view, setView] = useState<"questions" | "answers" | "raw">("questions"); if (loading) return ; if (error || !data) return ; const questions = data.parsed_data.questions?.questions ?? []; const grid = data.parsed_data.answer_grid; const teacher = data.parsed_data.teacher_answers?.answers ?? []; const subTabs: { key: typeof view; label: string }[] = [ { key: "questions", label: `題目結構資料 (${questions.length})` }, { key: "answers", label: `作答結構資料 (${data.results.length})` }, { key: "raw", label: `原始檔案 (${data.raw_files.length})` }, ]; return (

測驗詳情:{data.quiz.title}

{data.quiz.subject || "—"} · 狀態 {data.quiz.status} ·{" "} {new Date(data.quiz.created_at).toLocaleString()}

{subTabs.map((t) => ( ))}
{view === "questions" && ( questions.length === 0 ? ( ) : (
{questions.map((q, i) => { // Prefer the number-keyed teacher answer (gap-tolerant); fall back to the // positional grid cell. '=' means "no authoritative key" → don't show it. const rawOfficial = teacher.find((a) => a.question_number === q.number)?.correct_answer ?? grid?.official_answers?.[q.number - 1] ?? null; const official = rawOfficial === "=" ? null : rawOfficial; return (
Q{q.number}

{q.text || "(無題目文字)"}

{q.options?.length > 0 && (
    {q.options.map((o, j) =>
  • {o}
  • )}
)} {official && (

正解:{official}

)}
); })}
) )} {view === "answers" && (
{grid?.official_answers && (

標準答案

)} {data.results.length === 0 ? ( ) : ( data.results.map((r) => (
{r.student_name} {r.correct_count}/{r.total_questions} ·{" "} {r.score} 分
)) )}
)} {view === "raw" && ( data.raw_files.length === 0 ? ( ) : ( {data.raw_files.map((f) => ( ))}
{f.data_type} {f.file_name} {f.content_type || "—"} {f.size_bytes ? `${(f.size_bytes / 1024).toFixed(1)} KB` : "—"} {f.download_url ? ( 下載 / 檢視 ) : ( 不可用 )}
) )}
); } function AnswerChips({ answers, hideEquals }: { answers: (string | null)[]; hideEquals?: boolean }) { return (
{answers.map((a, i) => { const isEq = a === "="; const blank = a === null || a === ""; // '=' means "answered correctly" in a student row (show ✓), but "no // authoritative key" in the official-answer row (hideEquals → neutral). const showCorrect = isEq && !hideEquals; const neutral = blank || (isEq && hideEquals); const label = neutral ? "·" : showCorrect ? "✓" : a; return ( {i + 1} {label} ); })}
); } function Empty({ msg }: { msg: string }) { return
{msg}
; } function Table({ headers, children }: { headers: string[]; children: React.ReactNode }) { return (
{headers.map((h, i) => ( ))} {children}
{h}
); } function Td({ children }: { children: React.ReactNode }) { return {children}; } function Spinner() { return (
); } function ErrorBox({ msg }: { msg: string }) { return
{msg || "載入失敗"}
; }