feat(admin): add event create/edit form with validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:38:41 +08:00
parent b26d9ddada
commit db67287fa7
4 changed files with 507 additions and 3 deletions

View File

@@ -1,3 +1,127 @@
export function EventAdminFormContainer(_props: { eventId?: string }) {
return <div>TODO: Event Admin Form</div>;
import type { EventFormValues } from './event-admin-form.view';
import { useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { useAdminAgendaList } from '@/hooks/data/useAdminAgendaList';
import { useAdminEventDetail } from '@/hooks/data/useAdminEventDetail';
import { useCreateEvent, useCreateGuide, useUpdateEvent, useUpdateGuide } from '@/hooks/data/useAdminEventMutations';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { canManageEvents } from '@/lib/permissions';
import { base64ToUtf8 } from '@/lib/utils';
import { EventAdminFormSkeleton } from './event-admin-form.skeleton';
import { EventAdminFormView } from './event-admin-form.view';
interface EventAdminFormContainerProps {
eventId?: string;
}
export function EventAdminFormContainer({ eventId }: EventAdminFormContainerProps) {
const { data: userData } = useUserInfo();
const permissionLevel = userData.data?.permission_level ?? 0;
const navigate = useNavigate();
const mode = eventId !== undefined ? 'edit' : 'create';
// Fetch event detail (edit mode); placeholder returns undefined
const { data: eventData, isPending: isEventPending } = useAdminEventDetail(eventId ?? '');
// Fetch agenda list for pre-flight validation (edit mode)
const { data: agendaData } = useAdminAgendaList(eventId ?? '');
const { mutateAsync: createEvent } = useCreateEvent();
const { mutateAsync: updateEvent } = useUpdateEvent();
const { mutateAsync: createGuide } = useCreateGuide();
const { mutateAsync: updateGuide } = useUpdateGuide();
useEffect(() => {
if (!canManageEvents(permissionLevel)) {
void navigate({ to: '/' });
}
}, [permissionLevel, navigate]);
if (!canManageEvents(permissionLevel)) {
return null;
}
// In edit mode, wait for event data
if (mode === 'edit' && (isEventPending || eventData === undefined)) {
return <EventAdminFormSkeleton />;
}
// Derive pre-flight validation state from agenda data
const agendas = (agendaData as { agendas?: { status: string; start_time?: string; end_time?: string }[] } | undefined)?.agendas ?? [];
const pendingCount = agendas.filter(a => a.status === 'pending').length;
const unscheduledCount = agendas.filter(
a => a.status === 'approved' && (a.start_time === undefined || a.start_time === '' || a.end_time === undefined || a.end_time === ''),
).length;
const isAgendaPublishBlocked = mode === 'edit'
? { pendingCount, unscheduledCount }
: null;
// Map raw API data to form default values (decode base64 fields)
const rawEvent = eventData as {
name?: string;
subtitle?: string;
description?: string;
thumbnail?: string;
start_time?: string;
end_time?: string;
attendance_guide?: string;
type?: 'official' | 'party';
enable_kyc?: boolean;
is_agenda_published?: boolean;
} | undefined;
const defaultValues = rawEvent !== undefined
? {
name: rawEvent.name ?? '',
subtitle: rawEvent.subtitle ?? '',
description: base64ToUtf8(rawEvent.description ?? ''),
thumbnail: rawEvent.thumbnail ?? '',
start_time: rawEvent.start_time !== undefined ? rawEvent.start_time.slice(0, 16) : '',
end_time: rawEvent.end_time !== undefined ? rawEvent.end_time.slice(0, 16) : '',
attendance_guide: base64ToUtf8(rawEvent.attendance_guide ?? ''),
type: rawEvent.type ?? 'party',
enable_kyc: rawEvent.enable_kyc ?? false,
is_agenda_published: rawEvent.is_agenda_published ?? false,
}
: undefined;
async function handleSubmit(values: EventFormValues) {
if (mode === 'create') {
const created = await createEvent({ body: values }) as { data?: { event_id?: string } };
const newEventId = created?.data?.event_id;
if (newEventId !== undefined) {
try {
await createGuide({ body: { event_id: newEventId, attendance_guide: values.attendance_guide } });
}
catch {
toast('参会指南保存失败,活动已创建', { description: '请在编辑页面重新保存参会指南' });
}
void navigate({ to: '/admin/events/$eventId', params: { eventId: newEventId } });
}
}
else {
// parallel update: event + guide
const results = await Promise.allSettled([
updateEvent({ body: { ...values, event_id: eventId } }),
updateGuide({ body: { event_id: eventId, attendance_guide: values.attendance_guide } }),
]);
results.forEach((result) => {
if (result.status === 'rejected') {
toast.error('部分更新失败,请重试');
}
});
}
}
return (
<EventAdminFormView
mode={mode}
permissionLevel={permissionLevel}
defaultValues={defaultValues}
isAgendaPublishBlocked={isAgendaPublishBlocked}
onSubmit={handleSubmit}
/>
);
}

View File

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

View File

@@ -0,0 +1,314 @@
import { useForm } from '@tanstack/react-form';
import { toast } from 'sonner';
import z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { isOfficialAdmin } from '@/lib/permissions';
import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
const formSchema = z.object({
name: z.string().min(1, '名称不能为空'),
subtitle: z.string(),
description: z.string(),
thumbnail: z.string(),
start_time: z.string().min(1, '开始时间不能为空'),
end_time: z.string().min(1, '结束时间不能为空'),
attendance_guide: z.string(),
type: z.enum(['official', 'party']),
enable_kyc: z.boolean(),
is_agenda_published: z.boolean(),
}).refine(data => !data.start_time || !data.end_time || data.start_time < data.end_time, {
message: '结束时间必须晚于开始时间',
path: ['end_time'],
});
export type EventFormValues = z.infer<typeof formSchema>;
interface EventAdminFormViewProps {
mode: 'create' | 'edit';
permissionLevel: number;
defaultValues?: Partial<EventFormValues>;
isAgendaPublishBlocked?: { pendingCount: number; unscheduledCount: number } | null;
onSubmit: (values: EventFormValues) => Promise<void>;
}
export function EventAdminFormView({
mode,
permissionLevel,
defaultValues,
isAgendaPublishBlocked,
onSubmit,
}: EventAdminFormViewProps) {
const form = useForm<EventFormValues>({
defaultValues: {
name: defaultValues?.name ?? '',
subtitle: defaultValues?.subtitle ?? '',
description: base64ToUtf8(defaultValues?.description ?? ''),
thumbnail: defaultValues?.thumbnail ?? '',
start_time: defaultValues?.start_time ?? '',
end_time: defaultValues?.end_time ?? '',
attendance_guide: base64ToUtf8(defaultValues?.attendance_guide ?? ''),
type: defaultValues?.type ?? 'party',
enable_kyc: defaultValues?.enable_kyc ?? false,
is_agenda_published: defaultValues?.is_agenda_published ?? false,
},
validators: {
onBlur: formSchema,
},
onSubmit: async ({ value }) => {
try {
await onSubmit({
...value,
description: utf8ToBase64(value.description),
attendance_guide: utf8ToBase64(value.attendance_guide),
});
toast.success(mode === 'create' ? '活动创建成功' : '活动更新成功');
}
catch {
toast.error(mode === 'create' ? '创建失败,请重试' : '更新失败,请重试');
}
},
});
const title = mode === 'create' ? '创建活动' : '编辑活动';
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 lg:p-6">
<h2 className="text-lg font-semibold">{title}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="flex flex-col gap-4"
>
{/* Immutable badges in edit mode */}
{mode === 'edit' && defaultValues?.type !== undefined && (
<div className="flex gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium"></span>
<Badge variant="outline">
{defaultValues.type === 'official' ? 'Official' : 'Party'}
</Badge>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium"></span>
<Badge variant={defaultValues.enable_kyc ? 'default' : 'secondary'}>
{defaultValues.enable_kyc ? '启用' : '未启用'}
</Badge>
</div>
</div>
)}
{/* type selector — create mode only */}
{mode === 'create' && (
<form.Field name="type">
{field => (
<Field>
<FieldLabel htmlFor="type"></FieldLabel>
<Select
value={field.state.value}
onValueChange={val => field.handleChange(val as 'official' | 'party')}
disabled={!isOfficialAdmin(permissionLevel)}
>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="party">Party</SelectItem>
<SelectItem value="official">Official</SelectItem>
</SelectContent>
</Select>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
)}
{/* enable_kyc — create mode only */}
{mode === 'create' && (
<form.Field name="enable_kyc">
{field => (
<Field orientation="horizontal" className="my-2">
<FieldLabel htmlFor="enable_kyc"></FieldLabel>
<Switch
id="enable_kyc"
checked={field.state.value}
onCheckedChange={val => field.handleChange(val)}
/>
</Field>
)}
</form.Field>
)}
<form.Field name="name">
{field => (
<Field>
<FieldLabel htmlFor="name"> *</FieldLabel>
<Input
id="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="subtitle">
{field => (
<Field>
<FieldLabel htmlFor="subtitle"></FieldLabel>
<Input
id="subtitle"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="thumbnail">
{field => (
<Field>
<FieldLabel htmlFor="thumbnail"></FieldLabel>
<Input
id="thumbnail"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="start_time">
{field => (
<Field>
<FieldLabel htmlFor="start_time"> *</FieldLabel>
<Input
id="start_time"
type="datetime-local"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="end_time">
{field => (
<Field>
<FieldLabel htmlFor="end_time"> *</FieldLabel>
<Input
id="end_time"
type="datetime-local"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="description">
{field => (
<Field>
<FieldLabel htmlFor="description"> (Markdown)</FieldLabel>
<Textarea
id="description"
rows={6}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="attendance_guide">
{field => (
<Field>
<FieldLabel htmlFor="attendance_guide"> (Markdown)</FieldLabel>
<Textarea
id="attendance_guide"
rows={4}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
{/* is_agenda_published — edit mode only */}
{mode === 'edit' && (
<form.Field name="is_agenda_published">
{(field) => {
const isAlreadyPublished = field.state.value;
const blockError = !isAlreadyPublished && isAgendaPublishBlocked
? isAgendaPublishBlocked.pendingCount > 0
? `还有 ${isAgendaPublishBlocked.pendingCount} 个议程待审核`
: isAgendaPublishBlocked.unscheduledCount > 0
? `还有 ${isAgendaPublishBlocked.unscheduledCount} 个已通过议程未排期`
: null
: null;
return (
<Field orientation="horizontal" className="my-2">
<FieldLabel htmlFor="is_agenda_published"></FieldLabel>
<div className="flex flex-col gap-1">
<Switch
id="is_agenda_published"
checked={field.state.value}
disabled={isAlreadyPublished}
onCheckedChange={(val) => {
if (val && isAgendaPublishBlocked && (isAgendaPublishBlocked.pendingCount > 0 || isAgendaPublishBlocked.unscheduledCount > 0)) {
return;
}
field.handleChange(val);
}}
/>
{blockError !== null && (
<span className="text-destructive text-sm">{blockError}</span>
)}
{isAlreadyPublished && (
<span className="text-muted-foreground text-sm"></span>
)}
</div>
</Field>
);
}}
</form.Field>
)}
<div className="flex gap-2">
<form.Subscribe
selector={state => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit}>
{isSubmitting ? <Spinner /> : (mode === 'create' ? '创建' : '保存')}
</Button>
)}
/>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { EventAdminFormSkeleton } from '@/components/admin/events/event-admin-form.skeleton';
import { EventAdminFormView } from '@/components/admin/events/event-admin-form.view';
import { PERMISSION_LEVELS } from '@/lib/permissions';
const meta = {
title: 'Admin/Events/EventAdminForm',
component: EventAdminFormView,
} satisfies Meta<typeof EventAdminFormView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CreateLv30: Story = {
args: {
mode: 'create',
permissionLevel: PERMISSION_LEVELS.PARTY_EVENT_HOLDER,
onSubmit: async () => {},
},
};
export const CreateLv40: Story = {
args: {
mode: 'create',
permissionLevel: PERMISSION_LEVELS.OFFICIAL_ADMIN,
onSubmit: async () => {},
},
};
export const Edit: Story = {
args: {
mode: 'edit',
permissionLevel: PERMISSION_LEVELS.OFFICIAL_ADMIN,
defaultValues: {
name: 'NixOS 2026 Annual Meetup',
subtitle: '全国最大的 NixOS 用户聚会',
description: '这是活动的详细介绍内容。',
thumbnail: '',
start_time: '2026-04-15T09:00',
end_time: '2026-04-15T18:00',
attendance_guide: '请携带身份证参加活动。',
type: 'official',
enable_kyc: true,
is_agenda_published: false,
},
isAgendaPublishBlocked: { pendingCount: 2, unscheduledCount: 1 },
onSubmit: async () => {},
},
};
export const Loading: Story = {
render: () => <EventAdminFormSkeleton />,
};