2025-11-30 05:25:41 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* NASA Data Download Page
|
|
|
|
|
|
* Downloads position data for celestial bodies from NASA Horizons API
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Row,
|
|
|
|
|
|
Col,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Checkbox,
|
|
|
|
|
|
DatePicker,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Spin,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Collapse,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Progress,
|
|
|
|
|
|
Calendar,
|
|
|
|
|
|
Alert
|
|
|
|
|
|
} from 'antd';
|
|
|
|
|
|
import {
|
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
|
CheckCircleOutlined,
|
|
|
|
|
|
CloseCircleOutlined,
|
|
|
|
|
|
LoadingOutlined
|
|
|
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
|
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
|
|
|
|
|
import type { Dayjs } from 'dayjs';
|
|
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
|
|
import isBetween from 'dayjs/plugin/isBetween';
|
|
|
|
|
|
import { request } from '../../utils/request';
|
2025-12-01 08:52:04 +00:00
|
|
|
|
import { useToast } from '../../contexts/ToastContext';
|
2025-12-03 07:13:31 +00:00
|
|
|
|
import { useDataCutoffDate } from '../../hooks/useDataCutoffDate';
|
2025-11-30 05:25:41 +00:00
|
|
|
|
|
|
|
|
|
|
// Extend dayjs with isBetween plugin
|
|
|
|
|
|
dayjs.extend(isBetween);
|
|
|
|
|
|
|
|
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
|
|
const { RangePicker } = DatePicker;
|
|
|
|
|
|
|
|
|
|
|
|
interface CelestialBody {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
name_zh: string;
|
|
|
|
|
|
type: string;
|
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface GroupedBodies {
|
|
|
|
|
|
[type: string]: CelestialBody[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function NASADownload() {
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [bodies, setBodies] = useState<GroupedBodies>({});
|
|
|
|
|
|
const [selectedBodies, setSelectedBodies] = useState<string[]>([]);
|
|
|
|
|
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
2025-11-30 15:25:56 +00:00
|
|
|
|
dayjs().startOf('month'),
|
|
|
|
|
|
dayjs().endOf('month')
|
2025-11-30 05:25:41 +00:00
|
|
|
|
]);
|
|
|
|
|
|
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [loadingDates, setLoadingDates] = useState(false);
|
|
|
|
|
|
const [downloading, setDownloading] = useState(false);
|
|
|
|
|
|
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
|
2025-12-01 08:52:04 +00:00
|
|
|
|
const toast = useToast();
|
2025-11-30 05:25:41 +00:00
|
|
|
|
|
2025-12-03 07:13:31 +00:00
|
|
|
|
// Get data cutoff date
|
|
|
|
|
|
const { cutoffDate } = useDataCutoffDate();
|
|
|
|
|
|
|
2025-11-30 05:25:41 +00:00
|
|
|
|
// Type name mapping
|
|
|
|
|
|
const typeNames: Record<string, string> = {
|
|
|
|
|
|
star: '恒星',
|
|
|
|
|
|
planet: '行星',
|
|
|
|
|
|
dwarf_planet: '矮行星',
|
|
|
|
|
|
satellite: '卫星',
|
|
|
|
|
|
probe: '探测器',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadBodies();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedBodies.length > 0) {
|
|
|
|
|
|
loadAvailableDates();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setAvailableDates(new Set());
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedBodies, dateRange]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadBodies = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await request.get('/celestial/positions/download/bodies');
|
|
|
|
|
|
setBodies(data.bodies || {});
|
|
|
|
|
|
} catch (error) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.error('加载天体列表失败');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadAvailableDates = async () => {
|
|
|
|
|
|
if (selectedBodies.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
setLoadingDates(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const allDates = new Set<string>();
|
|
|
|
|
|
|
|
|
|
|
|
// Load available dates for the first selected body
|
|
|
|
|
|
const bodyId = selectedBodies[0];
|
|
|
|
|
|
const startDate = dateRange[0].format('YYYY-MM-DD');
|
|
|
|
|
|
const endDate = dateRange[1].format('YYYY-MM-DD');
|
|
|
|
|
|
|
|
|
|
|
|
const { data } = await request.get('/celestial/positions/download/status', {
|
|
|
|
|
|
params: {
|
|
|
|
|
|
body_id: bodyId,
|
|
|
|
|
|
start_date: startDate,
|
|
|
|
|
|
end_date: endDate
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
data.available_dates.forEach((date: string) => {
|
|
|
|
|
|
allDates.add(date);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setAvailableDates(allDates);
|
|
|
|
|
|
} catch (error) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.error('加载数据状态失败');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingDates(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBodySelect = (bodyId: string, checked: boolean) => {
|
|
|
|
|
|
setSelectedBodies(prev =>
|
|
|
|
|
|
checked ? [...prev, bodyId] : prev.filter(id => id !== bodyId)
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleTypeSelectAll = (type: string, checked: boolean) => {
|
|
|
|
|
|
const typeBodyIds = bodies[type].map(b => b.id);
|
|
|
|
|
|
setSelectedBodies(prev => {
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
return [...new Set([...prev, ...typeBodyIds])];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return prev.filter(id => !typeBodyIds.includes(id));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDateRangeChange = (dates: any) => {
|
|
|
|
|
|
if (dates && dates[0] && dates[1]) {
|
|
|
|
|
|
setDateRange([dates[0], dates[1]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownload = async (selectedDate?: Dayjs) => {
|
|
|
|
|
|
if (selectedBodies.length === 0) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.warning('请先选择至少一个天体');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let datesToDownload: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedDate) {
|
|
|
|
|
|
// Download single date
|
|
|
|
|
|
datesToDownload = [selectedDate.format('YYYY-MM-DD')];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Download all dates in range
|
|
|
|
|
|
const start = dateRange[0];
|
|
|
|
|
|
const end = dateRange[1];
|
|
|
|
|
|
let current = start;
|
|
|
|
|
|
|
|
|
|
|
|
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
|
|
|
|
|
datesToDownload.push(current.format('YYYY-MM-DD'));
|
|
|
|
|
|
current = current.add(1, 'day');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setDownloading(true);
|
|
|
|
|
|
setDownloadProgress({ current: 0, total: datesToDownload.length });
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-30 16:06:28 +00:00
|
|
|
|
if (selectedDate) {
|
|
|
|
|
|
// Sync download for single date
|
|
|
|
|
|
const { data } = await request.post('/celestial/positions/download', {
|
|
|
|
|
|
body_ids: selectedBodies,
|
|
|
|
|
|
dates: datesToDownload
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setDownloadProgress({ current: datesToDownload.length, total: datesToDownload.length });
|
|
|
|
|
|
|
|
|
|
|
|
if (data.total_success > 0) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.success(`成功下载 ${data.total_success} 条数据${data.total_failed > 0 ? `,${data.total_failed} 条失败` : ''}`);
|
2025-11-30 16:06:28 +00:00
|
|
|
|
loadAvailableDates();
|
|
|
|
|
|
} else {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.error('下载失败');
|
2025-11-30 16:06:28 +00:00
|
|
|
|
}
|
2025-11-30 05:25:41 +00:00
|
|
|
|
} else {
|
2025-11-30 16:06:28 +00:00
|
|
|
|
// Async download for range
|
|
|
|
|
|
await request.post('/celestial/positions/download-async', {
|
|
|
|
|
|
body_ids: selectedBodies,
|
|
|
|
|
|
dates: datesToDownload
|
|
|
|
|
|
});
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.success('后台下载任务已启动,请前往“系统任务”查看进度');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.error('请求失败');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setDownloading(false);
|
|
|
|
|
|
setDownloadProgress({ current: 0, total: 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Custom calendar cell renderer
|
|
|
|
|
|
const dateCellRender = (value: Dayjs) => {
|
|
|
|
|
|
const dateStr = value.format('YYYY-MM-DD');
|
|
|
|
|
|
const hasData = availableDates.has(dateStr);
|
|
|
|
|
|
const inRange = value.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
|
|
|
|
|
|
|
|
|
|
|
if (!inRange) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '4px 0' }}>
|
|
|
|
|
|
{hasData ? (
|
|
|
|
|
|
<Badge status="success" text="" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Badge status="default" text="" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const disabledDate = (current: Dayjs) => {
|
|
|
|
|
|
// Cannot select dates in the future
|
|
|
|
|
|
return current && current.isAfter(dayjs(), 'day');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCalendarDateClick = (date: Dayjs) => {
|
|
|
|
|
|
const dateStr = date.format('YYYY-MM-DD');
|
|
|
|
|
|
const hasData = availableDates.has(dateStr);
|
|
|
|
|
|
const inRange = date.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
|
|
|
|
|
|
|
|
|
|
|
if (!inRange) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.warning('请选择在日期范围内的日期');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasData) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.info('该日期已有数据');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedBodies.length === 0) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.warning('请先选择天体');
|
2025-11-30 05:25:41 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
handleDownload(date);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
2025-12-03 07:13:31 +00:00
|
|
|
|
{/* Data Cutoff Date Display */}
|
|
|
|
|
|
{cutoffDate && (
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
message={`数据截止日期: ${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
|
|
|
|
|
|
description="选择左侧天体,右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据(00:00 UTC)。"
|
|
|
|
|
|
type="success"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-11-30 05:25:41 +00:00
|
|
|
|
|
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
|
{/* Left: Body Selection */}
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Card
|
|
|
|
|
|
title="选择天体"
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
extra={
|
|
|
|
|
|
<Text type="secondary">
|
|
|
|
|
|
已选择: {selectedBodies.length}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Collapse
|
2025-12-03 07:13:31 +00:00
|
|
|
|
defaultActiveKey={[]}
|
2025-11-30 05:25:41 +00:00
|
|
|
|
items={Object.entries(bodies).map(([type, typeBodies]) => ({
|
|
|
|
|
|
key: type,
|
|
|
|
|
|
label: (
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
|
|
|
<span>{typeNames[type] || type}</span>
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={typeBodies.every(b => selectedBodies.includes(b.id))}
|
|
|
|
|
|
indeterminate={
|
|
|
|
|
|
typeBodies.some(b => selectedBodies.includes(b.id)) &&
|
|
|
|
|
|
!typeBodies.every(b => selectedBodies.includes(b.id))
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleTypeSelectAll(type, e.target.checked);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
全选
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
children: (
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
|
|
|
|
{typeBodies.map((body) => (
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
key={body.id}
|
|
|
|
|
|
checked={selectedBodies.includes(body.id)}
|
|
|
|
|
|
onChange={(e) => handleBodySelect(body.id, e.target.checked)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{body.name_zh || body.name}
|
|
|
|
|
|
{!body.is_active && <Badge status="default" text="(未激活)" style={{ marginLeft: 8 }} />}
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
),
|
|
|
|
|
|
}))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Right: Date Selection and Calendar */}
|
|
|
|
|
|
<Col span={16}>
|
|
|
|
|
|
<Card
|
|
|
|
|
|
title="选择下载日期"
|
|
|
|
|
|
extra={
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<RangePicker
|
|
|
|
|
|
value={dateRange}
|
|
|
|
|
|
onChange={handleDateRangeChange}
|
|
|
|
|
|
disabledDate={disabledDate}
|
|
|
|
|
|
format="YYYY-MM-DD"
|
|
|
|
|
|
allowClear={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
icon={<DownloadOutlined />}
|
|
|
|
|
|
onClick={() => handleDownload()}
|
|
|
|
|
|
disabled={selectedBodies.length === 0}
|
|
|
|
|
|
loading={downloading}
|
|
|
|
|
|
>
|
2025-11-30 16:06:28 +00:00
|
|
|
|
下载范围内数据 (后台任务)
|
2025-11-30 05:25:41 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Spin spinning={loadingDates} indicator={<LoadingOutlined spin />}>
|
|
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Badge status="success" text="已有数据" />
|
|
|
|
|
|
<Badge status="default" text="无数据(点击下载)" />
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{downloading && (
|
|
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={Math.round((downloadProgress.current / downloadProgress.total) * 100)}
|
|
|
|
|
|
status="active"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text type="secondary">
|
|
|
|
|
|
正在下载: {downloadProgress.current} / {downloadProgress.total}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Calendar
|
|
|
|
|
|
fullscreen={false}
|
|
|
|
|
|
value={dateRange[0]}
|
|
|
|
|
|
onSelect={handleCalendarDateClick}
|
|
|
|
|
|
cellRender={dateCellRender}
|
|
|
|
|
|
disabledDate={disabledDate}
|
|
|
|
|
|
validRange={[dateRange[0], dateRange[1]]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Spin>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|