// dashboard/src/hooks/useObservation.ts // // 观测数据 Hook —— 检索 / 下载 / 缓存库浏览 / 能力清单 四块状态 // // 后端端点(检索逻辑全部在后端): // GET /api/observation/capabilities —— 返回支持的 (source × product × subtypes) 组合 // GET /api/observation/search —— 按坐标 cone 检索候选源(不下载) // POST /api/observation/download —— 按坐标或标识符下载(写入 observation_cache) // GET /api/observation/list —— 列出已缓存条目(服务端分页 + 筛选) // // 前端只负责表单提交和结果渲染,不做任何筛选/分页/排序计算。 import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import axios from 'axios'; import { extractErrorMessage } from '../utils/apiError'; import type { ObservationRecord, Candidate, CapabilitySpec } from '../types'; import type { ObservationBatchResult, UnifiedSearchRequest, UnifiedSearchResult, ResolvedTarget, ProductSpec, } from '../components/observation/constants'; interface UseObservationProps { isAuthenticated: boolean | null; } // ── 检索表单状态 ── export interface SearchForm { source: string; product: string; subtype: string; ra: string; dec: string; radius: string; release: string; /// 数据发布的子版本(仅 LAMOST 有意义);空串表示用该 DR 的默认子版本 version: string; } export const EMPTY_SEARCH_FORM: SearchForm = { source: 'lamost', product: 'spectrum', subtype: '', ra: '', dec: '', radius: '0.1', release: '', version: '', }; export function useObservation({ isAuthenticated }: UseObservationProps) { // ════════════════════════════════════════════════════ // A. 能力清单(启动时加载一次) // ════════════════════════════════════════════════════ const [capabilities, setCapabilities] = useState([]); const capabilitiesLoadedRef = useRef(false); const fetchCapabilities = useCallback(async () => { if (capabilitiesLoadedRef.current) return; try { const res = await axios.get( '/api/observation/capabilities' ); setCapabilities(res.data ?? []); capabilitiesLoadedRef.current = true; } catch (e) { console.error('加载观测数据能力清单失败', e); } }, []); useEffect(() => { if (isAuthenticated !== true) return; fetchCapabilities(); }, [isAuthenticated, fetchCapabilities]); // ════════════════════════════════════════════════════ // B. 检索(search)—— 坐标 cone 检索候选源 // ════════════════════════════════════════════════════ const [searchForm, setSearchForm] = useState(EMPTY_SEARCH_FORM); // 当前 (source, product) 对应的能力描述(供前端表单派生选项与联动校验) const currentSpec = useMemo(() => { return capabilities.find( (c) => c.source === searchForm.source && c.product === searchForm.product ); }, [capabilities, searchForm.source, searchForm.product]); // 当 source/product 切换后: // 1. 若当前 release 不在新源的版本列表里,则回落到默认版本 // 2. 若当前 radius 超出硬性上限,则回落到建议上限(而非硬上限,避免误导) useEffect(() => { if (!currentSpec) return; const valid = currentSpec.releases; const hardMax = currentSpec.hard_max_radius_deg; const suggested = currentSpec.suggested_max_radius_deg; setSearchForm((f) => { let next = f; // release 回落(仅在该源区分版本时) if (valid.length > 0 && (!f.release || !valid.includes(f.release))) { next = { ...next, release: currentSpec.default_release ?? valid[0] }; } // radius 回落:超出硬性上限时回到建议值(不回到硬上限,避免默认就触发警告) const cur = parseFloat(f.radius); if (!isNaN(cur) && cur > hardMax) { next = { ...next, radius: String(suggested) }; } return next; }); }, [currentSpec]); // 当 release 切换后:若该 release 有子版本列表,则校验当前 version 是否在列表里, // 不在则清空(清空后端用默认子版本)。无子版本概念的源(release_versions 为空)也清空。 useEffect(() => { if (!currentSpec) return; const versionsMap = currentSpec.release_versions ?? {}; const versions = searchForm.release ? versionsMap[searchForm.release] : undefined; setSearchForm((f) => { if (!versions || versions.length === 0) { // 该 release 无子版本概念 return f.version ? { ...f, version: '' } : f; } if (!f.version || !versions.includes(f.version)) { return { ...f, version: '' }; // 清空 = 用默认(最新公开)子版本 } return f; }); }, [currentSpec, searchForm.release]); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [selectedSourceIds, setSelectedSourceIds] = useState>( new Set() ); const runSearch = useCallback(async () => { const ra = parseFloat(searchForm.ra); const dec = parseFloat(searchForm.dec); if (isNaN(ra) || isNaN(dec)) { setSearchError('请输入有效的 ra / dec 坐标'); setSearchResults([]); return; } setSearching(true); setSearchError(null); setSelectedSourceIds(new Set()); try { const res = await axios.get('/api/observation/search', { params: { source: searchForm.source, product: searchForm.product, subtype: searchForm.subtype || undefined, ra, dec, radius: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1), release: searchForm.release || undefined, version: searchForm.version || undefined, }, }); setSearchResults(res.data ?? []); } catch (e: unknown) { setSearchError( extractErrorMessage(e, '检索失败,请检查坐标与数据源参数') ); setSearchResults([]); } finally { setSearching(false); } }, [searchForm]); const toggleSelect = useCallback((sourceId: string) => { setSelectedSourceIds((prev) => { const next = new Set(prev); if (next.has(sourceId)) next.delete(sourceId); else next.add(sourceId); return next; }); }, []); const selectAll = useCallback(() => { setSelectedSourceIds(new Set(searchResults.map((c) => c.source_id))); }, [searchResults]); const selectNone = useCallback(() => { setSelectedSourceIds(new Set()); }, []); // ════════════════════════════════════════════════════ // C. 下载(download)—— 按坐标或标识符 // ════════════════════════════════════════════════════ const [downloading, setDownloading] = useState(false); const [downloadResult, setDownloadResult] = useState(null); const [downloadError, setDownloadError] = useState(null); /** 按标识符列表下载 */ const downloadByIds = useCallback( async (sourceIds: string[]) => { if (sourceIds.length === 0) return; setDownloading(true); setDownloadError(null); try { const res = await axios.post( '/api/observation/download', { source: searchForm.source, product: searchForm.product, subtype: searchForm.subtype || undefined, release: searchForm.release || undefined, version: searchForm.version || undefined, force: false, mode: 'identifiers', source_ids: sourceIds, } ); setDownloadResult(res.data); } catch (e: unknown) { setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试')); } finally { setDownloading(false); } }, [searchForm] ); /** 下载选中的候选源(标识符模式) */ const downloadSelected = useCallback(async () => { const ids = Array.from(selectedSourceIds); if (ids.length === 0) return; await downloadByIds(ids); }, [selectedSourceIds, downloadByIds]); /** 按坐标下载(cone 检索 + 全部下载) */ const downloadByCoordinates = useCallback( async (strategy: 'nearest' | 'all' = 'nearest') => { const ra = parseFloat(searchForm.ra); const dec = parseFloat(searchForm.dec); if (isNaN(ra) || isNaN(dec)) { setDownloadError('请输入有效的 ra / dec 坐标'); return; } setDownloading(true); setDownloadError(null); try { const res = await axios.post( '/api/observation/download', { source: searchForm.source, product: searchForm.product, subtype: searchForm.subtype || undefined, release: searchForm.release || undefined, version: searchForm.version || undefined, force: false, mode: 'coordinates', ra, dec, radius_deg: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1), strategy, } ); setDownloadResult(res.data); } catch (e: unknown) { setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试')); } finally { setDownloading(false); } }, [searchForm] ); // ════════════════════════════════════════════════════ // D. 统一全源检索(unified)—— 多目标 × 多源并发 cone 检索 // ════════════════════════════════════════════════════ const [unifiedResult, setUnifiedResult] = useState(null); const [unifiedSearching, setUnifiedSearching] = useState(false); const [unifiedError, setUnifiedError] = useState(null); // 勾选的候选源:key = `${source}#${product}#${subtype}#${source_id}` const [unifiedSelected, setUnifiedSelected] = useState>( new Set() ); /** 统一检索:跨目标 × 跨源并发 cone 检索 → 聚合候选源 */ const runUnifiedSearch = useCallback(async (req: UnifiedSearchRequest) => { setUnifiedSearching(true); setUnifiedError(null); setUnifiedSelected(new Set()); try { const res = await axios.post( '/api/observation/unified/search', req ); setUnifiedResult(res.data); } catch (e: unknown) { setUnifiedError( extractErrorMessage(e, '统一检索失败,请检查目标与源参数') ); setUnifiedResult(null); } finally { setUnifiedSearching(false); } }, []); /** 批量解析天体名称为坐标(供「天体名称列表」输入模式) */ const resolveNames = useCallback( async (names: string[]): Promise => { if (names.length === 0) return []; try { const res = await axios.post( '/api/observation/unified/resolve', { names } ); return res.data ?? []; } catch (e: unknown) { // 整体失败时,把每个名称标记为失败,便于前端展示 return names.map((n) => ({ name: n, error: extractErrorMessage(e, '名称解析服务不可用'), })); } }, [] ); /** 切换某个候选源的勾选状态 */ const toggleUnifiedSelect = useCallback((key: string) => { setUnifiedSelected((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); /** * 批量下载统一检索中勾选的候选源。 * 按 (source, product) 分组,逐组调用现有 POST /observation/download(mode=identifiers)。 * 返回每组的下载结果(前端用多个 ObservationResultCard 展示)。 */ const downloadUnifiedSelected = useCallback(async () => { if (unifiedSelected.size === 0 || !unifiedResult) return []; // 收集 key → {source, product, subtype, source_id} const groups = new Map< string, { spec: [string, ProductSpec]; ids: string[] } >(); for (const key of unifiedSelected) { const [source, product, subtype, ...idParts] = key.split('#'); const source_id = idParts.join('#'); // source_id 理论上不含 #,但保守处理 const groupKey = `${source}#${product}#${subtype}`; if (!groups.has(groupKey)) { groups.set(groupKey, { spec: [source, { product, subtype: subtype || undefined }], ids: [], }); } groups.get(groupKey)!.ids.push(source_id); } const results: ObservationBatchResult[] = []; setDownloading(true); setDownloadError(null); try { for (const { spec, ids } of groups.values()) { try { const res = await axios.post( '/api/observation/download', { source: spec[0], product: spec[1].product, subtype: spec[1].subtype || undefined, force: false, mode: 'identifiers', source_ids: ids, } ); results.push(res.data); } catch (e: unknown) { // 单组失败不中断,记录为 failures 占位 batch results.push({ source: spec[0], product: spec[1], matched_count: 0, products: [], failures: ids.map((id) => ({ source_label: id, error: extractErrorMessage(e, '下载失败'), })), }); } } // 把最后一组结果存入 downloadResult 供 ObservationResultCard 展示 if (results.length > 0) { setDownloadResult(results[results.length - 1]); } } finally { setDownloading(false); } return results; }, [unifiedSelected, unifiedResult]); // ════════════════════════════════════════════════════ // E. 缓存库(library)—— 服务端分页浏览已下载数据 // ════════════════════════════════════════════════════ const [libraryItems, setLibraryItems] = useState([]); const [libraryTotal, setLibraryTotal] = useState(0); const [libraryLoading, setLibraryLoading] = useState(false); const [libraryError, setLibraryError] = useState(null); // 筛选状态(提交给后端,不在前端筛) const [libSource, setLibSource] = useState('all'); const [libProduct, setLibProduct] = useState('all'); const [libSearch, setLibSearch] = useState(''); const [libSort, setLibSort] = useState<'created' | 'source' | 'product'>( 'created' ); const [libPage, setLibPage] = useState(1); const [libPageSize, setLibPageSize] = useState(12); const fetchLibrary = useCallback(async () => { if (isAuthenticated !== true) return; setLibraryLoading(true); setLibraryError(null); try { const res = await axios.get<{ items: ObservationRecord[]; total: number; }>('/api/observation/list', { params: { source: libSource !== 'all' ? libSource : undefined, product: libProduct !== 'all' ? libProduct : undefined, q: libSearch.trim() || undefined, sort: libSort, limit: libPageSize, offset: (libPage - 1) * libPageSize, }, }); setLibraryItems(res.data.items ?? []); setLibraryTotal(res.data.total ?? 0); } catch (e: unknown) { setLibraryError(extractErrorMessage(e, '加载缓存库失败,请检查后端连接')); setLibraryItems([]); } finally { setLibraryLoading(false); } }, [ isAuthenticated, libSource, libProduct, libSearch, libSort, libPage, libPageSize, ]); // 任意筛选/分页变更 → 重新请求后端 useEffect(() => { fetchLibrary(); }, [fetchLibrary]); const resetLibraryFilters = useCallback(() => { setLibSource('all'); setLibProduct('all'); setLibSearch(''); setLibSort('created'); setLibPage(1); }, []); return { // A. 能力清单 capabilities, currentSpec, fetchCapabilities, // B. 检索 searchForm, setSearchForm, searchResults, searching, searchError, runSearch, selectedSourceIds, toggleSelect, selectAll, selectNone, // C. 下载 downloading, downloadResult, downloadError, setDownloadResult, downloadSelected, downloadByIds, downloadByCoordinates, // D. 统一全源检索 unifiedResult, setUnifiedResult, unifiedSearching, unifiedError, runUnifiedSearch, resolveNames, unifiedSelected, toggleUnifiedSelect, downloadUnifiedSelected, // D. 缓存库 libraryItems, libraryTotal, libraryLoading, libraryError, fetchLibrary, libSource, setLibSource: (v: string) => { setLibSource(v); setLibPage(1); }, libProduct, setLibProduct: (v: string) => { setLibProduct(v); setLibPage(1); }, libSearch, setLibSearch: (v: string) => { setLibSearch(v); setLibPage(1); }, libSort, setLibSort: (v: 'created' | 'source' | 'product') => { setLibSort(v); setLibPage(1); }, libPage, setLibPage, libPageSize, setLibPageSize, resetLibraryFilters, }; }