将"以光谱为中心"的观测数据架构升级为 (Source × ProductType) 双轴正交模型,
光谱降级为与光变/测光/图像平级的产品类型之一;同步把分散的工具、API、缓存表
收敛为统一入口。新增 Gaia 光变曲线(EPOCH_PHOTOMETRY)支持。
【架构】services/spectra 整体替换为 services/observation(双轴正交)
- Source(LAMOST/Gaia/SDSS/DESI)× ProductType(Spectrum/LightCurve/Photometry/Image)
正交组合,新增源/产品类型为纯加法(OCP)
- ObservationFetcher trait + ObservationRegistry:每个有效组合实现一个 fetcher,
启动时注册;SDSS specobj/APOGEE 共用 key 按 subtype 二级路由
- cone 缓存逻辑模板方法化(trait 默认方法),消除各源 4 份重复代码
- 多文件 Artifact 模型:一个逻辑产物可含多文件(如 Gaia 光变 G/BP/RP 三波段各一 FITS)
- 统一编排 dispatch.rs:search(仅检索)/ download(检索+下载),支持坐标模式
(cone→选源→下载)与标识符模式(直按 ID 下载)双输入
【Agent 工具整合】26 → 24
- 新增 find_observation:跨源×跨产品×双模式统一观测下载,取代 find_spectrum
- catalog_operation 升级为 6 合 1(search/describe/query/cone/export/lookup),
取代独立的 query_vizier + cone_search
- citation_network + library_search 合并为 library.rs
【API 路由】
- 新增 /observation/{search,download,capabilities,list} 命名空间
- 移除 /catalog/{crossmatch,spectrum/download,spectrum/list}
- GET /observation/capabilities 暴露 registry 能力清单,前端动态渲染源/产品/版本
下拉(不再硬编码各源支持矩阵)
【数据库迁移】
- 新表 observation_cache:新增 product 列 + artifacts_json(多文件产物),无 TTL
(观测数据不可变,区别于 vizier_query_cache 的 7 天 TTL)
- 20260705140001:spectrum_cache 旧数据迁入 observation_cache,单文件→单元素 artifacts
【前端】
- 新 ObservationPanel(988 行):双视图(检索下载 / 缓存库),选项由 capabilities 动态生成
- 新 useObservation hook、ObservationResultCard、observation/constants、utils/apiError
【安全与韧性加固】
- sessions 锁 Mutex → RwLock(读多写少,降低争用)
- 新增 upload_rate_limiter;login_rate_limiter 容量保护(10000 上限,超限清最旧一半)
- bookmarklet API 密钥 SHA-1 → SHA-256;ADMIN_PASSWORD 长度上限 128
- *_TIMEOUT_SECS / EMBEDDING_DIM 非法值告警并回退默认;DB_POOL_SIZE 可配置(原硬编码 5)
- sqlite-vec 注册逻辑下沉至 utils::register_sqlite_vec_extension
569 lines
23 KiB
TypeScript
569 lines
23 KiB
TypeScript
// dashboard/src/features/search/SearchPanel.tsx
|
||
import React from 'react';
|
||
import {
|
||
Search,
|
||
Loader,
|
||
CheckCircle,
|
||
Copy,
|
||
Download,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
SlidersHorizontal,
|
||
AlertTriangle,
|
||
Lightbulb,
|
||
} from 'lucide-react';
|
||
import type { StandardPaper } from '../types';
|
||
import { CustomSelect } from '../components/CustomSelect';
|
||
import { PaperCard } from '../components/PaperCard';
|
||
import type { TabId } from '../components/layout/Sidebar';
|
||
|
||
interface SearchPanelProps {
|
||
searchQuery: string;
|
||
setSearchQuery: (query: string) => void;
|
||
searchSource: 'all' | 'ads' | 'arxiv';
|
||
setSearchSource: (src: 'all' | 'ads' | 'arxiv') => void;
|
||
searchRows: number;
|
||
searchStart: number;
|
||
searchSort: string;
|
||
handlePageChange: (newStart: number) => void;
|
||
handleSortChange: (newSort: string) => void;
|
||
handleRowsChange: (newRows: number) => void;
|
||
searching: boolean;
|
||
handleSearch: (e: React.FormEvent) => void;
|
||
searchResults: StandardPaper[];
|
||
exportingList: string[];
|
||
toggleExportItem: (bibcode: string) => void;
|
||
handleExportBibtex: () => void;
|
||
exporting: boolean;
|
||
bibtexContent: string | null;
|
||
downloadingBibcodes: Record<string, boolean>;
|
||
handleDownload: (bibcode: string, force?: boolean) => void;
|
||
selectedPaper: StandardPaper | null;
|
||
setSelectedPaper: (paper: StandardPaper | null) => void;
|
||
openReader: (paper: StandardPaper) => void;
|
||
setActiveTab: (tab: TabId) => void;
|
||
loadCitations: (bibcode: string, reset?: boolean) => void;
|
||
showAlert: (msg: string, title?: string) => void;
|
||
onShowDetail: (paper: StandardPaper) => void;
|
||
}
|
||
|
||
export function SearchPanel({
|
||
searchQuery,
|
||
setSearchQuery,
|
||
searchSource,
|
||
setSearchSource,
|
||
searchRows,
|
||
searchStart,
|
||
searchSort,
|
||
handlePageChange,
|
||
handleSortChange,
|
||
handleRowsChange,
|
||
searching,
|
||
handleSearch,
|
||
searchResults,
|
||
exportingList,
|
||
toggleExportItem,
|
||
handleExportBibtex,
|
||
exporting,
|
||
bibtexContent,
|
||
downloadingBibcodes,
|
||
handleDownload,
|
||
selectedPaper,
|
||
setSelectedPaper,
|
||
openReader,
|
||
setActiveTab,
|
||
loadCitations,
|
||
showAlert,
|
||
onShowDetail,
|
||
}: SearchPanelProps) {
|
||
const currentPage = Math.floor(searchStart / searchRows) + 1;
|
||
const hasPreviousPage = searchStart > 0;
|
||
const hasNextPage = searchResults.length >= searchRows;
|
||
|
||
const [showBuilder, setShowBuilder] = React.useState(false);
|
||
const [rules, setRules] = React.useState<
|
||
Array<{ field: string; op: string; val: string }>
|
||
>([{ field: 'all', op: 'AND', val: '' }]);
|
||
|
||
const updateQueryFromRules = (currentRules: typeof rules) => {
|
||
const qParts: string[] = [];
|
||
currentRules.forEach((rule, idx) => {
|
||
if (!rule.val.trim()) return;
|
||
let valStr = rule.val.trim();
|
||
if (
|
||
valStr.includes(' ') &&
|
||
!valStr.startsWith('"') &&
|
||
!valStr.startsWith('(')
|
||
) {
|
||
valStr = `"${valStr}"`;
|
||
}
|
||
|
||
const fieldPart =
|
||
rule.field !== 'all' ? `${rule.field}:${valStr}` : valStr;
|
||
|
||
if (idx === 0) {
|
||
qParts.push(fieldPart);
|
||
} else {
|
||
qParts.push(`${rule.op} ${fieldPart}`);
|
||
}
|
||
});
|
||
setSearchQuery(qParts.join(' '));
|
||
};
|
||
|
||
const handleAddRule = () => {
|
||
setRules((prev) => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||
};
|
||
|
||
const handleRemoveRule = (idx: number) => {
|
||
const next = rules.filter((_, i) => i !== idx);
|
||
setRules(next);
|
||
updateQueryFromRules(next);
|
||
};
|
||
|
||
const handleRuleChange = (
|
||
idx: number,
|
||
key: 'field' | 'op' | 'val',
|
||
value: string
|
||
) => {
|
||
const next = rules.map((r, i) => (i === idx ? { ...r, [key]: value } : r));
|
||
setRules(next);
|
||
updateQueryFromRules(next);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 w-full max-w-5xl mx-auto">
|
||
{/* 标题 */}
|
||
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
|
||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||
统一文献检索平台
|
||
</h2>
|
||
<p className="text-slate-500 text-xs">
|
||
快速检索 NASA ADS 和 arXiv
|
||
预印本学术文献,支持一键下载、格式转换与星系引用分析。
|
||
</p>
|
||
</div>
|
||
|
||
{/* 检索控制面板 */}
|
||
<div className="console-panel p-6 rounded-lg space-y-4">
|
||
<form onSubmit={handleSearch} className="space-y-4">
|
||
<div className="flex flex-col md:flex-row gap-3 items-stretch">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')"
|
||
className="w-full pl-12 pr-4 py-3 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 focus:bg-white transition-all text-sm font-medium"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowBuilder(!showBuilder)}
|
||
className={`px-4 py-3 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
|
||
showBuilder
|
||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||
: 'btn-console'
|
||
}`}
|
||
>
|
||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||
{showBuilder ? '隐藏构造器' : '条件构造器'}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={searching}
|
||
className="btn-console btn-console-primary px-6 py-3 rounded-md text-xs font-bold tracking-wider flex items-center gap-2 shrink-0"
|
||
>
|
||
{searching ? (
|
||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||
) : (
|
||
<Search className="w-3.5 h-3.5" />
|
||
)}
|
||
{searching ? '检索中...' : '检索文献'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 条件构造器 */}
|
||
{showBuilder && (
|
||
<div className="p-4 rounded-md bg-slate-50 border border-slate-200 space-y-3 transition-all">
|
||
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
|
||
<span>高级检索式条件构造器</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddRule}
|
||
className="text-xs text-slate-700 hover:text-slate-900 font-bold"
|
||
>
|
||
+ 添加检索条件
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{rules.map((rule, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="flex flex-col sm:flex-row sm:items-center gap-2 pb-3.5 sm:pb-0 border-b border-slate-200/60 sm:border-0 last:border-0 last:pb-0"
|
||
>
|
||
{idx > 0 ? (
|
||
<CustomSelect
|
||
value={rule.op}
|
||
onChange={(val) =>
|
||
handleRuleChange(idx, 'op', String(val))
|
||
}
|
||
className="w-full sm:w-24"
|
||
options={[
|
||
{ value: 'AND', label: '并且 (AND)' },
|
||
{ value: 'OR', label: '或者 (OR)' },
|
||
{ value: 'NOT', label: '排除 (NOT)' },
|
||
]}
|
||
/>
|
||
) : (
|
||
<div className="w-full sm:w-24 text-left sm:text-center text-[10px] sm:text-xs text-slate-400 sm:text-slate-500 font-bold sm:font-semibold py-1 uppercase tracking-wider select-none">
|
||
条件过滤
|
||
</div>
|
||
)}
|
||
|
||
<CustomSelect
|
||
value={rule.field}
|
||
onChange={(val) =>
|
||
handleRuleChange(idx, 'field', String(val))
|
||
}
|
||
className="w-full sm:w-32"
|
||
options={[
|
||
{ value: 'all', label: '任意字段' },
|
||
{ value: 'title', label: '标题名称' },
|
||
{ value: 'author', label: '作者名称' },
|
||
{ value: 'abs', label: '摘要内容' },
|
||
{ value: 'year', label: '年份范围' },
|
||
]}
|
||
/>
|
||
|
||
<input
|
||
type="text"
|
||
value={rule.val}
|
||
onChange={(e) =>
|
||
handleRuleChange(idx, 'val', e.target.value)
|
||
}
|
||
placeholder={
|
||
rule.field === 'year'
|
||
? '例如: 2020-2023 或 2022'
|
||
: rule.field === 'author'
|
||
? '例如: Althaus'
|
||
: '请输入检索词...'
|
||
}
|
||
className="w-full sm:flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
|
||
/>
|
||
|
||
{rules.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveRule(idx)}
|
||
className="text-rose-600 hover:text-rose-800 text-xs font-bold px-3 py-1.5 self-end sm:self-auto border border-rose-100 rounded-md sm:border-0 bg-rose-50/20 sm:bg-transparent mt-1 sm:mt-0 transition-all cursor-pointer"
|
||
>
|
||
删除条件
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-[11px] text-slate-500 flex flex-wrap gap-x-4 gap-y-1 px-1 items-center">
|
||
<span className="font-semibold text-slate-650 flex items-center gap-1">
|
||
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
|
||
检索语法小提示:
|
||
</span>
|
||
<span>
|
||
作者检索:{' '}
|
||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||
author:"Althaus"
|
||
</code>
|
||
</span>
|
||
<span>
|
||
标题检索:{' '}
|
||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||
title:"hot subdwarf"
|
||
</code>
|
||
</span>
|
||
<span>
|
||
年份范围:{' '}
|
||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||
year:2020-2023
|
||
</code>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-slate-200">
|
||
{/* 数据源平台 */}
|
||
<div className="flex gap-4">
|
||
{[
|
||
{ id: 'all', label: '全部数据源' },
|
||
{ id: 'ads', label: 'NASA ADS' },
|
||
{ id: 'arxiv', label: 'arXiv 预印本' },
|
||
].map((src) => (
|
||
<label
|
||
key={src.id}
|
||
className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="searchSource"
|
||
checked={searchSource === src.id}
|
||
onChange={() =>
|
||
setSearchSource(src.id as 'all' | 'ads' | 'arxiv')
|
||
}
|
||
className="accent-slate-700"
|
||
/>
|
||
<span
|
||
className={
|
||
searchSource === src.id
|
||
? 'text-slate-900 font-bold'
|
||
: 'text-slate-500 hover:text-slate-700'
|
||
}
|
||
>
|
||
{src.label}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
{/* 排序及每页条数 */}
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold text-slate-500">
|
||
排序:
|
||
</span>
|
||
<CustomSelect
|
||
value={searchSort}
|
||
onChange={(val) => handleSortChange(val)}
|
||
options={[
|
||
{ value: 'relevance', label: '相关度' },
|
||
{ value: 'date_desc', label: '发表日期 (由新到旧)' },
|
||
{ value: 'date_asc', label: '发表日期 (由旧到新)' },
|
||
{ value: 'citations_desc', label: '被引频次 (从高到低)' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold text-slate-500">
|
||
每页显示:
|
||
</span>
|
||
<CustomSelect
|
||
value={searchRows}
|
||
onChange={(val) => handleRowsChange(Number(val))}
|
||
options={[
|
||
{ value: 10, label: '10 条' },
|
||
{ value: 15, label: '15 条' },
|
||
{ value: 30, label: '30 条' },
|
||
{ value: 50, label: '50 条' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{exportingList.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={handleExportBibtex}
|
||
disabled={exporting}
|
||
className="btn-console btn-console-secondary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5"
|
||
>
|
||
{exporting ? (
|
||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||
) : null}
|
||
导出已选 ({exportingList.length}) 篇 BibTeX
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{/* BibTeX 导出显示 */}
|
||
{bibtexContent && (
|
||
<div className="console-panel p-5 rounded-lg border border-slate-200 relative overflow-hidden bg-slate-50/20">
|
||
<h3 className="text-xs font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||
<CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX 导出成功
|
||
</h3>
|
||
<pre className="bg-white p-4 rounded-md border border-slate-200 text-xs text-slate-800 font-mono overflow-x-auto whitespace-pre-wrap max-h-60 leading-relaxed shadow-inner">
|
||
{bibtexContent}
|
||
</pre>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(bibtexContent);
|
||
showAlert('BibTeX 数据已成功复制至剪贴板。', '复制成功');
|
||
}}
|
||
className="absolute top-5 right-5 btn-console btn-console-secondary px-3 py-1 rounded-md text-xs font-bold flex items-center gap-1"
|
||
>
|
||
<Copy className="w-3.5 h-3.5" /> 复制数据
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 检索列表 */}
|
||
<div className="relative min-h-[200px]">
|
||
{searching && (
|
||
<div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-lg">
|
||
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-lg shadow-sm border border-slate-200 text-center">
|
||
<Loader className="w-8 h-8 text-slate-600 animate-spin" />
|
||
<span className="text-xs font-bold text-slate-600">
|
||
正在与学术服务器通讯,检索文献数据中...
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div
|
||
className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-45 pointer-events-none filter blur-[1px]' : ''}`}
|
||
>
|
||
{searchResults.map((paper) => {
|
||
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
|
||
const isSelected = selectedPaper?.bibcode === paper.bibcode;
|
||
return (
|
||
<PaperCard
|
||
key={paper.bibcode}
|
||
paper={paper}
|
||
isSelected={isSelected}
|
||
onClick={() => openReader(paper)}
|
||
variant="list"
|
||
headerActions={
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => onShowDetail(paper)}
|
||
className="px-3 py-1.5 rounded-md bg-white border border-slate-250 text-slate-655 hover:bg-slate-50 hover:border-slate-350 transition-all text-xs font-bold flex items-center cursor-pointer"
|
||
>
|
||
详情
|
||
</button>
|
||
{paper.is_downloaded ? (
|
||
<span className="px-2.5 py-1 rounded-md bg-emerald-50 text-emerald-800 border border-emerald-200/60 text-xs font-bold flex items-center gap-1">
|
||
<CheckCircle className="w-3.5 h-3.5" /> 已下载
|
||
</span>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
{paper.pdf_error === 'no_resource' &&
|
||
paper.html_error === 'no_resource' ? (
|
||
<span
|
||
title="已手动标记为【无有效全文资源】,批量下载时自动跳过"
|
||
className="px-2 py-1 bg-slate-50 text-slate-600 border border-slate-200 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||
>
|
||
<AlertTriangle className="w-3 h-3 text-slate-455" />{' '}
|
||
无资源
|
||
</span>
|
||
) : (
|
||
(paper.pdf_error || paper.html_error) && (
|
||
<span
|
||
title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`}
|
||
className="px-2 py-1 bg-rose-50 text-rose-800 border border-rose-200/60 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||
>
|
||
<AlertTriangle className="w-3 h-3 text-rose-600" />{' '}
|
||
下载失败
|
||
</span>
|
||
)
|
||
)}
|
||
<button
|
||
onClick={() => handleDownload(paper.bibcode)}
|
||
disabled={isDownloading}
|
||
className="btn-console btn-console-primary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1 transition-all"
|
||
>
|
||
{isDownloading ? (
|
||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||
) : (
|
||
<Download className="w-3.5 h-3.5" />
|
||
)}
|
||
下载文献
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportingList.includes(paper.bibcode)}
|
||
onChange={() => toggleExportItem(paper.bibcode)}
|
||
className="rounded text-slate-700 border-slate-300"
|
||
/>
|
||
<span>选择</span>
|
||
</label>
|
||
</>
|
||
}
|
||
abstract={
|
||
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal">
|
||
{paper.abstract_text || '暂无文献摘要数据。'}
|
||
</p>
|
||
}
|
||
footerLeft={
|
||
<>
|
||
<button
|
||
onClick={() => openReader(paper)}
|
||
className="px-4 py-1.5 rounded-md bg-slate-100 border border-slate-250 text-xs font-bold text-slate-800 hover:bg-slate-200 transition-all"
|
||
>
|
||
双语阅读对照
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedPaper(paper);
|
||
setActiveTab('citation');
|
||
loadCitations(paper.bibcode, true);
|
||
}}
|
||
className="px-4 py-1.5 rounded-md bg-slate-50 border border-slate-250 text-xs font-bold text-slate-600 hover:border-slate-400 hover:text-slate-800 transition-all"
|
||
>
|
||
引用星系图
|
||
</button>
|
||
</>
|
||
}
|
||
footerRight={
|
||
<>
|
||
{paper.doi && <span>DOI: {paper.doi}</span>}
|
||
<span>
|
||
Bibcode:{' '}
|
||
<span className="font-semibold text-slate-700">
|
||
{paper.bibcode}
|
||
</span>
|
||
</span>
|
||
{paper.citation_count > 0 && (
|
||
<span>
|
||
被引次数:{' '}
|
||
<span className="text-sky-750 font-bold">
|
||
{paper.citation_count}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分页控制栏 */}
|
||
{searchResults.length > 0 && (
|
||
<div className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-lg max-w-5xl mx-auto shadow-sm">
|
||
<button
|
||
onClick={() => handlePageChange(searchStart - searchRows)}
|
||
disabled={!hasPreviousPage || searching}
|
||
className="px-4 py-2 border border-slate-250 rounded-md bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" /> 上一页
|
||
</button>
|
||
|
||
<span className="text-xs font-bold text-slate-600">
|
||
第 {currentPage} 页 (当前显示 {searchStart + 1} -{' '}
|
||
{searchStart + searchResults.length} 条)
|
||
</span>
|
||
|
||
<button
|
||
onClick={() => handlePageChange(searchStart + searchRows)}
|
||
disabled={!hasNextPage || searching}
|
||
className="px-4 py-2 border border-slate-250 rounded-md bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
|
||
>
|
||
下一页 <ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|