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:
+50
-1
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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}`, '操作出错');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// dashboard/src/utils/apiError.ts
|
||||
//
|
||||
// 统一的 axios 错误消息提取工具
|
||||
//
|
||||
// 后端 AppError 序列化为 JSON 对象 { "error": "..." }(src/api/error.rs),
|
||||
// 而非纯字符串。若直接存入 React state 并在 JSX 渲染,会触发
|
||||
// React error #31(Objects 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;
|
||||
}
|
||||
Reference in New Issue
Block a user