diff --git a/Cargo.lock b/Cargo.lock
index c4493be..3bfd358 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -140,6 +140,8 @@ dependencies = [
"clap",
"dashmap",
"dotenvy",
+ "fitsio",
+ "fitsio-sys",
"flate2",
"futures-util",
"git2",
@@ -243,6 +245,15 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "autotools"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "axum"
version = "0.7.9"
@@ -1207,6 +1218,26 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fitsio"
+version = "0.21.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b2a7b7eee7f284a95437774d1152e04706f690d549687004ab7ae73cc776d08"
+dependencies = [
+ "fitsio-sys",
+ "libc",
+]
+
+[[package]]
+name = "fitsio-sys"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14929d0b6fa44a87b374a138ea2256c02c1b5beae1dbc98f62a3e132f862fed1"
+dependencies = [
+ "autotools",
+ "pkg-config",
+]
+
[[package]]
name = "flate2"
version = "1.1.9"
diff --git a/Cargo.toml b/Cargo.toml
index 739a20b..01d80ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -63,6 +63,8 @@ walkdir = "2"
lru = "0.12"
git2 = { version = "0.18", default-features = false, features = ["vendored-libgit2"] }
dashmap = "6"
+fitsio = { version = "0.21", features = ["fitsio-src"] } # FITS 解析;fitsio-src 自带 CFITSIO 源码编译,无需系统装 cfitsio
+fitsio-sys = { version = "0.5", features = ["fitsio-src"] } # 直接访问 CFITSIO C API(读 Gaia XP 变长数组列 PD(55),fitsio crate 不支持)
[features]
default = []
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 0df1ae0..38f2ef9 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -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}
diff --git a/dashboard/src/components/observation/ObservationPreviewRenderer.tsx b/dashboard/src/components/observation/ObservationPreviewRenderer.tsx
new file mode 100644
index 0000000..b3202ea
--- /dev/null
+++ b/dashboard/src/components/observation/ObservationPreviewRenderer.tsx
@@ -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 ;
+
+ case 'lightcurve':
+ // TODO: 实现 LightCurvePlot 后替换占位
+ return (
+
+ 光变曲线预览(开发中)
+
+ );
+
+ case 'photometry':
+ // TODO: 实现 PhotometryTable 后替换占位
+ return (
+
+ 测光预览(开发中)
+
+ );
+
+ case 'image':
+ // image 变体已有 preview_data_url,直接渲染
+ return (
+
+ );
+
+ default:
+ // 穷尽性检查:未来新增 kind 会在编译期报错
+ return (
+
+ 未知预览类型
+
+ );
+ }
+}
diff --git a/dashboard/src/components/observation/ObservationResultCard.tsx b/dashboard/src/components/observation/ObservationResultCard.tsx
index 9827fa2..35a1693 100644
--- a/dashboard/src/components/observation/ObservationResultCard.tsx
+++ b/dashboard/src/components/observation/ObservationResultCard.tsx
@@ -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>({});
+
return (
{/* 标题栏:数据源 + 产品类型双标签 */}
@@ -103,30 +118,84 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
)}
{/* 多 artifact 展示(Gaia 光变 G/BP/RP 三波段) */}
- {p.artifacts.map((a, ai) => (
-
-
- {a.band && (
-
- {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 (
+
+
+
+ {a.band && (
+
+ {a.band}
+
+ )}
+ {a.file_format.toUpperCase()} ·{' '}
+ {formatFileSize(a.size_bytes)}
+
+ {previewable && (
+
+ )}
+
+
+ {a.file_format.toUpperCase()}
+
+
+
+ {/* 预览图(按 product 类型分发渲染) */}
+ {previewable && isOpen && (
+
+ {previewState.error ? (
+
+ {previewState.error}
+
+ ) : previewState.data ? (
+
+ ) : previewState.loading ? (
+
+
+ 解析 FITS 中...
+
+ ) : null}
+
)}
- {a.file_format.toUpperCase()} · {formatFileSize(a.size_bytes)}
-
-
-
- {a.file_format.toUpperCase()}
-
-
- ))}
+
+ );
+ })}
);
})}
diff --git a/dashboard/src/components/observation/SpectrumPlot.tsx b/dashboard/src/components/observation/SpectrumPlot.tsx
new file mode 100644
index 0000000..05a1502
--- /dev/null
+++ b/dashboard/src/components/observation/SpectrumPlot.tsx
@@ -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 = {
+ 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(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 无有效数据点
;
+ }
+
+ 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 (
+
+
+
+ {/* hover tooltip */}
+ {hover && (
+
+ λ={hover.wl.toFixed(1)} · f={fmtFlux(hover.flux)}
+
+ )}
+
+ {/* 多段图例 */}
+ {segments.length > 1 && (
+
+ {segments.map((seg, i) => (
+
+
+ {seg.band}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/observation/UnifiedSearchPanel.tsx b/dashboard/src/components/observation/UnifiedSearchPanel.tsx
new file mode 100644
index 0000000..883ef2b
--- /dev/null
+++ b/dashboard/src/components/observation/UnifiedSearchPanel.tsx
@@ -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;
+ resolveNames: (names: string[]) => Promise;
+ unifiedSelected: Set;
+ toggleUnifiedSelect: (key: string) => void;
+ downloadUnifiedSelected: () => Promise;
+ downloading: boolean;
+}
+
+export function UnifiedSearchPanel({
+ capabilities,
+ unifiedResult,
+ unifiedSearching,
+ unifiedError,
+ runUnifiedSearch,
+ resolveNames,
+ unifiedSelected,
+ toggleUnifiedSelect,
+ downloadUnifiedSelected,
+ downloading,
+}: UnifiedSearchPanelProps) {
+ // ── 输入模式 ──
+ const [inputMode, setInputMode] = useState('coordinates');
+ // ── 坐标列表文本(每行 ra,dec[,radius] 或 ra dec [radius])──
+ const [coordsText, setCoordsText] = useState('');
+ // ── 天体名称列表文本 ──
+ const [namesText, setNamesText] = useState('');
+ const [resolving, setResolving] = useState(false);
+ const [resolved, setResolved] = useState([]);
+ // ── 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>(
+ 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) => {
+ 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();
+ for (const c of capabilities) {
+ if (!map.has(c.product)) map.set(c.product, []);
+ map.get(c.product)!.push(c);
+ }
+ return map;
+ }, [capabilities]);
+
+ return (
+
+ {/* ── 输入模式切换 ── */}
+
+ setInputMode('coordinates')}
+ icon={}
+ label="坐标列表"
+ />
+ setInputMode('names')}
+ icon={}
+ label="天体名称"
+ />
+ setInputMode('csv')}
+ icon={}
+ label="CSV 导入"
+ />
+
+
+ {/* ── 目标输入区 ── */}
+
+ {inputMode === 'coordinates' && (
+ <>
+
+
+
+ {/* ── 公共参数 ── */}
+
+
+ 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"
+ />
+
+
+ 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"
+ />
+
+
+ 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"
+ />
+
+
+
+ {/* ── 源筛选 chip 组 ── */}
+
+
+
+
+
+
+
+
+
+ {Array.from(capsByProduct.entries()).map(([product, caps]) => (
+
+
+ {PRODUCT_LABEL[product] ?? product}
+
+ {caps.map((c) => {
+ const key = makeSourceKey(c.source, c.product);
+ const checked = effectiveSelected.has(key);
+ const theme = SOURCE_THEME[c.source];
+ return (
+
+ );
+ })}
+
+ ))}
+
+
+
+ {/* ── 执行检索 ── */}
+
+
+ {unifiedResult && (
+
+ 共 {unifiedResult.groups.length} 个源组合,
+ {unifiedResult.total_candidates} 个候选源
+
+ )}
+
+
+ {/* ── 错误 ── */}
+ {unifiedError && (
+
+ )}
+
+ {/* ── 名称解析失败提示 ── */}
+ {unifiedResult?.resolve_failures &&
+ unifiedResult.resolve_failures.length > 0 && (
+
+ {unifiedResult.resolve_failures.map((f, i) => (
+
+ {f.target_label}: {f.error}
+
+ ))}
+
+ )}
+
+ {/* ── 结果区:按 (source, product) 分组的候选源表 ── */}
+ {unifiedResult && unifiedResult.groups.length > 0 && (
+
+ )}
+
+ {/* ── 下载区 ── */}
+ {unifiedResult && unifiedResult.groups.length > 0 && (
+
+ )}
+
+ );
+}
+
+// ════════════════════════════════════════════════════════════
+// 子组件:结果分组表
+// ════════════════════════════════════════════════════════════
+
+function UnifiedResultGroups({
+ groups,
+ unifiedSelected,
+ toggleUnifiedSelect,
+}: {
+ groups: SourceCandidateGroup[];
+ unifiedSelected: Set;
+ toggleUnifiedSelect: (key: string) => void;
+}) {
+ return (
+
+ {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 (
+
+ {/* 分组头 */}
+
+
+
+
+ {theme.label}
+
+
+ {productLabel}
+ {g.product.subtype && (
+
+ /{g.product.subtype}
+
+ )}
+
+
+ {g.candidates.length} 个候选源
+
+
+
+
+ {/* 候选源表 */}
+
+
+ );
+ })}
+
+ );
+}
+
+// ════════════════════════════════════════════════════════════
+// 子组件:下载条
+// ════════════════════════════════════════════════════════════
+
+function UnifiedDownloadBar({
+ selectedCount,
+ onDownload,
+ downloading,
+}: {
+ selectedCount: number;
+ onDownload: () => Promise;
+ downloading: boolean;
+}) {
+ const [results, setResults] = useState(null);
+
+ const handleDownload = useCallback(async () => {
+ const r = await onDownload();
+ setResults(r);
+ }, [onDownload]);
+
+ return (
+
+
+
+ {results && results.length > 0 && (
+
+ 完成 {results.length} 组下载
+
+ )}
+
+ {/* 多组结果卡片横向展示 */}
+ {results && results.length > 0 && (
+
+ {results.map((r, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ════════════════════════════════════════════════════════════
+// 辅助组件与函数
+// ════════════════════════════════════════════════════════════
+
+function ModeTab({
+ active,
+ onClick,
+ icon,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ icon: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+/** 构造源筛选 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;
+}
diff --git a/dashboard/src/components/observation/constants.ts b/dashboard/src/components/observation/constants.ts
index 1916f76..91c9966 100644
--- a/dashboard/src/components/observation/constants.ts
+++ b/dashboard/src/components/observation/constants.ts
@@ -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;
+}
+
+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;
+}
diff --git a/dashboard/src/components/observation/useObservationPreview.ts b/dashboard/src/components/observation/useObservationPreview.ts
new file mode 100644
index 0000000..89c2934
--- /dev/null
+++ b/dashboard/src/components/observation/useObservationPreview.ts
@@ -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>({});
+
+ 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(
+ '/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 };
+}
diff --git a/dashboard/src/hooks/useObservation.ts b/dashboard/src/hooks/useObservation.ts
index 1c45ca1..7fdf425 100644
--- a/dashboard/src/hooks/useObservation.ts
+++ b/dashboard/src/hooks/useObservation.ts
@@ -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(null);
+ const [unifiedSearching, setUnifiedSearching] = useState(false);
+ const [unifiedError, setUnifiedError] = useState(null);
+ // 勾选的候选源:key = `${source}#${product}#${subtype}#${source_id}`
+ const [unifiedSelected, setUnifiedSelected] = useState>(
+ new Set()
+ );
+
+ /** 统一检索:跨目标 × 跨源并发 cone 检索 → 聚合候选源 */
+ const runUnifiedSearch = useCallback(async (req: UnifiedSearchRequest) => {
+ setUnifiedSearching(true);
+ setUnifiedError(null);
+ setUnifiedSelected(new Set());
+ try {
+ const res = await axios.post(
+ '/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 => {
+ if (names.length === 0) return [];
+ try {
+ const res = await axios.post(
+ '/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(
+ '/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([]);
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,
diff --git a/dashboard/src/pages/ObservationPanel.tsx b/dashboard/src/pages/ObservationPanel.tsx
index 8caf9cb..743a63e 100644
--- a/dashboard/src/pages/ObservationPanel.tsx
+++ b/dashboard/src/pages/ObservationPanel.tsx
@@ -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;
downloadByIds: (ids: string[]) => Promise;
downloadByCoordinates: (strategy?: 'nearest' | 'all') => Promise;
+ // 统一全源检索
+ unifiedResult: UnifiedSearchResult | null;
+ unifiedSearching: boolean;
+ unifiedError: string | null;
+ runUnifiedSearch: (
+ req: import('../components/observation/constants').UnifiedSearchRequest
+ ) => Promise;
+ resolveNames: (names: string[]) => Promise;
+ unifiedSelected: Set;
+ toggleUnifiedSelect: (key: string) => void;
+ downloadUnifiedSelected: () => Promise;
// 缓存库
libraryItems: ObservationRecord[];
libraryTotal: number;
@@ -118,6 +140,17 @@ export function ObservationPanel(props: ObservationPanelProps) {
检索下载
+