Spaces:
Runtime error
Runtime error
chih.yikuan
✨ Add database management: Supabase persistence, teacher profiles, admin dashboard
9f97e28 | 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_title: string; | |
| quiz_subject: string | null; | |
| quiz_created_at: string; | |
| total_questions: number; | |
| correct_count: number; | |
| score: number; | |
| } | |
| const TABS: { key: Tab; label: string }[] = [ | |
| { key: "overview", label: "總覽" }, | |
| { key: "teachers", label: "教師" }, | |
| { key: "sessions", label: "測驗" }, | |
| { key: "students", label: "學生" }, | |
| ]; | |
| export function AdminDashboard() { | |
| const [tab, setTab] = useState<Tab>("overview"); | |
| 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)]">管理後台</h2> | |
| <p className="text-[var(--color-text-muted)] mt-1">資料庫管理與使用分析</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 />} | |
| {tab === "teachers" && <Teachers />} | |
| {tab === "sessions" && <Sessions />} | |
| {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() { | |
| 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"> | |
| <StatCard label="教師" value={data.teachers} /> | |
| <StatCard label="學生" value={data.students} /> | |
| <StatCard label="測驗" value={data.quizzes} /> | |
| <StatCard label="已生成報告" value={data.reports} /> | |
| <StatCard label="學生成績紀錄" value={data.student_results} /> | |
| <StatCard label="提示詞" value={data.prompts} /> | |
| </div> | |
| <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() { | |
| const { data, loading, error, reload } = useFetch<{ sessions: SessionRow[] }>("/api/admin/sessions"); | |
| 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(); | |
| }; | |
| return ( | |
| <Table headers={["標題", "科目", "教師", "狀態", "報告", "建立時間", ""]}> | |
| {data.sessions.map((s) => ( | |
| <tr key={s.id} className="border-t border-[var(--color-border)]"> | |
| <Td>{s.title}</Td> | |
| <Td>{s.subject || "—"}</Td> | |
| <Td>{s.teacher_email}</Td> | |
| <Td>{s.status}</Td> | |
| <Td>{s.report_count}</Td> | |
| <Td>{new Date(s.created_at).toLocaleDateString()}</Td> | |
| <Td> | |
| <button onClick={() => remove(s)} className="text-sm text-red-400 hover:underline"> | |
| 刪除 | |
| </button> | |
| </Td> | |
| </tr> | |
| ))} | |
| </Table> | |
| ); | |
| } | |
| function Students() { | |
| const { data, loading, error } = useFetch<{ students: StudentRow[] }>("/api/admin/students"); | |
| const [selected, setSelected] = useState<number | null>(null); | |
| if (loading) return <Spinner />; | |
| if (error || !data) return <ErrorBox msg={error} />; | |
| if (selected !== null) { | |
| return <StudentDetail studentId={selected} onBack={() => setSelected(null)} />; | |
| } | |
| 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}`); | |
| 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> | |
| ) : ( | |
| <Table headers={["測驗", "科目", "日期", "答對", "總題數", "分數"]}> | |
| {data.timeline.map((t, i) => ( | |
| <tr key={i} className="border-t border-[var(--color-border)]"> | |
| <Td>{t.quiz_title}</Td> | |
| <Td>{t.quiz_subject || "—"}</Td> | |
| <Td>{new Date(t.quiz_created_at).toLocaleDateString()}</Td> | |
| <Td>{t.correct_count}</Td> | |
| <Td>{t.total_questions}</Td> | |
| <Td> | |
| <span className="font-semibold text-[var(--color-primary)]">{t.score}</span> | |
| </Td> | |
| </tr> | |
| ))} | |
| </Table> | |
| )} | |
| </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>; | |
| } | |