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,243 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 临床情景对话对练:输入情景标识发起会话 → 逐轮对话 → 结束生成三维评估报告。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Send, Stethoscope } from "lucide-react";
|
||||
|
||||
import {
|
||||
CredibilityBadge,
|
||||
RawDetails,
|
||||
ScoreBar,
|
||||
StatTile,
|
||||
} from "@/components/display";
|
||||
import { ErrorBanner, Loading, PageHeading } from "@/components/feedback";
|
||||
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 { UsageGuide } from "@/components/usage-guide";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { clinicalApi } from "@/lib/services";
|
||||
import type { DialogueReport } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface ChatTurn {
|
||||
role: "student" | "system";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function ClinicalPage() {
|
||||
const [scenarioId, setScenarioId] = useState("");
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [report, setReport] = useState<DialogueReport | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
after?.(await fn());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function extractSystemReply(v: any): string {
|
||||
return (
|
||||
v?.systemResponse ??
|
||||
v?.reply ??
|
||||
v?.message ??
|
||||
v?.turn?.systemResponse ??
|
||||
JSON.stringify(v)
|
||||
);
|
||||
}
|
||||
|
||||
function sendTurn() {
|
||||
if (!input || busy || !session) return;
|
||||
const text = input;
|
||||
setTurns((t) => [...t, { role: "student", text }]);
|
||||
setInput("");
|
||||
run(
|
||||
() => clinicalApi.sendTurn(session.id, { studentInput: text }),
|
||||
(v: any) =>
|
||||
setTurns((t) => [...t, { role: "system", text: extractSystemReply(v) }]),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeading
|
||||
icon={<Stethoscope className="size-5" />}
|
||||
title="临床模拟"
|
||||
description="模拟真实问诊、查体与医患沟通情景,与 AI 患者交互后获得多维评估反馈。"
|
||||
/>
|
||||
|
||||
<UsageGuide
|
||||
steps={[
|
||||
{ title: "输入情景标识", detail: "如「胸痛分诊」,选择要演练的临床情景。" },
|
||||
{ title: "开始对话", detail: "点击「开始对话」进入情景,AI 扮演患者 / 同行角色。" },
|
||||
{ title: "逐轮问诊", detail: "在输入框输入问诊或处置内容,回车或「发送」推进对话。" },
|
||||
{ title: "结束并评估", detail: "点击「结束并评估」获得沟通、临床思维等三维评分与点评。" },
|
||||
]}
|
||||
tip="尽量像真实问诊一样有条理地展开;评估报告含可信度标注,注意核验 AI 给出的信息。"
|
||||
/>
|
||||
|
||||
<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={scenarioId}
|
||||
onChange={(e) => setScenarioId(e.target.value)}
|
||||
placeholder="如:胸痛分诊"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!scenarioId || busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => clinicalApi.start(scenarioId),
|
||||
(v: any) => {
|
||||
setSession(v);
|
||||
setTurns([]);
|
||||
setReport(null);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
开始对话
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{session && !report && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>对话</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="scrollbar-thin mb-4 max-h-96 space-y-3 overflow-auto">
|
||||
{turns.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
输入你的第一句话开始对话。
|
||||
</p>
|
||||
)}
|
||||
{turns.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex",
|
||||
t.role === "student" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-4 py-2 text-sm",
|
||||
t.role === "student"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{busy && <Loading />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入对话内容…"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendTurn();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button disabled={!input || busy} onClick={sendTurn}>
|
||||
<Send /> 发送
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => clinicalApi.finish(session.id),
|
||||
(v: any) => setReport(v),
|
||||
)
|
||||
}
|
||||
>
|
||||
结束并评估
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>三维评估报告</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<StatTile
|
||||
label="综合评分"
|
||||
value={report.overallScore?.toFixed?.(0) ?? report.overallScore}
|
||||
tone="primary"
|
||||
/>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {report.turnCount} 轮对话
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{report.dimensions?.map((d) => (
|
||||
<div key={d.dimension}>
|
||||
<ScoreBar
|
||||
label={d.dimensionName}
|
||||
score={d.score}
|
||||
tone={d.score >= 60 ? "success" : "warning"}
|
||||
/>
|
||||
{d.comment && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{d.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<CredibilityBadge annotation={report.annotation} />
|
||||
</div>
|
||||
|
||||
<RawDetails data={report} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user