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
+378
View File
@@ -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">
&lt; 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>
);
}