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

368 lines
12 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.

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