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:
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课程对练:输入课程标识 → 生成题目 → 查看可对练题目 → 发起对练逐题作答 → 结束生成报告。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { GraduationCap } from "lucide-react";
|
||||
|
||||
import {
|
||||
CredibilityBadge,
|
||||
RawDetails,
|
||||
ScoreBar,
|
||||
StatTile,
|
||||
} from "@/components/display";
|
||||
import { RadialProgress } from "@/components/charts";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UsageGuide } from "@/components/usage-guide";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { practiceApi } from "@/lib/services";
|
||||
import type { AnswerResult, PracticeReport } from "@/lib/types";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export default function PracticePage() {
|
||||
const [courseId, setCourseId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [lastResult, setLastResult] = useState<AnswerResult | null>(null);
|
||||
const [report, setReport] = useState<PracticeReport | null>(null);
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const v = await fn();
|
||||
after?.(v);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const currentQuestion: any =
|
||||
session?.questions?.[session?.currentIndex ?? 0] ??
|
||||
session?.currentQuestion ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeading
|
||||
icon={<GraduationCap className="size-5" />}
|
||||
title="医学题库"
|
||||
description="基于教学大纲智能出题,在线作答并获得知识薄弱点分析与能力报告。"
|
||||
/>
|
||||
|
||||
<UsageGuide
|
||||
steps={[
|
||||
{ title: "输入课程标识", detail: "如「内科学101」,定位要对练的课程。" },
|
||||
{ title: "生成或查看题目", detail: "「生成题目」按课程内容出题,或「查看可对练题目」加载已有题。" },
|
||||
{ title: "发起对练并作答", detail: "点击「发起对练」,逐题输入答案(选择题填选项字母)。" },
|
||||
{ title: "结束生成报告", detail: "完成后「结束并生成报告」,查看正确率、薄弱环节与改进建议。" },
|
||||
]}
|
||||
tip="作答有计时,超时将判错;报告中的薄弱环节会回流到你的胜任力画像。"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>选择课程</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<Label>课程标识</Label>
|
||||
<Input
|
||||
value={courseId}
|
||||
onChange={(e) => setCourseId(e.target.value)}
|
||||
placeholder="如:内科学101"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!courseId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.generateQuestions(courseId, {}),
|
||||
(v: any) => setQuestions(v?.questions ?? v?.items ?? []),
|
||||
)
|
||||
}
|
||||
>
|
||||
生成题目
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!courseId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.listAvailableQuestions(courseId),
|
||||
(v: any) => setQuestions(Array.isArray(v) ? v : []),
|
||||
)
|
||||
}
|
||||
>
|
||||
查看可对练题目
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!courseId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.startSession(courseId),
|
||||
(v: any) => {
|
||||
setSession(v);
|
||||
setReport(null);
|
||||
setLastResult(null);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
发起对练
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{busy && <Loading />}
|
||||
|
||||
{questions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>题目({questions.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{questions.map((q: any, i: number) => (
|
||||
<li key={q.id ?? i} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-foreground">
|
||||
{i + 1}. {q.stem ?? q.title ?? q.content ?? "(题干)"}
|
||||
</p>
|
||||
{q.reviewStatus && (
|
||||
<Badge variant="warning">
|
||||
{q.reviewStatus === "pending" ? "待审核" :
|
||||
q.reviewStatus === "approved" ? "已通过" :
|
||||
q.reviewStatus === "returned" ? "已退回" :
|
||||
q.reviewStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{Array.isArray(q.options) && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
{q.options.map((o: any, oi: number) => (
|
||||
<li key={oi}>
|
||||
{o.key ?? String.fromCharCode(65 + oi)}.{" "}
|
||||
{o.text ?? o.label ?? String(o)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{session && !report && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>进行对练</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentQuestion ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{currentQuestion.stem ??
|
||||
currentQuestion.title ??
|
||||
currentQuestion.content ??
|
||||
"当前题目"}
|
||||
</p>
|
||||
{Array.isArray(currentQuestion.options) && (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
{currentQuestion.options.map((o: any, oi: number) => (
|
||||
<li key={oi}>
|
||||
{o.key ?? String.fromCharCode(65 + oi)}.{" "}
|
||||
{o.text ?? o.label ?? String(o)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="输入你的作答(选择题填选项字母,分析题填要点)"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
disabled={busy || !answer}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
practiceApi.submitAnswer(session.id, {
|
||||
questionId:
|
||||
currentQuestion.id ?? currentQuestion.questionId,
|
||||
answer,
|
||||
}),
|
||||
(v: any) => {
|
||||
setLastResult(v);
|
||||
setAnswer("");
|
||||
if (v?.session) setSession(v.session);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
提交作答
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => practiceApi.finishSession(session.id),
|
||||
(v: any) => setReport(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
结束并生成报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="本次对练暂无可作答题目,可直接结束生成报告。" />
|
||||
)}
|
||||
|
||||
{lastResult && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
|
||||
lastResult.correct
|
||||
? "bg-success/12 text-success"
|
||||
: "bg-destructive/12 text-destructive"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{lastResult.correct ? "✓ 回答正确" : "✗ 回答错误"}
|
||||
</span>
|
||||
{lastResult.timedOut && <Badge variant="warning">超时判错</Badge>}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
用时 {(lastResult.elapsedMs / 1000).toFixed(1)} 秒
|
||||
</span>
|
||||
</div>
|
||||
{lastResult.next && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
下一题:第 {lastResult.next.position} / {lastResult.next.total} 题
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && <PracticeReportView report={report} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 对练报告:正确率、用时、能力标签统计、薄弱环节与改进建议。 */
|
||||
function PracticeReportView({ report }: { report: PracticeReport }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>对练报告</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<RadialProgress
|
||||
value={report.accuracy}
|
||||
size={120}
|
||||
label={`${report.accuracy.toFixed(0)}%`}
|
||||
sublabel="正确率"
|
||||
tone={report.accuracy >= 60 ? "success" : "destructive"}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-3 gap-3">
|
||||
<StatTile label="题目总数" value={report.totalQuestions} />
|
||||
<StatTile
|
||||
label="正确 / 错误"
|
||||
value={`${report.correctCount}/${report.incorrectCount}`}
|
||||
/>
|
||||
<StatTile
|
||||
label="总用时"
|
||||
value={`${report.totalTimeSeconds}s`}
|
||||
tone="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.tagBreakdown?.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
||||
能力标签正确率
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{report.tagBreakdown.map((t) => (
|
||||
<ScoreBar
|
||||
key={t.tagId}
|
||||
label={`${t.tagId}(${t.correctCount}/${t.totalQuestions})`}
|
||||
score={t.correctnessRate}
|
||||
tone={t.correctnessRate >= 60 ? "success" : "destructive"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.weakAreas?.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
||||
薄弱环节(正确率 < 60%)
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{report.weakAreas.map((w) => (
|
||||
<Badge key={w.tagId} variant="destructive">
|
||||
{w.tagName ?? w.tagId}({w.correctnessRate.toFixed(0)}%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.suggestions?.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">改进建议</h3>
|
||||
<ul className="space-y-3">
|
||||
{report.suggestions.map((s, i) => (
|
||||
<li key={i} className="rounded-lg border border-border p-3">
|
||||
<p className="text-sm text-foreground">{s.content}</p>
|
||||
<div className="mt-2">
|
||||
<CredibilityBadge annotation={s.annotation} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RawDetails data={report} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user