AstroResearch/dashboard/src/pages/SearchPanel.tsx
Asfmq 2f1fd19d74 refactor: 观测层双轴正交重构——spectra→observation、工具/API 收敛、安全韧性加固
将"以光谱为中心"的观测数据架构升级为 (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
2026-07-07 01:34:02 +08:00

569 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

// 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>
);
}