cosmo/frontend/src/pages/admin/NASADownload.tsx

391 lines
12 KiB
TypeScript
Raw Normal View History

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';
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]>([
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 });
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) {
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) {
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) {
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 {
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('下载失败');
}
2025-11-30 05:25:41 +00:00
} else {
// Async download for range
await request.post('/celestial/positions/download-async', {
body_ids: selectedBodies,
dates: datesToDownload
});
toast.success('后台下载任务已启动,请前往“系统任务”查看进度');
2025-11-30 05:25:41 +00:00
}
} catch (error) {
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) {
toast.warning('请选择在日期范围内的日期');
2025-11-30 05:25:41 +00:00
return;
}
if (hasData) {
toast.info('该日期已有数据');
2025-11-30 05:25:41 +00:00
return;
}
if (selectedBodies.length === 0) {
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 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>
);
}