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

371 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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>
);
}