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

395 lines
14 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.

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