ClassLens / chatkit /frontend /src /components /admin /AdminDashboard.tsx
kuanz's picture
Dashboard for all teachers (own-data scoped) + fix report IDOR (#3)
d2075b0
Raw
History Blame Contribute Delete
21.2 kB
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>;
}