- 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 单次密码复用
1372 lines
50 KiB
TypeScript
1372 lines
50 KiB
TypeScript
// 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;
|
||
}
|