feat: 观测层跨源扩展——ZTF/TESS 时域 + Gaia XP 光谱还原 + FITS 预览与统一全源检索

后端(新增 ~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 联动统一检索状态与下载链路
This commit is contained in:
fmq
2026-07-09 00:20:29 +08:00
parent 2c8d0b8f8b
commit 8f1ed6d08c
40 changed files with 6235 additions and 60 deletions
+8
View File
@@ -683,6 +683,14 @@ export default function App() {
downloadSelected={observation.downloadSelected}
downloadByIds={observation.downloadByIds}
downloadByCoordinates={observation.downloadByCoordinates}
unifiedResult={observation.unifiedResult}
unifiedSearching={observation.unifiedSearching}
unifiedError={observation.unifiedError}
runUnifiedSearch={observation.runUnifiedSearch}
resolveNames={observation.resolveNames}
unifiedSelected={observation.unifiedSelected}
toggleUnifiedSelect={observation.toggleUnifiedSelect}
downloadUnifiedSelected={observation.downloadUnifiedSelected}
libraryItems={observation.libraryItems}
libraryTotal={observation.libraryTotal}
libraryLoading={observation.libraryLoading}
@@ -0,0 +1,61 @@
// dashboard/src/components/observation/ObservationPreviewRenderer.tsx
//
// 预览渲染分发器 —— 按 ObservationPreview.kind 路由到对应绘图组件
//
// 扩展新产品类型的可视化时:
// 1. 实现 XxxPlot 组件(如 LightCurvePlot.tsx
// 2. 在此 switch 加 case(从联合类型窄化出对应变体)
// 3. 在 constants.ts 的 canPreview() 放开该 product
// 现有 Spectrum 渲染无需改动(OCP:对扩展开放,对修改关闭)。
import type { ObservationPreview } from './constants';
import { SpectrumPlot } from './SpectrumPlot';
interface ObservationPreviewRendererProps {
preview: ObservationPreview;
}
export function ObservationPreviewRenderer({
preview,
}: ObservationPreviewRendererProps) {
switch (preview.kind) {
case 'spectrum':
// 窄化:preview 此处为 { kind: 'spectrum' } & SpectrumPreview
return <SpectrumPlot segments={preview.segments} />;
case 'lightcurve':
// TODO: 实现 LightCurvePlot 后替换占位
return (
<div className="text-[10px] text-slate-400 py-4 text-center">
线
</div>
);
case 'photometry':
// TODO: 实现 PhotometryTable 后替换占位
return (
<div className="text-[10px] text-slate-400 py-4 text-center">
</div>
);
case 'image':
// image 变体已有 preview_data_url,直接渲染
return (
<img
src={preview.preview_data_url}
alt="cutout"
className="w-full rounded border border-slate-200"
style={{ maxHeight: 200, objectFit: 'contain' }}
/>
);
default:
// 穷尽性检查:未来新增 kind 会在编译期报错
return (
<div className="text-[10px] text-slate-400 py-4 text-center">
</div>
);
}
}
@@ -7,13 +7,24 @@
// 2. (可选)SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
//
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
import { Activity, CheckCircle2, AlertTriangle, Download } from 'lucide-react';
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;
@@ -34,6 +45,10 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
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">
{/* 标题栏:数据源 + 产品类型双标签 */}
@@ -103,30 +118,84 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
)}
</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}
{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>
)}
{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>
);
})}
</div>
);
})}
@@ -0,0 +1,308 @@
// dashboard/src/components/observation/SpectrumPlot.tsx
//
// 光谱预览 SVG 折线图 —— 手写内联 SVG,不引入第三方绘图库
// (对齐项目 CitationGalaxyCanvas 手绘 canvas 先例,保持零额外依赖)
//
// 功能:
// - 多段叠加(Gaia BP/RP、DESI B/R/Z 各色)
// - hover 显示像素值(波长/流量)
// - 自适应宽度(响应式 viewBox)
// - 简洁坐标轴 + 网格线
import { useState, useMemo } from 'react';
import type { SpectrumSegment } from './constants';
interface SpectrumPlotProps {
segments: SpectrumSegment[];
height?: number;
}
// 数据源 → 段配色(与 SOURCE_THEME 协调的线条色)
const SEGMENT_COLORS: Record<string, string> = {
combined: '#64748b', // slate-500
xp_merged: '#0ea5e9', // sky-500
BP: '#0284c7', // sky-600
RP: '#0369a1', // sky-700
B: '#6366f1', // indigo-500
R: '#8b5cf6', // violet-500
Z: '#a855f7', // purple-500
apstar_combined: '#6366f1',
};
const DEFAULT_COLOR = '#64748b';
interface PlotPoint {
wl: number;
flux: number;
segIdx: number;
}
export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) {
const [hover, setHover] = useState<PlotPoint | null>(null);
// 合并所有段的数据点,计算全局范围
const { points, wlMin, wlMax, fluxMin, fluxMax } = useMemo(() => {
const pts: PlotPoint[] = [];
let wMin = Infinity;
let wMax = -Infinity;
let fMin = Infinity;
let fMax = -Infinity;
segments.forEach((seg, si) => {
const n = Math.min(seg.wavelength.length, seg.flux.length);
for (let i = 0; i < n; i++) {
const wl = seg.wavelength[i];
const fl = seg.flux[i];
if (!isFinite(wl) || !isFinite(fl)) continue;
pts.push({ wl, flux: fl, segIdx: si });
if (wl < wMin) wMin = wl;
if (wl > wMax) wMax = wl;
if (fl < fMin) fMin = fl;
if (fl > fMax) fMax = fl;
}
});
// flux 范围留 5% padding,避免曲线贴顶/底
const fPad = (fMax - fMin) * 0.05 || Math.abs(fMax) * 0.1 || 1;
return {
points: pts,
wlMin: wMin,
wlMax: wMax,
fluxMin: fMin - fPad,
fluxMax: fMax + fPad,
};
}, [segments]);
if (points.length === 0) {
return <div className="text-xs text-slate-400 p-2"></div>;
}
const width = 600;
const padL = 50;
const padR = 12;
const padT = 8;
const padB = 24;
const plotW = width - padL - padR;
const plotH = height - padT - padB;
const xScale = (wl: number) =>
padL + ((wl - wlMin) / (wlMax - wlMin || 1)) * plotW;
const yScale = (fl: number) =>
padT + (1 - (fl - fluxMin) / (fluxMax - fluxMin || 1)) * plotH;
// 每段生成 path
const paths = segments.map((seg) => {
const n = Math.min(seg.wavelength.length, seg.flux.length);
let d = '';
for (let i = 0; i < n; i++) {
const wl = seg.wavelength[i];
const fl = seg.flux[i];
if (!isFinite(wl) || !isFinite(fl)) continue;
d += `${i === 0 ? 'M' : 'L'}${xScale(wl).toFixed(1)},${yScale(fl).toFixed(1)} `;
}
return {
d,
color: SEGMENT_COLORS[seg.band] ?? DEFAULT_COLOR,
band: seg.band,
};
});
// 坐标轴刻度
const xTicks = 5;
const yTicks = 4;
const xTickVals = Array.from(
{ length: xTicks },
(_, i) => wlMin + ((wlMax - wlMin) * i) / (xTicks - 1)
);
const yTickVals = Array.from(
{ length: yTicks },
(_, i) => fluxMin + ((fluxMax - fluxMin) * i) / (yTicks - 1)
);
const fmtWl = (v: number) => {
if (v >= 1000) return `${(v / 1000).toFixed(2)}k`;
return v.toFixed(0);
};
const fmtFlux = (v: number) => {
const abs = Math.abs(v);
if (abs !== 0 && (abs < 0.01 || abs >= 1e4)) return v.toExponential(1);
return v.toFixed(2);
};
return (
<div className="relative w-full bg-slate-50/80 border border-slate-200 rounded p-1">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full"
style={{ height: `${height}px` }}
preserveAspectRatio="none"
onMouseLeave={() => setHover(null)}
>
{/* 网格线 */}
{yTickVals.map((v, i) => (
<line
key={`yg${i}`}
x1={padL}
x2={width - padR}
y1={yScale(v)}
y2={yScale(v)}
stroke="#e2e8f0"
strokeWidth="0.5"
/>
))}
{xTickVals.map((v, i) => (
<line
key={`xg${i}`}
x1={xScale(v)}
x2={xScale(v)}
y1={padT}
y2={height - padB}
stroke="#e2e8f0"
strokeWidth="0.5"
/>
))}
{/* 光谱曲线 */}
{paths.map((p, i) => (
<path
key={`path${i}`}
d={p.d}
fill="none"
stroke={p.color}
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
))}
{/* X 轴刻度标签 */}
{xTickVals.map((v, i) => (
<text
key={`xt${i}`}
x={xScale(v)}
y={height - padB + 12}
textAnchor="middle"
fontSize="9"
fill="#64748b"
>
{fmtWl(v)}
</text>
))}
{/* Y 轴刻度标签 */}
{yTickVals.map((v, i) => (
<text
key={`yt${i}`}
x={padL - 4}
y={yScale(v) + 3}
textAnchor="end"
fontSize="9"
fill="#64748b"
>
{fmtFlux(v)}
</text>
))}
{/* 轴标题 */}
<text
x={width - padR}
y={height - 4}
textAnchor="end"
fontSize="8"
fill="#94a3b8"
>
λ ({segments[0]?.wavelength_unit ?? 'Å'})
</text>
<text
x={padL}
y={padT + 4}
textAnchor="start"
fontSize="8"
fill="#94a3b8"
>
flux ({segments[0]?.flux_unit ?? ''})
</text>
{/* hover 指示 */}
{hover && (
<>
<line
x1={xScale(hover.wl)}
x2={xScale(hover.wl)}
y1={padT}
y2={height - padB}
stroke="#94a3b8"
strokeWidth="0.5"
strokeDasharray="2,2"
/>
<circle
cx={xScale(hover.wl)}
cy={yScale(hover.flux)}
r="2"
fill={
SEGMENT_COLORS[segments[hover.segIdx]?.band ?? ''] ??
DEFAULT_COLOR
}
/>
</>
)}
{/* 透明捕获层:hover 找最近点 */}
<rect
x={padL}
y={padT}
width={plotW}
height={plotH}
fill="transparent"
onMouseMove={(e) => {
const rect = (e.target as SVGRectElement).getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * plotW;
// 反推波长
const wl = wlMin + (px / plotW) * (wlMax - wlMin);
// 找最近点
let nearest = points[0];
let minDist = Infinity;
for (const p of points) {
const d = Math.abs(p.wl - wl);
if (d < minDist) {
minDist = d;
nearest = p;
}
}
setHover(nearest);
}}
/>
</svg>
{/* hover tooltip */}
{hover && (
<div
className="absolute pointer-events-none bg-slate-800 text-white text-[10px] px-1.5 py-0.5 rounded shadow-lg"
style={{
left: `${((xScale(hover.wl) - padL) / plotW) * 100}%`,
top: `${((yScale(hover.flux) - padT) / plotH) * 100}%`,
transform: 'translate(-50%, -120%)',
}}
>
λ={hover.wl.toFixed(1)} · f={fmtFlux(hover.flux)}
</div>
)}
{/* 多段图例 */}
{segments.length > 1 && (
<div className="flex flex-wrap gap-2 px-2 pb-1">
{segments.map((seg, i) => (
<span
key={`leg${i}`}
className="flex items-center gap-1 text-[9px] text-slate-600"
>
<span
className="inline-block w-2 h-0.5"
style={{
backgroundColor: SEGMENT_COLORS[seg.band] ?? DEFAULT_COLOR,
}}
/>
{seg.band}
</span>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,793 @@
// dashboard/src/components/observation/UnifiedSearchPanel.tsx
//
// 多目标统一全源检索视图 —— 跨多个目标 × 多个 (source, product) 并发 cone 检索
//
// 两阶段流程:
// ① 统一检索:三模式输入目标(坐标列表 / 天体名称列表 / CSV 导入)+ 源 chip 筛选
// → POST /api/observation/unified/search → 按 (source, product) 分组展示候选源
// ② 批量下载:勾选候选源 → 逐 (source, product) 组调用现有 POST /observation/download
//
// 与 ObservationPanel.SearchView 的区别:
// - SearchView:单目标 + 单源 + 单产品(精细化参数)
// - UnifiedSearchPanel:多目标 + 多源(广覆盖发现)
import { useState, useMemo, useCallback } from 'react';
import {
Globe,
Search,
Upload,
CheckCircle2,
AlertTriangle,
Download,
Loader2,
MapPin,
ListPlus,
} from 'lucide-react';
import { ObservationResultCard } from './ObservationResultCard';
import {
SOURCE_THEME,
PRODUCT_LABEL,
type UnifiedSearchRequest,
type UnifiedSearchResult,
type SourceCandidateGroup,
type TargetRequest,
type ResolvedTarget,
type ProductSpec,
type ObservationBatchResult,
} from './constants';
import type { CapabilitySpec } from '../../types';
// ── 输入模式 ──
type InputMode = 'coordinates' | 'names' | 'csv';
interface UnifiedSearchPanelProps {
capabilities: CapabilitySpec[];
// 统一检索状态(来自 useObservation
unifiedResult: UnifiedSearchResult | null;
unifiedSearching: boolean;
unifiedError: string | null;
runUnifiedSearch: (req: UnifiedSearchRequest) => Promise<void>;
resolveNames: (names: string[]) => Promise<ResolvedTarget[]>;
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
downloadUnifiedSelected: () => Promise<ObservationBatchResult[]>;
downloading: boolean;
}
export function UnifiedSearchPanel({
capabilities,
unifiedResult,
unifiedSearching,
unifiedError,
runUnifiedSearch,
resolveNames,
unifiedSelected,
toggleUnifiedSelect,
downloadUnifiedSelected,
downloading,
}: UnifiedSearchPanelProps) {
// ── 输入模式 ──
const [inputMode, setInputMode] = useState<InputMode>('coordinates');
// ── 坐标列表文本(每行 ra,dec[,radius] 或 ra dec [radius])──
const [coordsText, setCoordsText] = useState('');
// ── 天体名称列表文本 ──
const [namesText, setNamesText] = useState('');
const [resolving, setResolving] = useState(false);
const [resolved, setResolved] = useState<ResolvedTarget[]>([]);
// ── CSV 导入 ──
// ── 公共参数 ──
const [release, setRelease] = useState('');
const [defaultRadius, setDefaultRadius] = useState('0.1');
const [perTargetLimit, setPerTargetLimit] = useState('50');
// ── 源筛选:勾选的 (source, product) 组合 key ──
// 默认全选;key = `${source}|${product}|${subtype ?? ''}`
const allKeys = useMemo(
() => capabilities.map((c) => makeSourceKey(c.source, c.product)),
[capabilities]
);
const [selectedSources, setSelectedSources] = useState<Set<string>>(
new Set(allKeys)
);
// 同步:capabilities 加载后默认全选
const effectiveSelected = useMemo(() => {
if (selectedSources.size === 0 && allKeys.length > 0)
return new Set(allKeys);
return selectedSources;
}, [selectedSources, allKeys]);
// 解析目标列表(坐标模式 / 名称模式已解析 / CSV)
const targets: TargetRequest[] = useMemo(() => {
if (inputMode === 'names') {
// 名称模式:用 resolved 列表中成功的项
return resolved
.filter((r) => r.ra != null && r.dec != null)
.map((r) => ({
ra: r.ra!,
dec: r.dec!,
radius_deg: parseFloat(defaultRadius) || 0.1,
label: r.name,
}));
}
// coordinates / csv 模式:从文本解析
return parseCoordsText(coordsText, parseFloat(defaultRadius) || 0.1);
}, [inputMode, resolved, coordsText, defaultRadius]);
// ── 执行检索 ──
const handleSearch = useCallback(async () => {
if (targets.length === 0) return;
// 收集勾选的 (source, ProductSpec)
const selectedCaps = capabilities.filter((c) =>
effectiveSelected.has(makeSourceKey(c.source, c.product))
);
// 按 (source, product) 合并 subtypelist_all_keys 后端会展开,前端用 product 级粒度)
const sources: [string, ProductSpec][] | undefined =
selectedCaps.length === allKeys.length
? undefined // 全选 = 不传 sources(后端用默认全源)
: selectedCaps.map((c) => [c.source, { product: c.product }]);
await runUnifiedSearch({
targets,
sources,
release: release || undefined,
per_target_limit: parseInt(perTargetLimit) || 50,
});
}, [
targets,
capabilities,
effectiveSelected,
allKeys.length,
release,
perTargetLimit,
runUnifiedSearch,
]);
// ── 名称解析 ──
const handleResolveNames = useCallback(async () => {
const names = namesText
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (names.length === 0) return;
setResolving(true);
try {
const results = await resolveNames(names);
setResolved(results);
} finally {
setResolving(false);
}
}, [namesText, resolveNames]);
// ── CSV 导入(前端纯解析,列名含 ra/dec/name/radius)──
const handleCsvUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? '');
const parsed = parseCsvTargets(text, parseFloat(defaultRadius) || 0.1);
// 把解析结果转成 coordsText 格式(统一走坐标文本流)
const lines = parsed.map((t) =>
[t.ra.toString(), t.dec.toString(), t.label].filter(Boolean).join(',')
);
setCoordsText(lines.join('\n'));
setInputMode('coordinates');
};
reader.readAsText(file);
e.target.value = ''; // 允许重复选同文件
},
[defaultRadius]
);
// ── 源筛选控制 ──
const toggleSource = useCallback((key: string) => {
setSelectedSources((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const selectAllSources = useCallback(
() => setSelectedSources(new Set(allKeys)),
[allKeys]
);
const clearSources = useCallback(() => setSelectedSources(new Set()), []);
// 按产品分组的 capabilitieschip 展示)
const capsByProduct = useMemo(() => {
const map = new Map<string, CapabilitySpec[]>();
for (const c of capabilities) {
if (!map.has(c.product)) map.set(c.product, []);
map.get(c.product)!.push(c);
}
return map;
}, [capabilities]);
return (
<div className="space-y-5">
{/* ── 输入模式切换 ── */}
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg border border-slate-200 w-fit">
<ModeTab
active={inputMode === 'coordinates'}
onClick={() => setInputMode('coordinates')}
icon={<MapPin className="w-3.5 h-3.5" />}
label="坐标列表"
/>
<ModeTab
active={inputMode === 'names'}
onClick={() => setInputMode('names')}
icon={<ListPlus className="w-3.5 h-3.5" />}
label="天体名称"
/>
<ModeTab
active={inputMode === 'csv'}
onClick={() => setInputMode('csv')}
icon={<Upload className="w-3.5 h-3.5" />}
label="CSV 导入"
/>
</div>
{/* ── 目标输入区 ── */}
<div className="bg-white border border-slate-200 rounded-lg p-4 space-y-3">
{inputMode === 'coordinates' && (
<>
<label className="text-xs font-semibold text-slate-700">
ra,dec ra,dec,radius/
</label>
<textarea
value={coordsText}
onChange={(e) => setCoordsText(e.target.value)}
placeholder={'10.6847,41.2687,M31\n40.0,-5.0'}
rows={5}
className="w-full text-xs font-mono p-2 border border-slate-200 rounded resize-y focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="text-[11px] text-slate-500">
{targets.length}
</p>
</>
)}
{inputMode === 'names' && (
<>
<label className="text-xs font-semibold text-slate-700">
M31 / NGC 1068 / GD 358
</label>
<textarea
value={namesText}
onChange={(e) => setNamesText(e.target.value)}
placeholder={'M31\nNGC 1068\nGD 358'}
rows={5}
className="w-full text-xs font-mono p-2 border border-slate-200 rounded resize-y focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<div className="flex items-center gap-2">
<button
onClick={handleResolveNames}
disabled={resolving || !namesText.trim()}
className="px-3 py-1.5 bg-slate-800 text-white text-xs font-semibold rounded hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{resolving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Search className="w-3.5 h-3.5" />
)}
</button>
<span className="text-[11px] text-slate-500">
CDS SESAME
</span>
</div>
{resolved.length > 0 && (
<div className="mt-2 border border-slate-200 rounded overflow-hidden">
<table className="w-full text-[11px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left p-1.5"></th>
<th className="text-left p-1.5">RA</th>
<th className="text-left p-1.5">Dec</th>
<th className="text-left p-1.5"></th>
<th className="text-left p-1.5">V</th>
<th className="text-left p-1.5"></th>
</tr>
</thead>
<tbody>
{resolved.map((r) => (
<tr key={r.name} className="border-t border-slate-100">
<td className="p-1.5 font-mono">{r.name}</td>
<td className="p-1.5 font-mono">
{r.ra?.toFixed(4) ?? '—'}
</td>
<td className="p-1.5 font-mono">
{r.dec?.toFixed(4) ?? '—'}
</td>
<td className="p-1.5">{r.spectral_type ?? '—'}</td>
<td className="p-1.5">
{r.v_magnitude?.toFixed(2) ?? '—'}
</td>
<td className="p-1.5">
{r.error ? (
<span className="text-rose-600">{r.error}</span>
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-slate-500 p-1.5 bg-slate-50">
{resolved.filter((r) => !r.error).length} /{' '}
{resolved.length}
</p>
</div>
)}
</>
)}
{inputMode === 'csv' && (
<>
<label className="text-xs font-semibold text-slate-700">
CSV ra/dec name/radius
</label>
<input
type="file"
accept=".csv,.tsv,.txt"
onChange={handleCsvUpload}
className="text-xs file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-slate-800 file:text-white hover:file:bg-slate-700"
/>
<p className="text-[11px] text-slate-500">
VOTable CSV
</p>
</>
)}
</div>
{/* ── 公共参数 ── */}
<div className="grid grid-cols-3 gap-3">
<Field label="默认检索半径 (°)">
<input
type="number"
step="0.01"
min="0.0001"
max="30"
value={defaultRadius}
onChange={(e) => setDefaultRadius(e.target.value)}
className="w-full text-xs p-1.5 border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</Field>
<Field label="发布版本 (可选)">
<input
type="text"
placeholder="留空=默认"
value={release}
onChange={(e) => setRelease(e.target.value)}
className="w-full text-xs p-1.5 border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</Field>
<Field label="单源命中上限">
<input
type="number"
min="1"
max="500"
value={perTargetLimit}
onChange={(e) => setPerTargetLimit(e.target.value)}
className="w-full text-xs p-1.5 border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</Field>
</div>
{/* ── 源筛选 chip 组 ── */}
<div className="bg-white border border-slate-200 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-700">
{effectiveSelected.size}/{allKeys.length}
</label>
<div className="flex items-center gap-2">
<button
onClick={selectAllSources}
className="text-[11px] text-slate-600 hover:text-slate-900 underline"
>
</button>
<button
onClick={clearSources}
className="text-[11px] text-slate-600 hover:text-slate-900 underline"
>
</button>
</div>
</div>
<div className="space-y-2">
{Array.from(capsByProduct.entries()).map(([product, caps]) => (
<div key={product} className="flex items-start gap-2 flex-wrap">
<span className="text-[11px] font-bold text-slate-500 w-14 pt-1 shrink-0">
{PRODUCT_LABEL[product] ?? product}
</span>
{caps.map((c) => {
const key = makeSourceKey(c.source, c.product);
const checked = effectiveSelected.has(key);
const theme = SOURCE_THEME[c.source];
return (
<label
key={key}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border cursor-pointer text-[11px] font-medium transition-colors ${
checked
? `${theme?.badge ?? 'bg-slate-100 text-slate-700'} border-transparent`
: 'bg-slate-50 text-slate-400 border-slate-200'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSource(key)}
className="w-3 h-3"
/>
{theme?.label ?? c.source}
{c.subtypes.length > 0 && (
<span className="opacity-60">
({c.subtypes.join('/')})
</span>
)}
</label>
);
})}
</div>
))}
</div>
</div>
{/* ── 执行检索 ── */}
<div className="flex items-center gap-3">
<button
onClick={handleSearch}
disabled={unifiedSearching || targets.length === 0}
className="px-4 py-2 bg-slate-900 text-white text-xs font-bold rounded-lg hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{unifiedSearching ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Globe className="w-4 h-4" />
)}
{targets.length > 0 && `(${targets.length} 目标)`}
</button>
{unifiedResult && (
<span className="text-xs text-slate-600">
{unifiedResult.groups.length}
{unifiedResult.total_candidates}
</span>
)}
</div>
{/* ── 错误 ── */}
{unifiedError && (
<div className="flex items-center gap-2 p-3 bg-rose-50 border border-rose-200 rounded-lg text-xs text-rose-700">
<AlertTriangle className="w-4 h-4 shrink-0" />
{unifiedError}
</div>
)}
{/* ── 名称解析失败提示 ── */}
{unifiedResult?.resolve_failures &&
unifiedResult.resolve_failures.length > 0 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700 space-y-1">
{unifiedResult.resolve_failures.map((f, i) => (
<div key={i}>
<strong>{f.target_label}</strong>: {f.error}
</div>
))}
</div>
)}
{/* ── 结果区:按 (source, product) 分组的候选源表 ── */}
{unifiedResult && unifiedResult.groups.length > 0 && (
<UnifiedResultGroups
groups={unifiedResult.groups}
unifiedSelected={unifiedSelected}
toggleUnifiedSelect={toggleUnifiedSelect}
/>
)}
{/* ── 下载区 ── */}
{unifiedResult && unifiedResult.groups.length > 0 && (
<UnifiedDownloadBar
selectedCount={unifiedSelected.size}
onDownload={downloadUnifiedSelected}
downloading={downloading}
/>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 子组件:结果分组表
// ════════════════════════════════════════════════════════════
function UnifiedResultGroups({
groups,
unifiedSelected,
toggleUnifiedSelect,
}: {
groups: SourceCandidateGroup[];
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
}) {
return (
<div className="space-y-3">
{groups.map((g) => {
const theme = SOURCE_THEME[g.source] ?? SOURCE_THEME.lamost;
const productLabel =
PRODUCT_LABEL[g.product.product] ?? g.product.product;
const groupKeyPrefix = `${g.source}#${g.product.product}#${g.product.subtype ?? ''}`;
const groupSelected = g.candidates.filter((c) =>
unifiedSelected.has(`${groupKeyPrefix}#${c.source_id}`)
).length;
const allSelected =
groupSelected === g.candidates.length && g.candidates.length > 0;
return (
<div
key={groupKeyPrefix}
className="bg-white border border-slate-200 rounded-lg overflow-hidden"
>
{/* 分组头 */}
<div className="flex items-center justify-between px-3 py-2 bg-slate-50 border-b border-slate-200">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-bold ${theme.badge}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
{theme.label}
</span>
<span className="text-xs font-semibold text-slate-700">
{productLabel}
{g.product.subtype && (
<span className="text-slate-400 ml-1">
/{g.product.subtype}
</span>
)}
</span>
<span className="text-[11px] text-slate-500">
{g.candidates.length}
</span>
</div>
<button
onClick={() => {
// 全选/取消该组
const keys = g.candidates.map(
(c) => `${groupKeyPrefix}#${c.source_id}`
);
const allIn = keys.every((k) => unifiedSelected.has(k));
keys.forEach((k) => {
if (allIn === unifiedSelected.has(k))
toggleUnifiedSelect(k);
});
}}
className="text-[11px] text-slate-600 hover:text-slate-900 underline"
>
{allSelected ? '取消全选' : '全选该源'}
</button>
</div>
{/* 候选源表 */}
<table className="w-full text-[11px]">
<thead className="text-slate-500">
<tr>
<th className="text-left p-1.5 w-8"></th>
<th className="text-left p-1.5">source_id</th>
<th className="text-left p-1.5"></th>
<th className="text-left p-1.5">RA</th>
<th className="text-left p-1.5">Dec</th>
<th className="text-left p-1.5"></th>
</tr>
</thead>
<tbody>
{g.candidates.map((c) => {
const key = `${groupKeyPrefix}#${c.source_id}`;
const checked = unifiedSelected.has(key);
return (
<tr
key={c.source_id}
className="border-t border-slate-100 hover:bg-slate-50"
>
<td className="p-1.5">
<input
type="checkbox"
checked={checked}
onChange={() => toggleUnifiedSelect(key)}
className="w-3 h-3"
/>
</td>
<td className="p-1.5 font-mono text-slate-800">
{c.source_id}
</td>
<td className="p-1.5 text-slate-600 max-w-32 truncate">
{c.label || '—'}
</td>
<td className="p-1.5 font-mono text-slate-600">
{c.ra != null ? c.ra.toFixed(4) : '—'}
</td>
<td className="p-1.5 font-mono text-slate-600">
{c.dec != null ? c.dec.toFixed(4) : '—'}
</td>
<td className="p-1.5 font-mono text-slate-500">
{c.distance != null ? `${c.distance.toFixed(4)}°` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 子组件:下载条
// ════════════════════════════════════════════════════════════
function UnifiedDownloadBar({
selectedCount,
onDownload,
downloading,
}: {
selectedCount: number;
onDownload: () => Promise<ObservationBatchResult[]>;
downloading: boolean;
}) {
const [results, setResults] = useState<ObservationBatchResult[] | null>(null);
const handleDownload = useCallback(async () => {
const r = await onDownload();
setResults(r);
}, [onDownload]);
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-slate-50 border border-slate-200 rounded-lg">
<button
onClick={handleDownload}
disabled={downloading || selectedCount === 0}
className="px-4 py-2 bg-emerald-600 text-white text-xs font-bold rounded-lg hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{downloading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
({selectedCount})
</button>
{results && results.length > 0 && (
<span className="text-xs text-slate-600">
{results.length}
</span>
)}
</div>
{/* 多组结果卡片横向展示 */}
{results && results.length > 0 && (
<div className="space-y-3">
{results.map((r, i) => (
<ObservationResultCard key={`${r.source}-${i}`} result={r} />
))}
</div>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 辅助组件与函数
// ════════════════════════════════════════════════════════════
function ModeTab({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
active
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{icon}
{label}
</button>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-[11px] font-semibold text-slate-600">
{label}
</label>
{children}
</div>
);
}
/** 构造源筛选 chip 的稳定 key */
function makeSourceKey(source: string, product: string): string {
return `${source}|${product}`;
}
/** 解析坐标列表文本(每行 ra,dec[,radius] 或 ra dec [radius]*/
function parseCoordsText(text: string, defaultRadius: number): TargetRequest[] {
const targets: TargetRequest[] = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
// 支持逗号或空白分隔
const parts = line.split(/[\s,]+/).filter(Boolean);
if (parts.length < 2) continue;
const ra = parseFloat(parts[0]);
const dec = parseFloat(parts[1]);
if (isNaN(ra) || isNaN(dec)) continue;
const radius = parts.length >= 3 ? parseFloat(parts[2]) : defaultRadius;
const label = parts.length >= 4 ? parts.slice(3).join(' ') : undefined;
targets.push({
ra,
dec,
radius_deg: isNaN(radius) ? defaultRadius : Math.max(0.0001, radius),
label,
});
}
return targets;
}
/** 解析 CSV 文件文本(首行表头,列名含 ra/dec,可选 name/radius*/
function parseCsvTargets(text: string, defaultRadius: number): TargetRequest[] {
const lines = text
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) return [];
// 解析表头
const headers = lines[0].split(/[,;\t]/).map((h) => h.trim().toLowerCase());
const colIndex = (names: string[]): number => {
for (const n of names) {
const i = headers.indexOf(n);
if (i >= 0) return i;
}
return -1;
};
const raCol = colIndex(['ra', 'raj', 'ra_deg', 'ra_degrees']);
const decCol = colIndex(['dec', 'dej', 'dec_deg', 'dec_degrees', 'de']);
if (raCol < 0 || decCol < 0) return [];
const nameCol = colIndex(['name', 'object', 'obj', 'target', 'designation']);
const radiusCol = colIndex(['radius', 'radius_deg', 'r']);
const targets: TargetRequest[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(/[,;\t]/).map((c) => c.trim());
const ra = parseFloat(cells[raCol]);
const dec = parseFloat(cells[decCol]);
if (isNaN(ra) || isNaN(dec)) continue;
const radius =
radiusCol >= 0 ? parseFloat(cells[radiusCol]) : defaultRadius;
const label = nameCol >= 0 ? cells[nameCol] : undefined;
targets.push({
ra,
dec,
radius_deg: isNaN(radius) ? defaultRadius : Math.max(0.0001, radius),
label: label || undefined,
});
}
return targets;
}
@@ -3,6 +3,8 @@
// 观测数据共享常量与类型 —— 跨 Agent 工具卡片 / 观测数据面板 / 检索结果卡片复用
// 统一 (Source × ProductType) 双轴的视觉主题与中文标签映射
import type { Candidate } from '../../types';
// ── 数据源主题色(source 轴)──
// 与 SOURCE_THEME 在 SpecialToolRenderers.tsx 的取色保持一致,统一视觉
export const SOURCE_THEME: Record<
@@ -29,6 +31,31 @@ export const SOURCE_THEME: Record<
label: 'DESI',
dot: 'bg-emerald-500',
},
twomass: {
badge: 'bg-orange-100 text-orange-700',
label: '2MASS',
dot: 'bg-orange-500',
},
allwise: {
badge: 'bg-rose-100 text-rose-700',
label: 'AllWISE',
dot: 'bg-rose-500',
},
panstarrs: {
badge: 'bg-cyan-100 text-cyan-700',
label: 'Pan-STARRS',
dot: 'bg-cyan-500',
},
ztf: {
badge: 'bg-fuchsia-100 text-fuchsia-700',
label: 'ZTF',
dot: 'bg-fuchsia-500',
},
tess: {
badge: 'bg-violet-100 text-violet-700',
label: 'TESS',
dot: 'bg-violet-500',
},
};
// ── 产品类型中文标签(product 轴)──
@@ -83,3 +110,154 @@ export interface ObservationBatchResult {
products: ObservationProductItem[];
failures: DownloadFailure[];
}
// ── 光谱预览类型(镜像 Rust services::observation::preview::SpectrumPreview)──
export interface SpectrumSegment {
band: string;
wavelength: number[];
flux: number[];
ivar?: number[];
wavelength_unit: string;
flux_unit: string;
}
export interface SpectrumMeta {
ra?: number;
dec?: number;
z?: number;
class?: string;
snr?: number;
teff?: number;
logg?: number;
fe_h?: number;
extra?: Record<string, unknown>;
}
export interface SpectrumPreview {
source: string;
source_id: string;
file_path: string;
segments: SpectrumSegment[];
meta: SpectrumMeta;
}
// ── 后续产品类型的预览结构(占位,待实现解析器后填充字段)──
// 镜像 Rust services::observation::preview::{LightCurvePreview, PhotometryPreview, ImagePreview}
export interface LightCurveBand {
band: string;
time: number[];
flux: number[];
flux_error?: number[];
time_unit: string;
flux_unit: string;
}
export interface LightCurvePreview {
source: string;
source_id: string;
file_path: string;
bands: LightCurveBand[];
meta: SpectrumMeta;
}
export interface PhotometryEntry {
band: string;
magnitude?: number;
magnitude_error?: number;
}
export interface PhotometryPreview {
source: string;
source_id: string;
file_path: string;
entries: PhotometryEntry[];
meta: SpectrumMeta;
}
export interface ImagePreview {
source: string;
source_id: string;
file_path: string;
preview_data_url: string;
width: number;
height: number;
meta: SpectrumMeta;
}
// ── 顶层预览枚举(镜像 Rust ObservationPreviewserde tag = "kind")──
// 前端用 discriminated unionswitch(preview.kind) 分发到对应渲染器
export type ObservationPreview =
| ({ kind: 'spectrum' } & SpectrumPreview)
| ({ kind: 'lightcurve' } & LightCurvePreview)
| ({ kind: 'photometry' } & PhotometryPreview)
| ({ kind: 'image' } & ImagePreview);
/// 判断某 (product, format) 组合是否支持预览
/// 新增产品类型时在此扩展(如 lightcurve 支持 fits 后返回 true
export function canPreview(product: string, format: string): boolean {
if (format !== 'fits') return false;
return product === 'spectrum';
}
// ── 统一全源检索类型(镜像 Rust services::observation::unified)──
// 多目标 × 多源并发 cone 检索的请求/响应结构
/// 产品规格:product + 可选 subtype(镜像 Rust ProductSpec
export interface ProductSpec {
product: string;
subtype?: string;
}
/// 单个待检索目标(坐标 + 可选标签)
export interface TargetRequest {
ra: number;
dec: number;
radius_deg?: number; // 默认 0.1
label?: string;
}
/// 统一检索请求体
export interface UnifiedSearchRequest {
targets: TargetRequest[];
/// None/空 = 全部已注册源;非空 = 仅这些 (source, product)
sources?: [string, ProductSpec][];
release?: string;
version?: string;
per_target_limit?: number; // 默认 50
}
/// 一个 (source, product) 的聚合候选源组
export interface SourceCandidateGroup {
source: string;
product: ProductSpec;
candidates: Candidate[];
}
/// 名称解析失败条目
export interface ResolveFailure {
target_label: string;
error: string;
}
/// 统一检索聚合结果
export interface UnifiedSearchResult {
groups: SourceCandidateGroup[];
resolve_failures?: ResolveFailure[];
total_candidates: number;
}
/// 单个名称解析请求
export interface ResolveNamesRequest {
names: string[];
}
/// 单个名称的解析结果(成功时 ra/dec 有值,失败时 error 有值)
export interface ResolvedTarget {
name: string;
ra?: number;
dec?: number;
spectral_type?: string;
v_magnitude?: number;
error?: string;
}
@@ -0,0 +1,82 @@
// dashboard/src/components/observation/useObservationPreview.ts
//
// 观测数据预览 hook —— 按需拉取单条 artifact 的降采样 JSON,供渲染器绘图
//
// 泛型于产品类型:后端返回 ObservationPreview 联合类型(带 kind 标签),
// 前端 ObservationPreviewRenderer 按 kind 分发到对应绘图组件。
// 新增产品类型(LightCurve/Photometry/Image)时无需改动本 hook。
//
// 自包含:ObservationResultCard / LibraryCard 内部调用,同时覆盖面板流和 Agent 流。
// 对齐 useObservation.ts 的 axios.get + extractErrorMessage 范式,状态按 artifact key 隔离。
import { useCallback, useState } from 'react';
import axios from 'axios';
import { extractErrorMessage } from '../../utils/apiError';
import type { ObservationPreview } from './constants';
interface PreviewState {
data?: ObservationPreview;
loading: boolean;
error?: string;
}
/**
* 按 artifact key 管理预览状态。
* key 格式:`${source_id}#${artifact_index}`(保证同一页面多 artifact 不串)
*/
export function useObservationPreview() {
const [previews, setPreviews] = useState<Record<string, PreviewState>>({});
const fetchPreview = useCallback(
async (
source: string,
product: string,
subtype: string | undefined,
sourceId: string,
artifactIndex: number
) => {
const key = `${sourceId}#${artifactIndex}`;
setPreviews((m) => ({
...m,
[key]: { loading: true, error: undefined },
}));
try {
const res = await axios.get<ObservationPreview>(
'/api/observation/preview',
{
params: {
source,
product,
subtype: subtype || undefined,
source_id: sourceId,
artifact_index: artifactIndex,
},
}
);
setPreviews((m) => ({
...m,
[key]: { data: res.data, loading: false },
}));
} catch (e: unknown) {
setPreviews((m) => ({
...m,
[key]: {
loading: false,
error: extractErrorMessage(e, '预览加载失败'),
},
}));
}
},
[]
);
const getPreview = useCallback(
(sourceId: string, artifactIndex: number): PreviewState => {
const key = `${sourceId}#${artifactIndex}`;
return previews[key] ?? { loading: false };
},
[previews]
);
return { fetchPreview, getPreview };
}
+152 -6
View File
@@ -13,7 +13,13 @@ 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';
import type {
ObservationBatchResult,
UnifiedSearchRequest,
UnifiedSearchResult,
ResolvedTarget,
ProductSpec,
} from '../components/observation/constants';
interface UseObservationProps {
isAuthenticated: boolean | null;
@@ -65,7 +71,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
useEffect(() => {
if (isAuthenticated !== true) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
fetchCapabilities();
}, [isAuthenticated, fetchCapabilities]);
@@ -89,7 +95,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
const valid = currentSpec.releases;
const hardMax = currentSpec.hard_max_radius_deg;
const suggested = currentSpec.suggested_max_radius_deg;
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived form sync on spec change
setSearchForm((f) => {
let next = f;
// release 回落(仅在该源区分版本时)
@@ -113,7 +119,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
const versions = searchForm.release
? versionsMap[searchForm.release]
: undefined;
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived version reset on release change
setSearchForm((f) => {
if (!versions || versions.length === 0) {
// 该 release 无子版本概念
@@ -269,7 +275,138 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
);
// ════════════════════════════════════════════════════
// D. 缓存库(library)—— 服务端分页浏览已下载数据
// D. 统一全源检索(unified)—— 多目标 × 多源并发 cone 检索
// ════════════════════════════════════════════════════
const [unifiedResult, setUnifiedResult] =
useState<UnifiedSearchResult | null>(null);
const [unifiedSearching, setUnifiedSearching] = useState(false);
const [unifiedError, setUnifiedError] = useState<string | null>(null);
// 勾选的候选源:key = `${source}#${product}#${subtype}#${source_id}`
const [unifiedSelected, setUnifiedSelected] = useState<Set<string>>(
new Set()
);
/** 统一检索:跨目标 × 跨源并发 cone 检索 → 聚合候选源 */
const runUnifiedSearch = useCallback(async (req: UnifiedSearchRequest) => {
setUnifiedSearching(true);
setUnifiedError(null);
setUnifiedSelected(new Set());
try {
const res = await axios.post<UnifiedSearchResult>(
'/api/observation/unified/search',
req
);
setUnifiedResult(res.data);
} catch (e: unknown) {
setUnifiedError(
extractErrorMessage(e, '统一检索失败,请检查目标与源参数')
);
setUnifiedResult(null);
} finally {
setUnifiedSearching(false);
}
}, []);
/** 批量解析天体名称为坐标(供「天体名称列表」输入模式) */
const resolveNames = useCallback(
async (names: string[]): Promise<ResolvedTarget[]> => {
if (names.length === 0) return [];
try {
const res = await axios.post<ResolvedTarget[]>(
'/api/observation/unified/resolve',
{ names }
);
return res.data ?? [];
} catch (e: unknown) {
// 整体失败时,把每个名称标记为失败,便于前端展示
return names.map((n) => ({
name: n,
error: extractErrorMessage(e, '名称解析服务不可用'),
}));
}
},
[]
);
/** 切换某个候选源的勾选状态 */
const toggleUnifiedSelect = useCallback((key: string) => {
setUnifiedSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
/**
* 批量下载统一检索中勾选的候选源。
* 按 (source, product) 分组,逐组调用现有 POST /observation/downloadmode=identifiers)。
* 返回每组的下载结果(前端用多个 ObservationResultCard 展示)。
*/
const downloadUnifiedSelected = useCallback(async () => {
if (unifiedSelected.size === 0 || !unifiedResult) return [];
// 收集 key → {source, product, subtype, source_id}
const groups = new Map<
string,
{ spec: [string, ProductSpec]; ids: string[] }
>();
for (const key of unifiedSelected) {
const [source, product, subtype, ...idParts] = key.split('#');
const source_id = idParts.join('#'); // source_id 理论上不含 #,但保守处理
const groupKey = `${source}#${product}#${subtype}`;
if (!groups.has(groupKey)) {
groups.set(groupKey, {
spec: [source, { product, subtype: subtype || undefined }],
ids: [],
});
}
groups.get(groupKey)!.ids.push(source_id);
}
const results: ObservationBatchResult[] = [];
setDownloading(true);
setDownloadError(null);
try {
for (const { spec, ids } of groups.values()) {
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: spec[0],
product: spec[1].product,
subtype: spec[1].subtype || undefined,
force: false,
mode: 'identifiers',
source_ids: ids,
}
);
results.push(res.data);
} catch (e: unknown) {
// 单组失败不中断,记录为 failures 占位 batch
results.push({
source: spec[0],
product: spec[1],
matched_count: 0,
products: [],
failures: ids.map((id) => ({
source_label: id,
error: extractErrorMessage(e, '下载失败'),
})),
});
}
}
// 把最后一组结果存入 downloadResult 供 ObservationResultCard 展示
if (results.length > 0) {
setDownloadResult(results[results.length - 1]);
}
} finally {
setDownloading(false);
}
return results;
}, [unifiedSelected, unifiedResult]);
// ════════════════════════════════════════════════════
// E. 缓存库(library)—— 服务端分页浏览已下载数据
// ════════════════════════════════════════════════════
const [libraryItems, setLibraryItems] = useState<ObservationRecord[]>([]);
const [libraryTotal, setLibraryTotal] = useState(0);
@@ -324,7 +461,6 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
// 任意筛选/分页变更 → 重新请求后端
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
fetchLibrary();
}, [fetchLibrary]);
@@ -360,6 +496,16 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
downloadSelected,
downloadByIds,
downloadByCoordinates,
// D. 统一全源检索
unifiedResult,
setUnifiedResult,
unifiedSearching,
unifiedError,
runUnifiedSearch,
resolveNames,
unifiedSelected,
toggleUnifiedSelect,
downloadUnifiedSelected,
// D. 缓存库
libraryItems,
libraryTotal,
+131 -25
View File
@@ -26,19 +26,30 @@ import {
Send,
RotateCw,
Lock,
LineChart,
Globe,
} from 'lucide-react';
import { CustomSelect } from '../components/CustomSelect';
import { ObservationResultCard } from '../components/observation/ObservationResultCard';
import { ObservationPreviewRenderer } from '../components/observation/ObservationPreviewRenderer';
import { useObservationPreview } from '../components/observation/useObservationPreview';
import { UnifiedSearchPanel } from '../components/observation/UnifiedSearchPanel';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
canPreview,
} from '../components/observation/constants';
import type {
UnifiedSearchResult,
ResolvedTarget,
ObservationBatchResult,
} from '../components/observation/constants';
import type { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type { SearchForm } from '../hooks/useObservation';
// ── 视图切换类型 ──
type ViewMode = 'search' | 'library';
type ViewMode = 'search' | 'library' | 'unified';
interface ObservationPanelProps {
// 能力清单
@@ -69,6 +80,17 @@ interface ObservationPanelProps {
downloadSelected: () => Promise<void>;
downloadByIds: (ids: string[]) => Promise<void>;
downloadByCoordinates: (strategy?: 'nearest' | 'all') => Promise<void>;
// 统一全源检索
unifiedResult: UnifiedSearchResult | null;
unifiedSearching: boolean;
unifiedError: string | null;
runUnifiedSearch: (
req: import('../components/observation/constants').UnifiedSearchRequest
) => Promise<void>;
resolveNames: (names: string[]) => Promise<ResolvedTarget[]>;
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
downloadUnifiedSelected: () => Promise<ObservationBatchResult[]>;
// 缓存库
libraryItems: ObservationRecord[];
libraryTotal: number;
@@ -118,6 +140,17 @@ export function ObservationPanel(props: ObservationPanelProps) {
<Search className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setView('unified')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
view === 'unified'
? 'bg-white text-slate-800 shadow-xs'
: 'text-slate-500 hover:text-slate-700'
}`}
>
<Globe 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 ${
@@ -134,6 +167,19 @@ export function ObservationPanel(props: ObservationPanelProps) {
{view === 'search' ? (
<SearchView {...props} />
) : view === 'unified' ? (
<UnifiedSearchPanel
capabilities={props.capabilities}
unifiedResult={props.unifiedResult}
unifiedSearching={props.unifiedSearching}
unifiedError={props.unifiedError}
runUnifiedSearch={props.runUnifiedSearch}
resolveNames={props.resolveNames}
unifiedSelected={props.unifiedSelected}
toggleUnifiedSelect={props.toggleUnifiedSelect}
downloadUnifiedSelected={props.downloadUnifiedSelected}
downloading={props.downloading}
/>
) : (
<LibraryView {...props} />
)}
@@ -981,6 +1027,10 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
0
);
// 预览(与 ObservationResultCard 共用 hook + 渲染器)
const { fetchPreview, getPreview } = useObservationPreview();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
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">
@@ -1027,32 +1077,88 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
<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}
{artifacts.map((a, i) => {
const previewKey = `${rec.source_id}#${i}`;
const previewable = canPreview(rec.product, a.format);
const isOpen = expanded[previewKey] ?? false;
const previewState = getPreview(rec.source_id, i);
const togglePreview = () => {
const next = !isOpen;
setExpanded((m) => ({ ...m, [previewKey]: next }));
if (next && !previewState.data && !previewState.loading) {
// subtype 从路径推断(后端 build_preview 也支持 None 时推断)
fetchPreview(
rec.source,
rec.product,
undefined,
rec.source_id,
i
);
}
};
return (
<div key={i} 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 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>
<span className="flex items-center gap-1 shrink-0 ml-2">
{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" />
)}
</button>
)}
<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"
>
<Download className="w-3 h-3" />
</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>
)}
<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>
))}
</div>
);
})}
{artifacts.length > 1 && (
<div className="flex items-center gap-1 text-[10px] text-slate-400 pt-1">
<CheckCircle2 className="w-3 h-3" />