feat: add four new student modules (rotation, skill-video, exam-prep, academic) with backend APIs and frontend pages; add exam-prep question history with DB persistence

This commit is contained in:
freedakgmail
2026-06-04 11:50:25 +08:00
commit 995f4ecfbd
399 changed files with 73529 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
"use client";
import { useState } from "react";
import {
Award,
BookOpen,
CheckCircle2,
Target,
Timer,
} from "lucide-react";
import { ScoreBar } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { examPrepApi } from "@/lib/services";
const SUBJECTS = [
{ value: "internal", label: "内科学" },
{ value: "surgery", label: "外科学" },
{ value: "pediatrics", label: "儿科学" },
{ value: "obgyn", label: "妇产科学" },
{ value: "pharmacology", label: "药理学" },
{ value: "pathology", label: "病理学" },
{ value: "diagnostics", label: "诊断学" },
{ value: "ethics", label: "医学伦理" },
];
export default function ExamPrepPage() {
const [subject, setSubject] = useState("");
const [mode, setMode] = useState("auto");
const [question, setQuestion] = useState<any>(null);
const [answer, setAnswer] = useState("");
const [result, setResult] = useState<any>(null);
const [stats, setStats] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generateQuestion() {
setBusy(true);
setError(null);
setResult(null);
setAnswer("");
try {
const q = await examPrepApi.generateQuestion({ subject: subject || undefined, mode });
setQuestion(q);
} catch (err) {
setError(err instanceof ApiError ? err.message : "生成失败");
} finally {
setBusy(false);
}
}
async function submitAnswer() {
if (!answer.trim() || !question) return;
setBusy(true);
setError(null);
try {
const r = await examPrepApi.submitAnswer(question.id ?? "current", { answer });
setResult(r);
await loadStats();
} catch (err) {
setError(err instanceof ApiError ? err.message : "提交失败");
} finally {
setBusy(false);
}
}
async function loadStats() {
setBusy(true);
setError(null);
try {
const s = await examPrepApi.getStats();
setStats(s);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setBusy(false);
}
}
async function loadHistory() {
setBusy(true);
setError(null);
try {
const h = await examPrepApi.listHistory();
setHistory(Array.isArray(h) ? h : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setBusy(false);
}
}
async function handleDeleteRecord(recordId: string) {
setBusy(true);
setError(null);
try {
await examPrepApi.deleteHistory(recordId);
await loadHistory();
await loadStats();
} catch (err) {
setError(err instanceof ApiError ? err.message : "删除失败");
} finally {
setBusy(false);
}
}
async function handleDeleteAll() {
if (!confirm("确定要清空所有答题记录吗?此操作不可恢复。")) return;
setBusy(true);
setError(null);
try {
await examPrepApi.deleteAllHistory();
await loadHistory();
await loadStats();
} catch (err) {
setError(err instanceof ApiError ? err.message : "清空失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Award className="size-5" />}
title="执业医师备考"
description="针对执业医师考试的专项题库与进度追踪,强化薄弱科目。"
/>
<UsageGuide
steps={[
{ title: "选择科目", detail: "选择内科学、外科学等薄弱科目进行专项训练。" },
{ title: "生成试题", detail: "AI 根据执医考试大纲生成模拟题,覆盖高频考点。" },
{ title: "作答与解析", detail: "提交答案后获得详细解析与相关知识点扩展。" },
{ title: "追踪进度", detail: "系统自动统计各科目正确率,定位薄弱环节。" },
]}
tip="执业医师考试注重临床思维,建议在作答时写出推理过程,而非仅给出结论。"
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="size-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-40"
>
<option value=""></option>
{SUBJECTS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</Select>
<Select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="w-32"
>
<option value="auto"></option>
<option value="builtin"></option>
<option value="ai">AI生成</option>
</Select>
<Button onClick={generateQuestion} disabled={busy}>
{busy ? "生成中…" : "生成试题"}
</Button>
{selectedRecord && (
<Button variant="outline" onClick={() => setSelectedRecord(null)} disabled={busy}>
</Button>
)}
</div>
{error && <ErrorBanner message={error} />}
{question && (
<div className="space-y-4">
<div className="rounded-lg border border-primary/15 bg-primary/[0.03] p-4">
<p className="text-sm leading-relaxed text-foreground">
{question.content ?? question.question ?? JSON.stringify(question)}
</p>
{question.options && (
<ul className="mt-3 space-y-1">
{question.options.map((opt: string, i: number) => (
<li key={i} className="text-sm text-foreground/80">
{String.fromCharCode(65 + i)}. {opt}
</li>
))}
</ul>
)}
{question.subject && (
<Badge variant="info" className="mt-3">
{SUBJECTS.find((s) => s.value === question.subject)?.label ?? question.subject}
</Badge>
)}
</div>
{!result && (
<div className="space-y-2">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="输入你的答案或推理过程…"
rows={3}
/>
<Button onClick={submitAnswer} disabled={busy || !answer.trim()}>
</Button>
</div>
)}
{result && (
<div className="space-y-3">
<div
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
result.correct
? "bg-success/12 text-success"
: "bg-destructive/12 text-destructive"
}`}
>
{result.correct ? (
<><CheckCircle2 className="size-5" /> </>
) : (
<><Timer className="size-5" /> </>
)}
</div>
{result.explanation && (
<div className="rounded-lg bg-muted/50 p-4">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm leading-relaxed text-foreground/80">
{result.explanation}
</p>
</div>
)}
<Button
onClick={() => {
setQuestion(null);
setResult(null);
setAnswer("");
}}
>
</Button>
</div>
)}
</div>
)}
{selectedRecord && !question && (
<div className="space-y-4">
<div className="rounded-lg border border-primary/15 bg-primary/[0.03] p-4">
<p className="text-sm leading-relaxed text-foreground">{selectedRecord.content}</p>
{selectedRecord.options && selectedRecord.options.length > 0 && (
<ul className="mt-3 space-y-1">
{selectedRecord.options.map((opt: string, i: number) => (
<li key={i} className="text-sm text-foreground/80">
{String.fromCharCode(65 + i)}. {opt}
</li>
))}
</ul>
)}
<Badge variant="info" className="mt-3">
{SUBJECTS.find((s) => s.value === selectedRecord.subject)?.label ?? selectedRecord.subject}
</Badge>
</div>
<div className="space-y-2">
<div className="rounded-lg bg-muted/50 p-3">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm text-foreground/80">{selectedRecord.userAnswer}</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm text-foreground/80">{selectedRecord.correctAnswer}</p>
</div>
<div
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
selectedRecord.correct
? "bg-success/12 text-success"
: "bg-destructive/12 text-destructive"
}`}
>
{selectedRecord.correct ? (
<><CheckCircle2 className="size-5" /> </>
) : (
<><Timer className="size-5" /> </>
)}
</div>
{selectedRecord.explanation && (
<div className="rounded-lg bg-muted/50 p-4">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm leading-relaxed text-foreground/80">{selectedRecord.explanation}</p>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setSelectedRecord(null)}
>
</Button>
</div>
</div>
)}
{!question && !error && !selectedRecord && (
<EmptyState message="点击「生成试题」开始执业医师备考训练。" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="size-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadStats} disabled={busy}>
{busy ? "加载中…" : "刷新进度"}
</Button>
<Button variant="outline" size="sm" onClick={loadHistory} disabled={busy}>
{busy ? "加载中…" : "刷新记录"}
</Button>
{history.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleDeleteAll} disabled={busy} className="text-destructive hover:text-destructive">
</Button>
)}
</div>
{stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-lg font-bold text-foreground">{stats.totalAnswered ?? 0}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-lg font-bold text-foreground">{stats.correctRate ?? "—"}%</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
{stats.bySubject && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
{Object.entries(stats.bySubject).map(([key, val]: [string, any]) => {
const label = SUBJECTS.find((s) => s.value === key)?.label ?? key;
const hasData = val.correctRate !== null && val.correctRate !== undefined;
return (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
{hasData ? (
<span className={`font-medium ${
val.correctRate >= 80 ? "text-success" : val.correctRate >= 60 ? "text-warning" : "text-destructive"
}`}>
{val.correctRate}%
</span>
) : (
<span className="text-xs text-muted-foreground/60"></span>
)}
</div>
);
})}
</div>
)}
</div>
) : (
<EmptyState message="暂无统计数据,请先开始练习。" />
)}
{/* 答题历史 */}
{history.length > 0 && (
<div className="mt-5 space-y-2">
<p className="text-xs font-medium text-muted-foreground">{history.length}</p>
<div className="max-h-80 space-y-2 overflow-y-auto">
{history.map((h: any) => (
<div
key={h.id}
onClick={() => {
setSelectedRecord(h);
setQuestion(null);
setResult(null);
setAnswer("");
}}
className="relative cursor-pointer rounded-lg border border-border p-3 transition-colors hover:bg-muted/50"
>
<div className="mb-1 flex items-center justify-between">
<Badge variant={h.correct ? "success" : "destructive"}>
{h.correct ? "正确" : "错误"}
</Badge>
<div className="flex items-center gap-1">
<span className="text-[11px] text-muted-foreground">
{h.createdAt ? new Date(h.createdAt).toLocaleString() : ""}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteRecord(h.id);
}}
className="rounded p-1 text-muted-foreground/50 hover:bg-muted hover:text-destructive"
title="删除"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
</div>
<p className="line-clamp-2 text-xs text-foreground/90">{h.content}</p>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{h.userAnswer}</span>
<span>{h.correctAnswer}</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}