AstroResearch/dashboard/src/components/observation/UnifiedSearchPanel.tsx
Asfmq 5ad0cf1d28 - Config 按职责拆分为 LlmConfig/EmbeddingConfig/VisionConfig/CdsConfig/StorageConfig 五个子结构
- AppState 拆分为 LlmState/DataSourceState/SessionState 三个子结构,消除 50+ 平铺字段
- 新增 ServiceError 结构化错误类型替代 handler 中的 msg.contains() 字符串匹配
- 移除 api::handlers 兼容命名空间,路由直接引用 agent/auth/papers 等模块

- login_rate_limiter/upload_rate_limiter 从 Mutex\<HashMap\> 迁移为 DashMap(无锁)
- 新增 session_last_active: DashMap\<String, AtomicU64\>,auth 中间件快速路径免写锁
- 会话过期清理改为按间隔触发(300s),避免每次请求全表扫描
- MAX_SESSIONS 1000→10000,SSE 广播通道 256→1024

- AgentTool trait 新增 is_internal()/display_name(),SSE 事件携带工具元数据
- 新增 GET /chat/tools 端点暴露注册工具列表
- pending_questions/pending_permissions 增加 created_at 时间戳,自动清理过期条目(10min TTL)
- Agent 超时现在正确 abort 后台任务并设置取消令牌

- FITS 解析: APOGEE/DESI 改用 read_image+切片替代 read_rows(修复 fitsio panic)
- ZTF: CIRCLE 参数分隔符 +→空格(修复 IRSA 400),半径自动裁剪至硬上限
- MAST TESS: parse_tic_json 兼容数组/对象两种 API 响应格式
- 统一检索: per_target_limit 默认 50→1,sources 支持 per-source release/version
- Gaia 测光从 VizieR 镜像切换至官方 TAP 服务

- 向量化降级从串行改为并发 5 条/批(buffer_unordered)
- 混合检索 RRF 合并从借用改为 owned RetrievalResult

- PDF 中间件: URL 解码 %2F/%2E 后判扩展名;文件名过滤非 ASCII + 禁 \ 防头注入
- chat_agent 日志截断问题内容至 50 字符;list_sessions 强制 limit clamp

- 设计令牌三层架构: primitive→semantic→component,浅色暖纸张学术/暗色 Night Indigo
- useTheme hook + ThemeToggle 侧边栏组件 + main.tsx 防 FOUC 初始化
- 全组件从硬编码 slate 色迁移至语义令牌(bg-surface/text-content/border-subtle 等)
- 新增 ToastContainer 非阻塞通知系统

- deploy.sh 引入 SSH ControlMaster 单次密码复用
2026-07-11 14:12:11 +08:00

