feat(admin): add event admin list with table, sort, filter, pagination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:29:46 +08:00
parent 55a43788fe
commit 4091fbced3
4 changed files with 366 additions and 2 deletions

View File

@@ -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 <div>TODO: Event Admin List</div>;
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<string | undefined>(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 <EventAdminListSkeleton />;
}
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 (
<EventAdminListView
events={(data as { events?: unknown[] } | undefined)?.events ?? []}
total={(data as { total?: number } | undefined)?.total ?? 0}
page={page}
pageSize={20}
sortBy={sortBy}
sortDir={sortDir}
typeFilter={typeFilter}
permissionLevel={permissionLevel}
onPageChange={setPage}
onSortChange={handleSortChange}
onTypeFilterChange={handleTypeFilterChange}
onDelete={eventId => void handleDelete(eventId)}
/>
);
}

View File

@@ -1,5 +1,19 @@
import { Skeleton } from '@/components/ui/skeleton';
export function EventAdminListSkeleton() {
return <Skeleton className="h-96 w-full" />;
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4 lg:p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-9 w-24" />
</div>
<Skeleton className="h-10 w-64" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
<Skeleton className="h-9 w-48 self-center" />
</div>
);
}

View File

@@ -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 (
<TableHead
className="cursor-pointer select-none"
onClick={() => onSortChange(column)}
>
<span className="flex items-center gap-1">
{children}
{isActive && (sortDir === 'asc' ? '↑' : '↓')}
</span>
</TableHead>
);
}
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 (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4 lg:p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Button asChild>
<Link to="/admin/events/new"></Link>
</Button>
</div>
{showTypeFilter && (
<div className="flex gap-2">
{[
{ label: '全部', value: undefined },
{ label: 'Official', value: 'official' },
{ label: 'Party', value: 'party' },
].map(opt => (
<Button
key={opt.label}
variant={typeFilter === opt.value ? 'default' : 'outline'}
size="sm"
onClick={() => onTypeFilterChange(opt.value)}
>
{opt.label}
</Button>
))}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<SortableHeader column="name" sortBy={sortBy} sortDir={sortDir} onSortChange={onSortChange}></SortableHeader>
<TableHead></TableHead>
<SortableHeader column="start_time" sortBy={sortBy} sortDir={sortDir} onSortChange={onSortChange}></SortableHeader>
<SortableHeader column="join_count" sortBy={sortBy} sortDir={sortDir} onSortChange={onSortChange}></SortableHeader>
<SortableHeader column="checkin_count" sortBy={sortBy} sortDir={sortDir} onSortChange={onSortChange}></SortableHeader>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map(event => (
<TableRow key={event.event_id}>
<TableCell>
{event.thumbnail
? <img src={event.thumbnail} alt="" className="size-10 rounded object-cover" />
: <div className="size-10 rounded bg-muted" />}
</TableCell>
<TableCell className="font-medium">{event.name}</TableCell>
<TableCell>
<Badge variant={event.type === 'official' ? 'default' : 'secondary'}>
{event.type === 'official' ? 'Official' : 'Party'}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(event.start_time).toLocaleDateString('zh-CN')}
</TableCell>
<TableCell>{event.join_count}</TableCell>
<TableCell>{event.checkin_count}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" asChild>
<Link to="/admin/events/$eventId" params={{ eventId: event.event_id }}>
<IconEdit className="size-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" asChild>
<Link to="/admin/events/$eventId/attendance" params={{ eventId: event.event_id }}>
<IconUsers className="size-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" asChild>
<Link to="/admin/events/$eventId/agenda" params={{ eventId: event.event_id }}>
<IconFileText className="size-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" asChild>
<Link to="/admin/events/$eventId/stats" params={{ eventId: event.event_id }}>
<IconCalendarEvent className="size-4" />
</Link>
</Button>
{isOfficialAdmin(permissionLevel) && (
<Button
variant="ghost"
size="icon"
onClick={() => {
// eslint-disable-next-line no-alert
if (window.confirm('确定要删除此活动吗?此操作不可撤销。')) {
onDelete(event.event_id);
}
}}
>
<IconTrash className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
</Button>
<span className="text-sm text-muted-foreground">
{page}
{' '}
/
{' '}
{totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
</Button>
</div>
)}
</div>
);
}

View File

@@ -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<typeof EventAdminListView>;
export default meta;
type Story = StoryObj<typeof meta>;
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: () => <EventAdminListSkeleton />,
};