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

295 lines
10 KiB
TypeScript
Raw Normal View History

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