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