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:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
220
src/components/admin/events/event-admin-list.view.tsx
Normal file
220
src/components/admin/events/event-admin-list.view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/stories/admin/events/event-admin-list.stories.tsx
Normal file
44
src/stories/admin/events/event-admin-list.stories.tsx
Normal 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 />,
|
||||
};
|
||||
Reference in New Issue
Block a user