diff --git a/Cargo.lock b/Cargo.lock index cf8abab..7e421bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha1 0.10.6", + "sha2 0.10.9", "sqlite-vec", "sqlx", "subtle", diff --git a/Cargo.toml b/Cargo.toml index 867d032..206a5c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ path = "src/bin/health_check.rs" name = "astroresearch_cli" path = "src/bin/cli.rs" [dependencies] -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "signal", "time", "fs", "net", "sync", "process"] } axum = { version = "0.7", features = ["macros", "multipart"] } tower-http = { version = "0.5", features = ["cors", "fs", "trace", "set-header"] } sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "json"] } @@ -37,6 +37,7 @@ rand = "0.8" regex = "1.10" chrono = { version = "0.4", features = ["serde"] } sha1 = "0.10" +sha2 = "0.10" hmac = "0.12" subtle = "2" # 恒定时间密码比较,防时序侧信道攻击 base64 = "0.22" diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d5f4b73..7da86aa 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -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 | null>(null); @@ -451,6 +457,7 @@ export default function App() { {activeTab === 'reader' && '双语阅读'} {activeTab === 'citation' && '引用星系'} {activeTab === 'sync' && '批量任务'} + {activeTab === 'observation' && '观测数据'} {activeTab === 'agent' && '智能科研'} @@ -649,6 +656,48 @@ export default function App() { {activeTab === 'sync' && } + {activeTab === 'observation' && ( + + )} + {activeTab === 'agent' && ( void ) => Promise; openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void; - setActiveTab?: ( - tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent' - ) => void; + setActiveTab?: (tab: TabId) => void; citations?: ReturnType; library?: ReturnType; } diff --git a/dashboard/src/components/agent/AgentMetricsPanel.tsx b/dashboard/src/components/agent/AgentMetricsPanel.tsx index e274bc9..ad427d1 100644 --- a/dashboard/src/components/agent/AgentMetricsPanel.tsx +++ b/dashboard/src/components/agent/AgentMetricsPanel.tsx @@ -33,7 +33,7 @@ const TOOL_LABELS: Record = { 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 = { 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', diff --git a/dashboard/src/components/agent/AuditLogViewer.tsx b/dashboard/src/components/agent/AuditLogViewer.tsx index dd9f0d5..249b308 100644 --- a/dashboard/src/components/agent/AuditLogViewer.tsx +++ b/dashboard/src/components/agent/AuditLogViewer.tsx @@ -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: '任务管理', diff --git a/dashboard/src/components/agent/SpecialToolRenderers.tsx b/dashboard/src/components/agent/SpecialToolRenderers.tsx index c3e32d5..832ee31 100644 --- a/dashboard/src/components/agent/SpecialToolRenderers.tsx +++ b/dashboard/src/components/agent/SpecialToolRenderers.tsx @@ -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; citations?: ReturnType; - 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 = { - 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 ( -
- {/* 标题栏 */} -
-
- - 统一光谱检索 -
- - {theme.label} - -
- - {/* 查询信息 */} -
- {ra !== undefined && dec !== undefined && ( - - ra={ra.toFixed(4)} dec={dec.toFixed(4)} - {radius_deg !== undefined ? ` r=${radius_deg}°` : ''} - - )} - - 命中 {matched_count} 条 - - {downloads.length > 0 && ( - - - 下载 {downloads.length} - - )} - {failures.length > 0 && ( - - - 失败 {failures.length} - - )} -
- - {/* 下载结果列表 */} - {downloads.map((d, i) => ( -
-
- {d.source_label} - {d.cached ? ( - - 缓存 - - ) : ( - 新下载 - )} -
-
- - {d.file_format.toUpperCase()} · {d.size_bytes > 1024 ? `${(d.size_bytes / 1024).toFixed(1)} KB` : `${d.size_bytes} B`} - - - - FITS - -
-
- ))} - - {/* 失败列表 */} - {failures.map((f, i) => ( -
-
- - {f.source_label} -
-

{f.error}

-
- ))} - - {!hasResult && ( -

(该区域无光谱覆盖)

- )} -
- ); +export function FindObservationCard({ metadata }: FindObservationCardProps) { + return ; } diff --git a/dashboard/src/components/agent/ToolCallCard.tsx b/dashboard/src/components/agent/ToolCallCard.tsx index a1a894b..4d50802 100644 --- a/dashboard/src/components/agent/ToolCallCard.tsx +++ b/dashboard/src/components/agent/ToolCallCard.tsx @@ -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; library?: ReturnType; } @@ -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 ( - Promise; handleDownload: (bibcode: string, force?: boolean) => Promise; 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; showConfirm: (message: string, onConfirm: () => void, title?: string) => void; diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index d1b8239..36c5631 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -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; diff --git a/dashboard/src/components/observation/ObservationResultCard.tsx b/dashboard/src/components/observation/ObservationResultCard.tsx new file mode 100644 index 0000000..7ddc072 --- /dev/null +++ b/dashboard/src/components/observation/ObservationResultCard.tsx @@ -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 ( +
+ {/* 标题栏:数据源 + 产品类型双标签 */} +
+
+ + 观测数据下载 +
+
+ + {theme.label} + + + {productLabel} + {product.subtype ? ` · ${product.subtype}` : ''} + +
+
+ + {/* 查询信息 */} +
+ {ra !== undefined && dec !== undefined && ( + + ra={ra.toFixed(4)} dec={dec.toFixed(4)} + {radius_deg !== undefined ? ` r=${radius_deg}°` : ''} + + )} + + 命中 {matched_count} 条 + + {products.length > 0 && ( + + + 下载 {products.length} + + )} + {failures.length > 0 && ( + + + 失败 {failures.length} + + )} +
+ + {/* 产物列表(每个 product 含 1~N 个 artifact) */} + {products.map((p, i) => { + const allCached = + p.artifacts.length > 0 && p.artifacts.every((a) => a.cached); + return ( +
+
+ + {p.source_label} + + {allCached ? ( + + + 缓存 + + ) : ( + 新下载 + )} +
+ {/* 多 artifact 展示(Gaia 光变 G/BP/RP 三波段) */} + {p.artifacts.map((a, ai) => ( +
+ + {a.band && ( + + {a.band} + + )} + {a.file_format.toUpperCase()} ·{' '} + {formatFileSize(a.size_bytes)} + + + + {a.file_format.toUpperCase()} + +
+ ))} +
+ ); + })} + + {/* 失败列表 */} + {failures.map((f, i) => ( +
+
+ + {f.source_label} +
+

{f.error}

+
+ ))} + + {!hasResult && ( +

+ (该区域无{productLabel}数据覆盖) +

+ )} +
+ ); +} diff --git a/dashboard/src/components/observation/constants.ts b/dashboard/src/components/observation/constants.ts new file mode 100644 index 0000000..1916f76 --- /dev/null +++ b/dashboard/src/components/observation/constants.ts @@ -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 = { + 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; +} + +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[]; +} diff --git a/dashboard/src/hooks/useLibrary.ts b/dashboard/src/hooks/useLibrary.ts index 02b64d6..ce37944 100644 --- a/dashboard/src/hooks/useLibrary.ts +++ b/dashboard/src/hooks/useLibrary.ts @@ -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}`, '操作出错'); } }; diff --git a/dashboard/src/hooks/useObservation.ts b/dashboard/src/hooks/useObservation.ts new file mode 100644 index 0000000..8bf9918 --- /dev/null +++ b/dashboard/src/hooks/useObservation.ts @@ -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([]); + const capabilitiesLoadedRef = useRef(false); + + const fetchCapabilities = useCallback(async () => { + if (capabilitiesLoadedRef.current) return; + try { + const res = await axios.get( + '/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(EMPTY_SEARCH_FORM); + + // 当前 (source, product) 对应的能力描述(供前端表单派生选项与联动校验) + const currentSpec = useMemo(() => { + 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([]); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [selectedSourceIds, setSelectedSourceIds] = useState>( + 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('/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(null); + const [downloadError, setDownloadError] = useState(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( + '/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( + '/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([]); + const [libraryTotal, setLibraryTotal] = useState(0); + const [libraryLoading, setLibraryLoading] = useState(false); + const [libraryError, setLibraryError] = useState(null); + + // 筛选状态(提交给后端,不在前端筛) + const [libSource, setLibSource] = useState('all'); + const [libProduct, setLibProduct] = useState('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, + }; +} diff --git a/dashboard/src/hooks/useResearchAgent.ts b/dashboard/src/hooks/useResearchAgent.ts index 222b3be..43d6b00 100644 --- a/dashboard/src/hooks/useResearchAgent.ts +++ b/dashboard/src/hooks/useResearchAgent.ts @@ -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 { diff --git a/dashboard/src/hooks/useSyncState.ts b/dashboard/src/hooks/useSyncState.ts index 1237153..4b6cfce 100644 --- a/dashboard/src/hooks/useSyncState.ts +++ b/dashboard/src/hooks/useSyncState.ts @@ -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(); } }; diff --git a/dashboard/src/pages/LibraryPanel.tsx b/dashboard/src/pages/LibraryPanel.tsx index 8079dd1..a3e814b 100644 --- a/dashboard/src/pages/LibraryPanel.tsx +++ b/dashboard/src/pages/LibraryPanel.tsx @@ -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; - setActiveTab: ( - tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' - ) => void; + setActiveTab: (tab: TabId) => void; onShowDetail: (paper: StandardPaper) => void; onOpenReader: (paper: StandardPaper) => void; onOpenCitation: (paper: StandardPaper) => void; diff --git a/dashboard/src/pages/ObservationPanel.tsx b/dashboard/src/pages/ObservationPanel.tsx new file mode 100644 index 0000000..ae1448a --- /dev/null +++ b/dashboard/src/pages/ObservationPanel.tsx @@ -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>; + searchResults: Candidate[]; + searching: boolean; + searchError: string | null; + runSearch: () => Promise; + selectedSourceIds: Set; + 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; + downloadByIds: (ids: string[]) => Promise; + downloadByCoordinates: (strategy?: 'nearest' | 'all') => Promise; + // 缓存库 + libraryItems: ObservationRecord[]; + libraryTotal: number; + libraryLoading: boolean; + libraryError: string | null; + fetchLibrary: () => Promise; + 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('search'); + + return ( +
+ {/* 标题栏 */} +
+
+

+ 观测数据 +

+

+ 检索、下载与浏览多源观测数据(光谱 / 光变曲线 / 测光 / 图像) +

+
+ {/* 二级视图切换 SegControl */} +
+ + +
+
+ + {view === 'search' ? ( + + ) : ( + + )} +
+ ); +} + +// ════════════════════════════════════════════════════════════ +// 视图 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 ( +
+ {/* 表单卡片 */} +
+ {/* 输入模式切换 */} +
+ 输入模式: + + +
+ + {/* 源 / 产品 / 子类型 / release 公共字段 */} +
+ + { + 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} + /> + + + + setSearchForm((f) => ({ ...f, product: v, subtype: '' })) + } + className="w-full" + options={productOptions} + /> + + + + setSearchForm((f) => ({ + ...f, + subtype: v === '__none__' ? '' : v, + })) + } + className="w-full" + options={[ + { value: '__none__', label: '默认' }, + ...subtypeOptions, + ]} + /> + + + {releaseOptions.length === 0 ? ( +
+ 该源不区分版本 +
+ ) : ( + + setSearchForm((f) => ({ + ...f, + release: v === '__none__' ? '' : v, + })) + } + className="w-full" + options={[ + { value: '__none__', label: `默认${currentSpec?.default_release ? `(${currentSpec.default_release.toUpperCase()})` : ''}` }, + ...releaseOptions, + ]} + /> + )} +
+
+ + {/* LAMOST MRS 版本约束提示 */} + {lamostMrsBlocked && ( +
+ + {lamostMrsBlocked} + +
+ )} + + {/* 坐标模式表单 */} + {inputMode === 'coordinates' && ( + <> +
+ + + 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" + /> + + + + 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" + /> + + + + 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" + /> + +
+ + {/* 半径超出建议值的警告(不阻止提交,由后端决定是否超时) */} + {currentSpec && + parseFloat(searchForm.radius) > + currentSpec.suggested_max_radius_deg && + parseFloat(searchForm.radius) <= + currentSpec.hard_max_radius_deg && ( +
+ + + 当前半径 {searchForm.radius}° 超出建议值(≤ + {currentSpec.suggested_max_radius_deg}°),大范围查询可能因主表过大被服务端超时拒绝 + +
+ )} +
+ + + 先检索候选源,勾选后再下载;或直接"按坐标下载全部" + + +
+ + )} + + {/* 标识符模式表单 */} + {inputMode === 'identifiers' && ( + <> + +