基于 src/client/admin-shadcn/pages/Users.tsx 中用户管理页的实现,提取可复用的开发模式和最佳实践,适用于基于 Shadcn-ui 的管理后台页面开发。
src/client/admin-shadcn/pages/[EntityName].tsx// 1. 类型导入和定义
type CreateRequest = InferRequestType<typeof client.$post>['json'];
type UpdateRequest = InferRequestType<typeof client[':id']['$put']>['json'];
type EntityResponse = InferResponseType<typeof client.$get, 200>['data'][0];
// 2. 表单Schema直接使用后端定义
const createFormSchema = CreateEntityDto;
const updateFormSchema = UpdateEntityDto;
// 3. 主页面组件
export const EntityPage = () => {
// 状态管理
const [searchParams, setSearchParams] = useState({ page: 1, limit: 10, search: '' });
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntity, setEditingEntity] = useState<any>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [entityToDelete, setEntityToDelete] = useState<number | null>(null);
// 表单实例
const createForm = useForm<CreateRequest>({...});
const updateForm = useForm<UpdateRequest>({...});
// 数据查询
const { data, isLoading, refetch } = useQuery({...});
// 业务逻辑函数
const handleSearch = () => {...};
const handleCreateEntity = () => {...};
const handleEditEntity = () => {...};
const handleDeleteEntity = () => {...};
// 渲染
return (...);
};
InferRequestType 和 InferResponseType 从后端API自动提取类型// 分页和搜索参数
const [searchParams, setSearchParams] = useState({
page: 1,
limit: 10,
search: '',
// 其他筛选条件...
});
// 模态框状态
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntity, setEditingEntity] = useState<any>(null);
const [isCreateForm, setIsCreateForm] = useState(true);
const { data, isLoading, refetch } = useQuery({
queryKey: ['entities', searchParams],
queryFn: async () => {
const res = await entityClient.$get({
query: {
page: searchParams.page,
pageSize: searchParams.limit,
keyword: searchParams.search,
// 其他查询参数...
}
});
if (res.status !== 200) throw new Error('获取列表失败');
return await res.json();
}
});
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">页面标题</h1>
<Button onClick={handleCreateEntity}>
<Plus className="mr-2 h-4 w-4" />
创建实体
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>列表标题</CardTitle>
<CardDescription>列表描述信息</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索提示..."
value={searchParams.search}
onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
className="pl-8"
/>
</div>
<Button type="submit" variant="outline">
搜索
</Button>
</form>
</div>
</CardContent>
</Card>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>列标题1</TableHead>
<TableHead>列标题2</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.field1}</TableCell>
<TableCell>{item.field2}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => handleEdit(item)}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
// 创建表单
const createForm = useForm<CreateRequest>({
resolver: zodResolver(createFormSchema),
defaultValues: {
// 默认值设置
},
});
// 更新表单
const updateForm = useForm<UpdateRequest>({
resolver: zodResolver(updateFormSchema),
defaultValues: {
// 更新时默认值
},
});
<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">
{/* 创建专用字段 */}
<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">
{/* 编辑专用字段 */}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
取消
</Button>
<Button type="submit">更新</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
<FormField
control={form.control}
name="fieldName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center">
字段标签
{isRequired && <span className="text-red-500 ml-1">*</span>}
</FormLabel>
<FormControl>
<Input placeholder="请输入..." {...field} />
</FormControl>
<FormDescription>字段描述信息</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarFileId"
render={({ field }) => (
<FormItem>
<FormLabel>头像</FormLabel>
<FormControl>
<AvatarSelector
value={field.value || undefined}
onChange={(value) => field.onChange(value)}
maxSize={2} // MB
uploadPath="/avatars"
uploadButtonText="上传头像"
previewSize="medium"
placeholder="选择头像"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
// 操作成功后刷新数据
const handleCreateSubmit = async (data: CreateRequest) => {
try {
const res = await entityClient.$post({ json: data });
if (res.status !== 201) throw new Error('创建失败');
toast.success('创建成功');
setIsModalOpen(false);
refetch(); // 刷新数据
} catch (error) {
toast.error('操作失败,请重试');
}
};
if (isLoading) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">页面标题</h1>
<Button disabled>
<Plus className="mr-2 h-4 w-4" />
创建实体
</Button>
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-1/4" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</CardContent>
</Card>
</div>
);
}
{users.length === 0 && !isLoading && (
<div className="text-center py-8">
<p className="text-muted-foreground">暂无数据</p>
</div>
)}
try {
const res = await entityClient.$post({ json: data });
if (res.status !== 201) throw new Error('操作失败');
toast.success('操作成功');
} catch (error) {
console.error('操作失败:', error);
toast.error('操作失败,请重试');
}
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [entityToDelete, setEntityToDelete] = useState<number | null>(null);
// 删除确认对话框
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
<DialogDescription>
确定要删除这个实体吗?此操作无法撤销。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
取消
</Button>
<Button variant="destructive" onClick={confirmDelete}>
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
space-y-4space-y-4space-y-4gap-2sm:max-w-[500px]max-h-[90vh]max-w-smtext-2xl font-boldtext-lg font-semiboldtext-sm text-muted-foregroundUsers.tsx 作为模板InputTextareaSelectSwitchDatePickerAvatarSelector