Files
CollegeAIcenter/web/src/app/student/exam-prep/page.tsx
T

448 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}