feat(admin): add event create/edit form with validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
314
src/components/admin/events/event-admin-form.view.tsx
Normal file
314
src/components/admin/events/event-admin-form.view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/stories/admin/events/event-admin-form.stories.tsx
Normal file
53
src/stories/admin/events/event-admin-form.stories.tsx
Normal 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 />,
|
||||
};
|
||||
Reference in New Issue
Block a user