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
+370
View File
@@ -0,0 +1,370 @@
"use client";
/**
* 研究资料查询:自然语言问题 → 生成检索式 → 在选定来源检索 → 总结 / 生成引用。
*/
import { useState } from "react";
import { Search } from "lucide-react";
import { CredibilityBadge, InfoRow, RawDetails } from "@/components/display";
import { 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 { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { researchApi } from "@/lib/services";
import type { Citation, SearchQuery, Summary } from "@/lib/types";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
const SOURCES: { value: string; label: string }[] = [
{ value: "PUBMED", label: "PubMed" },
{ value: "CNKI", label: "中国知网" },
{ value: "WANFANG", label: "万方数据" },
{ value: "UPTODATE", label: "UpToDate" },
{ value: "COCHRANE", label: "Cochrane" },
];
/** 证据分级 → 配色(越强越绿)。 */
function evidenceVariant(
level: string,
): "success" | "info" | "warning" | "muted" {
if (level.startsWith("1")) return "success";
if (level.startsWith("2")) return "info";
if (level.startsWith("3") || level === "4") return "warning";
return "muted";
}
export default function ResearchPage() {
const [question, setQuestion] = useState("");
const [searchQuery, setSearchQuery] = useState<SearchQuery | null>(null);
const [selectedSources, setSelectedSources] = useState<string[]>(["PUBMED"]);
const [results, setResults] = useState<any>(null);
const [summary, setSummary] = useState<Summary | null>(null);
const [citations, setCitations] = useState<Citation[] | 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);
}
}
const items: any[] = results?.page?.items ?? results?.items ?? [];
function toggleSource(val: string) {
setSelectedSources((prev) =>
prev.includes(val) ? prev.filter((x) => x !== val) : [...prev, val],
);
}
return (
<div className="space-y-6">
<PageHeading
icon={<Search className="size-5" />}
title="循证检索"
description="按 PICO 框架将临床问题转为专业检索式,检索医学数据库、总结证据并生成规范引用。"
/>
<UsageGuide
steps={[
{ title: "描述研究问题", detail: "用自然语言输入问题,系统按 PICO 生成专业检索式。" },
{ title: "选择来源检索", detail: "勾选 PubMed / 中国知网等数据库,点击「检索」获取文献。" },
{ title: "生成总结", detail: "对检索结果一键总结,结论标注是否已核验与证据分级。" },
{ title: "生成引用", detail: "按温哥华格式等导出规范引用,便于写作。" },
]}
tip="标注「未验证」的结论务必回到原文核对;证据等级越高(1 类)可信度越强。"
/>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Textarea
rows={2}
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="用自然语言描述研究问题,如:他汀类药物对老年冠心病患者二级预防的疗效"
/>
<Button
disabled={!question || busy}
onClick={() =>
run(
() => researchApi.generateSearchQuery({ question }),
(v: any) => {
setSearchQuery(v?.query ?? v);
setResults(null);
setSummary(null);
setCitations(null);
},
)
}
>
</Button>
{searchQuery && (
<div className="rounded-lg border border-border bg-muted/50 p-4">
<p className="mb-2 font-mono text-sm text-primary">
{searchQuery.expression}
</p>
{searchQuery.meshTerms?.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{searchQuery.meshTerms.map((m) => (
<Badge key={m} variant="info">
MeSH: {m}
</Badge>
))}
</div>
)}
{searchQuery.pico && (
<div className="mt-2 grid grid-cols-2 gap-x-4 text-xs sm:grid-cols-4">
{searchQuery.pico.population && (
<InfoRow label="P 人群">
{searchQuery.pico.population}
</InfoRow>
)}
{searchQuery.pico.intervention && (
<InfoRow label="I 干预">
{searchQuery.pico.intervention}
</InfoRow>
)}
{searchQuery.pico.comparison && (
<InfoRow label="C 对照">
{searchQuery.pico.comparison}
</InfoRow>
)}
{searchQuery.pico.outcome && (
<InfoRow label="O 结局">{searchQuery.pico.outcome}</InfoRow>
)}
</div>
)}
{searchQuery.rationale && (
<p className="mt-2 text-xs text-muted-foreground">
{searchQuery.rationale}
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
{searchQuery && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-3 flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => toggleSource(s.value)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs transition",
selectedSources.includes(s.value)
? "border-primary bg-primary/10 text-primary"
: "border-input text-muted-foreground hover:bg-muted",
)}
>
{s.label}
</button>
))}
</div>
<Button
disabled={busy || selectedSources.length === 0}
onClick={() =>
run(
() =>
researchApi.search({
query: searchQuery,
sources: selectedSources,
}),
(v: any) => setResults(v),
)
}
>
</Button>
{results?.empty && (
<p className="mt-4 rounded-lg bg-warning/15 px-4 py-3 text-sm text-warning-foreground">
{results.notice ?? "未找到匹配资料"}
</p>
)}
{items.length > 0 && (
<ul className="mt-4 space-y-2">
{items.map((it: any, i: number) => (
<li key={it.id ?? i} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<a
href={it.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-primary hover:underline"
>
{it.title}
</a>
{it.source && <Badge variant="info">{it.source}</Badge>}
</div>
{it.authors && (
<p className="mt-1 text-xs text-muted-foreground">
{Array.isArray(it.authors)
? it.authors.join(", ")
: it.authors}
{it.publishedAt && ` · ${it.publishedAt}`}
</p>
)}
{it.abstract && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{it.abstract}
</p>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
)}
{items.length > 0 && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-3">
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => researchApi.summarize({ items }),
(v: Summary) => setSummary(v),
)
}
>
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() =>
researchApi.generateCitation({
items,
format: "VANCOUVER",
}),
(v: Citation[]) => setCitations(v),
)
}
>
</Button>
</div>
{summary && <SummaryView summary={summary} />}
{citations && citations.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-foreground">
{citations.length}
</h3>
<ol className="list-inside list-decimal space-y-1.5 text-sm text-foreground/80">
{citations.map((c, i) => (
<li key={c.itemId ?? i}>{c.text}</li>
))}
</ol>
</div>
)}
</CardContent>
</Card>
)}
{busy && <Loading />}
{error && <ErrorBanner message={error} />}
</div>
);
}
/** 资料总结:结论(含可追溯/未验证)+ 证据分级 + 提示 + 可信度。 */
function SummaryView({ summary }: { summary: Summary }) {
return (
<div className="mt-5">
<p className="mb-3 rounded-lg bg-warning/15 px-3 py-2 text-xs text-warning-foreground">
{summary.notice}
</p>
<h3 className="mb-2 text-sm font-semibold text-foreground"></h3>
<ul className="space-y-2">
{summary.conclusions?.map((c) => (
<li key={c.id} className="rounded-lg border border-border p-3 text-sm">
<p className="text-foreground">{c.statement}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
{c.verified ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="destructive">{c.unverifiedLabel ?? "未验证"}</Badge>
)}
{c.citations?.map((s, i) => (
<span key={i} className="text-xs text-muted-foreground">
[{s.title ?? s.id}]
</span>
))}
</div>
</li>
))}
</ul>
{summary.gradedItems?.length > 0 && (
<div className="mt-4">
<h3 className="mb-2 text-sm font-semibold text-foreground"></h3>
<ul className="space-y-1.5">
{summary.gradedItems.map((g) => (
<li
key={g.itemId}
className="flex items-center justify-between gap-3 text-sm"
>
<a
href={g.url}
target="_blank"
rel="noopener noreferrer"
className="truncate text-foreground/80 hover:text-primary hover:underline"
>
{g.title}
</a>
<Badge variant={evidenceVariant(g.evidenceLevel)}>
{g.evidenceLevel}
</Badge>
</li>
))}
</ul>
</div>
)}
<div className="mt-4">
<CredibilityBadge annotation={summary.annotation} />
</div>
<RawDetails data={summary} />
</div>
);
}