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

368 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-26 01:21:15 +00:00
/**
* My Celestial Bodies Page (User Follow)
* -
*/
import { useState, useEffect } from 'react';
import { Row, Col, Card, List, Tag, Button, Empty, Descriptions, Table, Space } from 'antd';
import { StarFilled, RocketOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
interface CelestialBody {
id: string;
name: string;
name_zh: string;
type: string;
is_active: boolean;
followed_at?: string;
}
interface CelestialEvent {
id: number;
title: string;
event_type: string;
event_time: string;
description: string;
details: any;
source: string;
}
export function MyCelestialBodies() {
const [loading, setLoading] = useState(false);
const [followedBodies, setFollowedBodies] = useState<CelestialBody[]>([]);
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
const [bodyEvents, setBodyEvents] = useState<CelestialEvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(false);
const toast = useToast();
useEffect(() => {
loadFollowedBodies();
}, []);
const loadFollowedBodies = async () => {
setLoading(true);
try {
const { data } = await request.get('/social/follows');
setFollowedBodies(data || []);
// 如果有数据,默认选中第一个
if (data && data.length > 0) {
handleSelectBody(data[0]);
}
} catch (error) {
toast.error('加载关注列表失败');
} finally {
setLoading(false);
}
};
const handleSelectBody = async (body: CelestialBody) => {
setSelectedBody(body);
setEventsLoading(true);
try {
const { data } = await request.get(`/events`, {
params: {
body_id: body.id,
limit: 100
}
});
setBodyEvents(data || []);
} catch (error) {
toast.error('加载天体事件失败');
setBodyEvents([]);
} finally {
setEventsLoading(false);
}
};
const handleUnfollow = async (bodyId: string) => {
try {
await request.delete(`/social/follow/${bodyId}`);
toast.success('已取消关注');
// 重新加载列表
await loadFollowedBodies();
// 如果取消关注的是当前选中的天体,清空右侧显示
if (selectedBody?.id === bodyId) {
setSelectedBody(null);
setBodyEvents([]);
}
} catch (error) {
toast.error('取消关注失败');
}
};
const getBodyTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'star': '恒星',
'planet': '行星',
'dwarf_planet': '矮行星',
'satellite': '卫星',
'comet': '彗星',
'asteroid': '小行星',
'probe': '探测器',
};
return labelMap[type] || type;
};
const getBodyTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'star': 'gold',
'planet': 'blue',
'dwarf_planet': 'cyan',
'satellite': 'geekblue',
'comet': 'purple',
'asteroid': 'volcano',
'probe': 'magenta',
};
return colorMap[type] || 'default';
};
const getEventTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'approach': '接近',
'close_approach': '近距离接近',
'eclipse': '食',
'conjunction': '合',
'opposition': '冲',
'transit': '凌',
};
return labelMap[type] || type;
};
const getEventTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'approach': 'blue',
'close_approach': 'magenta',
'eclipse': 'purple',
'conjunction': 'cyan',
'opposition': 'orange',
'transit': 'green',
};
return colorMap[type] || 'default';
};
const eventColumns: ColumnsType<CelestialEvent> = [
{
title: '事件',
dataIndex: 'title',
key: 'title',
ellipsis: true,
width: '40%',
},
{
title: '类型',
dataIndex: 'event_type',
key: 'event_type',
width: 200,
render: (type) => (
<Tag color={getEventTypeColor(type)}>
{getEventTypeLabel(type)}
</Tag>
),
filters: [
{ text: '接近', value: 'approach' },
{ text: '近距离接近', value: 'close_approach' },
{ text: '食', value: 'eclipse' },
{ text: '合', value: 'conjunction' },
{ text: '冲', value: 'opposition' },
{ text: '凌', value: 'transit' },
],
onFilter: (value, record) => record.event_type === value,
},
{
title: '时间',
dataIndex: 'event_time',
key: 'event_time',
width: 180,
render: (time) => new Date(time).toLocaleString('zh-CN'),
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
},
];
return (
<Row gutter={16} style={{ height: 'calc(100vh - 150px)' }}>
{/* 左侧:关注的天体列表 */}
<Col span={8}>
<Card
title={
<Space>
<StarFilled style={{ color: '#faad14' }} />
<span></span>
<Tag color="blue">{followedBodies.length}</Tag>
</Space>
}
extra={
<Button size="small" onClick={loadFollowedBodies} loading={loading}>
</Button>
}
bordered={false}
style={{ height: '100%', overflow: 'hidden' }}
bodyStyle={{ height: 'calc(100% - 57px)', overflowY: 'auto', padding: 0 }}
>
{followedBodies.length === 0 && !loading ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有关注任何天体"
style={{ marginTop: 60 }}
>
<p style={{ color: '#999', margin: '8px 0' }}>
</p>
</Empty>
) : (
<List
dataSource={followedBodies}
loading={loading}
renderItem={(body) => (
<List.Item
key={body.id}
onClick={() => handleSelectBody(body)}
style={{
cursor: 'pointer',
backgroundColor: selectedBody?.id === body.id ? '#f0f5ff' : 'transparent',
padding: '12px 16px',
transition: 'background-color 0.3s',
}}
actions={[
<Button
key="unfollow"
type="link"
danger
size="small"
icon={<StarFilled />}
onClick={(e) => {
e.stopPropagation();
handleUnfollow(body.id);
}}
>
</Button>,
]}
>
<List.Item.Meta
avatar={<StarFilled style={{ color: '#faad14', fontSize: 20 }} />}
title={
<Space>
<span>{body.name_zh || body.name}</span>
<Tag color={getBodyTypeColor(body.type)} style={{ marginLeft: 4 }}>
{getBodyTypeLabel(body.type)}
</Tag>
</Space>
}
description={
body.followed_at
? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}`
: body.name_zh ? body.name : undefined
}
/>
</List.Item>
)}
/>
)}
</Card>
</Col>
{/* 右侧:天体详情和事件 */}
<Col span={16}>
{selectedBody ? (
<Space direction="vertical" size="middle" style={{ width: '100%', height: '100%' }}>
{/* 天体资料 */}
<Card
title={
<Space>
<RocketOutlined />
<span>{selectedBody.name_zh || selectedBody.name}</span>
<Tag color={getBodyTypeColor(selectedBody.type)}>
{getBodyTypeLabel(selectedBody.type)}
</Tag>
</Space>
}
bordered={false}
>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="ID">{selectedBody.id}</Descriptions.Item>
<Descriptions.Item label="类型">
{getBodyTypeLabel(selectedBody.type)}
</Descriptions.Item>
<Descriptions.Item label="中文名">
{selectedBody.name_zh || '-'}
</Descriptions.Item>
<Descriptions.Item label="英文名">
{selectedBody.name}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedBody.is_active ? 'success' : 'default'}>
{selectedBody.is_active ? '活跃' : '已归档'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="关注时间">
{selectedBody.followed_at
? new Date(selectedBody.followed_at).toLocaleString('zh-CN')
: '-'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 天体事件列表 */}
<Card
title="相关天体事件"
bordered={false}
style={{ marginTop: 16 }}
>
<Table
columns={eventColumns}
dataSource={bodyEvents}
rowKey="id"
loading={eventsLoading}
size="small"
pagination={{
pageSize: 10,
showSizeChanger: false,
showTotal: (total) => `${total}`,
}}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无相关事件"
/>
),
}}
expandable={{
expandedRowRender: (record) => (
<div style={{ padding: '8px 16px' }}>
<p style={{ margin: 0 }}>
<strong></strong>
{record.description}
</p>
{record.details && (
<p style={{ margin: '8px 0 0', color: '#666' }}>
<strong></strong>
{JSON.stringify(record.details, null, 2)}
</p>
)}
</div>
),
}}
/>
</Card>
</Space>
) : (
<Card
bordered={false}
style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请从左侧选择一个天体查看详情"
/>
</Card>
)}
</Col>
</Row>
);
}