384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
/**
|
||
* 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';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
|
||
// 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]>([
|
||
dayjs().startOf('month'),
|
||
dayjs().endOf('month')
|
||
]);
|
||
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 });
|
||
const toast = useToast();
|
||
|
||
// 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) {
|
||
toast.error('加载天体列表失败');
|
||
} 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) {
|
||
toast.error('加载数据状态失败');
|
||
} 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) {
|
||
toast.warning('请先选择至少一个天体');
|
||
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 {
|
||
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) {
|
||
toast.success(`成功下载 ${data.total_success} 条数据${data.total_failed > 0 ? `,${data.total_failed} 条失败` : ''}`);
|
||
loadAvailableDates();
|
||
} else {
|
||
toast.error('下载失败');
|
||
}
|
||
} else {
|
||
// Async download for range
|
||
await request.post('/celestial/positions/download-async', {
|
||
body_ids: selectedBodies,
|
||
dates: datesToDownload
|
||
});
|
||
toast.success('后台下载任务已启动,请前往“系统任务”查看进度');
|
||
}
|
||
} catch (error) {
|
||
toast.error('请求失败');
|
||
} 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) {
|
||
toast.warning('请选择在日期范围内的日期');
|
||
return;
|
||
}
|
||
|
||
if (hasData) {
|
||
toast.info('该日期已有数据');
|
||
return;
|
||
}
|
||
|
||
if (selectedBodies.length === 0) {
|
||
toast.warning('请先选择天体');
|
||
return;
|
||
}
|
||
|
||
handleDownload(date);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<Alert
|
||
title="数据下载说明"
|
||
description="选择左侧天体,右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据(00:00 UTC)。"
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
|
||
<Row gutter={16}>
|
||
{/* Left: Body Selection */}
|
||
<Col span={8}>
|
||
<Card
|
||
title="选择天体"
|
||
loading={loading}
|
||
extra={
|
||
<Text type="secondary">
|
||
已选择: {selectedBodies.length}
|
||
</Text>
|
||
}
|
||
>
|
||
<Collapse
|
||
defaultActiveKey={['planet', 'dwarf_planet']}
|
||
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}
|
||
>
|
||
下载范围内数据 (后台任务)
|
||
</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>
|
||
);
|
||
}
|