feat: LAMOST DR12-14 与子版本体系接入、观测层安全加固与并发异步化
- LAMOST 新增 DR12/13/14 及子版本(v0/v1.0/v1.1/v2.0)维度,Internal 发布标记需登录认证并前端灰显,release×subtype 交叉约束下沉至 capabilities 统一声明 - ObservationFetcher trait 扩展版本/认证/交叉约束能力声明,version 参数贯穿 client→service→API→Agent tool→前端全链路 - 安全:observation cache SQL 全参数绑定 + LIKE 转义、cone_cache_hash 加长度前缀防碰撞、DESI survey/program 白名单防穿越 - 异步化:persist/cached_files_total_size/maybe_persist_tool_result迁移到 tokio::fs;cancelled_runs 与 session_permission_checkers改用 DashMap;auth 读锁优先 + 60s 节流 - Gaia 去 native-tls 改禁用连接池规避 UnexpectedEof,reqwest 移除 native-tls feature - 重构:Source/ProductType from_str 集中解析、download 模块拆分为 try_download_pdf/html、AgentRuntime::init 抽取共享逻辑 - 部署:新增 deploy.sh 一键打包推送脚本、catch-panic 启用
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!-- iOS PWA 独立全屏支持 -->
|
||||
|
||||
@@ -61,7 +61,13 @@ export default function App() {
|
||||
|
||||
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'observation' | 'agent'
|
||||
| 'search'
|
||||
| 'library'
|
||||
| 'reader'
|
||||
| 'citation'
|
||||
| 'sync'
|
||||
| 'observation'
|
||||
| 'agent'
|
||||
>(() => {
|
||||
const saved = localStorage.getItem('astro_active_tab');
|
||||
const validTabs = [
|
||||
|
||||
@@ -642,7 +642,9 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (typeof val === 'number') {
|
||||
// 数字保留合理精度
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(6).replace(/\.?0+$/, '');
|
||||
return Number.isInteger(val)
|
||||
? String(val)
|
||||
: val.toFixed(6).replace(/\.?0+$/, '');
|
||||
}
|
||||
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||||
return String(val);
|
||||
@@ -703,11 +705,17 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
<th
|
||||
key={i}
|
||||
className="px-2 py-1.5 text-left font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap"
|
||||
title={f.description || f.unit ? `${f.description || ''} ${f.unit ? `[${f.unit}]` : ''}`.trim() : undefined}
|
||||
title={
|
||||
f.description || f.unit
|
||||
? `${f.description || ''} ${f.unit ? `[${f.unit}]` : ''}`.trim()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{f.name}
|
||||
{f.unit && (
|
||||
<span className="text-slate-400 font-normal ml-1">[{f.unit}]</span>
|
||||
<span className="text-slate-400 font-normal ml-1">
|
||||
[{f.unit}]
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
@@ -744,7 +752,6 @@ export function VizierResultCard({ metadata }: VizierResultCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// 6. 统一观测数据下载结果卡片 (find_observation)
|
||||
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image)
|
||||
|
||||
@@ -108,7 +108,12 @@ export function ToolCallCard({
|
||||
metadata={
|
||||
metadata as {
|
||||
table_name?: string;
|
||||
fields: { name: string; description?: string; unit?: string; datatype?: string }[];
|
||||
fields: {
|
||||
name: string;
|
||||
description?: string;
|
||||
unit?: string;
|
||||
datatype?: string;
|
||||
}[];
|
||||
rows: unknown[][];
|
||||
row_count: number;
|
||||
truncated: boolean;
|
||||
|
||||
@@ -7,12 +7,7 @@
|
||||
// 2. (可选)SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
|
||||
//
|
||||
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { Activity, CheckCircle2, AlertTriangle, Download } from 'lucide-react';
|
||||
import {
|
||||
SOURCE_THEME,
|
||||
PRODUCT_LABEL,
|
||||
@@ -119,8 +114,7 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) {
|
||||
{a.band}
|
||||
</span>
|
||||
)}
|
||||
{a.file_format.toUpperCase()} ·{' '}
|
||||
{formatFileSize(a.size_bytes)}
|
||||
{a.file_format.toUpperCase()} · {formatFileSize(a.size_bytes)}
|
||||
</span>
|
||||
<a
|
||||
href={a.file_url}
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
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 { ObservationRecord, Candidate, CapabilitySpec } from '../types';
|
||||
import type { ObservationBatchResult } from '../components/observation/constants';
|
||||
|
||||
interface UseObservationProps {
|
||||
@@ -32,6 +28,8 @@ export interface SearchForm {
|
||||
dec: string;
|
||||
radius: string;
|
||||
release: string;
|
||||
/// 数据发布的子版本(仅 LAMOST 有意义);空串表示用该 DR 的默认子版本
|
||||
version: string;
|
||||
}
|
||||
|
||||
export const EMPTY_SEARCH_FORM: SearchForm = {
|
||||
@@ -42,6 +40,7 @@ export const EMPTY_SEARCH_FORM: SearchForm = {
|
||||
dec: '',
|
||||
radius: '0.1',
|
||||
release: '',
|
||||
version: '',
|
||||
};
|
||||
|
||||
export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
@@ -65,7 +64,9 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated === true) fetchCapabilities();
|
||||
if (isAuthenticated !== true) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
|
||||
fetchCapabilities();
|
||||
}, [isAuthenticated, fetchCapabilities]);
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -88,6 +89,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
const valid = currentSpec.releases;
|
||||
const hardMax = currentSpec.hard_max_radius_deg;
|
||||
const suggested = currentSpec.suggested_max_radius_deg;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived form sync on spec change
|
||||
setSearchForm((f) => {
|
||||
let next = f;
|
||||
// release 回落(仅在该源区分版本时)
|
||||
@@ -103,6 +105,27 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
});
|
||||
}, [currentSpec]);
|
||||
|
||||
// 当 release 切换后:若该 release 有子版本列表,则校验当前 version 是否在列表里,
|
||||
// 不在则清空(清空后端用默认子版本)。无子版本概念的源(release_versions 为空)也清空。
|
||||
useEffect(() => {
|
||||
if (!currentSpec) return;
|
||||
const versionsMap = currentSpec.release_versions ?? {};
|
||||
const versions = searchForm.release
|
||||
? versionsMap[searchForm.release]
|
||||
: undefined;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- derived version reset on release change
|
||||
setSearchForm((f) => {
|
||||
if (!versions || versions.length === 0) {
|
||||
// 该 release 无子版本概念
|
||||
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<Candidate[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
@@ -131,6 +154,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
dec,
|
||||
radius: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
|
||||
release: searchForm.release || undefined,
|
||||
version: searchForm.version || undefined,
|
||||
},
|
||||
});
|
||||
setSearchResults(res.data ?? []);
|
||||
@@ -169,13 +193,6 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
useState<ObservationBatchResult | null>(null);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
/** 下载选中的候选源(标识符模式) */
|
||||
const downloadSelected = useCallback(async () => {
|
||||
const ids = Array.from(selectedSourceIds);
|
||||
if (ids.length === 0) return;
|
||||
await downloadByIds(ids);
|
||||
}, [selectedSourceIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** 按标识符列表下载 */
|
||||
const downloadByIds = useCallback(
|
||||
async (sourceIds: string[]) => {
|
||||
@@ -190,6 +207,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
product: searchForm.product,
|
||||
subtype: searchForm.subtype || undefined,
|
||||
release: searchForm.release || undefined,
|
||||
version: searchForm.version || undefined,
|
||||
force: false,
|
||||
mode: 'identifiers',
|
||||
source_ids: sourceIds,
|
||||
@@ -205,6 +223,13 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
[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') => {
|
||||
@@ -224,6 +249,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
product: searchForm.product,
|
||||
subtype: searchForm.subtype || undefined,
|
||||
release: searchForm.release || undefined,
|
||||
version: searchForm.version || undefined,
|
||||
force: false,
|
||||
mode: 'coordinates',
|
||||
ra,
|
||||
@@ -265,25 +291,23 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
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,
|
||||
},
|
||||
}
|
||||
);
|
||||
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, '加载缓存库失败,请检查后端连接')
|
||||
);
|
||||
setLibraryError(extractErrorMessage(e, '加载缓存库失败,请检查后端连接'));
|
||||
setLibraryItems([]);
|
||||
} finally {
|
||||
setLibraryLoading(false);
|
||||
@@ -300,6 +324,7 @@ export function useObservation({ isAuthenticated }: UseObservationProps) {
|
||||
|
||||
// 任意筛选/分页变更 → 重新请求后端
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState in microtask
|
||||
fetchLibrary();
|
||||
}, [fetchLibrary]);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Database,
|
||||
Send,
|
||||
RotateCw,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { ObservationResultCard } from '../components/observation/ObservationResultCard';
|
||||
@@ -56,11 +57,13 @@ interface ObservationPanelProps {
|
||||
selectNone: () => void;
|
||||
// 下载
|
||||
downloading: boolean;
|
||||
downloadResult: import('../components/observation/constants').ObservationBatchResult | null;
|
||||
downloadResult:
|
||||
import('../components/observation/constants').ObservationBatchResult | null;
|
||||
downloadError: string | null;
|
||||
setDownloadResult: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
import('../components/observation/constants').ObservationBatchResult | null
|
||||
| import('../components/observation/constants').ObservationBatchResult
|
||||
| null
|
||||
>
|
||||
>;
|
||||
downloadSelected: () => Promise<void>;
|
||||
@@ -195,8 +198,7 @@ function SearchView({
|
||||
|
||||
const subtypeOptions = useMemo(() => {
|
||||
const cap = capabilities.find(
|
||||
(c) =>
|
||||
c.source === searchForm.source && c.product === searchForm.product
|
||||
(c) => c.source === searchForm.source && c.product === searchForm.product
|
||||
);
|
||||
return (cap?.subtypes ?? []).map((s) => ({ value: s, label: s }));
|
||||
}, [capabilities, searchForm.source, searchForm.product]);
|
||||
@@ -208,22 +210,42 @@ function SearchView({
|
||||
|
||||
// 当前源+产品支持的版本列表(供下拉)
|
||||
const releaseOptions = useMemo(() => {
|
||||
const authRequired = new Set(currentSpec?.releases_requiring_auth ?? []);
|
||||
return (currentSpec?.releases ?? []).map((r) => ({
|
||||
value: r,
|
||||
label: r.toUpperCase(),
|
||||
label: authRequired.has(r)
|
||||
? `${r.toUpperCase()} · 需登录`
|
||||
: r.toUpperCase(),
|
||||
}));
|
||||
}, [currentSpec]);
|
||||
|
||||
// LAMOST MRS 联动约束:MRS(中分辨率)从 DR7 起才支持,DR5/DR6 无 MRS 数据
|
||||
const lamostMrsBlocked = useMemo(() => {
|
||||
if (searchForm.source !== 'lamost' || searchForm.subtype !== 'mrs')
|
||||
return null;
|
||||
const rel = searchForm.release;
|
||||
if (rel === 'dr5' || rel === 'dr6') {
|
||||
return 'LAMOST 中分辨率(MRS)从 DR7 起才支持,当前版本无 MRS 数据';
|
||||
// 当前选中的 release 是否需要登录(Internal DR)
|
||||
const currentReleaseNeedsAuth = useMemo(() => {
|
||||
if (!searchForm.release || !currentSpec) return false;
|
||||
return (currentSpec.releases_requiring_auth ?? []).includes(
|
||||
searchForm.release
|
||||
);
|
||||
}, [searchForm.release, currentSpec]);
|
||||
|
||||
// 交叉约束:当前 release 是否支持当前 subtype(从 capabilities 派生,不硬编码)
|
||||
const subtypeBlocked = useMemo(() => {
|
||||
if (!currentSpec || !searchForm.release || !searchForm.subtype) return null;
|
||||
const map = currentSpec.subtypes_per_release;
|
||||
if (!map || Object.keys(map).length === 0) return null; // 无交叉约束
|
||||
const valid = map[searchForm.release];
|
||||
if (valid && !valid.includes(searchForm.subtype)) {
|
||||
return `'${searchForm.release.toUpperCase()}' 不支持子类型 '${searchForm.subtype}'`;
|
||||
}
|
||||
return null;
|
||||
}, [searchForm.source, searchForm.subtype, searchForm.release]);
|
||||
}, [currentSpec, searchForm.release, searchForm.subtype]);
|
||||
|
||||
// 当前 release 对应的子版本列表(仅 LAMOST 有内容)
|
||||
const versionOptions = useMemo(() => {
|
||||
if (!currentSpec || !searchForm.release) return [];
|
||||
const versionsMap = currentSpec.release_versions ?? {};
|
||||
const versions = versionsMap[searchForm.release] ?? [];
|
||||
return versions.map((v) => ({ value: v, label: v.toUpperCase() }));
|
||||
}, [currentSpec, searchForm.release]);
|
||||
|
||||
const handleSubmitIds = () => {
|
||||
const ids = idsText
|
||||
@@ -275,7 +297,7 @@ function SearchView({
|
||||
.map((c) => c.product);
|
||||
const safeProduct = newProducts.includes(f.product)
|
||||
? f.product
|
||||
: newProducts[0] ?? f.product;
|
||||
: (newProducts[0] ?? f.product);
|
||||
return { ...f, source: v, product: safeProduct, subtype: '' };
|
||||
});
|
||||
}}
|
||||
@@ -331,27 +353,67 @@ function SearchView({
|
||||
}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: '__none__', label: `默认${currentSpec?.default_release ? `(${currentSpec.default_release.toUpperCase()})` : ''}` },
|
||||
{
|
||||
value: '__none__',
|
||||
label: `默认${currentSpec?.default_release ? `(${currentSpec.default_release.toUpperCase()})` : ''}`,
|
||||
},
|
||||
...releaseOptions,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{/* 子版本(仅 LAMOST 有内容;其他源该 Field 不渲染) */}
|
||||
{versionOptions.length > 0 && (
|
||||
<Field label="子版本">
|
||||
<CustomSelect
|
||||
value={searchForm.version || '__none__'}
|
||||
onChange={(v) =>
|
||||
setSearchForm((f) => ({
|
||||
...f,
|
||||
version: v === '__none__' ? '' : v,
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: '__none__', label: '默认(最新公开)' },
|
||||
...versionOptions,
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LAMOST MRS 版本约束提示 */}
|
||||
{lamostMrsBlocked && (
|
||||
{/* Internal DR 需登录提示 */}
|
||||
{currentReleaseNeedsAuth && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-700 bg-rose-50 border border-rose-200 rounded-md p-2.5">
|
||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
{searchForm.release.toUpperCase()} 当前为 Internal 数据发布,需经
|
||||
oauth.china-vo.org 登录认证后才能访问,公开检索暂不可用
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 交叉约束提示:当前 release 不支持该 subtype */}
|
||||
{subtypeBlocked && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{lamostMrsBlocked}</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSearchForm((f) => ({ ...f, subtype: 'lrs' }))
|
||||
}
|
||||
className="ml-auto px-2 py-0.5 rounded bg-amber-100 hover:bg-amber-200 text-amber-800 font-bold text-[10px]"
|
||||
>
|
||||
切换为 LRS
|
||||
</button>
|
||||
<span>{subtypeBlocked}</span>
|
||||
{currentSpec?.subtypes && currentSpec.subtypes.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// 切换到该 release 支持的首个 subtype
|
||||
const map = currentSpec?.subtypes_per_release;
|
||||
const valid =
|
||||
(map && searchForm.release && map[searchForm.release]) ||
|
||||
currentSpec?.subtypes;
|
||||
setSearchForm((f) => ({ ...f, subtype: valid?.[0] ?? '' }));
|
||||
}}
|
||||
className="ml-auto px-2 py-0.5 rounded bg-amber-100 hover:bg-amber-200 text-amber-800 font-bold text-[10px]"
|
||||
>
|
||||
切换可用子类型
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -410,14 +472,15 @@ function SearchView({
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
当前半径 {searchForm.radius}° 超出建议值(≤
|
||||
{currentSpec.suggested_max_radius_deg}°),大范围查询可能因主表过大被服务端超时拒绝
|
||||
{currentSpec.suggested_max_radius_deg}
|
||||
°),大范围查询可能因主表过大被服务端超时拒绝
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={runSearch}
|
||||
disabled={searching}
|
||||
disabled={searching || currentReleaseNeedsAuth}
|
||||
className="btn-console btn-console-primary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{searching ? (
|
||||
@@ -432,7 +495,7 @@ function SearchView({
|
||||
</span>
|
||||
<button
|
||||
onClick={() => downloadByCoordinates('nearest')}
|
||||
disabled={downloading}
|
||||
disabled={downloading || currentReleaseNeedsAuth}
|
||||
className="ml-auto px-3 py-2 rounded-md text-xs font-bold flex items-center gap-1.5 bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
@@ -786,7 +849,10 @@ function LibraryView({
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{libraryItems.map((rec) => (
|
||||
<LibraryCard key={`${rec.source}|${rec.product}|${rec.source_id}`} rec={rec} />
|
||||
<LibraryCard
|
||||
key={`${rec.source}|${rec.product}|${rec.source_id}`}
|
||||
rec={rec}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -863,7 +929,11 @@ function Field({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={compact ? 'w-full sm:w-36 space-y-1.5 font-bold' : 'space-y-1.5'}>
|
||||
<div
|
||||
className={
|
||||
compact ? 'w-full sm:w-36 space-y-1.5 font-bold' : 'space-y-1.5'
|
||||
}
|
||||
>
|
||||
<label className="block text-slate-500 font-bold text-xs">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
@@ -894,7 +964,12 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
const theme = SOURCE_THEME[rec.source] ?? SOURCE_THEME.lamost;
|
||||
const productLabel = PRODUCT_LABEL[rec.product] ?? rec.product;
|
||||
|
||||
let artifacts: Array<{ path: string; format: string; size?: number; band?: string }> = [];
|
||||
let artifacts: Array<{
|
||||
path: string;
|
||||
format: string;
|
||||
size?: number;
|
||||
band?: string;
|
||||
}> = [];
|
||||
try {
|
||||
const parsed = JSON.parse(rec.artifacts_json);
|
||||
if (Array.isArray(parsed)) artifacts = parsed;
|
||||
@@ -910,7 +985,9 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-3.5 shadow-xs hover:shadow-md hover:border-slate-300 transition-all">
|
||||
<div className="flex items-center justify-between mb-2 pb-2 border-b border-slate-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}
|
||||
>
|
||||
{theme.label}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
|
||||
@@ -927,7 +1004,10 @@ function LibraryCard({ rec }: { rec: ObservationRecord }) {
|
||||
<div className="text-[9px] font-bold text-slate-400 tracking-widest uppercase">
|
||||
Source ID
|
||||
</div>
|
||||
<div className="font-mono text-xs text-slate-800 font-semibold truncate" title={rec.source_id}>
|
||||
<div
|
||||
className="font-mono text-xs text-slate-800 font-semibold truncate"
|
||||
title={rec.source_id}
|
||||
>
|
||||
{rec.source_id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,6 +250,12 @@ export interface CapabilitySpec {
|
||||
subtypes: string[];
|
||||
releases: string[];
|
||||
default_release?: string | null;
|
||||
/// 每个 release 对应的子版本列表(仅 LAMOST 有内容,如 {dr11: ["v0","v1.0","v1.1","v2.0"]})
|
||||
release_versions?: Record<string, string[]>;
|
||||
/// 需要登录认证才能访问的 release 列表(如 LAMOST Internal DR12-14),空表示全部公开
|
||||
releases_requiring_auth?: string[];
|
||||
/// 每个 release 实际支持的 subtype(交叉约束,如 LAMOST DR5/6 无 MRS);空表示全部支持
|
||||
subtypes_per_release?: Record<string, string[]>;
|
||||
suggested_max_radius_deg: number;
|
||||
hard_max_radius_deg: number;
|
||||
identifier_format?: string | null;
|
||||
|
||||
@@ -16,10 +16,7 @@ interface AxiosLikeError {
|
||||
///
|
||||
/// 优先级:response.data 为字符串 → 对象里的 error/message/detail/description
|
||||
/// → 整体 JSON 序列化兜底 → axios message → fallback
|
||||
export function extractErrorMessage(
|
||||
e: unknown,
|
||||
fallback: string
|
||||
): string {
|
||||
export function extractErrorMessage(e: unknown, fallback: string): string {
|
||||
const axiosError = e as AxiosLikeError;
|
||||
const data = axiosError?.response?.data;
|
||||
if (typeof data === 'string') return data;
|
||||
|
||||
Reference in New Issue
Block a user