Spaces:
Sleeping
Sleeping
| 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<Tab>("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 ( | |
| <main className="max-w-6xl mx-auto px-4 pb-12"> | |
| <div className="text-center mb-6"> | |
| <h2 className="font-display text-2xl font-bold text-[var(--color-text)]"> | |
| {isAdmin ? "管理後台" : "我的資料中心"} | |
| </h2> | |
| <p className="text-[var(--color-text-muted)] mt-1"> | |
| {isAdmin ? "資料庫管理與使用分析" : "您自己儲存的測驗、學生與成績"} | |
| </p> | |
| </div> | |
| <div className="flex justify-center gap-2 mb-6"> | |
| {tabs.map((t) => ( | |
| <button | |
| key={t.key} | |
| onClick={() => setTab(t.key)} | |
| className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${ | |
| tab === t.key | |
| ? "bg-[var(--color-primary)] text-white" | |
| : "text-[var(--color-text-muted)] hover:text-[var(--color-text)] border border-[var(--color-border)]" | |
| }`} | |
| > | |
| {t.label} | |
| </button> | |
| ))} | |
| </div> | |
| {tab === "overview" && <Overview isAdmin={isAdmin} />} | |
| {tab === "teachers" && isAdmin && <Teachers />} | |
| {tab === "sessions" && <Sessions isAdmin={isAdmin} />} | |
| {tab === "students" && <Students />} | |
| </main> | |
| ); | |
| } | |
| function useFetch<T>(path: string, deps: unknown[] = []) { | |
| const [data, setData] = useState<T | null>(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<T>(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 ( | |
| <div className="card p-6 text-center"> | |
| <div className="text-3xl font-bold text-[var(--color-primary)]">{value}</div> | |
| <div className="text-sm text-[var(--color-text-muted)] mt-1">{label}</div> | |
| </div> | |
| ); | |
| } | |
| function Overview({ isAdmin }: { isAdmin: boolean }) { | |
| const { data, loading, error } = useFetch<Stats>("/api/admin/stats"); | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| return ( | |
| <div> | |
| <div className="grid grid-cols-2 md:grid-cols-3 gap-4"> | |
| {isAdmin && <StatCard label="教師" value={data.teachers} />} | |
| <StatCard label={isAdmin ? "學生" : "我的學生"} value={data.students} /> | |
| <StatCard label={isAdmin ? "測驗" : "我的測驗"} value={data.quizzes} /> | |
| <StatCard label="已生成報告" value={data.reports} /> | |
| <StatCard label="學生成績紀錄" value={data.student_results} /> | |
| <StatCard label="提示詞" value={data.prompts} /> | |
| </div> | |
| {isAdmin && ( | |
| <p className="text-center text-xs text-[var(--color-text-muted)] mt-6"> | |
| 資料庫結構版本:{data.schema_version ?? "未知"} | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function Teachers() { | |
| const { data, loading, error, reload } = useFetch<{ users: TeacherRow[] }>("/api/admin/users"); | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| 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 ( | |
| <Table headers={["Email", "姓名", "學校", "測驗", "學生", "管理員", ""]}> | |
| {data.users.map((u) => ( | |
| <tr key={u.id} className="border-t border-[var(--color-border)]"> | |
| <Td>{u.email}</Td> | |
| <Td>{u.full_name || "—"}</Td> | |
| <Td>{u.school || "—"}</Td> | |
| <Td>{u.quiz_count}</Td> | |
| <Td>{u.student_count}</Td> | |
| <Td> | |
| <button onClick={() => toggleAdmin(u)} className="text-sm underline text-[var(--color-primary)]"> | |
| {u.is_admin ? "是" : "否"} | |
| </button> | |
| </Td> | |
| <Td> | |
| <button onClick={() => remove(u)} className="text-sm text-red-400 hover:underline"> | |
| 刪除 | |
| </button> | |
| </Td> | |
| </tr> | |
| ))} | |
| </Table> | |
| ); | |
| } | |
| function Sessions({ isAdmin }: { isAdmin: boolean }) { | |
| const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions"); | |
| const [detailId, setDetailId] = useState<number | null>(null); | |
| if (detailId !== null) { | |
| return <QuizDetail quizId={detailId} onBack={() => setDetailId(null)} />; | |
| } | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| const remove = async (s: SessionRow) => { | |
| if (!confirm(`刪除測驗「${s.title}」?`)) return; | |
| await apiDelete(`/api/admin/sessions/${s.id}`); | |
| reload(); | |
| }; | |
| const headers = isAdmin | |
| ? ["標題", "科目", "教師", "狀態", "報告", "建立時間", ""] | |
| : ["標題", "科目", "狀態", "報告", "建立時間", ""]; | |
| return ( | |
| <Table headers={headers}> | |
| {data.sessions.map((s) => ( | |
| <tr key={s.id} className="border-t border-[var(--color-border)]"> | |
| <Td>{s.title}</Td> | |
| <Td>{s.subject || "—"}</Td> | |
| {isAdmin && <Td>{s.teacher_email}</Td>} | |
| <Td>{s.status}</Td> | |
| <Td>{s.report_count}</Td> | |
| <Td>{new Date(s.created_at).toLocaleDateString()}</Td> | |
| <Td> | |
| <div className="flex gap-3"> | |
| <button onClick={() => setDetailId(s.id)} className="text-sm underline text-[var(--color-primary)]"> | |
| 查看詳情 | |
| </button> | |
| {isAdmin && ( | |
| <button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline"> | |
| 刪除 | |
| </button> | |
| )} | |
| </div> | |
| </Td> | |
| </tr> | |
| ))} | |
| </Table> | |
| ); | |
| } | |
| function Students() { | |
| const { data, loading, error } = useFetch<{ students: StudentRow[] }>("/api/admin/students"); | |
| const [selected, setSelected] = useState<number | null>(null); | |
| if (selected !== null) { | |
| return <StudentDetail studentId={selected} onBack={() => setSelected(null)} />; | |
| } | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| return ( | |
| <Table headers={["姓名", "教師", "測驗次數", "平均分數", ""]}> | |
| {data.students.map((s) => ( | |
| <tr key={s.id} className="border-t border-[var(--color-border)]"> | |
| <Td>{s.name}</Td> | |
| <Td>{s.teacher_email}</Td> | |
| <Td>{s.result_count}</Td> | |
| <Td>{s.avg_score ?? "—"}</Td> | |
| <Td> | |
| <button onClick={() => setSelected(s.id)} className="text-sm underline text-[var(--color-primary)]"> | |
| 查看歷程 | |
| </button> | |
| </Td> | |
| </tr> | |
| ))} | |
| </Table> | |
| ); | |
| } | |
| 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<number | null>(null); | |
| const [openRow, setOpenRow] = useState<number | null>(null); | |
| if (quizId !== null) { | |
| return <QuizDetail quizId={quizId} onBack={() => setQuizId(null)} />; | |
| } | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| return ( | |
| <div> | |
| <button onClick={onBack} className="text-sm text-[var(--color-text-muted)] hover:underline mb-4"> | |
| ← 返回學生列表 | |
| </button> | |
| <h3 className="font-display text-xl font-semibold mb-4 text-[var(--color-text)]"> | |
| {data.student.name} 的跨測驗歷程 | |
| </h3> | |
| {data.timeline.length === 0 ? ( | |
| <p className="text-[var(--color-text-muted)]">尚無成績紀錄</p> | |
| ) : ( | |
| <div className="space-y-3"> | |
| {data.timeline.map((t, i) => ( | |
| <div key={i} className="card p-4"> | |
| <div className="flex flex-wrap items-center justify-between gap-2"> | |
| <div> | |
| <span className="font-semibold text-[var(--color-text)]">{t.quiz_title}</span> | |
| <span className="text-sm text-[var(--color-text-muted)] ml-2"> | |
| {t.quiz_subject || "—"} · {new Date(t.quiz_created_at).toLocaleDateString()} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-4 text-sm"> | |
| <span> | |
| {t.correct_count}/{t.total_questions} 答對 ·{" "} | |
| <span className="font-semibold text-[var(--color-primary)]">{t.score} 分</span> | |
| </span> | |
| <button | |
| onClick={() => setOpenRow(openRow === i ? null : i)} | |
| className="underline text-[var(--color-primary)]" | |
| > | |
| {openRow === i ? "收合作答" : "查看作答"} | |
| </button> | |
| <button onClick={() => setQuizId(t.quiz_id)} className="underline text-[var(--color-primary)]"> | |
| 測驗詳情 | |
| </button> | |
| </div> | |
| </div> | |
| {openRow === i && ( | |
| <div className="mt-3 border-t border-[var(--color-border)] pt-3"> | |
| <p className="text-xs text-[var(--color-text-muted)] mb-2"> | |
| 原始作答資料(= 代表答對;字母為作答選項;空白為未作答) | |
| </p> | |
| <AnswerChips answers={t.answers?.answers ?? []} /> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function QuizDetail({ quizId, onBack }: { quizId: number; onBack: () => void }) { | |
| const { data, loading, error } = useFetch<QuizDetailData>(`/api/admin/sessions/${quizId}`); | |
| const [view, setView] = useState<"questions" | "answers" | "raw">("questions"); | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| 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 ( | |
| <div> | |
| <button onClick={onBack} className="text-sm text-[var(--color-text-muted)] hover:underline mb-4"> | |
| ← 返回 | |
| </button> | |
| <h3 className="font-display text-xl font-semibold mb-1 text-[var(--color-text)]"> | |
| 測驗詳情:{data.quiz.title} | |
| </h3> | |
| <p className="text-sm text-[var(--color-text-muted)] mb-4"> | |
| {data.quiz.subject || "—"} · 狀態 {data.quiz.status} ·{" "} | |
| {new Date(data.quiz.created_at).toLocaleString()} | |
| </p> | |
| <div className="flex gap-2 mb-4"> | |
| {subTabs.map((t) => ( | |
| <button | |
| key={t.key} | |
| onClick={() => setView(t.key)} | |
| className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${ | |
| view === t.key | |
| ? "bg-[var(--color-primary)] text-white" | |
| : "text-[var(--color-text-muted)] border border-[var(--color-border)]" | |
| }`} | |
| > | |
| {t.label} | |
| </button> | |
| ))} | |
| </div> | |
| {view === "questions" && ( | |
| questions.length === 0 ? ( | |
| <Empty msg="尚無題目結構資料" /> | |
| ) : ( | |
| <div className="space-y-2"> | |
| {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 ( | |
| <div key={`${q.number}-${i}`} className="card p-3"> | |
| <div className="flex items-start gap-2"> | |
| <span className="font-semibold text-[var(--color-primary)] shrink-0">Q{q.number}</span> | |
| <div className="min-w-0"> | |
| <p className="text-sm text-[var(--color-text)] whitespace-pre-wrap break-words">{q.text || "(無題目文字)"}</p> | |
| {q.options?.length > 0 && ( | |
| <ul className="mt-1 text-sm text-[var(--color-text-muted)] list-disc ml-5"> | |
| {q.options.map((o, j) => <li key={j}>{o}</li>)} | |
| </ul> | |
| )} | |
| {official && ( | |
| <p className="mt-1 text-sm"> | |
| 正解:<span className="font-semibold text-[var(--color-success)]">{official}</span> | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ) | |
| )} | |
| {view === "answers" && ( | |
| <div className="space-y-4"> | |
| {grid?.official_answers && ( | |
| <div className="card p-3"> | |
| <p className="text-xs text-[var(--color-text-muted)] mb-2">標準答案</p> | |
| <AnswerChips answers={grid.official_answers} hideEquals /> | |
| </div> | |
| )} | |
| {data.results.length === 0 ? ( | |
| <Empty msg="尚無學生作答結構資料" /> | |
| ) : ( | |
| data.results.map((r) => ( | |
| <div key={r.student_id} className="card p-3"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="font-semibold text-[var(--color-text)]">{r.student_name}</span> | |
| <span className="text-sm"> | |
| {r.correct_count}/{r.total_questions} ·{" "} | |
| <span className="font-semibold text-[var(--color-primary)]">{r.score} 分</span> | |
| </span> | |
| </div> | |
| <AnswerChips answers={r.answers?.answers ?? []} /> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| )} | |
| {view === "raw" && ( | |
| data.raw_files.length === 0 ? ( | |
| <Empty msg="此測驗沒有原始檔案(PDF/CSV)。較早上傳的資料未保存原始檔;之後上傳的會自動保存到 Supabase Storage。" /> | |
| ) : ( | |
| <Table headers={["類型", "檔名", "格式", "大小", "下載"]}> | |
| {data.raw_files.map((f) => ( | |
| <tr key={f.id} className="border-t border-[var(--color-border)]"> | |
| <Td>{f.data_type}</Td> | |
| <Td>{f.file_name}</Td> | |
| <Td>{f.content_type || "—"}</Td> | |
| <Td>{f.size_bytes ? `${(f.size_bytes / 1024).toFixed(1)} KB` : "—"}</Td> | |
| <Td> | |
| {f.download_url ? ( | |
| <a href={f.download_url} target="_blank" rel="noreferrer" className="underline text-[var(--color-primary)]"> | |
| 下載 / 檢視 | |
| </a> | |
| ) : ( | |
| <span className="text-[var(--color-text-muted)]">不可用</span> | |
| )} | |
| </Td> | |
| </tr> | |
| ))} | |
| </Table> | |
| ) | |
| )} | |
| </div> | |
| ); | |
| } | |
| function AnswerChips({ answers, hideEquals }: { answers: (string | null)[]; hideEquals?: boolean }) { | |
| return ( | |
| <div className="flex flex-wrap gap-1.5"> | |
| {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 ( | |
| <span | |
| key={i} | |
| title={`Q${i + 1}`} | |
| className={`inline-flex items-center justify-center min-w-[2.2rem] px-1.5 py-0.5 rounded text-xs border ${ | |
| showCorrect | |
| ? "bg-[var(--color-success)]/10 border-[var(--color-success)]/30 text-[var(--color-success)]" | |
| : neutral | |
| ? "border-[var(--color-border)] text-[var(--color-text-muted)]" | |
| : "border-[var(--color-border)] text-[var(--color-text)]" | |
| }`} | |
| > | |
| <span className="opacity-50 mr-0.5">{i + 1}</span> | |
| {label} | |
| </span> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function Empty({ msg }: { msg: string }) { | |
| return <div className="card p-6 text-center text-[var(--color-text-muted)] text-sm">{msg}</div>; | |
| } | |
| function Table({ headers, children }: { headers: string[]; children: React.ReactNode }) { | |
| return ( | |
| <div className="card overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="text-left text-[var(--color-text-muted)]"> | |
| {headers.map((h, i) => ( | |
| <th key={i} className="px-4 py-3 font-medium"> | |
| {h} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody className="text-[var(--color-text)]">{children}</tbody> | |
| </table> | |
| </div> | |
| ); | |
| } | |
| function Td({ children }: { children: React.ReactNode }) { | |
| return <td className="px-4 py-3">{children}</td>; | |
| } | |
| function Spinner() { | |
| return ( | |
| <div className="flex justify-center py-12"> | |
| <div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" /> | |
| </div> | |
| ); | |
| } | |
| function ErrorBox({ msg }: { msg: string }) { | |
| return <div className="card p-6 text-center text-red-400">{msg || "載入失敗"}</div>; | |
| } | |