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
This commit is contained in:
fmq
2026-07-07 01:34:02 +08:00
parent a156252bc3
commit 2f1fd19d74
71 changed files with 6229 additions and 4198 deletions
+50 -1
View File
@@ -9,6 +9,7 @@ import { ReaderPanel } from './pages/ReaderPanel';
import { CitationPanel } from './pages/CitationPanel';
import { SyncPanel } from './pages/SyncPanel';
import { ResearchAgentPanel } from './pages/ResearchAgentPanel';
import { ObservationPanel } from './pages/ObservationPanel';
import type { StandardPaper, NoteRecord } from './types';
import { GlobalDialog } from './components/dialogs/GlobalDialog';
import { UncachedPaperModal } from './components/dialogs/UncachedPaperModal';
@@ -22,6 +23,7 @@ import { useLibrary } from './hooks/useLibrary';
import { useSearch } from './hooks/useSearch';
import { useNotes } from './hooks/useNotes';
import { useCitations } from './hooks/useCitations';
import { useObservation } from './hooks/useObservation';
export default function App() {
// 移动端菜单显示状态
@@ -59,7 +61,7 @@ export default function App() {
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
const [activeTab, setActiveTab] = useState<
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'observation' | 'agent'
>(() => {
const saved = localStorage.getItem('astro_active_tab');
const validTabs = [
@@ -68,6 +70,7 @@ export default function App() {
'reader',
'citation',
'sync',
'observation',
'agent',
] as const;
return (
@@ -90,6 +93,9 @@ export default function App() {
const auth = useAuth();
const notes = useNotes();
const citations = useCitations();
const observation = useObservation({
isAuthenticated: auth.isAuthenticated,
});
// 协调:进入阅读器方法 (需要拉取正文和笔记)
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
@@ -451,6 +457,7 @@ export default function App() {
{activeTab === 'reader' && '双语阅读'}
{activeTab === 'citation' && '引用星系'}
{activeTab === 'sync' && '批量任务'}
{activeTab === 'observation' && '观测数据'}
{activeTab === 'agent' && '智能科研'}
</span>
</header>
@@ -649,6 +656,48 @@ export default function App() {
{activeTab === 'sync' && <SyncPanel />}
{activeTab === 'observation' && (
<ObservationPanel
capabilities={observation.capabilities}
currentSpec={observation.currentSpec}
searchForm={observation.searchForm}
setSearchForm={observation.setSearchForm}
searchResults={observation.searchResults}
searching={observation.searching}
searchError={observation.searchError}
runSearch={observation.runSearch}
selectedSourceIds={observation.selectedSourceIds}
toggleSelect={observation.toggleSelect}
selectAll={observation.selectAll}
selectNone={observation.selectNone}
downloading={observation.downloading}
downloadResult={observation.downloadResult}
downloadError={observation.downloadError}
setDownloadResult={observation.setDownloadResult}
downloadSelected={observation.downloadSelected}
downloadByIds={observation.downloadByIds}
downloadByCoordinates={observation.downloadByCoordinates}
libraryItems={observation.libraryItems}
libraryTotal={observation.libraryTotal}
libraryLoading={observation.libraryLoading}
libraryError={observation.libraryError}
fetchLibrary={observation.fetchLibrary}
libSource={observation.libSource}
setLibSource={observation.setLibSource}
libProduct={observation.libProduct}
setLibProduct={observation.setLibProduct}
libSearch={observation.libSearch}
setLibSearch={observation.setLibSearch}
libSort={observation.libSort}
setLibSort={observation.setLibSort}
libPage={observation.libPage}
setLibPage={observation.setLibPage}
libPageSize={observation.libPageSize}
setLibPageSize={observation.setLibPageSize}
resetLibraryFilters={observation.resetLibraryFilters}
/>
)}
{activeTab === 'agent' && (
<ResearchAgentPanel
showConfirm={showConfirm}
@@ -30,6 +30,7 @@ import type {
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
import type { useLibrary } from '../../hooks/useLibrary';
import type { TabId } from '../layout/Sidebar';
import { AskUserQuestionCard } from './AskUserQuestionCard';
import { PermissionRequestCard } from './PermissionRequestCard';
import { AgentMetricsPanel } from './AgentMetricsPanel';
@@ -94,9 +95,7 @@ interface AgentMessageListProps {
onSuccess?: () => void
) => Promise<void>;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
citations?: ReturnType<typeof useCitations>;
library?: ReturnType<typeof useLibrary>;
}
@@ -33,7 +33,7 @@ const TOOL_LABELS: Record<string, string> = {
query_target: '天体查询',
query_vizier: 'VizieR 星表',
cone_search: '锥形检索',
find_spectrum: '光谱检索下载',
find_observation: '观测数据下载',
catalog_operation: '星表操作',
save_note: '保存笔记',
todo_write: '任务管理',
@@ -69,7 +69,7 @@ const CATEGORY_COLORS: Record<string, string> = {
query_target: 'bg-amber-100 text-amber-700 border-amber-200',
query_vizier: 'bg-violet-100 text-violet-700 border-violet-200',
cone_search: 'bg-violet-100 text-violet-700 border-violet-200',
find_spectrum: 'bg-slate-200 text-slate-700 border-slate-300',
find_observation: 'bg-slate-200 text-slate-700 border-slate-300',
catalog_operation: 'bg-violet-100 text-violet-700 border-violet-200',
save_note: 'bg-teal-100 text-teal-700 border-teal-200',
todo_write: 'bg-orange-100 text-orange-700 border-orange-200',
@@ -36,7 +36,7 @@ function getToolDisplayName(name: string | null): string {
query_target: '天体查询',
query_vizier: 'VizieR 星表',
cone_search: '锥形检索',
find_spectrum: '光谱检索下载',
find_observation: '观测数据下载',
catalog_operation: '星表操作',
save_note: '保存笔记',
todo_write: '任务管理',
@@ -17,6 +17,7 @@ import {
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
import type { useLibrary } from '../../hooks/useLibrary';
import type { TabId } from '../layout/Sidebar';
// ==========================================
// 1. 天体参数卡片 (query_target)
@@ -204,9 +205,7 @@ interface PaperListCardProps {
};
library?: ReturnType<typeof useLibrary>;
citations?: ReturnType<typeof useCitations>;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
}
@@ -747,124 +746,20 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
// ==========================================
// 6. 统一光谱下载结果卡片 (find_spectrum)
// 6. 统一观测数据下载结果卡片 (find_observation)
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
// 支持多 artifact 产物(如 Gaia 光变 G/BP/RP 三波段各一个文件)
//
// 实现已抽出到共享组件 ObservationResultCard,本函数仅作为 Agent 工具结果
// 渲染入口(metadata 形状与 ObservationBatchResult 完全一致)。
// ==========================================
interface SpectrumDownloadItem {
survey: string;
source_label: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
import { ObservationResultCard } from '../observation/ObservationResultCard';
import type { ObservationBatchResult } from '../observation/constants';
interface FindObservationCardProps {
metadata: ObservationBatchResult;
}
interface FindSpectrumCardProps {
metadata: {
survey: string;
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
downloads: SpectrumDownloadItem[];
failures: { source_label: string; error: string }[];
};
}
const SURVEY_THEME: Record<string, { badge: string; label: string }> = {
lamost: { badge: 'bg-amber-100 text-amber-700', label: 'LAMOST' },
gaia: { badge: 'bg-sky-100 text-sky-700', label: 'Gaia' },
sdss: { badge: 'bg-indigo-100 text-indigo-700', label: 'SDSS' },
desi: { badge: 'bg-emerald-100 text-emerald-700', label: 'DESI' },
};
export function FindSpectrumCard({ metadata }: FindSpectrumCardProps) {
const { survey, ra, dec, radius_deg, matched_count, downloads = [], failures = [] } = metadata;
const theme = SURVEY_THEME[survey] || SURVEY_THEME.lamost;
const hasResult = downloads.length > 0 || failures.length > 0;
return (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏 */}
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
<Activity className="w-3.5 h-3.5 text-slate-500" />
<span></span>
</div>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}>
{theme.label}
</span>
</div>
{/* 查询信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{ra !== undefined && dec !== undefined && (
<span className="font-mono">
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
</span>
)}
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
{matched_count}
</span>
{downloads.length > 0 && (
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
<CheckCircle2 className="w-3 h-3" />
{downloads.length}
</span>
)}
{failures.length > 0 && (
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
<AlertTriangle className="w-3 h-3" />
{failures.length}
</span>
)}
</div>
{/* 下载结果列表 */}
{downloads.map((d, i) => (
<div key={i} className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-mono font-medium text-slate-700">{d.source_label}</span>
{d.cached ? (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
<CheckCircle2 className="w-3 h-3" />
</span>
) : (
<span className="text-[10px] text-slate-400"></span>
)}
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-slate-500">
{d.file_format.toUpperCase()} · {d.size_bytes > 1024 ? `${(d.size_bytes / 1024).toFixed(1)} KB` : `${d.size_bytes} B`}
</span>
<a
href={d.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>FITS</span>
</a>
</div>
</div>
))}
{/* 失败列表 */}
{failures.map((f, i) => (
<div key={`f${i}`} className="border border-rose-200 rounded bg-rose-50/50 p-2.5">
<div className="flex items-center gap-1 text-rose-700 font-medium">
<AlertTriangle className="w-3 h-3" />
<span className="font-mono text-[10px]">{f.source_label}</span>
</div>
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
</div>
))}
{!hasResult && (
<p className="text-[11px] text-slate-400 italic py-2">()</p>
)}
</div>
);
export function FindObservationCard({ metadata }: FindObservationCardProps) {
return <ObservationResultCard result={metadata} />;
}
+21 -17
View File
@@ -11,11 +11,12 @@ import {
TodoTaskCard,
BgTaskProgressCard,
VizierResultCard,
FindSpectrumCard,
FindObservationCard,
} from './SpecialToolRenderers';
import type { StandardPaper } from '../../types';
import type { useCitations } from '../../hooks/useCitations';
import type { useLibrary } from '../../hooks/useLibrary';
import type { TabId } from '../layout/Sidebar';
interface ToolCallCardProps {
step: number;
@@ -33,9 +34,7 @@ interface ToolCallCardProps {
onToggleArgs: () => void;
onToggleResult: () => void;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
citations?: ReturnType<typeof useCitations>;
library?: ReturnType<typeof useLibrary>;
}
@@ -119,31 +118,36 @@ export function ToolCallCard({
);
}
// 1c. 统一光谱下载(find_spectrum
// 1c. 统一观测数据下载(find_observation
if (
name === 'find_spectrum' &&
name === 'find_observation' &&
metadata &&
typeof metadata === 'object' &&
'survey' in metadata &&
'downloads' in metadata
'source' in metadata &&
'products' in metadata
) {
return (
<FindSpectrumCard
<FindObservationCard
metadata={
metadata as {
survey: string;
source: string;
product: { product: string; subtype?: string };
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
downloads: {
survey: string;
products: {
source: string;
source_id: string;
source_label: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
artifacts: {
band?: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
}[];
}[];
failures: { source_label: string; error: string }[];
}
@@ -35,8 +35,8 @@ export function getToolDisplayName(name: string): string {
return '查询 VizieR 星表';
case 'cone_search':
return '锥形检索天体';
case 'find_spectrum':
return '检索下载光谱';
case 'find_observation':
return '下载观测数据';
case 'catalog_operation':
return '星表操作';
case 'save_note':
@@ -3,6 +3,7 @@ import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../../utils/paper';
import { BaseModal } from './BaseModal';
import type { TabId } from '../layout/Sidebar';
interface PaperDetailModalProps {
paper: StandardPaper | null | undefined;
@@ -17,9 +18,7 @@ interface PaperDetailModalProps {
handleMarkNoResource: (bibcode: string, clear: boolean) => Promise<void>;
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
openReader: (paper: StandardPaper) => void;
setActiveTab: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab: (tab: TabId) => void;
setSelectedPaper: (paper: StandardPaper) => void;
loadCitations: (bibcode: string) => Promise<void>;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
+13 -1
View File
@@ -9,12 +9,19 @@ import {
ChevronLeft,
Sparkles,
LogOut,
Telescope,
} from 'lucide-react';
import type { StandardPaper } from '../../types';
import { Logo } from '../Logo';
export type TabId =
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
| 'search'
| 'library'
| 'reader'
| 'citation'
| 'sync'
| 'observation'
| 'agent';
interface SidebarProps {
activeTab: TabId;
@@ -127,6 +134,11 @@ export function Sidebar({
{ id: 'reader' as TabId, label: '双语阅读', icon: BookOpen },
{ id: 'citation' as TabId, label: '引用星系', icon: GitFork },
{ id: 'sync' as TabId, label: '批量任务', icon: RefreshCw },
{
id: 'observation' as TabId,
label: '观测数据',
icon: Telescope,
},
{ id: 'agent' as TabId, label: '智能科研', icon: Sparkles },
].map((tab) => {
const Icon = tab.icon;
@@ -0,0 +1,161 @@
// dashboard/src/components/observation/ObservationResultCard.tsx
//
// 观测数据下载结果卡片 —— 可复用渲染 ObservationBatch
//
// 用途:
// 1. 观测数据面板的"检索下载"视图在下载完成后展示结果
// 2. (可选)SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
//
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
import {
Activity,
CheckCircle2,
AlertTriangle,
Download,
} from 'lucide-react';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
type ObservationBatchResult,
} from './constants';
interface ObservationResultCardProps {
result: ObservationBatchResult;
}
export function ObservationResultCard({ result }: ObservationResultCardProps) {
const {
source,
product,
ra,
dec,
radius_deg,
matched_count,
products = [],
failures = [],
} = result;
const theme = SOURCE_THEME[source] ?? SOURCE_THEME.lamost;
const productLabel = PRODUCT_LABEL[product.product] ?? product.product;
const hasResult = products.length > 0 || failures.length > 0;
return (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏:数据源 + 产品类型双标签 */}
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
<Activity className="w-3.5 h-3.5 text-slate-500" />
<span></span>
</div>
<div className="flex items-center gap-1">
<span
className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}
>
{theme.label}
</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
{productLabel}
{product.subtype ? ` · ${product.subtype}` : ''}
</span>
</div>
</div>
{/* 查询信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{ra !== undefined && dec !== undefined && (
<span className="font-mono">
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
</span>
)}
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
{matched_count}
</span>
{products.length > 0 && (
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
<CheckCircle2 className="w-3 h-3" />
{products.length}
</span>
)}
{failures.length > 0 && (
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
<AlertTriangle className="w-3 h-3" />
{failures.length}
</span>
)}
</div>
{/* 产物列表(每个 product 含 1~N 个 artifact */}
{products.map((p, i) => {
const allCached =
p.artifacts.length > 0 && p.artifacts.every((a) => a.cached);
return (
<div
key={i}
className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5"
>
<div className="flex items-center justify-between">
<span className="font-mono font-medium text-slate-700">
{p.source_label}
</span>
{allCached ? (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
<CheckCircle2 className="w-3 h-3" />
</span>
) : (
<span className="text-[10px] text-slate-400"></span>
)}
</div>
{/* 多 artifact 展示(Gaia 光变 G/BP/RP 三波段) */}
{p.artifacts.map((a, ai) => (
<div
key={ai}
className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100"
>
<span className="text-slate-500 flex items-center gap-1">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium">
{a.band}
</span>
)}
{a.file_format.toUpperCase()} ·{' '}
{formatFileSize(a.size_bytes)}
</span>
<a
href={a.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>{a.file_format.toUpperCase()}</span>
</a>
</div>
))}
</div>
);
})}
{/* 失败列表 */}
{failures.map((f, i) => (
<div
key={`f${i}`}
className="border border-rose-200 rounded bg-rose-50/50 p-2.5"
>
<div className="flex items-center gap-1 text-rose-700 font-medium">
<AlertTriangle className="w-3 h-3" />
<span className="font-mono text-[10px]">{f.source_label}</span>
</div>
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
</div>
))}
{!hasResult && (
<p className="text-[11px] text-slate-400 italic py-2">
({productLabel})
</p>
)}
</div>
);
}
@@ -0,0 +1,85 @@
// dashboard/src/components/observation/constants.ts
//
// 观测数据共享常量与类型 —— 跨 Agent 工具卡片 / 观测数据面板 / 检索结果卡片复用
// 统一 (Source × ProductType) 双轴的视觉主题与中文标签映射
// ── 数据源主题色(source 轴)──
// 与 SOURCE_THEME 在 SpecialToolRenderers.tsx 的取色保持一致,统一视觉
export const SOURCE_THEME: Record<
string,
{ badge: string; label: string; dot: string }
> = {
lamost: {
badge: 'bg-amber-100 text-amber-700',
label: 'LAMOST',
dot: 'bg-amber-500',
},
gaia: {
badge: 'bg-sky-100 text-sky-700',
label: 'Gaia',
dot: 'bg-sky-500',
},
sdss: {
badge: 'bg-indigo-100 text-indigo-700',
label: 'SDSS',
dot: 'bg-indigo-500',
},
desi: {
badge: 'bg-emerald-100 text-emerald-700',
label: 'DESI',
dot: 'bg-emerald-500',
},
};
// ── 产品类型中文标签(product 轴)──
export const PRODUCT_LABEL: Record<string, string> = {
spectrum: '光谱',
lightcurve: '光变曲线',
photometry: '测光',
image: '图像',
};
// ── 文件大小格式化 ——
export function formatFileSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—';
if (bytes > 1024 * 1024 * 1024)
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes > 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
// ── 共享 TS 接口(镜像 Rust services::observation::types)──
export interface ObservationArtifact {
band?: string;
original_name?: string;
file_path: string;
file_url: string;
file_format: string;
size_bytes: number;
cached: boolean;
}
export interface ObservationProductItem {
source: string;
source_id: string;
source_label: string;
artifacts: ObservationArtifact[];
source_meta?: Record<string, unknown>;
}
export interface DownloadFailure {
source_label: string;
error: string;
}
export interface ObservationBatchResult {
source: string;
product: { product: string; subtype?: string };
ra?: number;
dec?: number;
radius_deg?: number;
matched_count: number;
products: ObservationProductItem[];
failures: DownloadFailure[];
}
+3 -5
View File
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
import type { TabId } from '../components/layout/Sidebar';
import { extractErrorMessage } from '../utils/apiError';
interface UseLibraryProps {
isAuthenticated: boolean | null;
@@ -260,9 +261,7 @@ export function useLibrary({
showAlert('手动文献文件上传导入成功!', '上传成功');
} catch (e: unknown) {
console.error('手动文件上传失败', e);
const axiosError = e as { response?: { data?: string } };
const errMsg =
axiosError.response?.data || '请确保上传的是合法且完整的文件。';
const errMsg = extractErrorMessage(e, '请确保上传的是合法且完整的文件。');
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
} finally {
setUploadingBibcode(null);
@@ -305,8 +304,7 @@ export function useLibrary({
);
} catch (e: unknown) {
console.error('标记更新失败', e);
const axiosError = e as { response?: { data?: string } };
const errMsg = axiosError.response?.data || '请稍后重试。';
const errMsg = extractErrorMessage(e, '请稍后重试。');
showAlert(`标记失败: ${errMsg}`, '操作出错');
}
};
+370
View File
@@ -0,0 +1,370 @@
// dashboard/src/hooks/useObservation.ts
//
// 观测数据 Hook —— 检索 / 下载 / 缓存库浏览 / 能力清单 四块状态
//
// 后端端点(检索逻辑全部在后端):
// GET /api/observation/capabilities —— 返回支持的 (source × product × subtypes) 组合
// GET /api/observation/search —— 按坐标 cone 检索候选源(不下载)
// POST /api/observation/download —— 按坐标或标识符下载(写入 observation_cache
// GET /api/observation/list —— 列出已缓存条目(服务端分页 + 筛选)
//
// 前端只负责表单提交和结果渲染,不做任何筛选/分页/排序计算。
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import axios from 'axios';
import { extractErrorMessage } from '../utils/apiError';
import type {
ObservationRecord,
Candidate,
CapabilitySpec,
} from '../types';
import type { ObservationBatchResult } from '../components/observation/constants';
interface UseObservationProps {
isAuthenticated: boolean | null;
}
// ── 检索表单状态 ──
export interface SearchForm {
source: string;
product: string;
subtype: string;
ra: string;
dec: string;
radius: string;
release: string;
}
export const EMPTY_SEARCH_FORM: SearchForm = {
source: 'lamost',
product: 'spectrum',
subtype: '',
ra: '',
dec: '',
radius: '0.1',
release: '',
};
export function useObservation({ isAuthenticated }: UseObservationProps) {
// ════════════════════════════════════════════════════
// A. 能力清单(启动时加载一次)
// ════════════════════════════════════════════════════
const [capabilities, setCapabilities] = useState<CapabilitySpec[]>([]);
const capabilitiesLoadedRef = useRef(false);
const fetchCapabilities = useCallback(async () => {
if (capabilitiesLoadedRef.current) return;
try {
const res = await axios.get<CapabilitySpec[]>(
'/api/observation/capabilities'
);
setCapabilities(res.data ?? []);
capabilitiesLoadedRef.current = true;
} catch (e) {
console.error('加载观测数据能力清单失败', e);
}
}, []);
useEffect(() => {
if (isAuthenticated === true) fetchCapabilities();
}, [isAuthenticated, fetchCapabilities]);
// ════════════════════════════════════════════════════
// B. 检索(search)—— 坐标 cone 检索候选源
// ════════════════════════════════════════════════════
const [searchForm, setSearchForm] = useState<SearchForm>(EMPTY_SEARCH_FORM);
// 当前 (source, product) 对应的能力描述(供前端表单派生选项与联动校验)
const currentSpec = useMemo<CapabilitySpec | undefined>(() => {
return capabilities.find(
(c) => c.source === searchForm.source && c.product === searchForm.product
);
}, [capabilities, searchForm.source, searchForm.product]);
// 当 source/product 切换后:
// 1. 若当前 release 不在新源的版本列表里,则回落到默认版本
// 2. 若当前 radius 超出硬性上限,则回落到建议上限(而非硬上限,避免误导)
useEffect(() => {
if (!currentSpec) return;
const valid = currentSpec.releases;
const hardMax = currentSpec.hard_max_radius_deg;
const suggested = currentSpec.suggested_max_radius_deg;
setSearchForm((f) => {
let next = f;
// release 回落(仅在该源区分版本时)
if (valid.length > 0 && (!f.release || !valid.includes(f.release))) {
next = { ...next, release: currentSpec.default_release ?? valid[0] };
}
// radius 回落:超出硬性上限时回到建议值(不回到硬上限,避免默认就触发警告)
const cur = parseFloat(f.radius);
if (!isNaN(cur) && cur > hardMax) {
next = { ...next, radius: String(suggested) };
}
return next;
});
}, [currentSpec]);
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(
new Set()
);
const runSearch = useCallback(async () => {
const ra = parseFloat(searchForm.ra);
const dec = parseFloat(searchForm.dec);
if (isNaN(ra) || isNaN(dec)) {
setSearchError('请输入有效的 ra / dec 坐标');
setSearchResults([]);
return;
}
setSearching(true);
setSearchError(null);
setSelectedSourceIds(new Set());
try {
const res = await axios.get<Candidate[]>('/api/observation/search', {
params: {
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
ra,
dec,
radius: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
release: searchForm.release || undefined,
},
});
setSearchResults(res.data ?? []);
} catch (e: unknown) {
setSearchError(
extractErrorMessage(e, '检索失败,请检查坐标与数据源参数')
);
setSearchResults([]);
} finally {
setSearching(false);
}
}, [searchForm]);
const toggleSelect = useCallback((sourceId: string) => {
setSelectedSourceIds((prev) => {
const next = new Set(prev);
if (next.has(sourceId)) next.delete(sourceId);
else next.add(sourceId);
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedSourceIds(new Set(searchResults.map((c) => c.source_id)));
}, [searchResults]);
const selectNone = useCallback(() => {
setSelectedSourceIds(new Set());
}, []);
// ════════════════════════════════════════════════════
// C. 下载(download)—— 按坐标或标识符
// ════════════════════════════════════════════════════
const [downloading, setDownloading] = useState(false);
const [downloadResult, setDownloadResult] =
useState<ObservationBatchResult | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
/** 下载选中的候选源(标识符模式) */
const downloadSelected = useCallback(async () => {
const ids = Array.from(selectedSourceIds);
if (ids.length === 0) return;
await downloadByIds(ids);
}, [selectedSourceIds]); // eslint-disable-line react-hooks/exhaustive-deps
/** 按标识符列表下载 */
const downloadByIds = useCallback(
async (sourceIds: string[]) => {
if (sourceIds.length === 0) return;
setDownloading(true);
setDownloadError(null);
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
force: false,
mode: 'identifiers',
source_ids: sourceIds,
}
);
setDownloadResult(res.data);
} catch (e: unknown) {
setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试'));
} finally {
setDownloading(false);
}
},
[searchForm]
);
/** 按坐标下载(cone 检索 + 全部下载) */
const downloadByCoordinates = useCallback(
async (strategy: 'nearest' | 'all' = 'nearest') => {
const ra = parseFloat(searchForm.ra);
const dec = parseFloat(searchForm.dec);
if (isNaN(ra) || isNaN(dec)) {
setDownloadError('请输入有效的 ra / dec 坐标');
return;
}
setDownloading(true);
setDownloadError(null);
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
force: false,
mode: 'coordinates',
ra,
dec,
radius_deg: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
strategy,
}
);
setDownloadResult(res.data);
} catch (e: unknown) {
setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试'));
} finally {
setDownloading(false);
}
},
[searchForm]
);
// ════════════════════════════════════════════════════
// D. 缓存库(library)—— 服务端分页浏览已下载数据
// ════════════════════════════════════════════════════
const [libraryItems, setLibraryItems] = useState<ObservationRecord[]>([]);
const [libraryTotal, setLibraryTotal] = useState(0);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState<string | null>(null);
// 筛选状态(提交给后端,不在前端筛)
const [libSource, setLibSource] = useState<string>('all');
const [libProduct, setLibProduct] = useState<string>('all');
const [libSearch, setLibSearch] = useState('');
const [libSort, setLibSort] = useState<'created' | 'source' | 'product'>(
'created'
);
const [libPage, setLibPage] = useState(1);
const [libPageSize, setLibPageSize] = useState(12);
const fetchLibrary = useCallback(async () => {
if (isAuthenticated !== true) return;
setLibraryLoading(true);
setLibraryError(null);
try {
const res = await axios.get<{ items: ObservationRecord[]; total: number }>(
'/api/observation/list',
{
params: {
source: libSource !== 'all' ? libSource : undefined,
product: libProduct !== 'all' ? libProduct : undefined,
q: libSearch.trim() || undefined,
sort: libSort,
limit: libPageSize,
offset: (libPage - 1) * libPageSize,
},
}
);
setLibraryItems(res.data.items ?? []);
setLibraryTotal(res.data.total ?? 0);
} catch (e: unknown) {
setLibraryError(
extractErrorMessage(e, '加载缓存库失败,请检查后端连接')
);
setLibraryItems([]);
} finally {
setLibraryLoading(false);
}
}, [
isAuthenticated,
libSource,
libProduct,
libSearch,
libSort,
libPage,
libPageSize,
]);
// 任意筛选/分页变更 → 重新请求后端
useEffect(() => {
fetchLibrary();
}, [fetchLibrary]);
const resetLibraryFilters = useCallback(() => {
setLibSource('all');
setLibProduct('all');
setLibSearch('');
setLibSort('created');
setLibPage(1);
}, []);
return {
// A. 能力清单
capabilities,
currentSpec,
fetchCapabilities,
// B. 检索
searchForm,
setSearchForm,
searchResults,
searching,
searchError,
runSearch,
selectedSourceIds,
toggleSelect,
selectAll,
selectNone,
// C. 下载
downloading,
downloadResult,
downloadError,
setDownloadResult,
downloadSelected,
downloadByIds,
downloadByCoordinates,
// D. 缓存库
libraryItems,
libraryTotal,
libraryLoading,
libraryError,
fetchLibrary,
libSource,
setLibSource: (v: string) => {
setLibSource(v);
setLibPage(1);
},
libProduct,
setLibProduct: (v: string) => {
setLibProduct(v);
setLibPage(1);
},
libSearch,
setLibSearch: (v: string) => {
setLibSearch(v);
setLibPage(1);
},
libSort,
setLibSort: (v: 'created' | 'source' | 'product') => {
setLibSort(v);
setLibPage(1);
},
libPage,
setLibPage,
libPageSize,
setLibPageSize,
resetLibraryFilters,
};
}
+5 -20
View File
@@ -9,6 +9,7 @@ import {
} from '../components/agent';
import type { TimelineItem } from '../components/agent';
import type { SessionSummary, MessageRecord } from '../types';
import { extractErrorMessage } from '../utils/apiError';
export type { SessionSummary, MessageRecord } from '../types';
@@ -433,11 +434,7 @@ export function useResearchAgent({
loadSessionHistory(currentSessionId, true);
fetchSessions(); // 更新侧栏 turn_count
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `回退失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `回退失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@@ -481,11 +478,7 @@ export function useResearchAgent({
}
}
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `恢复失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `恢复失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@@ -516,11 +509,7 @@ export function useResearchAgent({
await fetchSessions();
setCurrentSessionId(res.data.branch_session_id);
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `分叉失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `分叉失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
@@ -585,11 +574,7 @@ export function useResearchAgent({
// 触发自动重发
handleSend(questionText);
} catch (e: unknown) {
const axiosError = e as {
response?: { data?: string };
message?: string;
};
const errMsg = `重试失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
const errMsg = `重试失败: ${extractErrorMessage(e, '未知错误')}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
+6 -10
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { SavedSyncQuery } from '../types';
import { extractErrorMessage } from '../utils/apiError';
export interface BatchStatus {
active: boolean;
@@ -178,8 +179,7 @@ export function useSyncState() {
setTimeout(fetchSyncQueries, 500);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动快速同步失败。');
setErrorMsg(extractErrorMessage(e, '启动快速同步失败。'));
fetchStatus();
}
};
@@ -285,8 +285,7 @@ export function useSyncState() {
startBatchPolling();
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '启动批量任务失败。');
setBatchError(extractErrorMessage(e, '启动批量任务失败。'));
}
};
@@ -296,8 +295,7 @@ export function useSyncState() {
fetchBatchStatus();
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '停止任务失败。');
setBatchError(extractErrorMessage(e, '停止任务失败。'));
}
};
@@ -362,9 +360,8 @@ export function useSyncState() {
setEstimatedCount(res.data.total);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(
axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。'
extractErrorMessage(e, '估算文献总量失败,请检查 API 密钥或网络。')
);
} finally {
setEstimating(false);
@@ -399,8 +396,7 @@ export function useSyncState() {
setTimeout(fetchSyncQueries, 500);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动元数据同步任务失败。');
setErrorMsg(extractErrorMessage(e, '启动元数据同步任务失败。'));
fetchStatus();
}
};
+2 -3
View File
@@ -15,13 +15,12 @@ import {
import type { StandardPaper } from '../types';
import { CustomSelect } from '../components/CustomSelect';
import { PaperCard } from '../components/PaperCard';
import type { TabId } from '../components/layout/Sidebar';
interface LibraryPanelProps {
library: StandardPaper[];
fetchLibrary: () => Promise<void>;
setActiveTab: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
) => void;
setActiveTab: (tab: TabId) => void;
onShowDetail: (paper: StandardPaper) => void;
onOpenReader: (paper: StandardPaper) => void;
onOpenCitation: (paper: StandardPaper) => void;
+988
View File
@@ -0,0 +1,988 @@
// dashboard/src/pages/ObservationPanel.tsx
//
// 观测数据标签页 —— 双视图切换:检索下载 / 缓存库
//
// 视图 A(检索下载):
// 表单(源/产品/子类型/坐标/release) → GET /observation/search → 候选源列表(可勾选)
// → POST /observation/download → 下载结果卡片
// 标识符模式:textarea 输入 source_id → 直接 POST 下载
//
// 视图 B(缓存库):
// GET /observation/list(分页+筛选) → 已缓存数据卡片网格 + 分页栏
//
// 数据源/产品选项由 GET /observation/capabilities 动态生成,不硬编码。
import { useState, useMemo } from 'react';
import {
Telescope,
Search,
X,
CheckCircle2,
AlertTriangle,
Download,
Loader2,
Activity,
HardDrive,
Database,
Send,
RotateCw,
} from 'lucide-react';
import { CustomSelect } from '../components/CustomSelect';
import { ObservationResultCard } from '../components/observation/ObservationResultCard';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
} from '../components/observation/constants';
import type { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type { SearchForm } from '../hooks/useObservation';
// ── 视图切换类型 ──
type ViewMode = 'search' | 'library';
interface ObservationPanelProps {
// 能力清单
capabilities: CapabilitySpec[];
currentSpec?: CapabilitySpec;
// 检索
searchForm: SearchForm;
setSearchForm: React.Dispatch<React.SetStateAction<SearchForm>>;
searchResults: Candidate[];
searching: boolean;
searchError: string | null;
runSearch: () => Promise<void>;
selectedSourceIds: Set<string>;
toggleSelect: (id: string) => void;
selectAll: () => void;
selectNone: () => void;
// 下载
downloading: boolean;
downloadResult: import('../components/observation/constants').ObservationBatchResult | null;
downloadError: string | null;
setDownloadResult: React.Dispatch<
React.SetStateAction<
import('../components/observation/constants').ObservationBatchResult | null
>
>;
downloadSelected: () => Promise<void>;
downloadByIds: (ids: string[]) => Promise<void>;
downloadByCoordinates: (strategy?: 'nearest' | 'all') => Promise<void>;
// 缓存库
libraryItems: ObservationRecord[];
libraryTotal: number;
libraryLoading: boolean;
libraryError: string | null;
fetchLibrary: () => Promise<void>;
libSource: string;
setLibSource: (v: string) => void;
libProduct: string;
setLibProduct: (v: string) => void;
libSearch: string;
setLibSearch: (v: string) => void;
libSort: 'created' | 'source' | 'product';
setLibSort: (v: 'created' | 'source' | 'product') => void;
libPage: number;
setLibPage: (p: number) => void;
libPageSize: number;
setLibPageSize: (s: number) => void;
resetLibraryFilters: () => void;
}
export function ObservationPanel(props: ObservationPanelProps) {
const [view, setView] = useState<ViewMode>('search');
return (
<div className="w-full max-w-5xl mx-auto space-y-6">
{/* 标题栏 */}
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4 select-none">
<div>
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
</h2>
<p className="text-xs text-slate-500 mt-1">
/ 线 / /
</p>
</div>
{/* 二级视图切换 SegControl */}
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200">
<button
onClick={() => setView('search')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
view === 'search'
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<Search className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setView('library')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
view === 'library'
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<Database className="w-3.5 h-3.5" />
</button>
</div>
</div>
{view === 'search' ? (
<SearchView {...props} />
) : (
<LibraryView {...props} />
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 视图 A:检索下载
// ════════════════════════════════════════════════════════════
function SearchView({
capabilities,
currentSpec,
searchForm,
setSearchForm,
searchResults,
searching,
searchError,
runSearch,
selectedSourceIds,
toggleSelect,
selectAll,
selectNone,
downloading,
downloadResult,
downloadError,
setDownloadResult,
downloadSelected,
downloadByIds,
downloadByCoordinates,
}: ObservationPanelProps) {
const [inputMode, setInputMode] = useState<'coordinates' | 'identifiers'>(
'coordinates'
);
const [idsText, setIdsText] = useState('');
// 从 capabilities 派生选项
const sourceOptions = useMemo(() => {
const sources = Array.from(new Set(capabilities.map((c) => c.source)));
return [
...sources.map((s) => ({
value: s,
label: SOURCE_THEME[s]?.label ?? s,
})),
];
}, [capabilities]);
const productOptions = useMemo(() => {
const products = Array.from(
new Set(
capabilities
.filter((c) => c.source === searchForm.source)
.map((c) => c.product)
)
);
return products.map((p) => ({
value: p,
label: PRODUCT_LABEL[p] ?? p,
}));
}, [capabilities, searchForm.source]);
const subtypeOptions = useMemo(() => {
const cap = capabilities.find(
(c) =>
c.source === searchForm.source && c.product === searchForm.product
);
return (cap?.subtypes ?? []).map((s) => ({ value: s, label: s }));
}, [capabilities, searchForm.source, searchForm.product]);
// 当前选中源+产品的标识符格式提示
const identifierFormat = useMemo(() => {
return currentSpec?.identifier_format ?? undefined;
}, [currentSpec]);
// 当前源+产品支持的版本列表(供下拉)
const releaseOptions = useMemo(() => {
return (currentSpec?.releases ?? []).map((r) => ({
value: r,
label: r.toUpperCase(),
}));
}, [currentSpec]);
// LAMOST MRS 联动约束:MRS(中分辨率)从 DR7 起才支持,DR5/DR6 无 MRS 数据
const lamostMrsBlocked = useMemo(() => {
if (searchForm.source !== 'lamost' || searchForm.subtype !== 'mrs')
return null;
const rel = searchForm.release;
if (rel === 'dr5' || rel === 'dr6') {
return 'LAMOST 中分辨率(MRS)从 DR7 起才支持,当前版本无 MRS 数据';
}
return null;
}, [searchForm.source, searchForm.subtype, searchForm.release]);
const handleSubmitIds = () => {
const ids = idsText
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (ids.length > 0) downloadByIds(ids);
};
return (
<div className="space-y-5">
{/* 表单卡片 */}
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-xs space-y-4">
{/* 输入模式切换 */}
<div className="flex items-center gap-2 text-xs font-bold">
<span className="text-slate-500"></span>
<button
onClick={() => setInputMode('coordinates')}
className={`px-2.5 py-1 rounded-md transition-all ${
inputMode === 'coordinates'
? 'bg-blueprint text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
</button>
<button
onClick={() => setInputMode('identifiers')}
className={`px-2.5 py-1 rounded-md transition-all ${
inputMode === 'identifiers'
? 'bg-blueprint text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
</button>
</div>
{/* 源 / 产品 / 子类型 / release 公共字段 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="数据源">
<CustomSelect
value={searchForm.source}
onChange={(v) => {
setSearchForm((f) => {
// 切源后若当前产品不被新源支持,则回落到新源首个产品
const newProducts = capabilities
.filter((c) => c.source === v)
.map((c) => c.product);
const safeProduct = newProducts.includes(f.product)
? f.product
: newProducts[0] ?? f.product;
return { ...f, source: v, product: safeProduct, subtype: '' };
});
}}
className="w-full"
options={sourceOptions}
/>
</Field>
<Field label="产品类型">
<CustomSelect
value={searchForm.product}
onChange={(v) =>
setSearchForm((f) => ({ ...f, product: v, subtype: '' }))
}
className="w-full"
options={productOptions}
/>
</Field>
<Field label="子类型(可选)">
<CustomSelect
value={searchForm.subtype || '__none__'}
onChange={(v) =>
setSearchForm((f) => ({
...f,
subtype: v === '__none__' ? '' : v,
}))
}
className="w-full"
options={[
{ value: '__none__', label: '默认' },
...subtypeOptions,
]}
/>
</Field>
<Field
label={
releaseOptions.length === 0
? '数据发布版本'
: `数据发布版本(${releaseOptions.length}`
}
>
{releaseOptions.length === 0 ? (
<div className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-200 text-slate-400 text-xs font-medium select-none">
</div>
) : (
<CustomSelect
value={searchForm.release || '__none__'}
onChange={(v) =>
setSearchForm((f) => ({
...f,
release: v === '__none__' ? '' : v,
}))
}
className="w-full"
options={[
{ value: '__none__', label: `默认${currentSpec?.default_release ? `${currentSpec.default_release.toUpperCase()}` : ''}` },
...releaseOptions,
]}
/>
)}
</Field>
</div>
{/* LAMOST MRS 版本约束提示 */}
{lamostMrsBlocked && (
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>{lamostMrsBlocked}</span>
<button
onClick={() =>
setSearchForm((f) => ({ ...f, subtype: 'lrs' }))
}
className="ml-auto px-2 py-0.5 rounded bg-amber-100 hover:bg-amber-200 text-amber-800 font-bold text-[10px]"
>
LRS
</button>
</div>
)}
{/* 坐标模式表单 */}
{inputMode === 'coordinates' && (
<>
<div className="grid grid-cols-3 gap-3">
<Field label="RA(度)">
<input
type="number"
step="0.0001"
value={searchForm.ra}
onChange={(e) =>
setSearchForm((f) => ({ ...f, ra: e.target.value }))
}
placeholder="0~360"
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
<Field label="Dec(度)">
<input
type="number"
step="0.0001"
value={searchForm.dec}
onChange={(e) =>
setSearchForm((f) => ({ ...f, dec: e.target.value }))
}
placeholder="-90~90"
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
<Field
label={`检索半径(度,建议 ≤${currentSpec?.suggested_max_radius_deg ?? 1}°)`}
>
<input
type="number"
step="0.01"
max={currentSpec?.hard_max_radius_deg ?? 30}
value={searchForm.radius}
onChange={(e) =>
setSearchForm((f) => ({ ...f, radius: e.target.value }))
}
placeholder={`默认 0.1,建议 ≤${currentSpec?.suggested_max_radius_deg ?? 1}°`}
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
</div>
{/* 半径超出建议值的警告(不阻止提交,由后端决定是否超时) */}
{currentSpec &&
parseFloat(searchForm.radius) >
currentSpec.suggested_max_radius_deg &&
parseFloat(searchForm.radius) <=
currentSpec.hard_max_radius_deg && (
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>
{searchForm.radius}°
{currentSpec.suggested_max_radius_deg}°
</span>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={runSearch}
disabled={searching}
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
>
{searching ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Search className="w-3.5 h-3.5" />
)}
{searching ? '检索中...' : '检索候选源'}
</button>
<span className="text-[10px] text-slate-400">
"按坐标下载全部"
</span>
<button
onClick={() => downloadByCoordinates('nearest')}
disabled={downloading}
className="ml-auto px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5 bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</>
)}
{/* 标识符模式表单 */}
{inputMode === 'identifiers' && (
<>
<Field label="源标识符列表(逗号/换行/空格分隔)">
<textarea
value={idsText}
onChange={(e) => setIdsText(e.target.value)}
rows={3}
placeholder={
identifierFormat
? `格式:${identifierFormat},如 65214031805717376`
: '输入源标识符,多个用逗号或换行分隔'
}
className="w-full px-2.5 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-mono"
/>
</Field>
<button
onClick={handleSubmitIds}
disabled={downloading || !idsText.trim()}
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
>
{downloading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Send className="w-3.5 h-3.5" />
)}
</button>
</>
)}
</div>
{/* 检索错误 */}
{searchError && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{searchError}</span>
</div>
)}
{/* 候选源检索结果 */}
{inputMode === 'coordinates' && searchResults.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-blueprint" />
<span className="text-slate-400 font-normal">
{searchResults.length}
</span>
</h3>
<div className="flex items-center gap-2 text-[10px]">
<button
onClick={selectAll}
className="text-blueprint hover:underline font-bold"
>
</button>
<span className="text-slate-300">|</span>
<button
onClick={selectNone}
className="text-slate-500 hover:underline font-bold"
>
</button>
<span className="text-slate-400 ml-2">
{selectedSourceIds.size}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{searchResults.map((c) => {
const theme = SOURCE_THEME[c.source] ?? SOURCE_THEME.lamost;
const checked = selectedSourceIds.has(c.source_id);
return (
<label
key={c.source_id}
className={`flex items-start gap-2 p-2.5 rounded-md border cursor-pointer transition-all ${
checked
? 'border-blueprint bg-blueprint/5 ring-1 ring-blueprint'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelect(c.source_id)}
className="mt-0.5 w-3.5 h-3.5 accent-blueprint shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-1">
<span
className={`px-1.5 py-0.5 rounded text-[9px] font-bold ${theme.badge}`}
>
{theme.label}
</span>
<span className="font-mono text-[10px] text-slate-700 truncate font-semibold">
{c.source_id}
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
{c.ra != null && c.dec != null && (
<span className="font-mono">
({c.ra.toFixed(4)}, {c.dec.toFixed(4)})
</span>
)}
{c.distance != null && (
<span className="text-amber-600 font-medium">
Δ = {c.distance.toFixed(4)}°
</span>
)}
</div>
</div>
</label>
);
})}
</div>
<button
onClick={downloadSelected}
disabled={downloading || selectedSourceIds.size === 0}
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
>
{downloading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
{selectedSourceIds.size}
</button>
</div>
)}
{/* 检索无结果提示 */}
{inputMode === 'coordinates' &&
!searching &&
searchResults.length === 0 &&
!searchError &&
searchForm.ra &&
searchForm.dec && (
<div className="text-center py-8 text-xs text-slate-400">
<Telescope className="w-8 h-8 mx-auto mb-2 text-slate-300" />
</div>
)}
{/* 下载错误 */}
{downloadError && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{downloadError}</span>
</div>
)}
{/* 下载结果 */}
{downloadResult && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
</h3>
<button
onClick={() => setDownloadResult(null)}
className="text-[10px] text-slate-400 hover:text-slate-600"
>
<X className="w-3 h-3" />
</button>
</div>
<ObservationResultCard result={downloadResult} />
</div>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 视图 B:缓存库
// ════════════════════════════════════════════════════════════
function LibraryView({
libraryItems,
libraryTotal,
libraryLoading,
libraryError,
fetchLibrary,
libSource,
setLibSource,
libProduct,
setLibProduct,
libSearch,
setLibSearch,
libSort,
setLibSort,
libPage,
setLibPage,
libPageSize,
setLibPageSize,
resetLibraryFilters,
}: ObservationPanelProps) {
const totalPages = Math.max(1, Math.ceil(libraryTotal / libPageSize));
return (
<div className="space-y-5">
{/* 概览统计 */}
<div className="grid grid-cols-3 gap-3">
<StatCard
icon={<Database className="w-4 h-4 text-blueprint" />}
label="缓存条目"
value={libraryTotal.toString()}
/>
<StatCard
icon={<HardDrive className="w-4 h-4 text-star" />}
label="当前页"
value={`${libraryItems.length}`}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-emerald-600" />}
label="页码"
value={`${libPage} / ${totalPages}`}
/>
</div>
{/* 筛选工具栏 */}
<div className="flex flex-col sm:flex-row gap-3 bg-white p-4 rounded-lg border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
<div className="flex-1 space-y-1.5">
<label className="block text-slate-500 font-bold">
source_id
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
<input
type="text"
value={libSearch}
onChange={(e) => setLibSearch(e.target.value)}
placeholder="按 source_id 模糊匹配..."
className="w-full pl-9 pr-8 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
/>
{libSearch && (
<button
type="button"
onClick={() => setLibSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5"
>
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
<Field label="数据源" compact>
<CustomSelect
value={libSource}
onChange={setLibSource}
className="w-full"
options={[
{ value: 'all', label: '全部源' },
{ value: 'lamost', label: 'LAMOST' },
{ value: 'gaia', label: 'Gaia' },
{ value: 'sdss', label: 'SDSS' },
{ value: 'desi', label: 'DESI' },
]}
/>
</Field>
<Field label="产品类型" compact>
<CustomSelect
value={libProduct}
onChange={setLibProduct}
className="w-full"
options={[
{ value: 'all', label: '全部' },
{ value: 'spectrum', label: '光谱' },
{ value: 'lightcurve', label: '光变曲线' },
{ value: 'photometry', label: '测光' },
{ value: 'image', label: '图像' },
]}
/>
</Field>
<Field label="排序" compact>
<CustomSelect
value={libSort}
onChange={(v) => setLibSort(v as 'created' | 'source' | 'product')}
className="w-full"
options={[
{ value: 'created', label: '最近缓存' },
{ value: 'source', label: '按数据源' },
{ value: 'product', label: '按产品' },
]}
/>
</Field>
<div className="flex items-end gap-2">
<button
onClick={resetLibraryFilters}
className="btn-console px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5"
>
<X className="w-3 h-3" />
</button>
<button
onClick={fetchLibrary}
className="btn-console px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5"
>
<RotateCw className="w-3 h-3" />
</button>
</div>
</div>
{/* 错误提示 */}
{libraryError && (
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-3">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{libraryError}</span>
</div>
)}
{/* 加载态 */}
{libraryLoading && (
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-blueprint mb-2" />
<span className="text-xs">...</span>
</div>
)}
{/* 空态 */}
{!libraryLoading && libraryItems.length === 0 && !libraryError && (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Database className="w-10 h-10 mb-3 text-slate-300" />
<p className="text-xs font-medium"></p>
<p className="text-[10px] mt-1">
</p>
</div>
)}
{/* 列表 */}
{!libraryLoading && libraryItems.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{libraryItems.map((rec) => (
<LibraryCard key={`${rec.source}|${rec.product}|${rec.source_id}`} rec={rec} />
))}
</div>
{/* 分页栏 */}
<div className="flex items-center justify-between flex-wrap gap-3 pt-4 border-t border-slate-200 text-xs font-semibold text-slate-600">
<div className="flex items-center gap-2">
<span></span>
<CustomSelect
size="sm"
value={libPageSize}
onChange={(v) => {
setLibPageSize(v);
setLibPage(1);
}}
options={[
{ value: 12, label: '12' },
{ value: 24, label: '24' },
{ value: 48, label: '48' },
]}
/>
<span> · {libraryTotal} </span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setLibPage(1)}
disabled={libPage <= 1}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
<button
onClick={() => setLibPage(libPage - 1)}
disabled={libPage <= 1}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
<span className="px-2 font-mono">
{libPage} / {totalPages}
</span>
<button
onClick={() => setLibPage(libPage + 1)}
disabled={libPage >= totalPages}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
<button
onClick={() => setLibPage(totalPages)}
disabled={libPage >= totalPages}
className="px-2.5 py-1.5 rounded-md bg-white border border-slate-200 hover:bg-slate-50 disabled:opacity-40 font-bold"
>
</button>
</div>
</div>
</>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 局部子组件
// ════════════════════════════════════════════════════════════
function Field({
label,
children,
compact = false,
}: {
label: string;
children: React.ReactNode;
compact?: boolean;
}) {
return (
<div className={compact ? 'w-full sm:w-36 space-y-1.5 font-bold' : 'space-y-1.5'}>
<label className="block text-slate-500 font-bold text-xs">{label}</label>
{children}
</div>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="bg-white border border-slate-200 rounded-lg p-3 shadow-xs">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-500 tracking-wider uppercase mb-1.5">
{icon}
<span>{label}</span>
</div>
<div className="text-base font-bold text-slate-800">{value}</div>
</div>
);
}
function LibraryCard({ rec }: { rec: ObservationRecord }) {
const theme = SOURCE_THEME[rec.source] ?? SOURCE_THEME.lamost;
const productLabel = PRODUCT_LABEL[rec.product] ?? rec.product;
let artifacts: Array<{ path: string; format: string; size?: number; band?: string }> = [];
try {
const parsed = JSON.parse(rec.artifacts_json);
if (Array.isArray(parsed)) artifacts = parsed;
} catch {
// ignore
}
const totalSize = artifacts.reduce(
(acc, a) => acc + (typeof a.size === 'number' ? a.size : 0),
0
);
return (
<div className="bg-white border border-slate-200 rounded-lg p-3.5 shadow-xs hover:shadow-md hover:border-slate-300 transition-all">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-slate-100">
<div className="flex items-center gap-1.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}>
{theme.label}
</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
{productLabel}
</span>
</div>
<span className="text-[9px] text-slate-400 font-mono">
{rec.created_at.replace('T', ' ').slice(0, 19)}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<div className="min-w-0">
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
Source ID
</div>
<div className="font-mono text-xs text-slate-800 font-semibold truncate" title={rec.source_id}>
{rec.source_id}
</div>
</div>
{rec.ra != null && rec.dec != null && (
<div className="text-right shrink-0 ml-2">
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
</div>
<div className="font-mono text-[10px] text-slate-600">
{rec.ra.toFixed(4)}, {rec.dec.toFixed(4)}
</div>
</div>
)}
</div>
{artifacts.length === 0 ? (
<p className="text-[10px] text-slate-400 italic"></p>
) : (
<div className="space-y-1">
{artifacts.map((a, i) => (
<div
key={i}
className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100"
>
<span className="text-slate-500 flex items-center gap-1 min-w-0">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium shrink-0">
{a.band}
</span>
)}
<span className="font-mono uppercase shrink-0">{a.format}</span>
<span className="text-slate-400">
· {typeof a.size === 'number' ? formatFileSize(a.size) : '—'}
</span>
</span>
<a
href={`/api/files/${a.path}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline shrink-0 ml-2"
>
<Download className="w-3 h-3" />
</a>
</div>
))}
{artifacts.length > 1 && (
<div className="flex items-center gap-1 text-[10px] text-slate-400 pt-1">
<CheckCircle2 className="w-3 h-3" />
<span>
{artifacts.length} · {formatFileSize(totalSize)}
</span>
</div>
)}
</div>
)}
</div>
);
}
+2 -3
View File
@@ -5,6 +5,7 @@ import { AgentInputArea } from '../components/agent/AgentInputArea';
import type { StandardPaper } from '../types';
import type { useCitations } from '../hooks/useCitations';
import type { useLibrary } from '../hooks/useLibrary';
import type { TabId } from '../components/layout/Sidebar';
interface ResearchAgentPanelProps {
showConfirm?: (
@@ -14,9 +15,7 @@ interface ResearchAgentPanelProps {
) => void;
showAlert?: (message: string, title?: string) => void;
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
setActiveTab?: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
) => void;
setActiveTab?: (tab: TabId) => void;
citations?: ReturnType<typeof useCitations>;
library?: ReturnType<typeof useLibrary>;
}
+2 -3
View File
@@ -15,6 +15,7 @@ import {
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;
@@ -40,9 +41,7 @@ interface SearchPanelProps {
selectedPaper: StandardPaper | null;
setSelectedPaper: (paper: StandardPaper | null) => void;
openReader: (paper: StandardPaper) => void;
setActiveTab: (
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
) => void;
setActiveTab: (tab: TabId) => void;
loadCitations: (bibcode: string, reset?: boolean) => void;
showAlert: (msg: string, title?: string) => void;
onShowDetail: (paper: StandardPaper) => void;
+50
View File
@@ -206,3 +206,53 @@ export interface RetryResponse {
deleted_count: number;
session_id: string;
}
// ── 观测数据 (observation_cache) ──
// 镜像后端 src/services/observation/cache.rs::ObservationCacheRow
// 与 src/services/observation/types.rs::Artifact
export interface ObservationArtifactRecord {
path: string;
format: string;
size?: number;
band?: string;
original_name?: string;
}
export interface ObservationRecord {
source: string; // lamost / gaia / sdss / desi
product: string; // spectrum / lightcurve / photometry / image
source_id: string;
ra?: number | null;
dec?: number | null;
artifacts_json: string; // JSON 字符串,前端解析为 ObservationArtifactRecord[]
meta_json?: string | null;
created_at: string;
}
// ── 观测数据检索:候选源 (镜像 services::observation::fetcher::Candidate) ──
export interface Candidate {
source: string;
source_id: string;
label: string;
ra?: number | null;
dec?: number | null;
distance?: number | null;
raw?: Record<string, unknown>;
}
// ── 观测数据能力清单 (镜像 services::observation::registry::CapabilitySpec) ──
export interface CapabilitySpec {
source: string;
product: string;
subtypes: string[];
releases: string[];
default_release?: string | null;
suggested_max_radius_deg: number;
hard_max_radius_deg: number;
identifier_format?: string | null;
supports_coordinates: boolean;
supports_identifiers: boolean;
}
+40
View File
@@ -0,0 +1,40 @@
// dashboard/src/utils/apiError.ts
//
// 统一的 axios 错误消息提取工具
//
// 后端 AppError 序列化为 JSON 对象 { "error": "..." }src/api/error.rs),
// 而非纯字符串。若直接存入 React state 并在 JSX 渲染,会触发
// React error #31Objects are not valid as a React child)。
// 本工具按优先级从 axios 错误中提取人类可读字符串。
interface AxiosLikeError {
response?: { data?: unknown; status?: number };
message?: string;
}
/// 从 axios 错误中提取人类可读的字符串消息
///
/// 优先级:response.data 为字符串 → 对象里的 error/message/detail/description
/// → 整体 JSON 序列化兜底 → axios message → fallback
export function extractErrorMessage(
e: unknown,
fallback: string
): string {
const axiosError = e as AxiosLikeError;
const data = axiosError?.response?.data;
if (typeof data === 'string') return data;
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
for (const key of ['error', 'message', 'detail', 'description']) {
const v = obj[key];
if (typeof v === 'string') return v;
}
// 整个 JSON 序列化兜底(避免渲染 [object Object]
try {
return JSON.stringify(data);
} catch {
return fallback;
}
}
return axiosError?.message || fallback;
}