Keep research search recoverable and isolate browser checks [skip ci]

This commit is contained in:
2026-11-07 15:51:12 +08:00
parent 4478750909
commit d527d059eb
14 changed files with 189 additions and 46 deletions
+3
View File
@@ -51,6 +51,7 @@ svg { flex-shrink:0; }
.status-dot { width:5px; height:5px; background:var(--accent); border-radius:50%; flex-shrink:0; }
.index-grid { display:grid; grid-template-columns:180px minmax(0,1fr); gap:64px; padding:46px 0 70px; }
.index-aside h2 { font-size:12px; font-weight:500; margin-bottom:24px; }
.topic-list { max-height:min(60vh,560px); overflow-y:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:4px; margin:-4px; }
.topic { display:flex; justify-content:space-between; padding:9px 0; color:var(--secondary); font-size:12px; }
.topic.active { color:var(--ink); font-weight:600; }
.topic:hover { color:var(--accent); }
@@ -151,3 +152,5 @@ svg { flex-shrink:0; }
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } *,*:before,*:after { animation:none!important; transition:none!important; } }
.search-form select { border:0; background:transparent; color:var(--secondary); font-size:11px; max-width:100px; padding:3px 0; }
@media(max-width:640px) { .topic-list { display:flex; flex-wrap:wrap; gap:8px; width:100%; max-height:160px; padding:4px; margin:0; } }
+1 -2
View File
@@ -1,2 +1 @@
import Link from "next/link";
export default function NotFound() { return <main id="main" className="page-shell empty-state"><span className="section-kicker">404</span><h1>这篇论文不在当前索引里。</h1><p>如果正在使用样本模式,只有导出的 20 条论文可以打开。</p><Link className="text-link" href="/">返回论文索引 →</Link></main>; }
export default function NotFound() { return <main id="main" className="page-shell empty-state"><span className="section-kicker">404</span><h1>这篇论文不在当前索引里。</h1><p>请检查链接,或回到论文索引继续浏览。</p><a className="text-link" href="/">返回论文索引 →</a></main>; }
+13 -5
View File
@@ -1,16 +1,24 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowRight, ArrowUpRight, Search, X } from "lucide-react";
import { listPapers, normalizeFilters, paperDate, type Filters } from "@/lib/papers";
import { listPapers, normalizeFilters, paperDate, MAX_PAPER_PAGE, type Filters } from "@/lib/papers";
import { Button } from "@/components/ui/button";
export default async function Home({ searchParams }: { searchParams: Promise<Record<string,string | string[] | undefined>> }) {
const f = normalizeFilters(await searchParams), data = await listPapers(f);
const pages = Math.ceil(data.total / data.pageSize);
const pages = data.total === null ? 0 : Math.min(MAX_PAPER_PAGE, Math.ceil(data.total / data.pageSize));
const issue = data.mode === "error" ? {
timeout: ["这次检索耗时较长", "试试更具体的关键词,或清除筛选后重新浏览。"],
busy: ["论文索引正在处理较多请求", "请稍候重试。你的搜索条件已保留。"],
invalid_query: ["请调整搜索条件", "请缩短关键词或清除筛选后重试。"],
unavailable: ["论文暂时无法读取", "请稍后重试。你的搜索条件已保留。"],
}[data.reason] : null;
function href(changes: Filters) { return "/?" + new URLSearchParams(Object.entries({ ...f, ...changes }).filter(([, v]) => typeof v === "string" && !!v) as [string, string][]).toString(); }
if (!issue && data.page > Math.max(1, pages)) redirect(href({ page: String(Math.max(1, pages)) }));
return <main id="main" className="page-shell"><section className="index-intro"><div className="section-kicker">THE OPEN RESEARCH READER <span>01 — INDEX</span></div><h1>好研究,<br /><span className="muted-heading">值得被读到。</span></h1><div className="intro-bottom"><p>从一个问题,到一篇论文。<br />发现研究、阅读摘要,回到每一项工作的原始出处。</p><a href="#papers" className="text-link">浏览论文 <ArrowRight size={16} /></a></div></section>
{data.mode === "sample" && <div className="sample-note" role="note"><span className="status-dot" /><p>真实数据库样本 · 当前展示 2026-11-06 导出的前 20 条记录,搜索与筛选仅限此样本。</p><Link href="/about">数据说明 <ArrowUpRight size={13} /></Link></div>}
<section id="papers" className="index-grid"><aside className="index-aside"><h2>研究领域</h2><Link className={!f.topic ? "topic active" : "topic"} href={href({ topic: "", page: "1" })}>全部领域 <span>↗</span></Link>{data.topics.map(t => <Link key={t} className={f.topic === t ? "topic active" : "topic"} href={href({ topic: t, page: "1" })}>{t}</Link>)}<p className="aside-note">保留原始标题与摘要。<br />不把自动生成的解读当成论文结论。</p></aside><div className="paper-index"><div className="index-heading"><h2>{f.topic || "论文索引"}<span>{data.total.toString().padStart(2, "0")}</span></h2><form className="sort-form"><input type="hidden" name="q" value={f.q || ""} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="searchIn" value={f.searchIn || "text"} /><label htmlFor="sort" className="sr-only">排序</label><select id="sort" name="sort" defaultValue={f.sort || "recent"}><option value="recent">收录顺序</option><option value="title">标题 A–Z</option></select><button type="submit" className="quiet-button">排序</button></form></div>
<section id="papers" className="index-grid"><aside className="index-aside"><h2>研究领域</h2><div className="topic-list" role="region" aria-label="研究领域分类" tabIndex={0}><Link className={!f.topic ? "topic active" : "topic"} href={href({ topic: "", page: "1" })}>全部领域 <span>↗</span></Link>{data.topics.map(t => <Link key={t} className={f.topic === t ? "topic active" : "topic"} href={href({ topic: t, page: "1" })}>{t}</Link>)}</div><p className="aside-note">保留原始标题与摘要。<br />不把自动生成的解读当成论文结论。</p></aside><div className="paper-index"><div className="index-heading"><h2>{f.topic || "论文索引"}{data.total !== null && <span>{data.total.toLocaleString("zh-CN")}</span>}</h2><form className="sort-form"><input type="hidden" name="q" value={f.q || ""} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="searchIn" value={f.searchIn || "text"} /><label htmlFor="sort" className="sr-only">排序</label><select id="sort" name="sort" defaultValue={f.sort || "recent"}><option value="recent">收录顺序</option><option value="title">标题 A–Z</option></select><button type="submit" className="quiet-button">排序</button></form></div>
<form className="search-form" role="search"><Search size={18} aria-hidden /><select name="searchIn" aria-label="搜索范围" defaultValue={f.searchIn || "text"}><option value="text">标题 / 摘要</option><option value="author">作者</option></select><label htmlFor="paper-search" className="sr-only">搜索论文</label><input id="paper-search" name="q" placeholder={f.searchIn === "author" ? "搜索作者姓名…" : "搜索标题或摘要…"} defaultValue={f.q || ""} maxLength={200} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="sort" value={f.sort || "recent"} /><button type="submit" aria-label="搜索论文"><ArrowRight size={18} /></button></form>
{(f.q || f.topic) && <div className="filter-note"><span>{f.q ? `搜索 “${f.q}”` : f.topic}</span><Link href="/">清除筛选 <X size={13} /></Link></div>}
{data.items.length ? <ol className="paper-list">{data.items.map((p, i) => <li key={p.id}><article className="paper-row"><span className="row-number">{String((data.page - 1) * data.pageSize + i + 1).padStart(2, "0")}</span><div><div className="paper-meta"><span>{p.topics[0] || "Research"}</span><span>{paperDate(p.publishedAt)}</span></div><h3><Link href={`/papers/${p.id}`}>{p.title}</Link></h3><p className="paper-summary">{p.abstract || "来源未提供摘要。"}</p><div className="row-bottom"><span className="authors">{p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}</span><Link href={`/papers/${p.id}`} className="read-link">阅读简介 <ArrowUpRight size={15} /></Link></div></div></article></li>)}</ol> : <div className="empty-state"><h3>没有找到符合条件的论文</h3><p>{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}</p><Button asChild variant="outline"><Link href="/">查看全部论文</Link></Button></div>}
<nav className="pagination" aria-label="论文分页"><span>{data.total ? `${data.page} / ${Math.max(1,pages)}` : "0 条结果"}</span><div>{data.page > 1 && <Link href={href({ page: String(data.page - 1) })}>上一页</Link>}{data.page < pages && <Link href={href({ page: String(data.page + 1) })}>下一页 <ArrowRight size={14} /></Link>}</div></nav></div></section></main>;
{issue ? <div className="empty-state" role="alert"><h3>{issue[0]}</h3><p>{issue[1]}</p><div className="flex justify-center gap-4"><Button asChild variant="outline"><a href={href({})}>重新尝试</a></Button><Button asChild variant="ghost"><Link href="/">清除筛选</Link></Button></div></div> : data.items.length ? <ol className="paper-list">{data.items.map((p, i) => <li key={p.id}><article className="paper-row"><span className="row-number">{String((data.page - 1) * data.pageSize + i + 1).padStart(2, "0")}</span><div><div className="paper-meta"><span>{f.topic || p.topics[0] || "Research"}</span><span>{paperDate(p.publishedAt)}</span></div><h3><Link href={`/papers/${p.id}`}>{p.title}</Link></h3><p className="paper-summary">{p.abstract || "来源未提供摘要。"}</p><div className="row-bottom"><span className="authors">{p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}</span><Link href={`/papers/${p.id}`} className="read-link">阅读简介 <ArrowUpRight size={15} /></Link></div></div></article></li>)}</ol> : <div className="empty-state"><h3>没有找到符合条件的论文</h3><p>{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}</p><Button asChild variant="outline"><Link href="/">查看全部论文</Link></Button></div>}
{!issue && <nav className="pagination" aria-label="论文分页"><span>{data.total ? `${data.page} / ${Math.max(1,pages)}` : "0 条结果"}</span><div>{data.page > 1 && <Link href={href({ page: String(data.page - 1) })}>上一页</Link>}{data.page < pages && <Link href={href({ page: String(data.page + 1) })}>下一页 <ArrowRight size={14} /></Link>}</div></nav>}{data.total !== null && data.total > MAX_PAPER_PAGE * data.pageSize && <p className="source-caption" role="note">当前最多浏览前 {(MAX_PAPER_PAGE * data.pageSize).toLocaleString("zh-CN")} 条结果。搜索或筛选研究领域,可进一步缩小范围。</p>}</div></section></main>;
}
+17
View File
@@ -0,0 +1,17 @@
// Anonymous server-side transport. Expected HTTP/network failures are values;
// callers can keep the search form usable without falling back to sample data.
export function createPaperTransport(origin, requestUpstream = fetch) {
return async function request(path) {
let response;
try {
response = await requestUpstream(new URL(path, origin.replace(/\/$/, "") + "/"), {
cache: "no-store", signal: AbortSignal.timeout(12000), redirect: "error",
});
} catch (error) {
return { ok: false, status: error?.name === "TimeoutError" || error?.name === "AbortError" ? 504 : 503 };
}
if (!response.ok) return { ok: false, status: response.status };
try { return { ok: true, data: await response.json() }; }
catch { return { ok: false, status: 502 }; }
};
}
+14 -13
View File
@@ -1,13 +1,16 @@
import snapshot from "@/data/papers.json";
import { createPaperTransport } from "./paper-api.mjs";
export type Paper = { id: string; title: string; abstract: string | null; authors: string[]; publishedAt: string | null; topics: string[]; sources: { label: string; url: string; externalId: string }[]; doi?: string | null; journal?: string | null; comments?: string | null; publications?: { venue: string; year: number; track: string; status: string; evidenceUrl?: string }[] };
export type PaperPage = { items: Paper[]; total: number; page: number; pageSize: number; topics: string[]; mode: "sample" | "live"; capturedAt?: string };
export type PaperFailure = { mode: "error"; reason: "timeout" | "busy" | "invalid_query" | "unavailable"; items: Paper[]; total: null; page: number; pageSize: number; topics: string[] };
export type Filters = { q?: string; topic?: string; sort?: string; page?: string; searchIn?: string };
export function normalizeFilters(input: Record<string, string | string[] | undefined>): Filters {
const value = (key: string) => typeof input[key] === "string" ? (input[key] as string).slice(0, 200) : undefined;
return { q: value("q"), topic: value("topic"), sort: value("sort"), page: value("page"), searchIn: value("searchIn") };
}
const size = 8;
export const MAX_PAPER_PAGE = 1000;
export function safeExternalUrl(input: string | null | undefined) {
if (!input) return null;
try { const u = new URL(input); return u.protocol === "https:" || u.protocol === "http:" ? u.href : null; } catch { return null; }
@@ -15,23 +18,21 @@ export function safeExternalUrl(input: string | null | undefined) {
function isPaper(p: unknown): p is Paper {
if (!p || typeof p !== "object") return false;
const v = p as Paper;
return typeof v.id === "string" && typeof v.title === "string" && (typeof v.abstract === "string" || v.abstract === null) && Array.isArray(v.authors) && v.authors.every(x => typeof x === "string") && Array.isArray(v.topics) && v.topics.every(x => typeof x === "string") && Array.isArray(v.sources) && v.sources.every(x => typeof x.label === "string" && typeof x.externalId === "string" && typeof x.url === "string") && (v.publishedAt === null || typeof v.publishedAt === "string");
return typeof v.id === "string" && typeof v.title === "string" && (typeof v.abstract === "string" || v.abstract === null) && Array.isArray(v.authors) && v.authors.every(x => typeof x === "string") && Array.isArray(v.topics) && v.topics.every(x => typeof x === "string") && Array.isArray(v.sources) && v.sources.every(x => !!x && typeof x === "object" && typeof x.label === "string" && typeof x.externalId === "string" && typeof x.url === "string") && (v.publishedAt === null || typeof v.publishedAt === "string");
}
async function remote(path: string) {
const origin = process.env.PAPER_API_ORIGIN;
if (!origin) throw new Error("paper_api_not_configured");
const res = await fetch(new URL(path, origin.replace(/\/$/, "") + "/"), { cache: "no-store", signal: AbortSignal.timeout(12000), redirect: "error" });
if (res.status === 404) return null;
if (!res.ok) throw new Error("paper_api_unavailable");
return res.json();
return createPaperTransport(process.env.PAPER_API_ORIGIN || "")(path);
}
export async function listPapers(filters: Filters): Promise<PaperPage> {
const page = Math.max(1, Math.min(10000, Number.parseInt(filters.page || "1", 10) || 1));
export async function listPapers(filters: Filters): Promise<PaperPage | PaperFailure> {
const page = Math.max(1, Math.min(MAX_PAPER_PAGE, Number.parseInt(filters.page || "1", 10) || 1));
const q = (filters.q || "").trim().slice(0, 200), topic = filters.topic || "", sort = filters.sort === "title" ? "title" : "recent";
if (process.env.PAPER_API_ORIGIN) {
const params = new URLSearchParams({ q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", page: String(page), pageSize: String(size) });
const data = await remote("papers?" + params);
if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) throw new Error("invalid_paper_response");
const failure = (status: number): PaperFailure => ({ mode: "error", reason: status === 504 ? "timeout" : status === 429 ? "busy" : status === 400 ? "invalid_query" : "unavailable", items: [], total: null, page, pageSize: size, topics: topic ? [topic] : [] });
const response = await remote("papers?" + params);
if (!response.ok) return failure(response.status ?? 503);
const data = response.data;
if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) return failure(502);
return { ...data, page, pageSize: size, mode: "live" };
}
const all: Paper[] = snapshot.papers;
@@ -41,8 +42,8 @@ export async function listPapers(filters: Filters): Promise<PaperPage> {
return { items: items.slice((page - 1) * size, page * size), total: items.length, page, pageSize: size, topics, mode: "sample", capturedAt: snapshot.capturedAt };
}
export async function getPaper(id: string): Promise<Paper | null> {
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(id)) return null;
if (process.env.PAPER_API_ORIGIN) { const data = await remote("papers/" + encodeURIComponent(id)); if (data === null) return null; if (!isPaper(data)) throw new Error("invalid_paper_response"); return data; }
if (!/^[1-9][0-9]{0,18}$/.test(id) || BigInt(id) > 9223372036854775807n) return null;
if (process.env.PAPER_API_ORIGIN) { const response = await remote("papers/" + encodeURIComponent(id)); if (!response.ok) { if (response.status === 404) return null; throw new Error("paper_api_unavailable"); } if (!isPaper(response.data)) throw new Error("invalid_paper_response"); return response.data; }
return snapshot.papers.find(p => p.id === id) || null;
}
export function paperDate(date: string | null) {