diff --git a/src/components/admin/events/event-admin-list.container.tsx b/src/components/admin/events/event-admin-list.container.tsx index a661875..c02635e 100644 --- a/src/components/admin/events/event-admin-list.container.tsx +++ b/src/components/admin/events/event-admin-list.container.tsx @@ -1,3 +1,89 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { useDeleteEvent } from '@/hooks/data/useAdminEventMutations'; +import { useAdminEvents } from '@/hooks/data/useAdminEvents'; +import { useUserInfo } from '@/hooks/data/useUserInfo'; +import { canManageEvents } from '@/lib/permissions'; +import { EventAdminListSkeleton } from './event-admin-list.skeleton'; +import { EventAdminListView } from './event-admin-list.view'; + export function EventAdminListContainer() { - return
TODO: Event Admin List
; + const { data: userData } = useUserInfo(); + const permissionLevel = userData.data?.permission_level ?? 0; + const navigate = useNavigate(); + + // All hooks MUST be called before any conditional return (Rules of Hooks) + const [page, setPage] = useState(1); + const [sortBy, setSortBy] = useState('start_time'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [typeFilter, setTypeFilter] = useState(undefined); + + const { data, isPending } = useAdminEvents({ + type: typeFilter, + page, + page_size: 20, + sort_by: sortBy, + sort_dir: sortDir, + }); + + const { mutateAsync: deleteEvent } = useDeleteEvent(); + + // Permission guard — redirect after all hooks are called + useEffect(() => { + if (!canManageEvents(permissionLevel)) { + void navigate({ to: '/' }); + } + }, [permissionLevel, navigate]); + + if (!canManageEvents(permissionLevel)) { + return null; + } + + if (isPending) { + return ; + } + + function handleSortChange(column: string) { + if (sortBy === column) { + setSortDir(prev => prev === 'asc' ? 'desc' : 'asc'); + } + else { + setSortBy(column); + setSortDir('desc'); + } + } + + function handleTypeFilterChange(type: string | undefined) { + setTypeFilter(type); + setPage(1); + } + + async function handleDelete(eventId: string) { + try { + await deleteEvent({ event_id: eventId }); + toast.success('活动已删除'); + // TODO: invalidate admin events query after pnpm gen + } + catch { + toast.error('删除失败,请重试'); + } + } + + return ( + void handleDelete(eventId)} + /> + ); } diff --git a/src/components/admin/events/event-admin-list.skeleton.tsx b/src/components/admin/events/event-admin-list.skeleton.tsx index 0a5e423..77974ed 100644 --- a/src/components/admin/events/event-admin-list.skeleton.tsx +++ b/src/components/admin/events/event-admin-list.skeleton.tsx @@ -1,5 +1,19 @@ import { Skeleton } from '@/components/ui/skeleton'; export function EventAdminListSkeleton() { - return ; + return ( +
+
+ + +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ ); } diff --git a/src/components/admin/events/event-admin-list.view.tsx b/src/components/admin/events/event-admin-list.view.tsx new file mode 100644 index 0000000..0c706eb --- /dev/null +++ b/src/components/admin/events/event-admin-list.view.tsx @@ -0,0 +1,220 @@ +import type React from 'react'; +import { + IconCalendarEvent, + IconEdit, + IconFileText, + IconTrash, + IconUsers, +} from '@tabler/icons-react'; +import { Link } from '@tanstack/react-router'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { isOfficialAdmin } from '@/lib/permissions'; + +export interface AdminEventRow { + event_id: string; + name: string; + type: string; + thumbnail: string; + start_time: string; + end_time: string; + join_count: number; + checkin_count: number; + enable_kyc: boolean; +} + +interface EventAdminListViewProps { + events: AdminEventRow[]; + total: number; + page: number; + pageSize: number; + sortBy: string; + sortDir: 'asc' | 'desc'; + typeFilter: string | undefined; + permissionLevel: number; + onPageChange: (page: number) => void; + onSortChange: (sortBy: string) => void; + onTypeFilterChange: (type: string | undefined) => void; + onDelete: (eventId: string) => void; +} + +interface SortableHeaderProps { + column: string; + sortBy: string; + sortDir: 'asc' | 'desc'; + onSortChange: (col: string) => void; + children: React.ReactNode; +} + +function SortableHeader({ column, sortBy, sortDir, onSortChange, children }: SortableHeaderProps) { + const isActive = sortBy === column; + return ( + onSortChange(column)} + > + + {children} + {isActive && (sortDir === 'asc' ? '↑' : '↓')} + + + ); +} + +export function EventAdminListView({ + events, + total, + page, + pageSize, + sortBy, + sortDir, + typeFilter, + permissionLevel, + onPageChange, + onSortChange, + onTypeFilterChange, + onDelete, +}: EventAdminListViewProps) { + const totalPages = Math.ceil(total / pageSize); + const showTypeFilter = isOfficialAdmin(permissionLevel); + + return ( +
+
+

活动管理

+ +
+ + {showTypeFilter && ( +
+ {[ + { label: '全部', value: undefined }, + { label: 'Official', value: 'official' }, + { label: 'Party', value: 'party' }, + ].map(opt => ( + + ))} +
+ )} + + + + + + 名称 + 类型 + 开始时间 + 报名数 + 签到数 + 操作 + + + + {events.map(event => ( + + + {event.thumbnail + ? + :
} + + {event.name} + + + {event.type === 'official' ? 'Official' : 'Party'} + + + + {new Date(event.start_time).toLocaleDateString('zh-CN')} + + {event.join_count} + {event.checkin_count} + +
+ + + + + {isOfficialAdmin(permissionLevel) && ( + + )} +
+
+ + ))} + +
+ + {totalPages > 1 && ( +
+ + + {page} + {' '} + / + {' '} + {totalPages} + + +
+ )} +
+ ); +} diff --git a/src/stories/admin/events/event-admin-list.stories.tsx b/src/stories/admin/events/event-admin-list.stories.tsx new file mode 100644 index 0000000..93152cc --- /dev/null +++ b/src/stories/admin/events/event-admin-list.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { AdminEventRow } from '@/components/admin/events/event-admin-list.view'; +import { EventAdminListSkeleton } from '@/components/admin/events/event-admin-list.skeleton'; +import { EventAdminListView } from '@/components/admin/events/event-admin-list.view'; +import { PERMISSION_LEVELS } from '@/lib/permissions'; +import { exampleAdminEvents } from '../../admin.examples'; + +const meta = { + title: 'Admin/Events/EventAdminList', + component: EventAdminListView, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseArgs = { + events: exampleAdminEvents as AdminEventRow[], + total: 2, + page: 1, + pageSize: 20, + sortBy: 'start_time', + sortDir: 'desc' as const, + typeFilter: undefined, + permissionLevel: PERMISSION_LEVELS.OFFICIAL_ADMIN, + onPageChange: () => {}, + onSortChange: () => {}, + onTypeFilterChange: () => {}, + onDelete: () => {}, +}; + +export const Lv40: Story = { + args: baseArgs, +}; + +export const Lv30: Story = { + args: { + ...baseArgs, + permissionLevel: PERMISSION_LEVELS.PARTY_EVENT_HOLDER, + }, +}; + +export const Loading: Story = { + render: () => , +};