Quellcode durchsuchen

✨ feat(membership): 重构会员套餐管理页面

- 添加搜索功能支持按名称筛选套餐
- 实现分页功能提升大数据量场景性能
- 新增删除确认对话框防止误操作
- 优化表单交互体验,支持功能列表多行输入
- 添加加载骨架屏提升用户体验
- 使用后端schema定义确保类型安全
- 改进UI布局增加状态徽章和格式化时间显示
yourname vor 3 Monaten
Ursprung
Commit
44f0c88263

+ 569 - 248
src/client/admin/pages/MembershipPlans.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery } from '@tanstack/react-query';
 import { Button } from '@/client/components/ui/button';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
 import {
@@ -14,9 +14,9 @@ import {
   Dialog,
   DialogContent,
   DialogDescription,
+  DialogFooter,
   DialogHeader,
   DialogTitle,
-  DialogTrigger,
 } from '@/client/components/ui/dialog';
 import {
   Form,
@@ -37,190 +37,355 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/client/components/ui/select';
-import { z } from 'zod';
 import { useForm } from 'react-hook-form';
 import { zodResolver } from '@hookform/resolvers/zod';
-import { Plus, Edit, Trash2 } from 'lucide-react';
+import { Plus, Edit, Trash2, Search } from 'lucide-react';
 import { membershipPlanClient } from '@/client/api';
 import type { InferResponseType, InferRequestType } from 'hono/client';
 import { toast } from 'sonner';
+import { z } from 'zod';
+import { CreateMembershipPlanDto, UpdateMembershipPlanDto, MembershipType } from '@/server/modules/membership/membership-plan.schema';
+import { format } from 'date-fns';
+import { Badge } from '@/client/components/ui/badge';
+import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
+import { Skeleton } from '@/client/components/ui/skeleton';
 
-type MembershipPlan = InferResponseType<typeof membershipPlanClient.$get, 200>['data'][0];
+// 类型定义
+type MembershipPlanResponse = InferResponseType<typeof membershipPlanClient.$get, 200>['data'][0];
+type MembershipPlanListResponse = InferResponseType<typeof membershipPlanClient.$get, 200>;
 type CreateMembershipPlanRequest = InferRequestType<typeof membershipPlanClient.$post>['json'];
 type UpdateMembershipPlanRequest = InferRequestType<typeof membershipPlanClient[':id']['$put']>['json'];
 
-const formSchema = z.object({
-  name: z.string().min(1, '套餐名称不能为空'),
-  type: z.enum(['single', 'monthly', 'yearly', 'lifetime']),
-  price: z.number().min(0, '价格必须大于等于0'),
-  durationDays: z.number().min(0, '有效期必须大于等于0'),
-  description: z.string().optional(),
-  features: z.string().optional(),
-  isActive: z.boolean(),
-  sortOrder: z.number().min(0),
-});
-
-export default function MembershipPlans() {
-  const queryClient = useQueryClient();
-  const [open, setOpen] = useState(false);
-  const [editingPlan, setEditingPlan] = useState<MembershipPlan | null>(null);
-
-  const { data: plans, isLoading } = useQuery({
-    queryKey: ['membership-plans'],
-    queryFn: async () => {
-      const response = await membershipPlanClient.$get();
-      if (!response.ok) throw new Error('获取套餐失败');
-      const data = await response.json();
-      return data.data;
-    },
+// 直接使用后端的schema
+const createFormSchema = CreateMembershipPlanDto;
+const updateFormSchema = UpdateMembershipPlanDto;
+
+// 类型映射
+const typeLabels: Record<MembershipType, string> = {
+  [MembershipType.SINGLE]: '单次',
+  [MembershipType.MONTHLY]: '单月',
+  [MembershipType.YEARLY]: '年',
+  [MembershipType.LIFETIME]: '永久',
+};
+
+export const MembershipPlans = () => {
+  // 状态管理
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
   });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [editingPlan, setEditingPlan] = useState<MembershipPlanResponse | null>(null);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [planToDelete, setPlanToDelete] = useState<number | null>(null);
 
-  const createMutation = useMutation({
-    mutationFn: async (data: CreateMembershipPlanRequest) => {
-      const response = await membershipPlanClient.$post({ json: data });
-      if (!response.ok) throw new Error('创建套餐失败');
-      return response.json();
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['membership-plans'] });
-      toast.success('套餐创建成功');
-      setOpen(false);
-    },
-    onError: (error) => {
-      toast.error(error.message);
+  // 表单实例
+  const createForm = useForm<CreateMembershipPlanRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      name: '',
+      type: MembershipType.MONTHLY,
+      price: 0,
+      durationDays: 30,
+      description: null,
+      features: [],
+      isActive: 1,
+      sortOrder: 0,
     },
   });
 
-  const updateMutation = useMutation({
-    mutationFn: async ({ id, data }: { id: number; data: UpdateMembershipPlanRequest }) => {
-      const response = await membershipPlanClient[':id'].$put({ 
-        param: { id },
-        json: data
-       });
-      if (!response.ok) throw new Error('更新套餐失败');
-      return response.json();
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['membership-plans'] });
-      toast.success('套餐更新成功');
-      setOpen(false);
-    },
-    onError: (error) => {
-      toast.error(error.message);
-    },
+  const updateForm = useForm<UpdateMembershipPlanRequest>({
+    resolver: zodResolver(updateFormSchema),
   });
 
-  const deleteMutation = useMutation({
-    mutationFn: async (id: number) => {
-      const response = await membershipPlanClient[':id'].$delete({
-        param: { id }
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['membership-plans', searchParams],
+    queryFn: async () => {
+      const res = await membershipPlanClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+        },
       });
-      if (!response.ok) throw new Error('删除套餐失败');
-      return response.json();
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['membership-plans'] });
-      toast.success('套餐删除成功');
-    },
-    onError: (error) => {
-      toast.error(error.message);
+      if (res.status !== 200) throw new Error('获取套餐列表失败');
+      return await res.json();
     },
   });
 
