|
|
@@ -0,0 +1,750 @@
|
|
|
+---
|
|
|
+description: "Shadcn-ui 管理页表单创建/编辑表单分离指令"
|
|
|
+---
|
|
|
+
|
|
|
+将 创建/编辑表单分离, Form组件也分开
|
|
|
+
|
|
|
+## 核心特性
|
|
|
+
|
|
|
+### 1. 类型安全表单
|
|
|
+- **后端Schema复用**:直接使用后端定义的 Zod Schema
|
|
|
+- **RPC类型提取**:从 Hono 客户端自动推断类型
|
|
|
+- **一致的类型定义**:前后端类型完全同步
|
|
|
+
|
|
|
+### 2. 表单状态管理(推荐:创建/编辑表单分离模式)
|
|
|
+- **分离表单实例**:为创建和编辑分别使用独立的表单实例
|
|
|
+- **类型安全**:创建使用CreateSchema,编辑使用UpdateSchema,避免类型冲突
|
|
|
+- **字段差异处理**:创建时的必填字段在编辑时变为可选,敏感字段特殊处理
|
|
|
+- **状态隔离**:两种模式的状态完全独立,避免交叉污染
|
|
|
+
|
|
|
+### 3. 统一的UI组件模式
|
|
|
+- **Shadcn-ui组件集成**:使用标准的 Shadcn-ui 表单组件
|
|
|
+- **响应式布局**:适配不同屏幕尺寸
|
|
|
+- **无障碍支持**:完整的 ARIA 属性支持
|
|
|
+
|
|
|
+## 开发模板
|
|
|
+
|
|
|
+### 基础结构模板(创建/编辑分离模式)
|
|
|
+```typescript
|
|
|
+// 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);
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+### 表单字段模板
|
|
|
+
|
|
|
+#### 文本输入框
|
|
|
+```typescript
|
|
|
+<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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+#### 邮箱输入框
|
|
|
+```typescript
|
|
|
+<FormField
|
|
|
+ control={form.control}
|
|
|
+ name="email"
|
|
|
+ render={({ field }) => (
|
|
|
+ <FormItem>
|
|
|
+ <FormLabel>邮箱</FormLabel>
|
|
|
+ <FormControl>
|
|
|
+ <Input type="email" placeholder="请输入邮箱" {...field} />
|
|
|
+ </FormControl>
|
|
|
+ <FormMessage />
|
|
|
+ </FormItem>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+#### 密码输入框
|
|
|
+```typescript
|
|
|
+<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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+#### 开关控件(布尔值)
|
|
|
+```typescript
|
|
|
+<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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+#### 可选字段处理
|
|
|
+```typescript
|
|
|
+// 创建时:必须提供值
|
|
|
+nickname: z.string().optional()
|
|
|
+
|
|
|
+// 更新时:完全可选
|
|
|
+nickname: z.string().optional()
|
|
|
+```
|
|
|
+
|
|
|
+### 对话框集成模板
|
|
|
+```typescript
|
|
|
+<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
|
+ <DialogContent className="sm:max-w-[500px]">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>{isCreateForm ? '创建' : '编辑'}用户</DialogTitle>
|
|
|
+ <DialogDescription>
|
|
|
+ {isCreateForm ? '创建新记录' : '编辑现有记录'}
|
|
|
+ </DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+
|
|
|
+ <Form {...(isCreateForm ? createForm : updateForm)}>
|
|
|
+ <form onSubmit={form.handleSubmit(isCreateForm ? handleCreate : handleUpdate)} className="space-y-4">
|
|
|
+ {/* 表单字段 */}
|
|
|
+
|
|
|
+ <DialogFooter>
|
|
|
+ <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
|
|
|
+ 取消
|
|
|
+ </Button>
|
|
|
+ <Button type="submit">
|
|
|
+ {isCreateForm ? '创建' : '更新'}
|
|
|
+ </Button>
|
|
|
+ </DialogFooter>
|
|
|
+ </form>
|
|
|
+ </Form>
|
|
|
+ </DialogContent>
|
|
|
+</Dialog>
|
|
|
+```
|
|
|
+
|
|
|
+## 高级表单组件模板
|
|
|
+
|
|
|
+### 头像选择器集成
|
|
|
+```typescript
|
|
|
+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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+### 下拉选择框
|
|
|
+```typescript
|
|
|
+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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+### 数字输入框
|
|
|
+```typescript
|
|
|
+<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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+### 日期选择器
|
|
|
+```typescript
|
|
|
+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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+### 文本域
|
|
|
+```typescript
|
|
|
+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>
|
|
|
+ )}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+### 复选框组
|
|
|
+```typescript
|
|
|
+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` 的最佳实践,创建/编辑表单分离模式通过以下方式解决创建/编辑时数据对象类型差异问题:
|
|
|
+
|
|
|
+#### 核心优势
|
|
|
+1. **类型安全**:创建使用 `CreateEntityDto`,编辑使用 `UpdateEntityDto`,避免类型冲突
|
|
|
+2. **字段差异处理**:创建时的必填字段在编辑时自动变为可选
|
|
|
+3. **敏感字段处理**:密码等敏感字段在编辑时可设为可选
|
|
|
+4. **状态隔离**:两种模式完全独立,避免状态污染
|
|
|
+
|
|
|
+#### 完整实现模板
|
|
|
+```typescript
|
|
|
+// 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('更新失败,请重试');
|
|
|
+ }
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+#### 对话框渲染模板
|
|
|
+```tsx
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+## 最佳实践
|
|
|
+
|
|
|
+### 1. 表单验证
|
|
|
+- 使用 Zod Schema 进行类型验证
|
|
|
+- 必填字段标记红色星号
|
|
|
+- 提供清晰的错误提示
|
|
|
+
|
|
|
+### 2. 用户体验
|
|
|
+- 表单提交时显示加载状态
|
|
|
+- 操作成功后显示 toast 通知
|
|
|
+- 支持键盘导航和提交
|
|
|
+
|
|
|
+### 3. 数据管理
|
|
|
+- 创建后自动刷新数据列表
|
|
|
+- 编辑时回填现有数据
|
|
|
+- 支持表单重置功能
|
|
|
+
|
|
|
+### 4. 响应式设计
|
|
|
+- 对话框最大宽度 `sm:max-w-[500px]`
|
|
|
+- 表单间距统一使用 `space-y-4`
|
|
|
+- 移动端友好的布局
|
|
|
+
|
|
|
+### 5. 性能优化
|
|
|
+- 使用 React.memo 优化表单组件
|
|
|
+- 合理使用 useCallback 和 useMemo
|
|
|
+- 避免不必要的重新渲染
|
|
|
+
|
|
|
+### 6. 错误处理
|
|
|
+- 统一的错误处理机制
|
|
|
+- 友好的错误提示信息
|
|
|
+- 网络错误重试机制
|
|
|
+
|
|
|
+## 使用示例
|
|
|
+
|
|
|
+### 完整实现参考
|
|
|
+```typescript
|
|
|
+// 创建记录
|
|
|
+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('更新失败,请重试');
|
|
|
+ }
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+## 组件导入清单
|
|
|
+```typescript
|
|
|
+// 表单相关
|
|
|
+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';
|
|
|
+```
|
|
|
+
|
|
|
+## 常见问题解决方案
|
|
|
+
|
|
|
+### 1. 表单默认值问题
|
|
|
+```typescript
|
|
|
+// 正确处理 null/undefined 值
|
|
|
+defaultValues: {
|
|
|
+ name: null, // 允许 null
|
|
|
+ description: undefined, // 允许 undefined
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 数字类型转换
|
|
|
+```typescript
|
|
|
+// 在 onChange 中转换类型
|
|
|
+onChange={(e) => field.onChange(Number(e.target.value))}
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 日期类型处理
|
|
|
+```typescript
|
|
|
+// 日期选择器返回值处理
|
|
|
+onSelect={(date) => field.onChange(date ? new Date(date) : null)}
|
|
|
+```
|
|
|
+
|
|
|
+### 4. 数组类型处理
|
|
|
+```typescript
|
|
|
+// 复选框组处理数组
|
|
|
+onCheckedChange={(checked) => {
|
|
|
+ const newValue = checked
|
|
|
+ ? [...field.value, item.id]
|
|
|
+ : field.value.filter(id => id !== item.id);
|
|
|
+ field.onChange(newValue);
|
|
|
+}}
|
|
|
+```
|
|
|
+
|
|
|
+### 5. 表单重置注意事项
|
|
|
+```typescript
|
|
|
+// 更新表单时正确重置
|
|
|
+updateForm.reset({
|
|
|
+ ...data,
|
|
|
+ password: undefined, // 密码字段特殊处理
|
|
|
+ confirmPassword: undefined,
|
|
|
+});
|