1061 lines
37 KiB
TypeScript
1061 lines
37 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col, Tabs, Card, Tooltip } from 'antd';
|
||
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined, StarOutlined } from '@ant-design/icons';
|
||
import type { UploadFile } from 'antd/es/upload/interface';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import MdEditor from 'react-markdown-editor-lite';
|
||
import MarkdownIt from 'markdown-it';
|
||
import 'react-markdown-editor-lite/lib/index.css';
|
||
|
||
import { DataTable } from '../../components/admin/DataTable';
|
||
import { request } from '../../utils/request';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
|
||
const MdEditorParser = new MarkdownIt();
|
||
|
||
interface CelestialBody {
|
||
id: string;
|
||
name: string;
|
||
name_zh: string;
|
||
short_name?: string; // NASA SBDB API short name
|
||
type: string;
|
||
system_id?: number;
|
||
description: string;
|
||
details?: string; // Added details field
|
||
is_active: boolean;
|
||
extra_data?: {
|
||
orbit_period_days?: number;
|
||
orbit_color?: string;
|
||
[key: string]: any; // Allow other extra data
|
||
};
|
||
resources?: {
|
||
[key: string]: Array<{
|
||
id: number;
|
||
file_path: string;
|
||
file_size: number;
|
||
mime_type: string;
|
||
}>;
|
||
};
|
||
has_resources?: boolean;
|
||
orbit_info?: {
|
||
num_points: number;
|
||
period_days?: number;
|
||
};
|
||
}
|
||
|
||
interface StarSystem {
|
||
id: number;
|
||
name: string;
|
||
name_zh: string;
|
||
planet_count: number;
|
||
}
|
||
|
||
export function CelestialBodies() {
|
||
const [loading, setLoading] = useState(false);
|
||
const [data, setData] = useState<CelestialBody[]>([]);
|
||
const [filteredData, setFilteredData] = useState<CelestialBody[]>([]);
|
||
const [starSystems, setStarSystems] = useState<StarSystem[]>([]);
|
||
const [selectedSystemId, setSelectedSystemId] = useState<number | null>(1); // 默认选择太阳系
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingRecord, setEditingRecord] = useState<CelestialBody | null>(null);
|
||
const [form] = Form.useForm();
|
||
const [searching, setSearching] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [uploading, setUploading] = useState(false);
|
||
const [refreshResources, setRefreshResources] = useState(0);
|
||
const [activeTabKey, setActiveTabKey] = useState('basic'); // State for active tab
|
||
const toast = useToast();
|
||
|
||
// 加载恒星系统列表
|
||
useEffect(() => {
|
||
loadStarSystems();
|
||
}, []);
|
||
|
||
// 当选择的恒星系改变时,重新加载天体数据
|
||
useEffect(() => {
|
||
if (selectedSystemId !== null) {
|
||
loadData();
|
||
}
|
||
}, [selectedSystemId]);
|
||
|
||
const loadStarSystems = async () => {
|
||
try {
|
||
const { data: result } = await request.get('/star-systems', {
|
||
params: { limit: 1000 }
|
||
});
|
||
setStarSystems(result.systems || []);
|
||
} catch (error) {
|
||
toast.error('加载恒星系统列表失败');
|
||
}
|
||
};
|
||
|
||
const loadData = async () => {
|
||
if (selectedSystemId === null) {
|
||
setData([]);
|
||
setFilteredData([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const { data: result } = await request.get('/celestial/list', {
|
||
params: { system_id: selectedSystemId }
|
||
});
|
||
setData(result.bodies || []);
|
||
setFilteredData(result.bodies || []);
|
||
} catch (error) {
|
||
toast.error('加载数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Search handler
|
||
const handleSearch = (keyword: string) => {
|
||
const lowerKeyword = keyword.toLowerCase();
|
||
const filtered = data.filter(
|
||
(item) =>
|
||
item.name.toLowerCase().includes(lowerKeyword) ||
|
||
item.name_zh?.toLowerCase().includes(lowerKeyword) ||
|
||
item.id.includes(lowerKeyword)
|
||
);
|
||
setFilteredData(filtered);
|
||
};
|
||
|
||
// Add handler
|
||
const handleAdd = () => {
|
||
setEditingRecord(null);
|
||
form.resetFields();
|
||
setSearchQuery('');
|
||
setActiveTabKey('basic'); // Reset to basic tab
|
||
// Default values
|
||
form.setFieldsValue({
|
||
is_active: true,
|
||
type: 'probe',
|
||
system_id: selectedSystemId // Auto-set to current selected system
|
||
});
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
// Search NASA Horizons by name
|
||
const handleNASASearch = async () => {
|
||
if (!searchQuery.trim()) {
|
||
toast.warning('请输入天体名称或ID');
|
||
return;
|
||
}
|
||
|
||
setSearching(true);
|
||
try {
|
||
const { data: result } = await request.get('/celestial/search', {
|
||
params: { name: searchQuery }
|
||
});
|
||
|
||
if (result.success) {
|
||
// Check if this body already exists in our database
|
||
const existingBody = data.find(b => b.id === result.data.id);
|
||
|
||
if (existingBody) {
|
||
Modal.warning({
|
||
title: '天体已存在',
|
||
content: (
|
||
<div>
|
||
<p>找到天体: <strong>{result.data.full_name}</strong></p>
|
||
<p>ID: <strong>{result.data.id}</strong></p>
|
||
<p style={{ color: '#faad14', marginTop: '10px' }}>
|
||
⚠️ 该天体已在数据库中,名称为: <strong>{existingBody.name}</strong>
|
||
</p>
|
||
<p style={{ fontSize: '12px', color: '#888' }}>
|
||
如需修改,请在列表中直接编辑该天体。
|
||
</p>
|
||
</div>
|
||
),
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Auto-fill form with search results
|
||
form.setFieldsValue({
|
||
id: result.data.id,
|
||
name: result.data.name,
|
||
});
|
||
|
||
// Check if ID looks like it might not be a proper numeric ID
|
||
const isNumericId = /^-?\d+$/.test(result.data.id);
|
||
|
||
if (isNumericId) {
|
||
toast.success(`找到天体: ${result.data.full_name}`);
|
||
} else {
|
||
// Warn user that ID might need manual correction
|
||
Modal.warning({
|
||
title: '找到天体,但请确认 ID',
|
||
content: (
|
||
<div>
|
||
<p>找到天体: <strong>{result.data.full_name}</strong></p>
|
||
<p>自动填充的 ID 为: <strong>{result.data.id}</strong></p>
|
||
<p style={{ color: '#faad14' }}>
|
||
⚠️ 建议: 如果知道该天体的数字 ID,请手动修改为数字 ID(如:-48)以便后续查询位置数据。
|
||
</p>
|
||
<p style={{ fontSize: '12px', color: '#888' }}>
|
||
提示:您可以在 <a href="https://ssd.jpl.nasa.gov/horizons/" target="_blank" rel="noopener noreferrer">NASA Horizons</a> 网站查询准确的数字 ID
|
||
</p>
|
||
</div>
|
||
),
|
||
});
|
||
}
|
||
} else {
|
||
toast.error(result.error || '查询失败');
|
||
}
|
||
} catch (error: any) {
|
||
toast.error(error.response?.data?.detail || '查询失败');
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
};
|
||
|
||
// Edit handler
|
||
const handleEdit = async (record: CelestialBody) => {
|
||
setEditingRecord(record);
|
||
|
||
// Parse extra_data if it's a string (from backend JSON field)
|
||
let extraData = record.extra_data;
|
||
if (typeof extraData === 'string') {
|
||
try {
|
||
extraData = JSON.parse(extraData);
|
||
} catch (e) {
|
||
console.error('Failed to parse extra_data:', e);
|
||
extraData = {};
|
||
}
|
||
}
|
||
|
||
// Fetch orbit information if exists
|
||
let orbitInfo = null;
|
||
try {
|
||
const { data: orbitData } = await request.get(`/celestial/orbits/${record.id}`);
|
||
if (orbitData) {
|
||
orbitInfo = {
|
||
num_points: orbitData.num_points,
|
||
period_days: orbitData.period_days
|
||
};
|
||
console.log('Loaded orbit info for', record.id, orbitInfo);
|
||
}
|
||
} catch (e) {
|
||
// Orbit not found or error - this is fine, not all bodies have orbits
|
||
console.log('No orbit data for', record.id);
|
||
}
|
||
|
||
// Properly set form values including nested extra_data and orbit info
|
||
const formValues = {
|
||
...record,
|
||
extra_data: extraData || {}, // Ensure extra_data is an object
|
||
orbit_info: orbitInfo
|
||
};
|
||
|
||
form.setFieldsValue(formValues);
|
||
|
||
setActiveTabKey('basic'); // Reset to basic tab
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
// Delete handler
|
||
const handleDelete = async (record: CelestialBody) => {
|
||
try {
|
||
await request.delete(`/celestial/${record.id}`);
|
||
toast.success('删除成功');
|
||
loadData();
|
||
} catch (error) {
|
||
toast.error('删除失败');
|
||
}
|
||
};
|
||
|
||
// Status change handler
|
||
const handleStatusChange = async (record: CelestialBody, checked: boolean) => {
|
||
try {
|
||
await request.put(`/celestial/${record.id}`, { is_active: checked });
|
||
toast.success(`状态更新成功`);
|
||
// Update local state to avoid full reload
|
||
const newData = data.map(item =>
|
||
item.id === record.id ? { ...item, is_active: checked } : item
|
||
);
|
||
setData(newData);
|
||
setFilteredData(newData); // Should re-filter if needed, but simplistic here
|
||
} catch (error) {
|
||
toast.error('状态更新失败');
|
||
}
|
||
};
|
||
|
||
// Form submit
|
||
const handleModalOk = async () => {
|
||
let values: any;
|
||
try {
|
||
values = await form.validateFields();
|
||
|
||
if (editingRecord) {
|
||
// Update
|
||
await request.put(`/celestial/${editingRecord.id}`, values);
|
||
toast.success('更新成功');
|
||
} else {
|
||
// Create
|
||
await request.post('/celestial/', values);
|
||
toast.success('创建成功');
|
||
}
|
||
|
||
setIsModalOpen(false);
|
||
loadData();
|
||
} catch (error: any) {
|
||
console.error(error);
|
||
// Check for specific error messages
|
||
if (error.response?.status === 400) {
|
||
const detail = error.response?.data?.detail;
|
||
if (detail && detail.includes('already exists')) {
|
||
toast.error(`天体已存在: ${values?.id || ''}`);
|
||
} else {
|
||
toast.error(detail || '请检查表单数据是否完整');
|
||
}
|
||
} else if (error.errorFields) {
|
||
// Validation error
|
||
toast.error('请填写所有必填字段');
|
||
} else {
|
||
toast.error(error.response?.data?.detail || '操作失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
// Handle resource upload
|
||
const handleResourceUpload = async (file: File, resourceType: string) => {
|
||
if (!editingRecord) {
|
||
toast.error('请先选择要编辑的天体');
|
||
return false;
|
||
}
|
||
|
||
setUploading(true);
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
try {
|
||
const response = await request.post(
|
||
`/celestial/resources/upload?body_id=${editingRecord.id}&resource_type=${resourceType}`,
|
||
formData,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
}
|
||
);
|
||
|
||
toast.success(`${response.data.message} (上传到 ${response.data.upload_directory} 目录)`);
|
||
// Trigger a refresh of resources
|
||
setRefreshResources(prev => prev + 1);
|
||
return false; // Prevent default upload behavior
|
||
} catch (error: any) {
|
||
toast.error(error.response?.data?.detail || '上传失败');
|
||
return false;
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
// Generate orbit for a celestial body
|
||
const handleGenerateOrbit = async (record: CelestialBody) => {
|
||
if (!['planet', 'dwarf_planet'].includes(record.type)) {
|
||
toast.warning('只有行星和矮行星可以生成轨道');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
await request.post(
|
||
`/celestial/admin/orbits/generate?body_ids=${record.id}`
|
||
);
|
||
|
||
// 提示用户任务已启动
|
||
toast.success('轨道生成任务已启动,请前往"系统任务"查看进度', 5000);
|
||
} catch (error: any) {
|
||
toast.error(error.response?.data?.detail || '轨道生成任务启动失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Handle resource delete
|
||
const handleResourceDelete = async (resourceId: number) => {
|
||
try {
|
||
await request.delete(`/celestial/resources/${resourceId}`);
|
||
toast.success('删除成功');
|
||
setRefreshResources(prev => prev + 1); // Trigger reload
|
||
} catch (error: any) {
|
||
toast.error(error.response?.data?.detail || '删除失败');
|
||
}
|
||
};
|
||
|
||
const columns: ColumnsType<CelestialBody> = [
|
||
{
|
||
title: 'ID',
|
||
dataIndex: 'id',
|
||
key: 'id',
|
||
width: 100,
|
||
sorter: (a, b) => a.id.localeCompare(b.id),
|
||
},
|
||
{
|
||
title: '英文名',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||
},
|
||
{
|
||
title: '中文名',
|
||
dataIndex: 'name_zh',
|
||
key: 'name_zh',
|
||
},
|
||
{
|
||
title: '所属系统',
|
||
dataIndex: 'system_id',
|
||
key: 'system_id',
|
||
width: 120,
|
||
render: (systemId: number) => {
|
||
const system = starSystems.find(s => s.id === systemId);
|
||
return system ? (
|
||
<Tag color="blue" icon={<StarOutlined />}>
|
||
{system.name_zh || system.name}
|
||
</Tag>
|
||
) : '-';
|
||
},
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'type',
|
||
key: 'type',
|
||
filters: [
|
||
{ text: '恒星', value: 'star' },
|
||
{ text: '行星', value: 'planet' },
|
||
{ text: '矮行星', value: 'dwarf_planet' },
|
||
{ text: '卫星', value: 'satellite' },
|
||
{ text: '彗星', value: 'comet' },
|
||
{ text: '探测器', value: 'probe' },
|
||
],
|
||
onFilter: (value, record) => record.type === value,
|
||
render: (type: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
star: '恒星',
|
||
planet: '行星',
|
||
dwarf_planet: '矮行星',
|
||
satellite: '卫星',
|
||
probe: '探测器',
|
||
comet: '彗星',
|
||
};
|
||
return typeMap[type] || type;
|
||
},
|
||
},
|
||
{
|
||
title: '描述',
|
||
dataIndex: 'description',
|
||
key: 'description',
|
||
ellipsis: true,
|
||
},
|
||
{
|
||
title: '资源配置',
|
||
key: 'resources',
|
||
width: 120,
|
||
render: (_, record) => {
|
||
if (record.has_resources) {
|
||
const resourceTypes = Object.keys(record.resources || {});
|
||
return (
|
||
<Badge status="success" text={`${resourceTypes.length} 类`} />
|
||
);
|
||
}
|
||
return <Badge status="default" text="未配置" />;
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
{/* Star System Selector */}
|
||
<Card className="mb-4" style={{ marginBottom: 16 }}>
|
||
<Space>
|
||
<StarOutlined style={{ color: '#1890ff', fontSize: 18 }} />
|
||
<span style={{ fontWeight: 500 }}>选择恒星系统:</span>
|
||
<Select
|
||
showSearch
|
||
style={{ width: 400 }}
|
||
value={selectedSystemId}
|
||
onChange={setSelectedSystemId}
|
||
placeholder="请选择恒星系统"
|
||
loading={starSystems.length === 0}
|
||
optionFilterProp="children"
|
||
filterOption={(input, option) => {
|
||
const system = starSystems.find(s => s.id === option?.value);
|
||
if (!system) return false;
|
||
const searchText = input.toLowerCase();
|
||
return (
|
||
system.name.toLowerCase().includes(searchText) ||
|
||
system.name_zh?.toLowerCase().includes(searchText) ||
|
||
system.id.toString().includes(searchText)
|
||
);
|
||
}}
|
||
>
|
||
{starSystems.map(system => (
|
||
<Select.Option key={system.id} value={system.id}>
|
||
{system.name_zh || system.name}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
{selectedSystemId && (
|
||
<span style={{ color: '#888', fontSize: 12 }}>
|
||
已选择:{starSystems.find(s => s.id === selectedSystemId)?.name_zh || starSystems.find(s => s.id === selectedSystemId)?.name}
|
||
</span>
|
||
)}
|
||
</Space>
|
||
</Card>
|
||
|
||
<DataTable
|
||
title="天体数据管理"
|
||
columns={columns}
|
||
dataSource={filteredData}
|
||
loading={loading}
|
||
total={filteredData.length}
|
||
onSearch={handleSearch}
|
||
onAdd={handleAdd}
|
||
onEdit={handleEdit}
|
||
onDelete={handleDelete}
|
||
onStatusChange={handleStatusChange}
|
||
statusField="is_active"
|
||
rowKey="id"
|
||
pageSize={10}
|
||
customActions={(record) => {
|
||
// Show "Generate Orbit" button for all types, but disable for non-planets
|
||
const canGenerateOrbit = ['planet', 'dwarf_planet'].includes(record.type);
|
||
|
||
return (
|
||
<Popconfirm
|
||
title="确认生成轨道"
|
||
description={`确定要为 ${record.name_zh || record.name} 生成轨道吗?`}
|
||
onConfirm={() => handleGenerateOrbit(record)}
|
||
okText="确认"
|
||
cancelText="取消"
|
||
disabled={!canGenerateOrbit}
|
||
>
|
||
<Tooltip title={canGenerateOrbit ? "生成轨道" : "仅行星和矮行星可生成轨道"}>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={canGenerateOrbit ? "text-green-600 hover:text-green-500" : ""}
|
||
loading={loading}
|
||
disabled={!canGenerateOrbit}
|
||
>
|
||
生成轨道
|
||
</Button>
|
||
</Tooltip>
|
||
</Popconfirm>
|
||
);
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title={editingRecord ? '编辑天体' : '新增天体'}
|
||
open={isModalOpen}
|
||
onOk={handleModalOk}
|
||
onCancel={() => setIsModalOpen(false)}
|
||
width={1000}
|
||
>
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
>
|
||
{editingRecord ? (
|
||
// 编辑模式:显示双tab
|
||
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
|
||
<Tabs.TabPane tab="基础信息" key="basic">
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="id"
|
||
label="JPL Horizons ID"
|
||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||
>
|
||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="type"
|
||
label="类型"
|
||
rules={[{ required: true, message: '请选择类型' }]}
|
||
>
|
||
<Select>
|
||
<Select.Option value="star">恒星</Select.Option>
|
||
<Select.Option value="planet">行星</Select.Option>
|
||
<Select.Option value="dwarf_planet">矮行星</Select.Option>
|
||
<Select.Option value="satellite">卫星</Select.Option>
|
||
<Select.Option value="comet">彗星</Select.Option>
|
||
<Select.Option value="probe">探测器</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* Hidden system_id field - auto-set from selector */}
|
||
<Form.Item name="system_id" hidden>
|
||
<InputNumber />
|
||
</Form.Item>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="name"
|
||
label="英文名"
|
||
rules={[{ required: true, message: '请输入英文名' }]}
|
||
>
|
||
<Input placeholder="例如:Voyager 1" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="name_zh"
|
||
label="中文名"
|
||
>
|
||
<Input placeholder="例如:旅行者1号" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="short_name"
|
||
label="简称"
|
||
tooltip="NASA SBDB API使用的短名称(如Jupiter的简称是Juptr)"
|
||
>
|
||
<Input placeholder="例如:Juptr" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Form.Item
|
||
name="description"
|
||
label="描述"
|
||
>
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
|
||
{/* Orbit parameters and info for planets and dwarf planets */}
|
||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) =>
|
||
prevValues.type !== currentValues.type || prevValues.orbit_info !== currentValues.orbit_info
|
||
}>
|
||
{({ getFieldValue }) => {
|
||
const bodyType = getFieldValue('type');
|
||
const orbitInfo = getFieldValue('orbit_info');
|
||
|
||
if (!['planet', 'dwarf_planet'].includes(bodyType)) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Alert
|
||
message="轨道参数与信息"
|
||
description={
|
||
<div>
|
||
{/* Editable orbit parameters */}
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name={['extra_data', 'orbit_period_days']}
|
||
label="轨道周期(天)"
|
||
tooltip="完整公转一周所需的天数"
|
||
>
|
||
<InputNumber
|
||
style={{ width: '100%' }}
|
||
min={0}
|
||
step={1}
|
||
placeholder="例如:365.25(地球)"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name={['extra_data', 'orbit_color']}
|
||
label="轨道颜色"
|
||
tooltip="轨道线的显示颜色(HEX格式)"
|
||
>
|
||
<Input
|
||
type="color"
|
||
placeholder="#4A90E2"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* Display orbit info if exists (read-only) */}
|
||
{editingRecord && orbitInfo && orbitInfo.num_points && (
|
||
<div style={{
|
||
marginTop: 16,
|
||
paddingTop: 16,
|
||
borderTop: '1px solid #d9d9d9',
|
||
background: '#f0f9ff',
|
||
padding: '12px',
|
||
borderRadius: '4px'
|
||
}}>
|
||
<div style={{ fontWeight: 600, marginBottom: 8, color: '#1890ff' }}>
|
||
已生成的轨道数据:
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '24px' }}>
|
||
<div>
|
||
<strong>轨道点数:</strong> {orbitInfo.num_points?.toLocaleString()} 个点
|
||
</div>
|
||
{orbitInfo.period_days && (
|
||
<div>
|
||
<strong>实际周期:</strong> {orbitInfo.period_days.toFixed(2)} 天
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
}
|
||
type="info"
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
);
|
||
}}
|
||
</Form.Item>
|
||
|
||
{editingRecord && (
|
||
<ResourceManager
|
||
bodyId={editingRecord.id}
|
||
bodyType={editingRecord.type}
|
||
resources={editingRecord.resources}
|
||
hasResources={editingRecord.has_resources}
|
||
onUpload={handleResourceUpload}
|
||
onDelete={handleResourceDelete}
|
||
uploading={uploading}
|
||
refreshTrigger={refreshResources}
|
||
toast={toast}
|
||
/>
|
||
)}
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="详细信息" key="details">
|
||
<Form.Item name="details" style={{ marginBottom: 0 }}>
|
||
<MdEditor
|
||
value={form.getFieldValue('details')}
|
||
style={{ height: '500px' }}
|
||
renderHTML={(text) => MdEditorParser.render(text)}
|
||
onChange={({ text }) => form.setFieldsValue({ details: text })}
|
||
/>
|
||
</Form.Item>
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
) : (
|
||
// 新增模式:只显示基础信息
|
||
<>
|
||
<Alert
|
||
title="智能搜索提示"
|
||
description={
|
||
<div>
|
||
<p>推荐使用 <strong>JPL Horizons 数字 ID</strong> 进行搜索,可获得最准确的结果。</p>
|
||
<p style={{ marginTop: 4 }}>
|
||
示例:Hubble 的 ID 是 <code>-48</code>,Voyager 1 的 ID 是 <code>-31</code>
|
||
</p>
|
||
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
|
||
不知道 ID?可以先用名称搜索,系统会尽量提取 ID,或提示您手动确认。
|
||
</p>
|
||
</div>
|
||
}
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
|
||
<Form.Item label="从 NASA 数据库搜索">
|
||
<Space.Compact style={{ width: '100%' }}>
|
||
<Input
|
||
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onPressEnter={handleNASASearch}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SearchOutlined />}
|
||
onClick={handleNASASearch}
|
||
loading={searching}
|
||
>
|
||
搜索
|
||
</Button>
|
||
</Space.Compact>
|
||
</Form.Item>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="id"
|
||
label="JPL Horizons ID"
|
||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||
>
|
||
<Input placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="type"
|
||
label="类型"
|
||
rules={[{ required: true, message: '请选择类型' }]}
|
||
>
|
||
<Select>
|
||
<Select.Option value="star">恒星</Select.Option>
|
||
<Select.Option value="planet">行星</Select.Option>
|
||
<Select.Option value="dwarf_planet">矮行星</Select.Option>
|
||
<Select.Option value="satellite">卫星</Select.Option>
|
||
<Select.Option value="comet">彗星</Select.Option>
|
||
<Select.Option value="probe">探测器</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* Hidden system_id field - auto-set from selector */}
|
||
<Form.Item name="system_id" hidden>
|
||
<InputNumber />
|
||
</Form.Item>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="name"
|
||
label="英文名"
|
||
rules={[{ required: true, message: '请输入英文名' }]}
|
||
>
|
||
<Input placeholder="例如:Voyager 1" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="name_zh"
|
||
label="中文名"
|
||
>
|
||
<Input placeholder="例如:旅行者1号" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="short_name"
|
||
label="简称"
|
||
tooltip="NASA SBDB API使用的短名称(如Jupiter的简称是Juptr)"
|
||
>
|
||
<Input placeholder="例如:Juptr" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Form.Item
|
||
name="description"
|
||
label="描述"
|
||
>
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Resource Manager Component
|
||
function ResourceManager({
|
||
bodyId,
|
||
bodyType,
|
||
resources,
|
||
hasResources,
|
||
onUpload,
|
||
onDelete,
|
||
uploading,
|
||
refreshTrigger,
|
||
toast,
|
||
}: {
|
||
bodyId: string;
|
||
bodyType: string;
|
||
resources?: any;
|
||
hasResources?: boolean;
|
||
onUpload: (file: File, resourceType: string) => Promise<boolean>;
|
||
onDelete: (resourceId: number) => Promise<void>;
|
||
uploading: boolean;
|
||
refreshTrigger: number;
|
||
toast: any;
|
||
}) {
|
||
const [currentResources, setCurrentResources] = useState(resources);
|
||
|
||
// Load resources when modal opens
|
||
useEffect(() => {
|
||
request.get(`/celestial/resources/${bodyId}`)
|
||
.then(({ data }) => {
|
||
const grouped: any = {};
|
||
data.resources.forEach((res: any) => {
|
||
if (!grouped[res.resource_type]) {
|
||
grouped[res.resource_type] = [];
|
||
}
|
||
grouped[res.resource_type].push(res);
|
||
});
|
||
setCurrentResources(grouped);
|
||
})
|
||
.catch(() => {
|
||
toast.error('加载资源列表失败');
|
||
});
|
||
}, [refreshTrigger, bodyId]);
|
||
|
||
const resourceTypes = [
|
||
{ key: 'icon', label: '图标 (上传到 icon 目录)', type: 'image' },
|
||
{ key: 'texture', label: bodyType === 'probe' ? '纹理 (上传到 model 目录)' : '纹理 (上传到 texture 目录)', type: 'file' },
|
||
{ key: 'model', label: bodyType === 'probe' ? '模型 (上传到 model 目录)' : '模型 (上传到 texture 目录)', type: 'file' },
|
||
];
|
||
|
||
return (
|
||
<Form.Item label="资源配置">
|
||
|
||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||
{resourceTypes.map(({ key, label, type }) => (
|
||
<div key={key}>
|
||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{label}</div>
|
||
|
||
{type === 'image' && currentResources?.[key] && currentResources[key].length > 0 ? (
|
||
// Image preview for icon
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||
<img
|
||
src={`/upload/${currentResources[key][0].file_path}`}
|
||
alt="Icon preview"
|
||
style={{
|
||
width: 80,
|
||
height: 80,
|
||
objectFit: 'contain',
|
||
border: '1px solid #d9d9d9',
|
||
borderRadius: 4,
|
||
padding: 8,
|
||
backgroundColor: '#fafafa'
|
||
}}
|
||
/>
|
||
<div>
|
||
<Upload
|
||
beforeUpload={(file) => onUpload(file, key)}
|
||
showUploadList={false}
|
||
disabled={uploading}
|
||
accept="image/*"
|
||
>
|
||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||
更换图标
|
||
</Button>
|
||
</Upload>
|
||
<div style={{ marginTop: 8 }}>
|
||
<Popconfirm
|
||
title="确认删除图标?"
|
||
onConfirm={() => onDelete(currentResources[key][0].id)}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
type="link"
|
||
danger
|
||
size="small"
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
删除图标
|
||
</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||
({(currentResources[key][0].file_size / 1024).toFixed(2)} KB)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : type === 'image' ? (
|
||
// No icon uploaded yet
|
||
<Upload
|
||
beforeUpload={(file) => onUpload(file, key)}
|
||
showUploadList={false}
|
||
disabled={uploading}
|
||
accept="image/*"
|
||
>
|
||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||
上传图标
|
||
</Button>
|
||
</Upload>
|
||
) : (
|
||
// File upload for texture/model
|
||
<>
|
||
<Upload
|
||
beforeUpload={(file) => onUpload(file, key)}
|
||
showUploadList={false}
|
||
disabled={uploading}
|
||
>
|
||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||
上传 {label.split(' ')[0]}
|
||
</Button>
|
||
</Upload>
|
||
|
||
{currentResources?.[key] && currentResources[key].length > 0 && (
|
||
<div style={{ marginTop: 8 }}>
|
||
{currentResources[key].map((res: any) => (
|
||
<div key={res.id} style={{ marginBottom: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||
<Tag color="blue">{res.file_path}</Tag>
|
||
<span style={{ fontSize: 12, color: '#888' }}>
|
||
({(res.file_size / 1024).toFixed(2)} KB)
|
||
</span>
|
||
<Popconfirm
|
||
title="确认删除?"
|
||
onConfirm={() => onDelete(res.id)}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
type="link"
|
||
danger
|
||
size="small"
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
{key === 'model' && (
|
||
<div style={{ marginLeft: 8 }}>
|
||
<Space size="small">
|
||
<span style={{ fontSize: 12, color: '#666' }}>显示缩放:</span>
|
||
<InputNumber
|
||
size="small"
|
||
min={0.1}
|
||
max={5}
|
||
step={0.1}
|
||
defaultValue={res.extra_data?.scale || 1.0}
|
||
style={{ width: 80 }}
|
||
placeholder="1.0"
|
||
onChange={(value) => {
|
||
// Update scale in resource
|
||
const newScale = value || 1.0;
|
||
request.put(`/celestial/resources/${res.id}`, {
|
||
extra_data: { ...res.extra_data, scale: newScale }
|
||
}).then(() => {
|
||
toast.success('缩放参数已更新');
|
||
}).catch(() => {
|
||
toast.error('更新失败');
|
||
});
|
||
}}
|
||
/>
|
||
<span style={{ fontSize: 11, color: '#999' }}>
|
||
(推荐: Webb=0.3, 旅行者=1.5)
|
||
</span>
|
||
</Space>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</Space>
|
||
</Form.Item>
|
||
);
|
||
} |