448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|