From db67287fa7c880bc2de39bada78cefd4feb4bb0a Mon Sep 17 00:00:00 2001 From: Noa Virellia Date: Thu, 26 Mar 2026 14:38:41 +0800 Subject: [PATCH] feat(admin): add event create/edit form with validation Co-Authored-By: Claude Sonnet 4.6 --- .../events/event-admin-form.container.tsx | 128 ++++++- .../events/event-admin-form.skeleton.tsx | 15 +- .../admin/events/event-admin-form.view.tsx | 314 ++++++++++++++++++ .../admin/events/event-admin-form.stories.tsx | 53 +++ 4 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 src/components/admin/events/event-admin-form.view.tsx create mode 100644 src/stories/admin/events/event-admin-form.stories.tsx diff --git a/src/components/admin/events/event-admin-form.container.tsx b/src/components/admin/events/event-admin-form.container.tsx index 7b98933..94c586f 100644 --- a/src/components/admin/events/event-admin-form.container.tsx +++ b/src/components/admin/events/event-admin-form.container.tsx @@ -1,3 +1,127 @@ -export function EventAdminFormContainer(_props: { eventId?: string }) { - return
TODO: Event Admin Form
; +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 ; + } + + // 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 ( + + ); } diff --git a/src/components/admin/events/event-admin-form.skeleton.tsx b/src/components/admin/events/event-admin-form.skeleton.tsx index 855fdb7..1fe6242 100644 --- a/src/components/admin/events/event-admin-form.skeleton.tsx +++ b/src/components/admin/events/event-admin-form.skeleton.tsx @@ -1,5 +1,18 @@ import { Skeleton } from '@/components/ui/skeleton'; export function EventAdminFormSkeleton() { - return ; + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+ +
+ ); } diff --git a/src/components/admin/events/event-admin-form.view.tsx b/src/components/admin/events/event-admin-form.view.tsx new file mode 100644 index 0000000..830d9d7 --- /dev/null +++ b/src/components/admin/events/event-admin-form.view.tsx @@ -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; + +interface EventAdminFormViewProps { + mode: 'create' | 'edit'; + permissionLevel: number; + defaultValues?: Partial; + isAgendaPublishBlocked?: { pendingCount: number; unscheduledCount: number } | null; + onSubmit: (values: EventFormValues) => Promise; +} + +export function EventAdminFormView({ + mode, + permissionLevel, + defaultValues, + isAgendaPublishBlocked, + onSubmit, +}: EventAdminFormViewProps) { + const form = useForm({ + 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 ( +
+

{title}

+
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + className="flex flex-col gap-4" + > + {/* Immutable badges in edit mode */} + {mode === 'edit' && defaultValues?.type !== undefined && ( +
+
+ 类型 + + {defaultValues.type === 'official' ? 'Official' : 'Party'} + +
+
+ 实名认证 + + {defaultValues.enable_kyc ? '启用' : '未启用'} + +
+
+ )} + + {/* type selector — create mode only */} + {mode === 'create' && ( + + {field => ( + + 活动类型 + + + + )} + + )} + + {/* enable_kyc — create mode only */} + {mode === 'create' && ( + + {field => ( + + 启用实名认证 + field.handleChange(val)} + /> + + )} + + )} + + + {field => ( + + 名称 * + field.handleChange(e.target.value)} + /> + + + )} + + + + {field => ( + + 副标题 + field.handleChange(e.target.value)} + /> + + + )} + + + + {field => ( + + 封面图片链接 + field.handleChange(e.target.value)} + /> + + + )} + + + + {field => ( + + 开始时间 * + field.handleChange(e.target.value)} + /> + + + )} + + + + {field => ( + + 结束时间 * + field.handleChange(e.target.value)} + /> + + + )} + + + + {field => ( + + 活动介绍 (Markdown) +