Files
CollegeAIcenter/web/src/app/student/practice/page.tsx
T

379 lines
13 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 { 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>
);
}