395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { Badge, Button, Form, Modal, Popconfirm, Select, Space, Tag, Tooltip } from 'antd';
|
||
import { GlobalOutlined, StarOutlined } from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
|
||
import { DataTable } from '../../components/admin/DataTable';
|
||
import { AdminPage } from '../../components/admin/AdminPage';
|
||
import { useListPageSize } from './useListPageSize';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
import { request } from '../../utils/request';
|
||
import { CelestialBodyModal } from './celestial-bodies/CelestialBodyModal';
|
||
import type { CelestialBody, StarSystem } from './celestial-bodies/types';
|
||
|
||
const bodyTypeLabels: Record<string, string> = {
|
||
star: '恒星',
|
||
planet: '行星',
|
||
dwarf_planet: '矮行星',
|
||
satellite: '卫星',
|
||
comet: '彗星',
|
||
probe: '探测器',
|
||
};
|
||
|
||
function getErrorDetail(error: unknown, fallback: string) {
|
||
if (typeof error !== 'object' || error === null || !('response' in error)) return fallback;
|
||
const response = (error as { response?: { data?: { detail?: string } } }).response;
|
||
return response?.data?.detail || fallback;
|
||
}
|
||
|
||
function filterBodies(items: CelestialBody[], searchTerm: string) {
|
||
const normalized = searchTerm.toLowerCase();
|
||
return items.filter((item) =>
|
||
item.name.toLowerCase().includes(normalized)
|
||
|| item.name_zh?.toLowerCase().includes(normalized)
|
||
|| item.id.toLowerCase().includes(normalized)
|
||
);
|
||
}
|
||
|
||
export function CelestialBodies() {
|
||
// 每页数量由系统参数 page_size 控制
|
||
const systemPageSize = useListPageSize();
|
||
const [loading, setLoading] = useState(false);
|
||
const [data, setData] = useState<CelestialBody[]>([]);
|
||
const [keyword, setKeyword] = useState('');
|
||
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 [searching, setSearching] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [uploading, setUploading] = useState(false);
|
||
const [refreshResources, setRefreshResources] = useState(0);
|
||
const [form] = Form.useForm();
|
||
const toast = useToast();
|
||
|
||
const filteredData = useMemo(() => filterBodies(data, keyword), [data, keyword]);
|
||
|
||
useEffect(() => {
|
||
request.get('/star-systems', { params: { limit: 1000 } })
|
||
.then(({ data: result }) => setStarSystems(result.systems || []))
|
||
.catch(() => toast.error('加载恒星系统列表失败'));
|
||
}, [toast]);
|
||
|
||
useEffect(() => {
|
||
if (selectedSystemId === null) {
|
||
setData([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
request.get('/celestial/list', { params: { system_id: selectedSystemId } })
|
||
.then(({ data: result }) => {
|
||
const bodies = result.bodies || [];
|
||
setData(bodies);
|
||
})
|
||
.catch(() => toast.error('加载数据失败'))
|
||
.finally(() => setLoading(false));
|
||
}, [selectedSystemId, toast]);
|
||
|
||
const reloadData = async () => {
|
||
if (selectedSystemId === null) return;
|
||
setLoading(true);
|
||
try {
|
||
const { data: result } = await request.get('/celestial/list', { params: { system_id: selectedSystemId } });
|
||
const bodies = result.bodies || [];
|
||
setData(bodies);
|
||
} catch {
|
||
toast.error('加载数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSearch = (searchTerm: string) => {
|
||
setKeyword(searchTerm);
|
||
};
|
||
|
||
const handleAdd = () => {
|
||
setEditingRecord(null);
|
||
setSearchQuery('');
|
||
form.resetFields();
|
||
form.setFieldsValue({ is_active: true, type: 'probe', system_id: selectedSystemId });
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
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) {
|
||
toast.error(result.error || '查询失败');
|
||
return;
|
||
}
|
||
|
||
const existingBody = data.find((body) => body.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: 10 }}>该天体已在数据库中,名称为: <strong>{existingBody.name}</strong></p>
|
||
<p style={{ fontSize: 12, color: '#888' }}>如需修改,请在列表中直接编辑该天体。</p>
|
||
</div>
|
||
),
|
||
});
|
||
return;
|
||
}
|
||
|
||
form.setFieldsValue({ id: result.data.id, name: result.data.name });
|
||
if (/^-?\d+$/.test(result.data.id)) {
|
||
toast.success(`找到天体: ${result.data.full_name}`);
|
||
} else {
|
||
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,以便后续查询位置数据。</p>
|
||
<p style={{ fontSize: 12, color: '#888' }}>
|
||
可在 <a href="https://ssd.jpl.nasa.gov/horizons/" target="_blank" rel="noopener noreferrer">NASA Horizons</a> 查询准确 ID。
|
||
</p>
|
||
</div>
|
||
),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toast.error(getErrorDetail(error, '查询失败'));
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
};
|
||
|
||
const handleEdit = async (record: CelestialBody) => {
|
||
form.resetFields();
|
||
setEditingRecord(record);
|
||
|
||
let extraData = record.extra_data;
|
||
if (typeof extraData === 'string') {
|
||
try {
|
||
extraData = JSON.parse(extraData);
|
||
} catch {
|
||
extraData = {};
|
||
}
|
||
}
|
||
|
||
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 };
|
||
} catch {
|
||
orbitInfo = null;
|
||
}
|
||
|
||
form.setFieldsValue({ ...record, extra_data: extraData || {}, orbit_info: orbitInfo });
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleDelete = async (record: CelestialBody) => {
|
||
try {
|
||
await request.delete(`/celestial/${record.id}`);
|
||
toast.success('删除成功');
|
||
await reloadData();
|
||
} catch {
|
||
toast.error('删除失败');
|
||
}
|
||
};
|
||
|
||
const handleStatusChange = async (record: CelestialBody, checked: boolean) => {
|
||
try {
|
||
await request.put(`/celestial/${record.id}`, { is_active: checked });
|
||
const updated = data.map((item) => item.id === record.id ? { ...item, is_active: checked } : item);
|
||
setData(updated);
|
||
toast.success('状态更新成功');
|
||
} catch {
|
||
toast.error('状态更新失败');
|
||
}
|
||
};
|
||
|
||
const handleModalOk = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
if (editingRecord) {
|
||
await request.put(`/celestial/${editingRecord.id}`, values);
|
||
toast.success('更新成功');
|
||
} else {
|
||
await request.post('/celestial/', values);
|
||
toast.success('创建成功');
|
||
}
|
||
setIsModalOpen(false);
|
||
await reloadData();
|
||
} catch (error) {
|
||
if (typeof error === 'object' && error !== null && 'errorFields' in error) {
|
||
toast.error('请填写所有必填字段');
|
||
} else {
|
||
toast.error(getErrorDetail(error, '操作失败'));
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleResourceUpload = async (file: File, resourceType: string) => {
|
||
if (!editingRecord) 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);
|
||
setRefreshResources((value) => value + 1);
|
||
} catch (error) {
|
||
toast.error(getErrorDetail(error, '上传失败'));
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const handleResourceDelete = async (resourceId: number) => {
|
||
try {
|
||
await request.delete(`/celestial/resources/${resourceId}`);
|
||
toast.success('删除成功');
|
||
setRefreshResources((value) => value + 1);
|
||
} catch (error) {
|
||
toast.error(getErrorDetail(error, '删除失败'));
|
||
}
|
||
};
|
||
|
||
const handleGenerateOrbit = async (record: CelestialBody) => {
|
||
if (!['planet', 'dwarf_planet'].includes(record.type)) return;
|
||
setLoading(true);
|
||
try {
|
||
await request.post(`/celestial/admin/orbits/generate?body_ids=${record.id}`);
|
||
toast.success('轨道生成任务已启动,请前往"系统任务"查看进度', 5000);
|
||
} catch (error) {
|
||
toast.error(getErrorDetail(error, '轨道生成任务启动失败'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
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((item) => item.id === systemId);
|
||
return system ? <Tag color="blue" icon={<StarOutlined />}>{system.name_zh || system.name}</Tag> : '-';
|
||
},
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'type',
|
||
key: 'type',
|
||
filters: Object.entries(bodyTypeLabels).map(([value, text]) => ({ text, value })),
|
||
onFilter: (value, record) => record.type === value,
|
||
render: (type: string) => bodyTypeLabels[type] || type,
|
||
},
|
||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||
{
|
||
title: '资源配置',
|
||
key: 'resources',
|
||
width: 120,
|
||
render: (_, record) => record.has_resources
|
||
? <Badge status="success" text={`${Object.keys(record.resources || {}).length} 类`} />
|
||
: <Badge status="default" text="未配置" />,
|
||
},
|
||
];
|
||
|
||
const selectedSystem = starSystems.find((system) => system.id === selectedSystemId);
|
||
|
||
return (
|
||
<AdminPage
|
||
icon={<GlobalOutlined />}
|
||
title="天体数据管理" description="按恒星系统维护天体基础信息、显示资源与轨道数据">
|
||
<DataTable
|
||
columns={columns}
|
||
dataSource={filteredData}
|
||
loading={loading}
|
||
total={filteredData.length}
|
||
onRefresh={() => void reloadData()}
|
||
onSearch={handleSearch}
|
||
searchPlaceholder="搜索 ID / 英文名 / 中文名"
|
||
onAdd={handleAdd}
|
||
addText="新增天体"
|
||
onEdit={handleEdit}
|
||
onDelete={handleDelete}
|
||
deleteConfirmTitle="确认删除该天体?"
|
||
deleteConfirmDescription="删除后该天体的位置与资源数据将不可用"
|
||
onStatusChange={handleStatusChange}
|
||
statusField="is_active"
|
||
rowKey="id"
|
||
pageSize={systemPageSize}
|
||
toolbar={
|
||
<Space size={8}>
|
||
<StarOutlined style={{ color: 'var(--adm-primary)' }} />
|
||
<Select
|
||
showSearch
|
||
style={{ width: 260 }}
|
||
value={selectedSystemId}
|
||
onChange={setSelectedSystemId}
|
||
placeholder="选择恒星系统"
|
||
loading={starSystems.length === 0}
|
||
options={starSystems.map((system) => ({ value: system.id, label: system.name_zh || system.name }))}
|
||
filterOption={(input, option) => {
|
||
const system = starSystems.find((item) => item.id === option?.value);
|
||
const searchText = input.toLowerCase();
|
||
return Boolean(system && (
|
||
system.name.toLowerCase().includes(searchText)
|
||
|| system.name_zh?.toLowerCase().includes(searchText)
|
||
|| system.id.toString().includes(searchText)
|
||
));
|
||
}}
|
||
/>
|
||
{selectedSystem ? (
|
||
<span className="adm-cell-sub">当前:{selectedSystem.name_zh || selectedSystem.name}</span>
|
||
) : null}
|
||
</Space>
|
||
}
|
||
customActions={(record) => {
|
||
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" disabled={!canGenerateOrbit}>
|
||
生成轨道
|
||
</Button>
|
||
</Tooltip>
|
||
</Popconfirm>
|
||
);
|
||
}}
|
||
/>
|
||
|
||
<CelestialBodyModal
|
||
key={`${editingRecord?.id || 'new'}-${isModalOpen}`}
|
||
form={form}
|
||
record={editingRecord}
|
||
open={isModalOpen}
|
||
onOk={handleModalOk}
|
||
onCancel={() => setIsModalOpen(false)}
|
||
searchQuery={searchQuery}
|
||
onSearchQueryChange={setSearchQuery}
|
||
onNasaSearch={handleNasaSearch}
|
||
searching={searching}
|
||
uploading={uploading}
|
||
refreshResources={refreshResources}
|
||
onResourceUpload={handleResourceUpload}
|
||
onResourceDelete={handleResourceDelete}
|
||
toast={toast}
|
||
/>
|
||
</AdminPage>
|
||
);
|
||
}
|