|
|
@@ -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>
|
|
|
);
|
|
|
-}
|
|
|
+};
|