1372 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<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 [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<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 [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<string, typeof selectableSources>();
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<string>();
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<Set<string> | 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<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 [expandedNodes, setExpandedNodes] = useState<Set<string>>(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<string, SourceTreeNode[]>();
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<string | undefined, typeof selectableSources>();
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 (
<div key={node.id} className="flex flex-col select-none">
<div
onClick={handleNodeClick}
className="flex items-center justify-between py-1 px-1.5 rounded hover:bg-sunken/45 transition-all cursor-pointer"
style={{ paddingLeft: `${(node.level - 1) * 12}px` }}
>
<div className="flex items-center gap-2">
{/* 子折叠箭头 */}
{hasChildren ? (
<span
className={`w-3.5 h-3.5 flex items-center justify-center text-[7px] text-tertiary transition-transform duration-200 ${
isExpanded ? 'rotate-90 text-blueprint' : ''
}`}
>
</span>
) : (
<span className="w-3.5" />
)}
{/* 精美自定义复选框 */}
<div
onClick={handleCheckboxChange}
className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all cursor-pointer ${
allChecked
? 'bg-blueprint border-blueprint text-white'
: isIndeterminate
? 'bg-blueprint/25 border-blueprint text-blueprint'
: 'border-default hover:border-secondary bg-surface'
}`}
>
{allChecked && (
<svg className="w-2.5 h-2.5 fill-current" viewBox="0 0 20 20">
<path d="M0 11l2-2 5 5L18 3l2 2L7 18z" />
</svg>
)}
{isIndeterminate && (
<div className="w-1.5 h-0.5 bg-blueprint rounded-sm" />
)}
</div>
{/* 子节点标签 */}
<span className="text-[11px] font-bold text-tertiary hover:text-secondary transition-colors">
{node.label || '默认'}
</span>
</div>
</div>
{/* 虚线连接子孙 */}
{hasChildren && isExpanded && (
<div className="flex flex-col gap-1 mt-1 border-l border-dashed border-subtle/50 ml-2.5 pl-1 animate-in fade-in duration-100">
{node.children!.map((child) => renderNestedNode(child))}
</div>
)}
</div>
);
},
[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 (
<div
key={node.id}
className={`relative group select-none flex flex-col rounded-lg border transition-all duration-200 ${
allChecked
? 'bg-surface border-blueprint/60 shadow-sm'
: someChecked
? 'bg-surface border-blueprint/35 shadow-sm'
: 'bg-sunken/15 border-subtle hover:border-default hover:bg-sunken/25'
}`}
>
{/* 紧凑卡片头部行 */}
<div
onClick={handleNodeClick}
className="flex items-center justify-between p-2 cursor-pointer select-none"
>
<div className="flex items-center gap-2">
{/* 精美主复选框 */}
<div
onClick={handleCheckboxChange}
className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all cursor-pointer ${
allChecked
? 'bg-blueprint border-blueprint text-white'
: isIndeterminate
? 'bg-blueprint/20 border-blueprint text-blueprint'
: 'border-default hover:border-secondary bg-surface'
}`}
>
{allChecked && (
<svg className="w-2.5 h-2.5 fill-current" viewBox="0 0 20 20">
<path d="M0 11l2-2 5 5L18 3l2 2L7 18z" />
</svg>
)}
{isIndeterminate && (
<div className="w-1.5 h-0.5 bg-blueprint rounded-sm" />
)}
</div>
{/* 源中文/英文名 */}
<span className="text-[11px] font-bold text-secondary tracking-wide">
{theme.label}
</span>
</div>
{/* 指示微标和折叠指示 */}
<div className="flex items-center gap-1">
<span className="text-[9px] font-semibold text-tertiary px-1 py-0.5 rounded bg-sunken scale-90">
{node.leafKeys.length}
</span>
{hasChildren && (
<span
className={`w-3.5 h-3.5 flex items-center justify-center text-[9px] text-tertiary transition-transform duration-300 ${
isExpanded ? 'rotate-180 text-secondary' : 'rotate-0'
}`}
>
</span>
)}
</div>
</div>
{/* 悬浮树形结构展示框 */}
{hasChildren && isExpanded && (
<div
onClick={(e) => 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"
>
<div className="text-[10px] text-tertiary font-extrabold pb-1 border-b border-subtle/50 mb-1">
{theme.label}
</div>
{node.children!.map((child) => renderNestedNode(child))}
</div>
)}
</div>
);
},
[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 (
<div className="space-y-5">
{/* ── 统一检索表单卡片 ── */}
<div className="console-panel rounded-lg p-5 space-y-4">
{/* 输入模式切换 */}
<div className="flex items-center justify-between flex-wrap gap-3 pb-3 border-b border-subtle select-none">
<div className="flex items-center gap-2 text-xs font-bold">
<span className="text-secondary"></span>
<div className="flex items-center gap-1 bg-sunken p-1 rounded-lg 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>
</div>
{/* ── 目标输入区 ── */}
<div className="space-y-3">
{inputMode === 'coordinates' && (
<div className="space-y-1.5 flex flex-col">
<label className="text-xs font-bold text-secondary block">
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 px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono resize-y"
/>
<p className="text-[11px] text-tertiary font-semibold">
{targets.length}
</p>
</div>
)}
{inputMode === 'names' && (
<div className="space-y-1.5 flex flex-col">
<label className="text-xs font-bold text-secondary block">
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 px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono resize-y"
/>
<div className="flex items-center gap-2">
<button
onClick={handleResolveNames}
disabled={resolving || !namesText.trim()}
className="btn-console px-3 py-1.5 text-xs font-semibold rounded 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-tertiary font-medium">
CDS SESAME
</span>
</div>
{resolved.length > 0 && (
<div className="mt-2 border border-default rounded overflow-hidden">
<table className="w-full text-[11px]">
<thead className="bg-sunken text-secondary">
<tr className="font-bold border-b border-default/60">
<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-subtle hover:bg-sunken/40"
>
<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-danger">{r.error}</span>
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-success" />
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-secondary p-1.5 bg-sunken font-medium">
{resolved.filter((r) => !r.error).length} /{' '}
{resolved.length}
</p>
</div>
)}
</div>
)}
{inputMode === 'csv' && (
<div className="space-y-1.5 flex flex-col">
<label className="text-xs font-bold text-secondary block">
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-md file:border file:border-default file:text-xs file:font-bold file:bg-sunken file:text-content hover:file:bg-surface transition-all"
/>
<p className="text-[11px] text-tertiary font-medium">
VOTable CSV
</p>
</div>
)}
</div>
{/* ── 公共参数 ── */}
<div className="flex flex-wrap gap-6 pt-2 border-t border-subtle">
<Field label="默认检索半径 (°)">
<input
type="number"
step="0.01"
min="0.0001"
max="30"
value={defaultRadius}
onChange={(e) => setDefaultRadius(e.target.value)}
className="w-36 px-2.5 py-1.5 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
/>
</Field>
<Field label="单源命中上限">
<input
type="number"
min="1"
max="500"
value={perTargetLimit}
onChange={(e) => setPerTargetLimit(e.target.value)}
className="w-36 px-2.5 py-1.5 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
/>
</Field>
</div>
{/* ── 源筛选 chip 组 ── */}
<div className="space-y-3 pt-3 border-t border-subtle">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-secondary">
{effectiveSelected.size}/{allKeys.length}
</label>
<div className="flex items-center gap-1.5">
<button
onClick={selectAllSources}
className="btn-console px-2 py-0.5 rounded text-[10px] font-bold bg-blueprint/15 text-blueprint border-blueprint/20 hover:bg-blueprint/25 transition-all"
>
</button>
<button
onClick={clearSources}
className="btn-console px-2 py-0.5 rounded text-[10px] font-bold bg-sunken border border-default text-secondary hover:bg-surface hover:text-content transition-all"
>
</button>
</div>
</div>
<div className="space-y-4">
{Array.from(sourcesByProduct.entries()).map(([product, nodes]) => (
<div
key={product}
className="flex flex-col gap-2.5 pb-3.5 border-b border-subtle/30 last:border-0"
>
<span className="text-[11px] font-extrabold text-secondary shrink-0 uppercase tracking-wider">
{PRODUCT_LABEL[product] ?? product}
</span>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 flex-1 mt-1">
{nodes.map((node) => renderTreeNode(node))}
</div>
</div>
))}
</div>
</div>
{/* ── 执行检索 ── */}
<div className="flex items-center gap-3 pt-3 border-t border-subtle">
<button
onClick={handleSearch}
disabled={unifiedSearching || targets.length === 0}
className="btn-console btn-console-primary px-4 py-2 text-xs font-bold rounded-lg 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-secondary font-semibold">
{unifiedResult.groups.length}
{unifiedResult.total_candidates}
</span>
)}
</div>
</div>
{/* ── 错误 ── */}
{unifiedError && (
<div className="flex items-center gap-2 p-3 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-lg text-xs text-rose-700 dark:text-rose-300">
<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 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg text-xs text-amber-700 dark:text-amber-300 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}
onDownload={handleDownload}
downloading={downloading}
/>
)}
{/* ── 下载区 ── */}
{unifiedResult && unifiedResult.groups.length > 0 && (
<UnifiedDownloadBar
selectedCount={unifiedSelected.size}
onDownload={handleDownload}
downloading={downloading}
results={downloadResults}
/>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 子组件:结果分组表
// ════════════════════════════════════════════════════════════
function UnifiedResultGroups({
groups,
unifiedSelected,
toggleUnifiedSelect,
onDownload,
downloading,
}: {
groups: SourceCandidateGroup[];
unifiedSelected: Set<string>;
toggleUnifiedSelect: (key: string) => void;
onDownload: () => void;
downloading: boolean;
}) {
const totalCandidatesCount = groups.reduce(
(acc, g) => acc + g.candidates.length,
0
);
const allKeys = useMemo(() => {
return groups.flatMap((g) => {
const groupKeyPrefix = [
g.source,
g.product.product,
g.product.subtype ?? '',
g.release ?? '',
g.version ?? '',
].join('#');
return g.candidates.map((c) => `${groupKeyPrefix}#${c.source_id}`);
});
}, [groups]);
const isAllAllSelected = useMemo(() => {
return allKeys.length > 0 && allKeys.every((k) => unifiedSelected.has(k));
}, [allKeys, unifiedSelected]);
const selectAllAll = () => {
if (isAllAllSelected) {
allKeys.forEach((key) => {
if (unifiedSelected.has(key)) {
toggleUnifiedSelect(key);
}
});
} else {
allKeys.forEach((key) => {
if (!unifiedSelected.has(key)) {
toggleUnifiedSelect(key);
}
});
}
};
return (
<div className="space-y-4">
{/* 全局所有源控制面板 */}
{groups.length > 0 && (
<div className="console-panel rounded-lg p-3 bg-surface border border-subtle shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 select-none animate-in fade-in duration-300">
<div className="flex items-center gap-2">
<span className="font-extrabold text-[10px] text-content uppercase tracking-wider">
</span>
<span className="text-[9px] font-bold bg-sunken px-2 py-0.5 rounded-full border border-subtle text-secondary">
{unifiedSelected.size} / {totalCandidatesCount}
</span>
</div>
<div className="flex items-center gap-2.5">
<button
onClick={selectAllAll}
className={`px-3 py-1 rounded-full text-[10px] font-bold border transition-all cursor-pointer ${
isAllAllSelected
? 'bg-amber-500/10 text-amber-700 border-amber-500/30 hover:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/40'
: 'bg-blueprint text-white border-blueprint hover:opacity-90'
}`}
>
{isAllAllSelected ? '取消全选所有源' : '全选所有源'}
</button>
<span className="text-border-subtle h-4 w-[1px] bg-border-subtle hidden sm:inline" />
<button
onClick={onDownload}
disabled={downloading || unifiedSelected.size === 0}
className="btn-console bg-emerald-600 border-emerald-600 text-white hover:bg-emerald-500 hover:border-emerald-500 px-3 py-1 text-[10px] font-bold rounded-full disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer transition-all"
>
{downloading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
({unifiedSelected.size})
</button>
</div>
</div>
)}
{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 ?? '',
g.release ?? '',
g.version ?? '',
].join('#');
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="console-panel rounded-lg overflow-hidden border border-subtle bg-surface"
>
{/* 分组头 */}
<div className="flex items-center justify-between px-3.5 py-2 bg-sunken border-b border-subtle">
<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-secondary">
{productLabel}
{g.product.subtype && (
<span className="text-tertiary ml-1">
/{g.product.subtype}
</span>
)}
{(g.release || g.version) && (
<span className="text-blueprint ml-1 font-bold">
(
{[g.release?.toUpperCase(), g.version]
.filter(Boolean)
.join(' ')}
)
</span>
)}
</span>
<span className="text-[11px] text-secondary">
{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={`px-2 py-0.5 rounded text-[9px] font-bold border transition-all cursor-pointer ${
allSelected
? 'bg-blueprint/10 text-blueprint border-blueprint/30 hover:bg-blueprint/20'
: 'bg-surface text-secondary border-subtle hover:bg-sunken hover:border-strong'
}`}
>
{allSelected ? '取消全选' : '全选该源'}
</button>
</div>
{/* 候选源表 */}
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead className="text-secondary select-none">
<tr className="bg-surface/50 border-b border-subtle">
<th className="text-left p-2.5 w-10"></th>
<th className="text-left p-2.5">source_id</th>
<th className="text-left p-2.5"></th>
<th className="text-left p-2.5">RA</th>
<th className="text-left p-2.5">Dec</th>
<th className="text-left p-2.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}
onClick={() => toggleUnifiedSelect(key)}
className={`border-b border-subtle last:border-b-0 hover:bg-sunken transition-colors cursor-pointer select-none ${
checked ? 'bg-blueprint/[0.02]' : 'bg-surface'
}`}
>
<td className="p-2.5">
<input
type="checkbox"
checked={checked}
readOnly
className="w-3.5 h-3.5 accent-blueprint pointer-events-none"
/>
</td>
<td className="p-2.5 font-mono text-content">
{c.source_id}
</td>
<td className="p-2.5 text-secondary max-w-32 truncate">
{c.label || '—'}
</td>
<td className="p-2.5 font-mono text-secondary">
{c.ra != null ? c.ra.toFixed(4) : '—'}
</td>
<td className="p-2.5 font-mono text-secondary">
{c.dec != null ? c.dec.toFixed(4) : '—'}
</td>
<td className="p-2.5 font-mono text-secondary">
{c.distance != null
? `${c.distance.toFixed(4)}°`
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
</div>
);
}
// ════════════════════════════════════════════════════════════
// 子组件:下载条
// ════════════════════════════════════════════════════════════
function UnifiedDownloadBar({
selectedCount,
onDownload,
downloading,
results,
}: {
selectedCount: number;
onDownload: () => void;
downloading: boolean;
results: ObservationBatchResult[] | null;
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-sunken rounded-lg">
<button
onClick={onDownload}
disabled={downloading || selectedCount === 0}
className="btn-console bg-emerald-600 border-emerald-600 text-white hover:bg-emerald-500 hover:border-emerald-500 px-4 py-2 text-xs font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 cursor-pointer transition-all"
>
{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-secondary font-bold">
{results.length}
</span>
)}
</div>
{/* 多组结果卡片横向展示 */}
{results && results.length > 0 && (
<div className="space-y-3 animate-in fade-in duration-300">
{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-surface text-content shadow-xs'
: 'text-secondary hover:text-content'
}`}
>
{icon}
{label}
</button>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center gap-2">
<label className="text-[11px] font-semibold text-secondary whitespace-nowrap">
{label}
</label>
{children}
</div>
);
}
/** 解析坐标列表文本(每行 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;
}