368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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>
|
|||
|
|
);
|
|||
|
|
}
|