// 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, useEffect } 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 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 [downloadResults, setDownloadResults] = useState< ObservationBatchResult[] | null >(null); // 当重新检索结果变化时,重置下载结果 useEffect(() => { setDownloadResults(null); }, [unifiedResult]); const handleDownload = useCallback(async () => { const r = await downloadUnifiedSelected(); setDownloadResults(r); }, [downloadUnifiedSelected]); // ── 输入模式 ── 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 [defaultRadius, setDefaultRadius] = useState('0.1'); const [perTargetLimit, setPerTargetLimit] = useState('1'); // ── 可选择的检索源规格列表(展开所有的版本/子类型组合) ── const selectableSources = useMemo(() => { const list: { key: string; source: string; product: string; subtype?: string; release?: string; version?: string; label: string; }[] = []; for (const c of capabilities) { const subtypes = c.subtypes.length > 0 ? c.subtypes : [undefined]; for (const subtype of subtypes) { const releases = c.releases.length > 0 ? c.releases : [undefined]; for (const release of releases) { const versions = release && c.release_versions && c.release_versions[release] && c.release_versions[release].length > 0 ? c.release_versions[release] : [undefined]; for (const version of versions) { const key = [ c.source, c.product, subtype || '', release || '', version || '', ].join('|'); // 构建用户友好标签,如:LAMOST 光谱 - lrs (DR11 v1.0) let label = `${SOURCE_THEME[c.source]?.label ?? c.source} ${PRODUCT_LABEL[c.product] ?? c.product}`; if (subtype) { label += ` - ${subtype}`; } const verParts: string[] = []; if (release) { verParts.push(release.toUpperCase()); } if (version) { verParts.push(version); } if (verParts.length > 0) { label += ` (${verParts.join(' ')})`; } list.push({ key, source: c.source, product: c.product, subtype, release, version, label, }); } } } } return list; }, [capabilities]); // ── 源筛选:默认勾选逻辑 (望远镜、数据类型、子类型、发布版本全选,子版本只选最新) ── const defaultSelectedKeys = useMemo(() => { const groups = new Map(); for (const s of selectableSources) { const comboKey = `${s.source}|${s.product}|${s.subtype || ''}|${s.release || ''}`; if (!groups.has(comboKey)) { groups.set(comboKey, []); } groups.get(comboKey)!.push(s); } const selected = new Set(); for (const items of groups.values()) { if (items.length === 1) { selected.add(items[0].key); } else { // 找出最新子版本的项 let latestItem = items[0]; for (let i = 1; i < items.length; i++) { const item = items[i]; const vA = latestItem.version || ''; const vB = item.version || ''; const cmp = vB.localeCompare(vA, undefined, { numeric: true, sensitivity: 'base', }); if (cmp > 0) { latestItem = item; } } selected.add(latestItem.key); } } return Array.from(selected); }, [selectableSources]); // ── 源筛选:全部可用组合 key ── const allKeys = useMemo( () => selectableSources.map((s) => s.key), [selectableSources] ); const [selectedSources, setSelectedSources] = useState | null>( null ); // 第一次加载完能力后默认选择最新的子版本以及所有发布版本 useEffect(() => { if (selectedSources === null && defaultSelectedKeys.length > 0) { setSelectedSources(new Set(defaultSelectedKeys)); } }, [defaultSelectedKeys, selectedSources]); // 同步选中的有效 Set const effectiveSelected = useMemo(() => { if (selectedSources === null) { return new Set(defaultSelectedKeys); } return selectedSources; }, [selectedSources, defaultSelectedKeys]); // 解析目标列表(坐标模式 / 名称模式已解析 / 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; // 收集选中的 specs const selectedSpecs = selectableSources.filter((s) => effectiveSelected.has(s.key) ); const sources = selectedSpecs.map((s) => ({ source: s.source, product: { product: s.product, subtype: s.subtype || undefined, }, release: s.release || undefined, version: s.version || undefined, })); await runUnifiedSearch({ targets, sources: sources.length === allKeys.length ? undefined : sources, per_target_limit: parseInt(perTargetLimit) || 1, }); }, [ targets, selectableSources, effectiveSelected, allKeys.length, 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 [expandedNodes, setExpandedNodes] = useState>(new Set()); const toggleExpand = useCallback((nodeId: string) => { setExpandedNodes((prev) => { const next = new Set(prev); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); }, []); // 按产品和源多级树形分组的可选择检索源 interface SourceTreeNode { id: string; label: string; level: number; // 0: source, 1: subtype, 2: release, 3: version children?: SourceTreeNode[]; leafKeys: string[]; } const sourcesByProduct = useMemo(() => { const map = new Map(); for (const c of capabilities) { const product = c.product; if (!map.has(product)) { map.set(product, []); } const sourcesInProduct = map.get(product)!; const sourceNodeId = `${c.source}|${product}`; if (sourcesInProduct.some((node) => node.id === sourceNodeId)) { continue; } // 筛选该 (source, product) 的所有可勾选叶子节点 specs const matchingSources = selectableSources.filter( (s) => s.source === c.source && s.product === c.product ); if (matchingSources.length === 0) continue; const sourceLabel = SOURCE_THEME[c.source]?.label ?? c.source; // 1. 按 subtype 分组 const bySubtype = new Map(); for (const ms of matchingSources) { const st = ms.subtype || undefined; if (!bySubtype.has(st)) bySubtype.set(st, []); bySubtype.get(st)!.push(ms); } const subtypeNodes: SourceTreeNode[] = []; for (const [st, stItems] of bySubtype.entries()) { const subtypeLabel = st || ''; const subtypeNodeId = `${sourceNodeId}|${subtypeLabel}`; // 2. 按 release 分组 const byRelease = new Map< string | undefined, typeof selectableSources >(); for (const item of stItems) { const rel = item.release || undefined; if (!byRelease.has(rel)) byRelease.set(rel, []); byRelease.get(rel)!.push(item); } const releaseNodes: SourceTreeNode[] = []; for (const [rel, relItems] of byRelease.entries()) { const releaseLabel = rel ? rel.toUpperCase() : ''; const releaseNodeId = `${subtypeNodeId}|${rel || ''}`; // 3. 按 version 分组 const versionNodes: SourceTreeNode[] = []; for (const item of relItems) { if (item.version) { versionNodes.push({ id: `${releaseNodeId}|${item.version}`, label: item.version, level: 3, leafKeys: [item.key], }); } } if (versionNodes.length > 0) { releaseNodes.push({ id: releaseNodeId, label: releaseLabel, level: 2, children: versionNodes, leafKeys: relItems.map((item) => item.key), }); } else { releaseNodes.push({ id: releaseNodeId, label: releaseLabel, level: 2, leafKeys: relItems.map((item) => item.key), }); } } // 整理释放节点 if (releaseNodes.length > 0) { const hasValidRelease = releaseNodes.some((rn) => rn.label !== ''); if (hasValidRelease) { subtypeNodes.push({ id: subtypeNodeId, label: subtypeLabel, level: 1, children: releaseNodes, leafKeys: stItems.map((item) => item.key), }); } else { const directChildren = releaseNodes.flatMap( (rn) => rn.children || [] ); if (directChildren.length > 0) { subtypeNodes.push({ id: subtypeNodeId, label: subtypeLabel, level: 1, children: directChildren, leafKeys: stItems.map((item) => item.key), }); } else { subtypeNodes.push({ id: subtypeNodeId, label: subtypeLabel, level: 1, leafKeys: stItems.map((item) => item.key), }); } } } } // 整理源节点的子节点 const hasValidSubtype = subtypeNodes.some((sn) => sn.label !== ''); let finalChildren: SourceTreeNode[] | undefined = undefined; if (hasValidSubtype) { finalChildren = subtypeNodes; } else { const directChildren = subtypeNodes.flatMap((sn) => sn.children || []); if (directChildren.length > 0) { finalChildren = directChildren; } } sourcesInProduct.push({ id: sourceNodeId, label: sourceLabel, level: 0, children: finalChildren, leafKeys: matchingSources.map((item) => item.key), }); } return map; }, [capabilities, selectableSources]); // ── 渲染子孙层级节点 (Level 1, 2, 3) ── const renderNestedNode = useCallback( (node: SourceTreeNode) => { const isExpanded = expandedNodes.has(node.id); const hasChildren = node.children && node.children.length > 0; const allChecked = node.leafKeys.every((key) => effectiveSelected.has(key) ); const someChecked = node.leafKeys.some((key) => effectiveSelected.has(key) ); const isIndeterminate = someChecked && !allChecked; const handleCheckboxChange = (e: React.MouseEvent) => { e.stopPropagation(); setSelectedSources((prev) => { const next = new Set(prev); if (allChecked) { node.leafKeys.forEach((k) => next.delete(k)); } else { node.leafKeys.forEach((k) => next.add(k)); } return next; }); }; const handleNodeClick = (e: React.MouseEvent) => { e.stopPropagation(); if (hasChildren) { toggleExpand(node.id); } }; return (
{/* 子折叠箭头 */} {hasChildren ? ( ) : ( )} {/* 精美自定义复选框 */}
{allChecked && ( )} {isIndeterminate && (
)}
{/* 子节点标签 */} {node.label || '默认'}
{/* 虚线连接子孙 */} {hasChildren && isExpanded && (
{node.children!.map((child) => renderNestedNode(child))}
)}
); }, [expandedNodes, effectiveSelected, toggleExpand] ); // ── 渲染顶层源卡片 (Level 0) ── const renderRootCard = useCallback( (node: SourceTreeNode) => { const isExpanded = expandedNodes.has(node.id); const hasChildren = node.children && node.children.length > 0; const sourceKey = node.id.split('|')[0]; const theme = SOURCE_THEME[sourceKey] || { label: node.label, dot: 'bg-blueprint', badge: '', }; const allChecked = node.leafKeys.every((key) => effectiveSelected.has(key) ); const someChecked = node.leafKeys.some((key) => effectiveSelected.has(key) ); const isIndeterminate = someChecked && !allChecked; const handleCheckboxChange = (e: React.MouseEvent) => { e.stopPropagation(); setSelectedSources((prev) => { const next = new Set(prev); if (allChecked) { node.leafKeys.forEach((k) => next.delete(k)); } else { node.leafKeys.forEach((k) => next.add(k)); } return next; }); }; const handleNodeClick = () => { if (hasChildren) { toggleExpand(node.id); } }; return (
{/* 紧凑卡片头部行 */}
{/* 精美主复选框 */}
{allChecked && ( )} {isIndeterminate && (
)}
{/* 源中文/英文名 */} {theme.label}
{/* 指示微标和折叠指示 */}
{node.leafKeys.length} 项 {hasChildren && ( )}
{/* 悬浮树形结构展示框 */} {hasChildren && isExpanded && (
e.stopPropagation()} className="absolute left-0 right-0 top-full mt-1 z-50 bg-surface border border-default rounded-lg shadow-xl p-2.5 flex flex-col gap-1.5 animate-in fade-in slide-in-from-top-1 duration-150 max-h-60 overflow-y-auto" >
{theme.label} 选项配置
{node.children!.map((child) => renderNestedNode(child))}
)}
); }, [expandedNodes, effectiveSelected, toggleExpand, renderNestedNode] ); // ── 统一递归渲染树入口 ── const renderTreeNode = useCallback( (node: SourceTreeNode) => { if (node.level === 0) { return renderRootCard(node); } return renderNestedNode(node); }, [renderRootCard, renderNestedNode] ); // ── 源筛选控制 ── const selectAllSources = useCallback( () => setSelectedSources(new Set(allKeys)), [allKeys] ); const clearSources = useCallback(() => setSelectedSources(new Set()), []); return (
{/* ── 统一检索表单卡片 ── */}
{/* 输入模式切换 */}
检索模式:
setInputMode('coordinates')} icon={} label="坐标列表" /> setInputMode('names')} icon={} label="天体名称" /> setInputMode('csv')} icon={} label="CSV 导入" />
{/* ── 目标输入区 ── */}
{inputMode === 'coordinates' && (