后端(新增 ~4400 行):
- 新增 IRSA/IPAC、MAST 两客户端:ZTF 光变曲线(IRSA REST,CSV,免认证)、
TESS 光变(MAST TIC cone search + LC FITS),均复用 SSRF 安全重定向 + 重试
- Gaia DR3 XP_CONTINUOUS 光谱还原(gaia_xp/):逆向 GaiaXPy calibrate() 算法,
55 个 Hermite 系数 → 采样光谱;design_matrix 启动时 OnceLock 预计算一次。
因 fitsio crate 遇 PD(55) 变长数组列会 panic,改用 fitsio-sys 直连 CFITSIO
原生读取;新增 fitsio/fitsio-sys(vendored,无需系统 cfitsio)
- FITS 预览解析层(preview.rs):跨 LAMOST/SDSS/DESI/Gaia XP 的异构 FITS
(BinTable/Image/Hermite 系数)统一归一化为 ObservationPreview JSON
(Spectrum/LightCurve/Photometry/Image 标签枚举,OCP 可扩展)
- 测光 fetcher(photometry.rs):2MASS/AllWISE/Pan-STARRS/Gaia 四源,
均复用 VizieR/Gaia TAP 查询、零新 HTTP 代码,配置驱动表名/列名差异
- 统一全源检索(unified.rs):在 dispatch 之上叠加多目标 × 多源并发 cone 扇出,
失败隔离,两阶段(检索聚合 → 勾选后复用现有 download 端点批量下载)
- registry 注册 6 个新 fetcher + list_all_keys() 暴露全 (Source,ProductSpec)
组合供前端细粒度勾选;Source 枚举增 5 个变体
- 新增 3 条路由:GET /observation/preview、POST /observation/unified/{search,resolve}
前端(新增 ~1600 行):
- UnifiedSearchPanel:三模式目标输入(坐标/天体名/CSV)+ 源 chip 筛选,
按 (source,product) 分组展示候选源并支持勾选批量下载
- SpectrumPlot:手写内联 SVG 光谱折线图(零第三方绘图库),多段叠加 + hover 取值
- ObservationPreviewRenderer + useObservationPreview:预览渲染接入
- useObservation/ObservationResultCard/App 联动统一检索状态与下载链路
225 lines
8.6 KiB
TypeScript
225 lines
8.6 KiB
TypeScript
// dashboard/src/components/observation/ObservationResultCard.tsx
|
||
//
|
||
// 观测数据下载结果卡片 —— 可复用渲染 ObservationBatch
|
||
//
|
||
// 用途:
|
||
// 1. 观测数据面板的"检索下载"视图在下载完成后展示结果
|
||
// 2. (可选)SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
|
||
//
|
||
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
|
||
import { useState } from 'react';
|
||
import {
|
||
Activity,
|
||
CheckCircle2,
|
||
AlertTriangle,
|
||
Download,
|
||
LineChart,
|
||
Loader2,
|
||
} from 'lucide-react';
|
||
import {
|
||
SOURCE_THEME,
|
||
PRODUCT_LABEL,
|
||
formatFileSize,
|
||
canPreview,
|
||
type ObservationBatchResult,
|
||
} from './constants';
|
||
import { ObservationPreviewRenderer } from './ObservationPreviewRenderer';
|
||
import { useObservationPreview } from './useObservationPreview';
|
||
|
||
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;
|
||
|
||
// 预览:自包含 hook,面板流 + Agent 流共用(两者都渲染本卡片)
|
||
const { fetchPreview, getPreview } = useObservationPreview();
|
||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||
|
||
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) => {
|
||
const previewKey = `${p.source_id}#${ai}`;
|
||
const previewable = canPreview(product.product, a.file_format);
|
||
const isOpen = expanded[previewKey] ?? false;
|
||
const previewState = getPreview(p.source_id, ai);
|
||
const togglePreview = () => {
|
||
const next = !isOpen;
|
||
setExpanded((m) => ({ ...m, [previewKey]: next }));
|
||
if (next && !previewState.data && !previewState.loading) {
|
||
fetchPreview(
|
||
source,
|
||
product.product,
|
||
product.subtype,
|
||
p.source_id,
|
||
ai
|
||
);
|
||
}
|
||
};
|
||
return (
|
||
<div key={ai} className="space-y-1">
|
||
<div 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>
|
||
<span className="flex items-center gap-1">
|
||
{previewable && (
|
||
<button
|
||
onClick={togglePreview}
|
||
className="flex items-center gap-0.5 font-medium text-slate-500 hover:text-slate-800 transition-colors"
|
||
title="预览"
|
||
>
|
||
{previewState.loading ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<LineChart className="w-3 h-3" />
|
||
)}
|
||
<span>预览</span>
|
||
</button>
|
||
)}
|
||
<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>
|
||
</span>
|
||
</div>
|
||
{/* 预览图(按 product 类型分发渲染) */}
|
||
{previewable && isOpen && (
|
||
<div className="pl-2">
|
||
{previewState.error ? (
|
||
<p className="text-[10px] text-rose-500 py-1">
|
||
{previewState.error}
|
||
</p>
|
||
) : previewState.data ? (
|
||
<ObservationPreviewRenderer
|
||
preview={previewState.data}
|
||
/>
|
||
) : previewState.loading ? (
|
||
<div className="flex items-center gap-1 text-[10px] text-slate-400 py-4 justify-center">
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
解析 FITS 中...
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|