基于 src/client/admin-shadcn/pages/Users.tsx 中用户管理表单的实现,提取可复用的表单开发模式和最佳实践,适用于基于 Shadcn-ui 的管理后台表单开发。
// 1. 类型定义(使用后端真实类型)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
import { Input } from '@/client/components/ui/input';
import { Button } from '@/client/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
// 2. 分离的表单配置
const [isCreateForm, setIsCreateForm] = useState(true);
const [editingEntity, setEditingEntity] = useState<any>(null); // 用于存储编辑时的实体数据
// 3. 独立的表单实例
const createForm = useForm<CreateRequest>({
resolver: zodResolver(CreateEntityDto), // 使用创建专用的Schema
defaultValues: {
// 创建时必填字段的默认值
},
});
const updateForm = useForm<UpdateRequest>({
resolver: zodResolver(UpdateEntityDto), // 使用更新专用的Schema
defaultValues: {
// 更新时可选字段的默认值(会被实际数据覆盖)
},
});
// 4. 表单切换逻辑(核心模式)
const handleCreateEntity = () => {
setEditingEntity(null);
setIsCreateForm(true);
createForm.reset({
// 创建时的初始值(必填字段必须有值)
});
setIsModalOpen(true);
};
const handleEditEntity = (entity: EntityResponse) => {
setEditingEntity(entity);
setIsCreateForm(false);
updateForm.reset({
...entity,
// 特殊处理:敏感字段在编辑时设为可选
password: undefined, // 密码在更新时可选,不修改则留空
// 其他需要特殊处理的字段
});
setIsModalOpen(true);
};
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
用户名
<span className="text-red-500 ml-1">*</span>
</FormLabel>
<FormControl>
<Input placeholder="请输入用户名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input type="email" placeholder="请输入邮箱" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
密码
<span className="text-red-500 ml-1">*</span>
</FormLabel>
<FormControl>
<Input type="password" placeholder="请输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isDisabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">状态</FormLabel>
<FormDescription>
禁用后用户将无法登录系统
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === 1}
onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
/>
</FormControl>
</FormItem>
)}
/>
// 创建时:必须提供值
nickname: z.string().optional()
// 更新时:完全可选
nickname: z.string().optional()
import AvatarSelector from '@/client/admin-shadcn/components/AvatarSelector';
<FormField
control={form.control}
name="avatarFileId"
render={({ field }) => (
<FormItem>
<FormLabel>头像</FormLabel>
<FormControl>
<AvatarSelector
value={field.value || undefined}
onChange={(value) => field.onChange(value)}
maxSize={2}
uploadPath="/avatars"
uploadButtonText="上传头像"
previewSize="medium"
placeholder="选择头像"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>状态</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择状态" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="0">启用</SelectItem>
<SelectItem value="1">禁用</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="age"
render={({ field }) => (
<FormItem>
<FormLabel>年龄</FormLabel>
<FormControl>
<Input
type="number"
placeholder="请输入年龄"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
import { Popover, PopoverContent, PopoverTrigger } from '@/client/components/ui/popover';
import { Calendar } from '@/client/components/ui/calendar';
import { CalendarIcon } from 'lucide-react';
import { cn } from '@/client/lib/utils';
<FormField
control={form.control}
name="birthDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>出生日期</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "yyyy-MM-dd")
) : (
<span>选择日期</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
import { Textarea } from '@/client/components/ui/textarea';
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>描述</FormLabel>
<FormControl>
<Textarea
placeholder="请输入描述信息"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
import { Checkbox } from '@/client/components/ui/checkbox';
<FormField
control={form.control}
name="permissions"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel className="text-base">权限设置</FormLabel>
<FormDescription>
选择该用户拥有的权限
</FormDescription>
</div>
<div className="space-y-2">
{permissions.map((permission) => (
<FormField
key={permission.id}
control={form.control}
name="permissions"
render={({ field }) => {
return (
<FormItem
key={permission.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(permission.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, permission.id])
: field.onChange(
field.value?.filter(
(value) => value !== permission.id
)
)
}}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{permission.name}
</FormLabel>
<FormDescription>
{permission.description}
</FormDescription>
</div>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
基于 src/client/admin-shadcn/pages/Users.tsx 的最佳实践,创建/编辑表单分离模式通过以下方式解决创建/编辑时数据对象类型差异问题:
CreateEntityDto,编辑使用 UpdateEntityDto,避免类型冲突// 1. 类型定义(使用真实后端类型)
type CreateRequest = InferRequestType<typeof apiClient.$post>['json'];
type UpdateRequest = InferRequestType<typeof apiClient[':id']['$put']>['json'];
type EntityResponse = InferResponseType<typeof apiClient.$get, 200>['data'][0];
// 2. 状态管理
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntity, setEditingEntity] = useState<EntityResponse | null>(null);
const [isCreateForm, setIsCreateForm] = useState(true);
// 3. 分离的表单实例
const createForm = useForm<CreateRequest>({
resolver: zodResolver(CreateEntityDto),
defaultValues: {
// 创建时必须提供的默认值
},
});
const updateForm = useForm<UpdateRequest>({
resolver: zodResolver(UpdateEntityDto),
defaultValues: {
// 更新时的默认值(会被实际数据覆盖)
},
});
// 4. 表单操作函数
const handleCreate = () => {
setEditingEntity(null);
setIsCreateForm(true);
createForm.reset({
// 创建时的初始值
status: 1, // 示例:默认启用
});
setIsModalOpen(true);
};
const handleEdit = (entity: EntityResponse) => {
setEditingEntity(entity);
setIsCreateForm(false);
updateForm.reset({
...entity,
// 关键:处理创建/编辑字段差异
password: undefined, // 密码在更新时可选
confirmPassword: undefined,
// 其他需要特殊处理的字段
});
setIsModalOpen(true);
};
// 5. 提交处理
const handleCreateSubmit = async (data: CreateRequest) => {
try {
const res = await apiClient.$post({ json: data });
if (res.status !== 201) throw new Error('创建失败');
toast.success('创建成功');
setIsModalOpen(false);
refetch();
} catch (error) {
toast.error('创建失败,请重试');
}
};
const handleUpdateSubmit = async (data: UpdateRequest) => {
if (!editingEntity) return;
try {
const res = await apiClient[':id']['$put']({
param: { id: editingEntity.id },
json: data
});
if (res.status !== 200) throw new Error('更新失败');
toast.success('更新成功');
setIsModalOpen(false);
refetch();
} catch (error) {
toast.error('更新失败,请重试');
}
};
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isCreateForm ? '创建实体' : '编辑实体'}</DialogTitle>
<DialogDescription>
{isCreateForm ? '创建一个新的实体' : '编辑现有实体信息'}
</DialogDescription>
</DialogHeader>
{isCreateForm ? (
// 创建表单(独立渲染)
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
{/* 创建专用字段 - 必填 */}
<FormField
control={createForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
用户名
<span className="text-red-500 ml-1">*</span>
</FormLabel>
<FormControl>
<Input placeholder="请输入用户名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 创建时必填的密码 */}
<FormField
control={createForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
密码
<span className="text-red-500 ml-1">*</span>
</FormLabel>
<FormControl>
<Input type="password" placeholder="请输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
取消
</Button>
<Button type="submit">创建</Button>
</DialogFooter>
</form>
</Form>
) : (
// 编辑表单(独立渲染)
<Form {...updateForm}>
<form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
{/* 编辑专用字段 - 可选 */}
<FormField
control={updateForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
用户名
<span className="text-red-500 ml-1">*</span>
</FormLabel>
<FormControl>
<Input placeholder="请输入用户名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 编辑时可选的密码 */}
<FormField
control={updateForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>新密码</FormLabel>
<FormControl>
<Input type="password" placeholder="留空则不修改密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
取消
</Button>
<Button type="submit">更新</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
sm:max-w-[500px]space-y-4// 创建记录
const handleCreateSubmit = async (data: CreateFormData) => {
try {
const res = await apiClient.$post({ json: data });
if (res.status !== 201) throw new Error('创建失败');
toast.success('创建成功');
setIsModalOpen(false);
refetch();
} catch (error) {
toast.error('创建失败,请重试');
}
};
// 更新记录
const handleUpdateSubmit = async (data: UpdateFormData) => {
try {
const res = await apiClient[':id']['$put']({
param: { id: editingData.id },
json: data
});
if (res.status !== 200) throw new Error('更新失败');
toast.success('更新成功');
setIsModalOpen(false);
refetch();
} catch (error) {
toast.error('更新失败,请重试');
}
};
// 表单相关
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
import { Input } from '@/client/components/ui/input';
import { Button } from '@/client/components/ui/button';
import { Switch } from '@/client/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
import { Textarea } from '@/client/components/ui/textarea';
import { Checkbox } from '@/client/components/ui/checkbox';
// 对话框相关
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
// 高级组件
import { Popover, PopoverContent, PopoverTrigger } from '@/client/components/ui/popover';
import { Calendar } from '@/client/components/ui/calendar';
import AvatarSelector from '@/client/admin-shadcn/components/AvatarSelector';
// 表单工具
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { cn } from '@/client/lib/utils';
// 正确处理 null/undefined 值
defaultValues: {
name: null, // 允许 null
description: undefined, // 允许 undefined
}
// 在 onChange 中转换类型
onChange={(e) => field.onChange(Number(e.target.value))}
// 日期选择器返回值处理
onSelect={(date) => field.onChange(date ? new Date(date) : null)}
// 复选框组处理数组
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, item.id]
: field.value.filter(id => id !== item.id);
field.onChange(newValue);
}}
```typescript // 更新表单时正确重置 updateForm.reset({ ...data, password: undefined, // 密码字段特殊处理 confirmPassword: undefined, });