-  const form = useForm<z.infer<typeof formSchema>>({
-    resolver: zodResolver(formSchema),
-    defaultValues: {
-      name: '',
-      type: 'single',
-      price: 0,
-      durationDays: 0,
-      description: '',
-      features: '',
-      isActive: true,
-      sortOrder: 0,
-    },
-  });
+  // 业务逻辑函数
+  const handleSearch = () => {
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+    refetch();
+  };
 
-  const handleCreate = () => {
+  const handleCreatePlan = () => {
+    setIsCreateForm(true);
     setEditingPlan(null);
-    form.reset({
+    createForm.reset({
       name: '',
-      type: 'single',
-      price: 27,
-      durationDays: 1,
-      description: '',
-      features: '',
-      isActive: true,
+      type: MembershipType.MONTHLY,
+      price: 29.99,
+      durationDays: 30,
+      description: null,
+      features: [],
+      isActive: 1,
       sortOrder: 0,
     });
-    setOpen(true);
+    setIsModalOpen(true);
   };
 
-  const handleEdit = (plan: MembershipPlan) => {
+  const handleEditPlan = (plan: MembershipPlanResponse) => {
+    setIsCreateForm(false);
     setEditingPlan(plan);
-    form.reset({
+    updateForm.reset({
       name: plan.name,
       type: plan.type,
       price: plan.price,
       durationDays: plan.durationDays,
-      description: plan.description || '',
-      features: plan.features?.join(', ') || '',
-      isActive: plan.isActive === 1,
+      description: plan.description,
+      features: plan.features,
+      isActive: plan.isActive,
       sortOrder: plan.sortOrder,
     });
-    setOpen(true);
+    setIsModalOpen(true);
+  };
+
+  const handleDeletePlan = (id: number) => {
+    setPlanToDelete(id);
+    setDeleteDialogOpen(true);
   };
 
-  const handleSubmit = (values: z.infer<typeof formSchema>) => {
-    const data = {
-      ...values,
-      features: values.features?.split(',').map(f => f.trim()).filter(f => f) || [],
-      isActive: values.isActive ? 1 : 0,
-    };
-
-    if (editingPlan) {
-      updateMutation.mutate({ id: editingPlan.id, data });
-    } else {
-      createMutation.mutate(data);
+  const confirmDelete = async () => {
+    if (!planToDelete) return;
+
+    try {
+      const res = await membershipPlanClient[':id']['$delete']({
+        param: { id: planToDelete.toString() },
+      });
+      
+      if (res.status === 204) {
+        toast.success('删除成功');
+        setDeleteDialogOpen(false);
+        setPlanToDelete(null);
+        refetch();
+      } else {
+        const error = await res.json();
+        toast.error(error.message || '删除失败');
+      }
+    } catch (error) {
+      toast.error('删除失败,请重试');
     }
   };
 
-  const getTypeLabel = (type: string) => {
-    const labels = {
-      single: '单次',
-      monthly: '单月',
-      yearly: '年',
-      lifetime: '永久',
-    };
-    return labels[type as keyof typeof labels] || type;
+  const handleCreateSubmit = async (data: CreateMembershipPlanRequest) => {
+    try {
+      const res = await membershipPlanClient.$post({ json: data });
+      if (res.status === 201) {
+        toast.success('创建成功');
+        setIsModalOpen(false);
+        refetch();
+      } else {
+        const error = await res.json();
+        toast.error(error.message || '创建失败');
+      }
+    } catch (error) {
+      toast.error('创建失败,请重试');
+    }
   };
 
+  const handleUpdateSubmit = async (data: UpdateMembershipPlanRequest) => {
+    if (!editingPlan) return;
+
+    try {
+      const res = await membershipPlanClient[':id']['$put']({
+        param: { id: editingPlan.id.toString() },
+        json: data,
+      });
+      if (res.status === 200) {
+        toast.success('更新成功');
+        setIsModalOpen(false);
+        refetch();
+      } else {
+        const error = await res.json();
+        toast.error(error.message || '更新失败');
+      }
+    } catch (error) {
+      toast.error('更新失败,请重试');
+    }
+  };
+
+  // 渲染骨架屏
+  if (isLoading) {
+    return (
+      <div className="container mx-auto py-10 space-y-4">
+        <div className="flex justify-between items-center">
+          <Skeleton className="h-8 w-32" />
+          <Skeleton className="h-10 w-32" />
+        </div>
+        
+        <Card>
+          <CardHeader>
+            <Skeleton className="h-6 w-48" />
+            <Skeleton className="h-4 w-64" />
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-3">
+              {[...Array(5)].map((_, i) => (
+                <div key={i} className="flex gap-4">
+                  <Skeleton className="h-10 flex-1" />
+                  <Skeleton className="h-10 flex-1" />
+                  <Skeleton className="h-10 flex-1" />
+                  <Skeleton className="h-10 flex-1" />
+                  <Skeleton className="h-10 w-20" />
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
   return (
-    <div className="container mx-auto py-10">
-      <div className="flex justify-between items-center mb-6">
-        <h1 className="text-3xl font-bold">会员套餐管理</h1>
-        <Dialog open={open} onOpenChange={setOpen}>
-          <DialogTrigger asChild>
-            <Button onClick={handleCreate}>
-              <Plus className="w-4 h-4 mr-2" />
-              创建套餐
-            </Button>
-          </DialogTrigger>
-          <DialogContent className="max-w-2xl">
-            <DialogHeader>
-              <DialogTitle>{editingPlan ? '编辑套餐' : '创建套餐'}</DialogTitle>
-              <DialogDescription>
-                {editingPlan ? '修改套餐信息' : '创建新的会员套餐'}
-              </DialogDescription>
-            </DialogHeader>
-            <Form {...form}>
-              <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+    <div className="container mx-auto py-10 space-y-4">
+      {/* 页面标题区域 */}
+      <div className="flex justify-between items-center">
+        <h1 className="text-2xl font-bold">会员套餐管理</h1>
+        <Button onClick={handleCreatePlan}>
+          <Plus className="mr-2 h-4 w-4" />
+          创建套餐
+        </Button>
+      </div>
+
+      {/* 搜索区域 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>套餐列表</CardTitle>
+          <CardDescription>管理所有会员套餐</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="mb-4">
+            <form onSubmit={(e) => { e.preventDefault(); 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>
+
+          {/* 数据表格 */}
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>套餐名称</TableHead>
+                  <TableHead>类型</TableHead>
+                  <TableHead>价格</TableHead>
+                  <TableHead>有效期</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>排序</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data?.data.map((plan) => (
+                  <TableRow key={plan.id}>
+                    <TableCell className="font-medium">{plan.name}</TableCell>
+                    <TableCell>
+                      <Badge variant="outline">{typeLabels[plan.type]}</Badge>
+                    </TableCell>
+                    <TableCell>¥{plan.price.toFixed(2)}</TableCell>
+                    <TableCell>
+                      {plan.durationDays === 0 ? (
+                        <Badge variant="default">永久</Badge>
+                      ) : (
+                        `${plan.durationDays}天`
+                      )}
+                    </TableCell>
+                    <TableCell>
+                      <Badge variant={plan.isActive === 1 ? 'default' : 'secondary'}>
+                        {plan.isActive === 1 ? '启用' : '禁用'}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>{plan.sortOrder}</TableCell>
+                    <TableCell>
+                      {format(new Date(plan.createdAt), 'yyyy-MM-dd HH:mm')}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditPlan(plan)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeletePlan(plan.id)}
+                        >
+                          <Trash2 className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+
+            {data?.data.length === 0 && !isLoading && (
+              <div className="text-center py-8">
+                <p className="text-muted-foreground">暂无数据</p>
+              </div>
+            )}
+          </div>
+
+          {/* 分页 */}
+          <DataTablePagination
+            currentPage={searchParams.page}
+            pageSize={searchParams.limit}
+            totalCount={data?.pagination.total || 0}
+            onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
+          />
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑模态框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[600px] 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={form.control}
+                  control={createForm.control}
                   name="name"
                   render={({ field }) => (
                     <FormItem>
-                      <FormLabel>套餐名称</FormLabel>
+                      <FormLabel className="flex items-center">
+                        套餐名称
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
                       <FormControl>
                         <Input placeholder="请输入套餐名称" {...field} />
                       </FormControl>
@@ -228,13 +393,16 @@ export default function MembershipPlans() {
                     </FormItem>
                   )}
                 />
-                
+
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="type"
                   render={({ field }) => (
                     <FormItem>
-                      <FormLabel>套餐类型</FormLabel>
+                      <FormLabel className="flex items-center">
+                        套餐类型
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
                       <Select onValueChange={field.onChange} defaultValue={field.value}>
                         <FormControl>
                           <SelectTrigger>
@@ -242,10 +410,10 @@ export default function MembershipPlans() {
                           </SelectTrigger>
                         </FormControl>
                         <SelectContent>
-                          <SelectItem value="single">单次(24小时)</SelectItem>
-                          <SelectItem value="monthly">单月</SelectItem>
-                          <SelectItem value="yearly">年</SelectItem>
-                          <SelectItem value="lifetime">永久</SelectItem>
+                          <SelectItem value={MembershipType.SINGLE}>单次使用</SelectItem>
+                          <SelectItem value={MembershipType.MONTHLY}>单月会员</SelectItem>
+                          <SelectItem value={MembershipType.YEARLY}>年度会员</SelectItem>
+                          <SelectItem value={MembershipType.LIFETIME}>永久会员</SelectItem>
                         </SelectContent>
                       </Select>
                       <FormMessage />
@@ -254,18 +422,22 @@ export default function MembershipPlans() {
                 />
 
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="price"
                   render={({ field }) => (
                     <FormItem>
-                      <FormLabel>价格(元)</FormLabel>
+                      <FormLabel className="flex items-center">
+                        价格(元)
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
                       <FormControl>
-                        <Input 
-                          type="number" 
+                        <Input
+                          type="number"
                           step="0.01"
+                          min="0"
                           placeholder="请输入价格"
                           {...field}
-                          onChange={e => field.onChange(parseFloat(e.target.value))}
+                          onChange={(e) => field.onChange(parseFloat(e.target.value))}
                         />
                       </FormControl>
                       <FormMessage />
@@ -274,17 +446,21 @@ export default function MembershipPlans() {
                 />
 
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="durationDays"
                   render={({ field }) => (
                     <FormItem>
-                      <FormLabel>有效期(天)</FormLabel>
+                      <FormLabel className="flex items-center">
+                        有效期(天)
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
                       <FormControl>
-                        <Input 
+                        <Input
                           type="number"
+                          min="0"
                           placeholder="请输入有效期天数,0表示永久"
                           {...field}
-                          onChange={e => field.onChange(parseInt(e.target.value))}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
                         />
                       </FormControl>
                       <FormDescription>0表示永久有效</FormDescription>
@@ -294,16 +470,17 @@ export default function MembershipPlans() {
                 />
 
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="description"
                   render={({ field }) => (
                     <FormItem>
                       <FormLabel>套餐描述</FormLabel>
                       <FormControl>
-                        <Textarea 
+                        <Textarea
                           placeholder="请输入套餐描述"
                           className="resize-none"
                           {...field}
+                          value={field.value || ''}
                         />
                       </FormControl>
                       <FormMessage />
@@ -312,36 +489,45 @@ export default function MembershipPlans() {
                 />
 
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="features"
                   render={({ field }) => (
                     <FormItem>
                       <FormLabel>套餐功能</FormLabel>
                       <FormControl>
-                        <Textarea 
-                          placeholder="请输入套餐功能,用逗号分隔"
+                        <Textarea
+                          placeholder="请输入套餐功能,每行一个功能"
                           className="resize-none"
-                          {...field}
+                          rows={4}
+                          value={Array.isArray(field.value) ? field.value.join('\n') : ''}
+                          onChange={(e) => {
+                            const features = e.target.value
+                              .split('\n')
+                              .map(f => f.trim())
+                              .filter(f => f);
+                            field.onChange(features);
+                          }}
                         />
                       </FormControl>
-                      <FormDescription>多个功能用逗号分隔</FormDescription>
+                      <FormDescription>每行输入一个功能特性</FormDescription>
                       <FormMessage />
                     </FormItem>
                   )}
                 />
 
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="sortOrder"
                   render={({ field }) => (
                     <FormItem>
                       <FormLabel>排序</FormLabel>
                       <FormControl>
-                        <Input 
+                        <Input
                           type="number"
+                          min="0"
                           placeholder="请输入排序值"
                           {...field}
-                          onChange={e => field.onChange(parseInt(e.target.value))}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
                         />
                       </FormControl>
                       <FormDescription>数值越小排序越靠前</FormDescription>
@@ -351,7 +537,7 @@ export default function MembershipPlans() {
                 />
 
                 <FormField
-                  control={form.control}
+                  control={createForm.control}
                   name="isActive"
                   render={({ field }) => (
                     <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
@@ -361,99 +547,234 @@ export default function MembershipPlans() {
                       </div>
                       <FormControl>
                         <Switch
-                          checked={field.value}
-                          onCheckedChange={field.onChange}
+                          checked={field.value === 1}
+                          onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
                         />
                       </FormControl>
                     </FormItem>
                   )}
                 />
 
-                <div className="flex justify-end space-x-2">
-                  <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
                     取消
                   </Button>
-                  <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
-                    {editingPlan ? '更新' : '创建'}
-                  </Button>
-                </div>
+                  <Button type="submit">创建</Button>
+                </DialogFooter>
               </form>
             </Form>
-          </DialogContent>
-        </Dialog>
-      </div>
+          ) : (
+            <Form {...updateForm}>
+              <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
+                <FormField
+                  control={updateForm.control}
+                  name="name"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        套餐名称
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入套餐名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
 
-      <Card>
-        <CardHeader>
-          <CardTitle>套餐列表</CardTitle>
-          <CardDescription>管理所有会员套餐</CardDescription>
-        </CardHeader>
-        <CardContent>
-          <Table>
-            <TableHeader>
-              <TableRow>
-                <TableHead>套餐名称</TableHead>
-                <TableHead>类型</TableHead>
-                <TableHead>价格</TableHead>
-                <TableHead>有效期</TableHead>
-                <TableHead>状态</TableHead>
-                <TableHead>排序</TableHead>
-                <TableHead>创建时间</TableHead>
-                <TableHead>操作</TableHead>
-              </TableRow>
-            </TableHeader>
-            <TableBody>
-              {isLoading ? (
-                <TableRow>
-                  <TableCell colSpan={8} className="text-center">加载中...</TableCell>
-                </TableRow>
-              ) : (
-                plans?.map((plan) => (
-                  <TableRow key={plan.id}>
-                    <TableCell>{plan.name}</TableCell>
-                    <TableCell>{getTypeLabel(plan.type)}</TableCell>
-                    <TableCell>¥{plan.price}</TableCell>
-                    <TableCell>
-                      {plan.durationDays === 0 ? '永久' : `${plan.durationDays}天`}
-                    </TableCell>
-                    <TableCell>
-                      <span className={plan.isActive ? 'text-green-600' : 'text-red-600'}>
-                        {plan.isActive ? '启用' : '禁用'}
-                      </span>
-                    </TableCell>
-                    <TableCell>{plan.sortOrder}</TableCell>
-                    <TableCell>
-                      {new Date(plan.createdAt).toLocaleDateString()}
-                    </TableCell>
-                    <TableCell>
-                      <div className="flex space-x-2">
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          onClick={() => handleEdit(plan)}
-                        >
-                          <Edit className="w-4 h-4" />
-                        </Button>
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          onClick={() => {
-                            if (window.confirm('确定要删除这个套餐吗?')) {
-                              deleteMutation.mutate(plan.id);
-                            }
+                <FormField
+                  control={updateForm.control}
+                  name="type"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        套餐类型
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <Select onValueChange={field.onChange} defaultValue={field.value}>
+                        <FormControl>
+                          <SelectTrigger>
+                            <SelectValue placeholder="选择套餐类型" />
+                          </SelectTrigger>
+                        </FormControl>
+                        <SelectContent>
+                          <SelectItem value={MembershipType.SINGLE}>单次使用</SelectItem>
+                          <SelectItem value={MembershipType.MONTHLY}>单月会员</SelectItem>
+                          <SelectItem value={MembershipType.YEARLY}>年度会员</SelectItem>
+                          <SelectItem value={MembershipType.LIFETIME}>永久会员</SelectItem>
+                        </SelectContent>
+                      </Select>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="price"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        价格(元)
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          step="0.01"
+                          min="0"
+                          placeholder="请输入价格"
+                          {...field}
+                          onChange={(e) => field.onChange(parseFloat(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="durationDays"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        有效期(天)
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          min="0"
+                          placeholder="请输入有效期天数,0表示永久"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormDescription>0表示永久有效</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="description"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>套餐描述</FormLabel>
+                      <FormControl>
+                        <Textarea
+                          placeholder="请输入套餐描述"
+                          className="resize-none"
+                          {...field}
+                          value={field.value || ''}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="features"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>套餐功能</FormLabel>
+                      <FormControl>
+                        <Textarea
+                          placeholder="请输入套餐功能,每行一个功能"
+                          className="resize-none"
+                          rows={4}
+                          value={Array.isArray(field.value) ? field.value.join('\n') : ''}
+                          onChange={(e) => {
+                            const features = e.target.value
+                              .split('\n')
+                              .map(f => f.trim())
+                              .filter(f => f);
+                            field.onChange(features);
                           }}
-                        >
-                          <Trash2 className="w-4 h-4" />
-                        </Button>
+                        />
+                      </FormControl>
+                      <FormDescription>每行输入一个功能特性</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="sortOrder"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>排序</FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          min="0"
+                          placeholder="请输入排序值"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormDescription>数值越小排序越靠前</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="isActive"
+                  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>
-                    </TableCell>
-                  </TableRow>
-                ))
-              )}
-            </TableBody>
-          </Table>
-        </CardContent>
-      </Card>
+                      <FormControl>
+                        <Switch
+                          checked={field.value === 1}
+                          onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
+                        />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit">更新</Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 删除确认对话框 */}
+      <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>
     </div>
   );
-}
+};

+ 13 - 13
src/server/modules/membership/membership-plan.schema.ts

@@ -21,11 +21,11 @@ export const MembershipPlanSchema = z.object({
     description: '套餐类型:single-单次,monthly-单月,yearly-年,lifetime-永久',
     example: MembershipType.MONTHLY
   }),
-  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能小于0').max(999999.99, '价格不能超过999999.99').openapi({
+  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能小于0').max(999999.99, '价格不能超过999999.99').openapi({
     description: '价格',
     example: 29.99
   }),
-  durationDays: z.coerce.number().int('有效期天数必须是整数').min(0, '有效期天数不能小于0').openapi({
+  durationDays: z.coerce.number<number>().int('有效期天数必须是整数').min(0, '有效期天数不能小于0').openapi({
     description: '有效期天数(永久为0)',
     example: 30
   }),
@@ -37,11 +37,11 @@ export const MembershipPlanSchema = z.object({
     description: '套餐功能列表',
     example: ['无限制文档处理', '高级AI功能', '优先客服支持']
   }),
-  isActive: z.coerce.number().int().min(0).max(1).default(1).openapi({
+  isActive: z.coerce.number<number>().int().min(0).max(1).default(1).openapi({
     description: '是否启用(0-禁用,1-启用)',
     example: 1
   }),
-  sortOrder: z.coerce.number().int().default(0).openapi({
+  sortOrder: z.coerce.number<number>().int().default(0).openapi({
     description: '排序',
     example: 0
   }),
@@ -61,15 +61,15 @@ export const CreateMembershipPlanDto = z.object({
     description: '套餐名称',
     example: '月度会员'
   }),
-  type: z.nativeEnum(MembershipType).openapi({
+  type: z.enum(MembershipType).openapi({
     description: '套餐类型:single-单次,monthly-单月,yearly-年,lifetime-永久',
     example: MembershipType.MONTHLY
   }),
-  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0.01, '价格必须大于0').max(999999.99, '价格不能超过999999.99').openapi({
+  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0.01, '价格必须大于0').max(999999.99, '价格不能超过999999.99').openapi({
     description: '价格',
     example: 29.99
   }),
-  durationDays: z.coerce.number().int('有效期天数必须是整数').min(0, '有效期天数不能小于0').openapi({
+  durationDays: z.coerce.number<number>().int('有效期天数必须是整数').min(0, '有效期天数不能小于0').openapi({
     description: '有效期天数(永久为0)',
     example: 30
   }),
@@ -81,11 +81,11 @@ export const CreateMembershipPlanDto = z.object({
     description: '套餐功能列表',
     example: ['无限制文档处理', '高级AI功能', '优先客服支持']
   }),
-  isActive: z.coerce.number().int().min(0).max(1).default(1).optional().openapi({
+  isActive: z.coerce.number<number>().int().min(0).max(1).default(1).optional().openapi({
     description: '是否启用(0-禁用,1-启用)',
     example: 1
   }),
-  sortOrder: z.coerce.number().int().default(0).optional().openapi({
+  sortOrder: z.coerce.number<number>().int().default(0).optional().openapi({
     description: '排序',
     example: 0
   })
@@ -101,11 +101,11 @@ export const UpdateMembershipPlanDto = z.object({
     description: '套餐类型:single-单次,monthly-单月,yearly-年,lifetime-永久',
     example: MembershipType.YEARLY
   }),
-  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0.01, '价格必须大于0').max(999999.99, '价格不能超过999999.99').optional().openapi({
+  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0.01, '价格必须大于0').max(999999.99, '价格不能超过999999.99').optional().openapi({
     description: '价格',
     example: 39.99
   }),
-  durationDays: z.coerce.number().int('有效期天数必须是整数').min(0, '有效期天数不能小于0').optional().openapi({
+  durationDays: z.coerce.number<number>().int('有效期天数必须是整数').min(0, '有效期天数不能小于0').optional().openapi({
     description: '有效期天数(永久为0)',
     example: 365
   }),
@@ -117,11 +117,11 @@ export const UpdateMembershipPlanDto = z.object({
     description: '套餐功能列表',
     example: ['更新后的功能1', '更新后的功能2']
   }),
-  isActive: z.coerce.number().int().min(0).max(1).optional().openapi({
+  isActive: z.coerce.number<number>().int().min(0).max(1).optional().openapi({
     description: '是否启用(0-禁用,1-启用)',
     example: 1
   }),
-  sortOrder: z.coerce.number().int().optional().openapi({
+  sortOrder: z.coerce.number<number>().int().optional().openapi({
     description: '排序',
     example: 1
   })