379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|