295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/**
|
||
* 我的天体(普通用户)
|
||
*
|
||
* 左侧是已关注天体列表,右侧是天体资料与相关天象事件。
|
||
*/
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { Button, Card, Col, Descriptions, Empty, Row, Table, Tag } from 'antd';
|
||
import { ReloadOutlined, RocketOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
|
||
import { request } from '../../utils/request';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
import { AdminPage } from '../../components/admin/AdminPage';
|
||
|
||
interface FollowedBody {
|
||
id: string;
|
||
name: string;
|
||
name_zh?: string | null;
|
||
type: string;
|
||
is_active: boolean;
|
||
followed_at?: string;
|
||
}
|
||
|
||
interface BodyEvent {
|
||
id: number;
|
||
title: string;
|
||
event_type: string;
|
||
event_time: string;
|
||
description: string;
|
||
details?: Record<string, unknown>;
|
||
}
|
||
|
||
const BODY_TYPE_LABELS: Record<string, string> = {
|
||
star: '恒星',
|
||
planet: '行星',
|
||
dwarf_planet: '矮行星',
|
||
satellite: '卫星',
|
||
comet: '彗星',
|
||
asteroid: '小行星',
|
||
probe: '探测器',
|
||
};
|
||
|
||
const BODY_TYPE_COLORS: Record<string, string> = {
|
||
star: 'gold',
|
||
planet: 'blue',
|
||
dwarf_planet: 'cyan',
|
||
satellite: 'geekblue',
|
||
comet: 'purple',
|
||
asteroid: 'volcano',
|
||
probe: 'magenta',
|
||
};
|
||
|
||
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||
approach: '接近',
|
||
close_approach: '近距离接近',
|
||
eclipse: '食',
|
||
conjunction: '合',
|
||
opposition: '冲',
|
||
transit: '凌',
|
||
};
|
||
|
||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||
approach: 'blue',
|
||
close_approach: 'magenta',
|
||
eclipse: 'purple',
|
||
conjunction: 'cyan',
|
||
opposition: 'orange',
|
||
transit: 'green',
|
||
};
|
||
|
||
export function MyCelestialBodies() {
|
||
const [loading, setLoading] = useState(false);
|
||
const [bodies, setBodies] = useState<FollowedBody[]>([]);
|
||
const [selectedBody, setSelectedBody] = useState<FollowedBody | null>(null);
|
||
const [events, setEvents] = useState<BodyEvent[]>([]);
|
||
const [eventsLoading, setEventsLoading] = useState(false);
|
||
const toast = useToast();
|
||
|
||
const loadEvents = useCallback(async (body: FollowedBody) => {
|
||
setEventsLoading(true);
|
||
try {
|
||
const { data } = await request.get<BodyEvent[]>('/events', { params: { body_id: body.id, limit: 100 } });
|
||
setEvents(data || []);
|
||
} catch {
|
||
toast.error('加载天体事件失败');
|
||
setEvents([]);
|
||
} finally {
|
||
setEventsLoading(false);
|
||
}
|
||
}, [toast]);
|
||
|
||
const loadFollowedBodies = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await request.get<FollowedBody[]>('/social/follows');
|
||
const list = data || [];
|
||
setBodies(list);
|
||
if (list.length > 0) {
|
||
const next = list.find((item) => item.id === selectedBody?.id) ?? list[0];
|
||
setSelectedBody(next);
|
||
await loadEvents(next);
|
||
} else {
|
||
setSelectedBody(null);
|
||
setEvents([]);
|
||
}
|
||
} catch {
|
||
toast.error('加载关注列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [loadEvents, selectedBody?.id, toast]);
|
||
|
||
useEffect(() => {
|
||
void loadFollowedBodies();
|
||
// 仅在首次进入页面时加载关注列表,后续交互自行刷新。
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const handleSelectBody = async (body: FollowedBody) => {
|
||
setSelectedBody(body);
|
||
await loadEvents(body);
|
||
};
|
||
|
||
const handleUnfollow = async (bodyId: string) => {
|
||
try {
|
||
await request.delete(`/social/follow/${bodyId}`);
|
||
toast.success('已取消关注');
|
||
if (selectedBody?.id === bodyId) {
|
||
setSelectedBody(null);
|
||
setEvents([]);
|
||
}
|
||
await loadFollowedBodies();
|
||
} catch {
|
||
toast.error('取消关注失败');
|
||
}
|
||
};
|
||
|
||
const eventColumns: ColumnsType<BodyEvent> = [
|
||
{ title: '事件', dataIndex: 'title', key: 'title', ellipsis: true, width: '40%' },
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'event_type',
|
||
key: 'event_type',
|
||
width: 160,
|
||
render: (type: string) => (
|
||
<Tag color={EVENT_TYPE_COLORS[type] || 'default'}>{EVENT_TYPE_LABELS[type] || type}</Tag>
|
||
),
|
||
filters: Object.entries(EVENT_TYPE_LABELS).map(([value, text]) => ({ text, value })),
|
||
onFilter: (value, record) => record.event_type === value,
|
||
},
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'event_time',
|
||
key: 'event_time',
|
||
width: 180,
|
||
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
||
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<AdminPage
|
||
icon={<StarOutlined />}
|
||
title="我的天体" description="查看已关注天体及其相关天象事件">
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={24} lg={9} xl={8}>
|
||
<Card
|
||
className="adm-panel adm-scroll-panel"
|
||
title={
|
||
<span className="adm-section-title">
|
||
<StarFilled style={{ color: '#d4a72c' }} />
|
||
关注列表
|
||
<Tag>{bodies.length}</Tag>
|
||
</span>
|
||
}
|
||
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadFollowedBodies()} loading={loading}>刷新</Button>}
|
||
style={{ height: 520 }}
|
||
>
|
||
{bodies.length === 0 && !loading ? (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="还没有关注任何天体"
|
||
style={{ marginTop: 72 }}
|
||
>
|
||
<div className="adm-cell-sub">在可视化首页点击天体,进入详情后即可关注</div>
|
||
</Empty>
|
||
) : (
|
||
<div className="adm-list">
|
||
{bodies.map((body) => (
|
||
<div
|
||
key={body.id}
|
||
className={`adm-list-item ${selectedBody?.id === body.id ? 'is-selected' : ''}`}
|
||
onClick={() => void handleSelectBody(body)}
|
||
>
|
||
<StarFilled style={{ color: '#d4a72c', fontSize: 18 }} />
|
||
<div className="adm-list-item-body">
|
||
<div className="adm-list-item-title">
|
||
<span className="adm-cell-strong">{body.name_zh || body.name}</span>
|
||
<Tag color={BODY_TYPE_COLORS[body.type] || 'default'}>
|
||
{BODY_TYPE_LABELS[body.type] || body.type}
|
||
</Tag>
|
||
</div>
|
||
<div className="adm-cell-sub">
|
||
{body.followed_at
|
||
? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}`
|
||
: body.name}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="text"
|
||
danger
|
||
size="small"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void handleUnfollow(body.id);
|
||
}}
|
||
>
|
||
取消关注
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col xs={24} lg={15} xl={16}>
|
||
<div className="adm-stack">
|
||
<Card
|
||
className="adm-panel"
|
||
title={
|
||
selectedBody ? (
|
||
<span className="adm-section-title">
|
||
<RocketOutlined />
|
||
{selectedBody.name_zh || selectedBody.name}
|
||
<Tag color={BODY_TYPE_COLORS[selectedBody.type] || 'default'}>
|
||
{BODY_TYPE_LABELS[selectedBody.type] || selectedBody.type}
|
||
</Tag>
|
||
</span>
|
||
) : '天体资料'
|
||
}
|
||
>
|
||
{selectedBody ? (
|
||
<Descriptions column={2} size="small" bordered>
|
||
<Descriptions.Item label="ID">{selectedBody.id}</Descriptions.Item>
|
||
<Descriptions.Item label="类型">
|
||
{BODY_TYPE_LABELS[selectedBody.type] || 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 ? 'green' : 'default'}>
|
||
{selectedBody.is_active ? '活跃' : '已归档'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="关注时间">
|
||
{selectedBody.followed_at ? new Date(selectedBody.followed_at).toLocaleString('zh-CN') : '-'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
) : (
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请从左侧选择一个天体" />
|
||
)}
|
||
</Card>
|
||
|
||
<Card className="adm-panel" title="相关天体事件">
|
||
<Table
|
||
className="adm-table"
|
||
columns={eventColumns}
|
||
dataSource={events}
|
||
rowKey="id"
|
||
loading={eventsLoading}
|
||
size="small"
|
||
pagination={{ pageSize: 10, showSizeChanger: false, showTotal: (count) => `共 ${count} 条` }}
|
||
locale={{
|
||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无相关事件" />,
|
||
}}
|
||
expandable={{
|
||
expandedRowRender: (record) => (
|
||
<div style={{ padding: '4px 8px' }}>
|
||
<div><strong>描述:</strong>{record.description || '-'}</div>
|
||
{record.details ? (
|
||
<pre className="adm-detail-pre">{JSON.stringify(record.details, null, 2)}</pre>
|
||
) : null}
|
||
</div>
|
||
),
|
||
}}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
</AdminPage>
|
||
);
|
||
}
|