2025-12-26 01:21:15 +00:00
|
|
|
|
/**
|
2026-09-20 05:48:43 +00:00
|
|
|
|
* 我的天体(普通用户)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 左侧是已关注天体列表,右侧是天体资料与相关天象事件。
|
2025-12-26 01:21:15 +00:00
|
|
|
|
*/
|
2026-09-20 05:48:43 +00:00
|
|
|
|
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';
|
2025-12-26 01:21:15 +00:00
|
|
|
|
import type { ColumnsType } from 'antd/es/table';
|
2026-09-20 05:48:43 +00:00
|
|
|
|
|
2025-12-26 01:21:15 +00:00
|
|
|
|
import { request } from '../../utils/request';
|
|
|
|
|
|
import { useToast } from '../../contexts/ToastContext';
|
2026-09-20 05:48:43 +00:00
|
|
|
|
import { AdminPage } from '../../components/admin/AdminPage';
|
2025-12-26 01:21:15 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
interface FollowedBody {
|
2025-12-26 01:21:15 +00:00
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
2026-09-20 05:48:43 +00:00
|
|
|
|
name_zh?: string | null;
|
2025-12-26 01:21:15 +00:00
|
|
|
|
type: string;
|
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
|
followed_at?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
interface BodyEvent {
|
2025-12-26 01:21:15 +00:00
|
|
|
|
id: number;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
event_type: string;
|
|
|
|
|
|
event_time: string;
|
|
|
|
|
|
description: string;
|
2026-09-20 05:48:43 +00:00
|
|
|
|
details?: Record<string, unknown>;
|
2025-12-26 01:21:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-26 01:21:15 +00:00
|
|
|
|
export function MyCelestialBodies() {
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const [bodies, setBodies] = useState<FollowedBody[]>([]);
|
|
|
|
|
|
const [selectedBody, setSelectedBody] = useState<FollowedBody | null>(null);
|
|
|
|
|
|
const [events, setEvents] = useState<BodyEvent[]>([]);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
const [eventsLoading, setEventsLoading] = useState(false);
|
|
|
|
|
|
const toast = useToast();
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
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]);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const loadFollowedBodies = useCallback(async () => {
|
2025-12-26 01:21:15 +00:00
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
2026-09-20 05:48:43 +00:00
|
|
|
|
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([]);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
}
|
2026-09-20 05:48:43 +00:00
|
|
|
|
} catch {
|
2025-12-26 01:21:15 +00:00
|
|
|
|
toast.error('加载关注列表失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-09-20 05:48:43 +00:00
|
|
|
|
}, [loadEvents, selectedBody?.id, toast]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void loadFollowedBodies();
|
|
|
|
|
|
// 仅在首次进入页面时加载关注列表,后续交互自行刷新。
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, []);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const handleSelectBody = async (body: FollowedBody) => {
|
2025-12-26 01:21:15 +00:00
|
|
|
|
setSelectedBody(body);
|
2026-09-20 05:48:43 +00:00
|
|
|
|
await loadEvents(body);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleUnfollow = async (bodyId: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await request.delete(`/social/follow/${bodyId}`);
|
|
|
|
|
|
toast.success('已取消关注');
|
|
|
|
|
|
if (selectedBody?.id === bodyId) {
|
|
|
|
|
|
setSelectedBody(null);
|
2026-09-20 05:48:43 +00:00
|
|
|
|
setEvents([]);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
}
|
2026-09-20 05:48:43 +00:00
|
|
|
|
await loadFollowedBodies();
|
|
|
|
|
|
} catch {
|
2025-12-26 01:21:15 +00:00
|
|
|
|
toast.error('取消关注失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
const eventColumns: ColumnsType<BodyEvent> = [
|
|
|
|
|
|
{ title: '事件', dataIndex: 'title', key: 'title', ellipsis: true, width: '40%' },
|
2025-12-26 01:21:15 +00:00
|
|
|
|
{
|
|
|
|
|
|
title: '类型',
|
|
|
|
|
|
dataIndex: 'event_type',
|
|
|
|
|
|
key: 'event_type',
|
2026-09-20 05:48:43 +00:00
|
|
|
|
width: 160,
|
|
|
|
|
|
render: (type: string) => (
|
|
|
|
|
|
<Tag color={EVENT_TYPE_COLORS[type] || 'default'}>{EVENT_TYPE_LABELS[type] || type}</Tag>
|
2025-12-26 01:21:15 +00:00
|
|
|
|
),
|
2026-09-20 05:48:43 +00:00
|
|
|
|
filters: Object.entries(EVENT_TYPE_LABELS).map(([value, text]) => ({ text, value })),
|
2025-12-26 01:21:15 +00:00
|
|
|
|
onFilter: (value, record) => record.event_type === value,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '时间',
|
|
|
|
|
|
dataIndex: 'event_time',
|
|
|
|
|
|
key: 'event_time',
|
|
|
|
|
|
width: 180,
|
2026-09-20 05:48:43 +00:00
|
|
|
|
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
2025-12-26 01:21:15 +00:00
|
|
|
|
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<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>
|
2025-12-26 01:21:15 +00:00
|
|
|
|
<Button
|
2026-09-20 05:48:43 +00:00
|
|
|
|
type="text"
|
2025-12-26 01:21:15 +00:00
|
|
|
|
danger
|
|
|
|
|
|
size="small"
|
2026-09-20 05:48:43 +00:00
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
void handleUnfollow(body.id);
|
2025-12-26 01:21:15 +00:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
取消关注
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
2025-12-26 01:21:15 +00:00
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<Col xs={24} lg={15} xl={16}>
|
|
|
|
|
|
<div className="adm-stack">
|
2025-12-26 01:21:15 +00:00
|
|
|
|
<Card
|
2026-09-20 05:48:43 +00:00
|
|
|
|
className="adm-panel"
|
2025-12-26 01:21:15 +00:00
|
|
|
|
title={
|
2026-09-20 05:48:43 +00:00
|
|
|
|
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>
|
|
|
|
|
|
) : '天体资料'
|
2025-12-26 01:21:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
{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="请从左侧选择一个天体" />
|
|
|
|
|
|
)}
|
2025-12-26 01:21:15 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<Card className="adm-panel" title="相关天体事件">
|
2025-12-26 01:21:15 +00:00
|
|
|
|
<Table
|
2026-09-20 05:48:43 +00:00
|
|
|
|
className="adm-table"
|
2025-12-26 01:21:15 +00:00
|
|
|
|
columns={eventColumns}
|
2026-09-20 05:48:43 +00:00
|
|
|
|
dataSource={events}
|
2025-12-26 01:21:15 +00:00
|
|
|
|
rowKey="id"
|
|
|
|
|
|
loading={eventsLoading}
|
|
|
|
|
|
size="small"
|
2026-09-20 05:48:43 +00:00
|
|
|
|
pagination={{ pageSize: 10, showSizeChanger: false, showTotal: (count) => `共 ${count} 条` }}
|
2025-12-26 01:21:15 +00:00
|
|
|
|
locale={{
|
2026-09-20 05:48:43 +00:00
|
|
|
|
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无相关事件" />,
|
2025-12-26 01:21:15 +00:00
|
|
|
|
}}
|
|
|
|
|
|
expandable={{
|
|
|
|
|
|
expandedRowRender: (record) => (
|
2026-09-20 05:48:43 +00:00
|
|
|
|
<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}
|
2025-12-26 01:21:15 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
2026-09-20 05:48:43 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</AdminPage>
|
2025-12-26 01:21:15 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|