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

384 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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>
);
}