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

571 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden 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.

/**
* Celestial Bodies Management Page
*/
import { useState, useEffect } from 'react';
import { message, Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd/es/upload/interface';
import { DataTable } from '../../components/admin/DataTable';
import { request } from '../../utils/request';
interface CelestialBody {
id: string;
name: string;
name_zh: string;
type: string;
description: string;
is_active: boolean;
resources?: {
[key: string]: Array<{
id: number;
file_path: string;
file_size: number;
mime_type: string;
}>;
};
has_resources?: boolean;
}
export function CelestialBodies() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<CelestialBody[]>([]);
const [filteredData, setFilteredData] = useState<CelestialBody[]>([]);
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);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const { data: result } = await request.get('/celestial/list');
setData(result.bodies || []);
setFilteredData(result.bodies || []);
} catch (error) {
message.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('');
// Default values
form.setFieldsValue({ is_active: true, type: 'probe' });
setIsModalOpen(true);
};
// Search NASA Horizons by name
const handleNASASearch = async () => {
if (!searchQuery.trim()) {
message.warning('请输入天体名称或ID');
return;
}
setSearching(true);
try {
const { data: result } = await request.get('/celestial/search', {
params: { name: searchQuery }
});
if (result.success) {
// 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) {
message.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 {
message.error(result.error || '查询失败');
}
} catch (error: any) {
message.error(error.response?.data?.detail || '查询失败');
} finally {
setSearching(false);
}
};
// Edit handler
const handleEdit = (record: CelestialBody) => {
setEditingRecord(record);
form.setFieldsValue(record);
setIsModalOpen(true);
};
// Delete handler
const handleDelete = async (record: CelestialBody) => {
try {
await request.delete(`/celestial/${record.id}`);
message.success('删除成功');
loadData();
} catch (error) {
message.error('删除失败');
}
};
// Status change handler
const handleStatusChange = async (record: CelestialBody, checked: boolean) => {
try {
await request.put(`/celestial/${record.id}`, { is_active: checked });
message.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) {
message.error('状态更新失败');
}
};
// Form submit
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingRecord) {
// Update
await request.put(`/celestial/${editingRecord.id}`, values);
message.success('更新成功');
} else {
// Create
await request.post('/celestial/', values);
message.success('创建成功');
}
setIsModalOpen(false);
loadData();
} catch (error) {
console.error(error);
// message.error('操作失败'); // request interceptor might already handle this
}
};
// Handle resource upload
const handleResourceUpload = async (file: File, resourceType: string) => {
if (!editingRecord) {
message.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',
},
}
);
message.success(`${response.data.message} (上传到 ${response.data.upload_directory} 目录)`);
setRefreshResources(prev => prev + 1); // Trigger reload
return false; // Prevent default upload behavior
} catch (error: any) {
message.error(error.response?.data?.detail || '上传失败');
return false;
} finally {
setUploading(false);
}
};
// Handle resource delete
const handleResourceDelete = async (resourceId: number) => {
try {
await request.delete(`/celestial/resources/${resourceId}`);
message.success('删除成功');
setRefreshResources(prev => prev + 1); // Trigger reload
} catch (error: any) {
message.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: 'type',
key: 'type',
filters: [
{ text: '行星', value: 'planet' },
{ text: '恒星', value: 'star' },
{ text: '卫星', value: 'satellite' },
{ text: '探测器', value: 'probe' },
{ text: '矮行星', value: 'dwarf_planet' },
{ text: '彗星', value: 'comet' },
],
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 (
<>
<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}
/>
<Modal
title={editingRecord ? '编辑天体' : '新增天体'}
open={isModalOpen}
onOk={handleModalOk}
onCancel={() => setIsModalOpen(false)}
width={800}
>
<Form
form={form}
layout="vertical"
>
{!editingRecord && (
<>
<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 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="planet"></Select.Option>
<Select.Option value="dwarf_planet"></Select.Option>
<Select.Option value="satellite"></Select.Option>
<Select.Option value="probe"></Select.Option>
<Select.Option value="star"></Select.Option>
<Select.Option value="comet"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<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>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={2} />
</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}
/>
)}
</Form>
</Modal>
</>
);
}
// Resource Manager Component
function ResourceManager({
bodyId,
bodyType,
resources,
hasResources,
onUpload,
onDelete,
uploading,
refreshTrigger,
}: {
bodyId: string;
bodyType: string;
resources?: any;
hasResources?: boolean;
onUpload: (file: File, resourceType: string) => Promise<boolean>;
onDelete: (resourceId: number) => Promise<void>;
uploading: boolean;
refreshTrigger: number;
}) {
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(() => {
message.error('加载资源列表失败');
});
}, [refreshTrigger, bodyId]);
const resourceTypes = [
{ key: 'texture', label: bodyType === 'probe' ? '纹理 (上传到 model 目录)' : '纹理 (上传到 texture 目录)' },
{ key: 'model', label: bodyType === 'probe' ? '模型 (上传到 model 目录)' : '模型 (上传到 texture 目录)' },
];
return (
<Form.Item label="资源配置">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{resourceTypes.map(({ key, label }) => (
<div key={key}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{label}</div>
<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(() => {
message.success('缩放参数已更新');
}).catch(() => {
message.error('更新失败');
});
}}
/>
<span style={{ fontSize: 11, color: '#999' }}>
(: Webb=0.3, =1.5)
</span>
</Space>
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</Space>
</Form.Item>
);
}