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:
@@ -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) 合并 subtype(list_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()), []);
|
||||
|
||||
// 按产品分组的 capabilities(chip 展示)
|
||||
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 ObservationPreview,serde tag = "kind")──
|
||||
// 前端用 discriminated union:switch(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 };
|
||||
}
|
||||
@@ -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/download(mode=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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user