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

295 lines
10 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.

/**
* 我的天体(普通用户)
*
* 左侧是已关注天体列表,右侧是天体资料与相关天象事件。
*/
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>
);
}