Răsfoiți Sursa

✨ feat(coupon): add coupon log management page

- create coupon log management page with CRUD functionality
- implement data fetching and pagination for coupon logs
- add search functionality for coupon ID and batch number
- create forms for adding and editing coupon logs
- implement status badges for success/failure visualization
- add date formatting and result status display
- include confirmation dialog for delete operations
- add toast notifications for user feedback on operations
yourname 6 luni în urmă
părinte
comite
efb6608ab8
1 a modificat fișierele cu 588 adăugiri și 0 ștergeri
  1. 588 0
      src/client/admin-shadcn/pages/CouponLogs.tsx

+ 588 - 0
src/client/admin-shadcn/pages/CouponLogs.tsx

@@ -0,0 +1,588 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Plus, Edit, Trash2, RefreshCw, Search } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { format } from 'date-fns';
+import { zhCN } from 'date-fns/locale';
+
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Label } from '@/client/components/ui/label';
+import { Switch } from '@/client/components/ui/switch';
+import { 
+  Dialog, 
+  DialogContent, 
+  DialogDescription, 
+  DialogFooter, 
+  DialogHeader, 
+  DialogTitle 
+} from '@/client/components/ui/dialog';
+import {
+  Form,
+  FormControl,
+  FormDescription,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/client/components/ui/form';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/client/components/ui/select';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/client/components/ui/table';
+import {
+  Pagination,
+  PaginationContent,
+  PaginationEllipsis,
+  PaginationItem,
+  PaginationLink,
+  PaginationNext,
+  PaginationPrevious,
+} from '@/client/components/ui/pagination';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Badge } from '@/client/components/ui/badge';
+import { couponLogClient } from '@/client/api';
+
+import { 
+  CreateCouponLogDto, 
+  UpdateCouponLogDto, 
+  CouponLogSchema 
+} from '@/server/modules/coupon-logs/coupon-log.schema';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { toast } from 'sonner';
+
+// 类型定义
+type CouponLog = InferResponseType<typeof couponLogClient.$get, 200>['data'][0];
+type CreateCouponLogRequest = InferRequestType<typeof couponLogClient.$post>['json'];
+type UpdateCouponLogRequest = InferRequestType<typeof couponLogClient[':id']['$put']>['json'];
+
+// 创建和更新表单验证
+const createSchema = CreateCouponLogDto;
+const updateSchema = UpdateCouponLogDto;
+
+export default function CouponLogs() {
+  const queryClient = useQueryClient();
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [editingData, setEditingData] = useState<CouponLog | null>(null);
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+  const [keyword, setKeyword] = useState('');
+
+  // 查询券日志列表
+  const { data: couponLogs, isLoading, refetch } = useQuery({
+    queryKey: ['coupon-logs', page, pageSize, keyword],
+    queryFn: async () => {
+      const response = await couponLogClient.$get({
+        query: { page, pageSize, keyword }
+      });
+      if (response.status !== 200) {
+        throw new Error('获取领券日志失败');
+      }
+      return response.json();
+    }
+  });
+
+  // 创建表单
+  const createForm = useForm<CreateCouponLogRequest>({
+    resolver: zodResolver(createSchema),
+    defaultValues: {
+      couponId: '',
+      batchId: '',
+      batchCategoryId: 1,
+      exchangeCodeId: 1,
+      userId: 1,
+      result: 1,
+      failReason: null
+    }
+  });
+
+  // 更新表单
+  const updateForm = useForm<UpdateCouponLogRequest>({
+    resolver: zodResolver(updateSchema),
+    defaultValues: {}
+  });
+
+  // 创建券日志
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateCouponLogRequest) => {
+      const response = await couponLogClient.$post({ json: data });
+      if (response.status !== 201) {
+        throw new Error('创建失败');
+      }
+      return response.json();
+    },
+    onSuccess: () => {
+      toast({ title: '创建成功' });
+      setIsModalOpen(false);
+      refetch();
+      createForm.reset();
+    },
+    onError: (error) => {
+      toast({ 
+        title: '创建失败', 
+        description: error.message,
+        variant: 'destructive' 
+      });
+    }
+  });
+
+  // 更新券日志
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateCouponLogRequest }) => {
+      const response = await couponLogClient[':id']['$put']({
+        param: { id: id.toString() },
+        json: data
+      });
+      if (response.status !== 200) {
+        throw new Error('更新失败');
+      }
+      return response.json();
+    },
+    onSuccess: () => {
+      toast({ title: '更新成功' });
+      setIsModalOpen(false);
+      refetch();
+    },
+    onError: (error) => {
+      toast({ 
+        title: '更新失败', 
+        description: error.message,
+        variant: 'destructive' 
+      });
+    }
+  });
+
+  // 删除券日志
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const response = await couponLogClient[':id']['$delete']({
+        param: { id: id.toString() }
+      });
+      if (response.status !== 200) {
+        throw new Error('删除失败');
+      }
+      return response.json();
+    },
+    onSuccess: () => {
+      toast({ title: '删除成功' });
+      refetch();
+    },
+    onError: (error) => {
+      toast({ 
+        title: '删除失败', 
+        description: error.message,
+        variant: 'destructive' 
+      });
+    }
+  });
+
+  // 打开创建对话框
+  const handleOpenCreate = () => {
+    setIsCreateForm(true);
+    setEditingData(null);
+    createForm.reset();
+    setIsModalOpen(true);
+  };
+
+  // 打开编辑对话框
+  const handleOpenEdit = (log: CouponLog) => {
+    setIsCreateForm(false);
+    setEditingData(log);
+    updateForm.reset({
+      couponId: log.couponId,
+      batchId: log.batchId,
+      batchCategoryId: log.batchCategoryId,
+      exchangeCodeId: log.exchangeCodeId,
+      userId: log.userId,
+      result: log.result,
+      failReason: log.failReason
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理创建提交
+  const handleCreateSubmit = (data: CreateCouponLogRequest) => {
+    createMutation.mutate(data);
+  };
+
+  // 处理更新提交
+  const handleUpdateSubmit = (data: UpdateCouponLogRequest) => {
+    if (editingData) {
+      updateMutation.mutate({ id: editingData.id, data });
+    }
+  };
+
+  // 处理删除
+  const handleDelete = (id: number) => {
+    if (window.confirm('确定要删除这条领券日志吗?')) {
+      deleteMutation.mutate(id);
+    }
+  };
+
+  // 格式化日期
+  const formatDate = (date: string) => {
+    return format(new Date(date), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
+  };
+
+  // 获取结果状态文本
+  const getResultText = (result: number) => {
+    return result === 1 ? '成功' : '失败';
+  };
+
+  // 获取结果状态样式
+  const getResultStyle = (result: number) => {
+    return result === 1 
+      ? 'bg-green-100 text-green-800' 
+      : 'bg-red-100 text-red-800';
+  };
+
+  const totalPages = Math.ceil((couponLogs?.pagination?.total || 0) / pageSize);
+
+  return (
+    <div className="p-6 space-y-6">
+      <div className="flex justify-between items-center">
+        <div>
+          <h1 className="text-3xl font-bold tracking-tight">领券日志管理</h1>
+          <p className="text-muted-foreground">管理用户的领券记录和状态</p>
+        </div>
+        <div className="flex gap-2">
+          <Button onClick={handleOpenCreate}>
+            <Plus className="w-4 h-4 mr-2" />
+            创建日志
+          </Button>
+          <Button variant="outline" onClick={() => refetch()}>
+            <RefreshCw className="w-4 h-4 mr-2" />
+            刷新
+          </Button>
+        </div>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div>
+              <CardTitle>领券日志列表</CardTitle>
+              <CardDescription>
+                显示所有用户的领券记录,包括成功和失败的情况
+              </CardDescription>
+            </div>
+            <div className="flex items-center space-x-2">
+              <div className="relative">
+                <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+                <Input
+                  placeholder="搜索券ID、批次号..."
+                  value={keyword}
+                  onChange={(e) => setKeyword(e.target.value)}
+                  className="pl-8 w-64"
+                />
+              </div>
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>ID</TableHead>
+                  <TableHead>券ID</TableHead>
+                  <TableHead>批次号</TableHead>
+                  <TableHead>分类ID</TableHead>
+                  <TableHead>用户ID</TableHead>
+                  <TableHead>结果</TableHead>
+                  <TableHead>失败原因</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  <TableRow>
+                    <TableCell colSpan={9} className="text-center">
+                      加载中...
+                    </TableCell>
+                  </TableRow>
+                ) : couponLogs?.data?.length === 0 ? (
+                  <TableRow>
+                    <TableCell colSpan={9} className="text-center">
+                      暂无数据
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  couponLogs?.data?.map((log) => (
+                    <TableRow key={log.id}>
+                      <TableCell>{log.id}</TableCell>
+                      <TableCell className="font-mono text-sm">{log.couponId}</TableCell>
+                      <TableCell className="font-mono text-sm">{log.batchId}</TableCell>
+                      <TableCell>{log.batchCategoryId}</TableCell>
+                      <TableCell>{log.userId}</TableCell>
+                      <TableCell>
+                        <Badge className={getResultStyle(log.result)}>
+                          {getResultText(log.result)}
+                        </Badge>
+                      </TableCell>
+                      <TableCell>
+                        {log.failReason || '-'}
+                      </TableCell>
+                      <TableCell>{formatDate(log.createdAt)}</TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end space-x-2">
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => handleOpenEdit(log)}
+                          >
+                            <Edit className="w-4 h-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => handleDelete(log.id)}
+                          >
+                            <Trash2 className="w-4 h-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
+              </TableBody>
+            </Table>
+          </div>
+
+          {/* 分页 */}
+          <div className="flex items-center justify-between mt-4">
+            <div className="text-sm text-muted-foreground">
+              共 {couponLogs?.pagination?.total || 0} 条记录
+            </div>
+            <div className="space-x-2">
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() => setPage(Math.max(1, page - 1))}
+                disabled={page <= 1}
+              >
+                上一页
+              </Button>
+              <span className="text-sm">
+                第 {page} 页 / 共 {totalPages} 页
+              </span>
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() => setPage(Math.min(totalPages, page + 1))}
+                disabled={page >= totalPages}
+              >
+                下一页
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑对话框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[600px]">
+          <DialogHeader>
+            <DialogTitle>{isCreateForm ? '创建领券日志' : '编辑领券日志'}</DialogTitle>
+            <DialogDescription>
+              {isCreateForm ? '创建新的领券日志记录' : '编辑现有的领券日志信息'}
+            </DialogDescription>
+          </DialogHeader>
+
+          <Form {...(isCreateForm ? createForm : updateForm)}>
+            <form 
+              onSubmit={(isCreateForm ? createForm : updateForm).handleSubmit(
+                isCreateForm ? handleCreateSubmit : handleUpdateSubmit
+              )} 
+              className="space-y-4"
+            >
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="couponId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      券ID
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入券ID" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="batchId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      批次号
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入批次号" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <div className="grid grid-cols-2 gap-4">
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="batchCategoryId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        批次分类ID
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input 
+                          type="number" 
+                          placeholder="请输入批次分类ID" 
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="exchangeCodeId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        兑换码ID
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input 
+                          type="number" 
+                          placeholder="请输入兑换码ID" 
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </div>
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="userId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      用户ID
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input 
+                        type="number" 
+                        placeholder="请输入用户ID" 
+                        {...field}
+                        onChange={(e) => field.onChange(parseInt(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="result"
+                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>
+                      <Select
+                        value={field.value.toString()}
+                        onValueChange={(value) => field.onChange(parseInt(value))}
+                      >
+                        <SelectTrigger className="w-[180px]">
+                          <SelectValue />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="0">失败</SelectItem>
+                          <SelectItem value="1">成功</SelectItem>
+                        </SelectContent>
+                      </Select>
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="failReason"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>失败原因</FormLabel>
+                    <FormControl>
+                      <Input 
+                        placeholder="请输入失败原因(可选)" 
+                        {...field}
+                        value={field.value || ''}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button 
+                  type="button" 
+                  variant="outline" 
+                  onClick={() => setIsModalOpen(false)}
+                >
+                  取消
+                </Button>
+                <Button 
+                  type="submit" 
+                  disabled={
+                    isCreateForm 
+                      ? createMutation.isPending 
+                      : updateMutation.isPending
+                  }
+                >
+                  {isCreateForm ? '创建' : '更新'}
+                </Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}