Просмотр исходного кода

✨ feat(admin): add wechat pay configuration management

- add wechat pay config menu item in admin sidebar
- create wechat pay config management page with CRUD operations
- implement form validation and data handling for pay configurations
- add search, pagination and configuration status management
- support copying existing configurations for quick setup
yourname 6 месяцев назад
Родитель
Сommit
47a1fb45fc

+ 9 - 1
src/client/admin-shadcn/menu.tsx

@@ -12,7 +12,8 @@ import {
   Ticket,
   Gift,
   QrCode,
-  CreditCard
+  CreditCard,
+  CreditCard as CreditCardIcon
 } from 'lucide-react';
 
 export interface MenuItem {
@@ -137,6 +138,13 @@ export const useMenu = () => {
           icon: <QrCode className="h-4 w-4" />,
           path: '/admin/redemption-codes',
           permission: 'coupon:manage'
+        },
+        {
+          key: 'wechat-pay-config',
+          label: '支付配置',
+          icon: <CreditCard className="h-4 w-4" />,
+          path: '/admin/wechat-pay-config',
+          permission: 'payment:manage'
         }
       ]
     },

+ 553 - 0
src/client/admin-shadcn/pages/WechatPayConfig.tsx

@@ -0,0 +1,553 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { format } from 'date-fns';
+import { zhCN } from 'date-fns/locale';
+import { Plus, Search, Edit, Trash2, Copy, Eye, EyeOff } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+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 { Input } from '@/client/components/ui/input';
+import { Switch } from '@/client/components/ui/switch';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+import { Textarea } from '@/client/components/ui/textarea';
+import { Badge } from '@/client/components/ui/badge';
+import { Skeleton } from '@/client/components/ui/skeleton';
+
+import { wechatPayConfigClient } from '@/client/api';
+import type { InferRequestType, InferResponseType } from 'hono/client';
+import { CreateWechatPayConfigDto, UpdateWechatPayConfigDto } from '@/server/modules/wechat-pay/wechat-pay-config.schema';
+
+type CreateRequest = InferRequestType<typeof wechatPayConfigClient.$post>['json'];
+type UpdateRequest = InferRequestType<typeof wechatPayConfigClient[':id']['$put']>['json'];
+type WechatPayConfig = InferResponseType<typeof wechatPayConfigClient.$get, 200>['data'][0];
+
+export const WechatPayConfigPage = () => {
+  const queryClient = useQueryClient();
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingConfig, setEditingConfig] = useState<WechatPayConfig | null>(null);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [searchParams, setSearchParams] = useState({ page: 1, pageSize: 10, keyword: '' });
+  const [showPrivateKey, setShowPrivateKey] = useState(false);
+  const [showApiV3Key, setShowApiV3Key] = useState(false);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [configToDelete, setConfigToDelete] = useState<number | null>(null);
+
+  // 查询数据
+  const { data, isLoading } = useQuery({
+    queryKey: ['wechatPayConfig', searchParams],
+    queryFn: async () => {
+      const res = await wechatPayConfigClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.pageSize,
+          keyword: searchParams.keyword,
+        }
+      });
+      if (res.status !== 200) throw new Error('获取配置列表失败');
+      return await res.json();
+    }
+  });
+
+  // 创建配置
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateRequest) => {
+      const res = await wechatPayConfigClient.$post({ json: data });
+      if (res.status !== 201) throw new Error('创建配置失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('创建配置成功');
+      setIsModalOpen(false);
+      queryClient.invalidateQueries({ queryKey: ['wechatPayConfig'] });
+    },
+    onError: (error) => {
+      toast.error('创建配置失败');
+    }
+  });
+
+  // 更新配置
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateRequest }) => {
+      const res = await wechatPayConfigClient[':id']['$put']({
+        param: { id },
+        json: data
+      });
+      if (res.status !== 200) throw new Error('更新配置失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('更新配置成功');
+      setIsModalOpen(false);
+      queryClient.invalidateQueries({ queryKey: ['wechatPayConfig'] });
+    },
+    onError: (error) => {
+      toast.error('更新配置失败');
+    }
+  });
+
+  // 删除配置
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const res = await wechatPayConfigClient[':id']['$delete']({
+        param: { id }
+      });
+      if (res.status !== 204) throw new Error('删除配置失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('删除配置成功');
+      setDeleteDialogOpen(false);
+      queryClient.invalidateQueries({ queryKey: ['wechatPayConfig'] });
+    },
+    onError: (error) => {
+      toast.error('删除配置失败');
+    }
+  });
+
+  // 表单配置
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(CreateWechatPayConfigDto),
+    defaultValues: {
+      merchantId: '',
+      appId: '',
+      privateKey: '',
+      certificateSerialNo: '',
+      apiV3Key: '',
+      isActive: 1,
+    },
+  });
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(UpdateWechatPayConfigDto),
+    defaultValues: {
+      merchantId: '',
+      appId: '',
+      privateKey: '',
+      certificateSerialNo: '',
+      apiV3Key: '',
+      isActive: 1,
+    },
+  });
+
+  // 打开创建表单
+  const handleCreate = () => {
+    setEditingConfig(null);
+    setIsCreateForm(true);
+    createForm.reset({
+      merchantId: '',
+      appId: '',
+      privateKey: '',
+      certificateSerialNo: '',
+      apiV3Key: '',
+      isActive: 1,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 打开编辑表单
+  const handleEdit = (config: WechatPayConfig) => {
+    setEditingConfig(config);
+    setIsCreateForm(false);
+    updateForm.reset({
+      merchantId: config.merchantId,
+      appId: config.appId,
+      privateKey: config.privateKey,
+      certificateSerialNo: config.certificateSerialNo,
+      apiV3Key: config.apiV3Key,
+      isActive: config.isActive,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理删除
+  const handleDelete = (id: number) => {
+    setConfigToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  const confirmDelete = () => {
+    if (configToDelete) {
+      deleteMutation.mutate(configToDelete);
+    }
+  };
+
+  // 提交表单
+  const handleSubmit = (data: CreateRequest | UpdateRequest) => {
+    if (isCreateForm) {
+      createMutation.mutate(data as CreateRequest);
+    } else if (editingConfig) {
+      updateMutation.mutate({ id: editingConfig.id, data: data as UpdateRequest });
+    }
+  };
+
+  // 复制配置
+  const handleCopy = (config: WechatPayConfig) => {
+    const newConfig = {
+      merchantId: config.merchantId,
+      appId: config.appId,
+      privateKey: config.privateKey,
+      certificateSerialNo: config.certificateSerialNo,
+      apiV3Key: config.apiV3Key,
+      isActive: 0, // 默认禁用复制的配置
+    };
+    createForm.reset(newConfig);
+    setIsCreateForm(true);
+    setIsModalOpen(true);
+  };
+
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault();
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  const truncateText = (text: string, maxLength: number) => {
+    return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
+  };
+
+  return (
+    <div className="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>
+        <Button onClick={handleCreate}>
+          <Plus className="mr-2 h-4 w-4" />
+          创建配置
+        </Button>
+      </div>
+
+      {/* 搜索区域 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>配置列表</CardTitle>
+          <CardDescription>查看和管理所有微信支付配置</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleSearch} className="flex gap-2 mb-4">
+            <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="搜索商户号或应用ID..."
+                value={searchParams.keyword}
+                onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
+                className="pl-8"
+              />
+            </div>
+            <Button type="submit" variant="outline">
+              搜索
+            </Button>
+          </form>
+
+          {/* 数据表格 */}
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>商户号</TableHead>
+                  <TableHead>应用ID</TableHead>
+                  <TableHead>证书序列号</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  Array.from({ length: 5 }).map((_, i) => (
+                    <TableRow key={i}>
+                      <TableCell><Skeleton className="h-4 w-20" /></TableCell>
+                      <TableCell><Skeleton className="h-4 w-32" /></TableCell>
+                      <TableCell><Skeleton className="h-4 w-24" /></TableCell>
+                      <TableCell><Skeleton className="h-4 w-16" /></TableCell>
+                      <TableCell><Skeleton className="h-4 w-20" /></TableCell>
+                      <TableCell><Skeleton className="h-4 w-16" /></TableCell>
+                    </TableRow>
+                  ))
+                ) : data?.data.length === 0 ? (
+                  <TableRow>
+                    <TableCell colSpan={6} className="text-center py-8">
+                      <div className="text-muted-foreground">暂无数据</div>
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  data?.data.map((config) => (
+                    <TableRow key={config.id}>
+                      <TableCell className="font-medium">{config.merchantId}</TableCell>
+                      <TableCell className="font-mono text-sm">
+                        {truncateText(config.appId, 16)}
+                      </TableCell>
+                      <TableCell className="font-mono text-sm">
+                        {truncateText(config.certificateSerialNo, 12)}
+                      </TableCell>
+                      <TableCell>
+                        <Badge variant={config.isActive === 1 ? 'default' : 'secondary'}>
+                          {config.isActive === 1 ? '启用' : '禁用'}
+                        </Badge>
+                      </TableCell>
+                      <TableCell>
+                        {format(new Date(config.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                      </TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end gap-1">
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleEdit(config)}
+                            title="编辑"
+                          >
+                            <Edit className="h-4 w-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleCopy(config)}
+                            title="复制"
+                          >
+                            <Copy className="h-4 w-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleDelete(config.id)}
+                            title="删除"
+                          >
+                            <Trash2 className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
+              </TableBody>
+            </Table>
+          </div>
+
+          {/* 分页 */}
+          {data?.pagination && (
+            <div className="flex items-center justify-between mt-4">
+              <div className="text-sm text-muted-foreground">
+                共 {data.pagination.total} 条记录
+              </div>
+              <div className="flex gap-2">
+                <Button
+                  variant="outline"
+                  size="sm"
+                  disabled={searchParams.page <= 1}
+                  onClick={() => setSearchParams(prev => ({ ...prev, page: prev.page - 1 }))}
+                >
+                  上一页
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  disabled={searchParams.page >= Math.ceil(data.pagination.total / searchParams.pageSize)}
+                  onClick={() => setSearchParams(prev => ({ ...prev, page: prev.page + 1 }))}
+                >
+                  下一页
+                </Button>
+              </div>
+            </div>
+          )}
+        </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>
+          
+          <Form {...(isCreateForm ? createForm : updateForm)}>
+            <form onSubmit={(isCreateForm ? createForm : updateForm).handleSubmit(handleSubmit)} className="space-y-4">
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="merchantId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      商户号
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入微信支付商户号" {...field} />
+                    </FormControl>
+                    <FormDescription>微信支付商户号,通常是10位数字</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="appId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      应用ID
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入微信应用ID" {...field} />
+                    </FormControl>
+                    <FormDescription>微信公众号或小程序的AppID</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="privateKey"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      商户私钥
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <div className="relative">
+                        <Textarea
+                          placeholder="请输入商户私钥内容"
+                          className="font-mono text-sm"
+                          rows={6}
+                          {...field}
+                        />
+                        <Button
+                          type="button"
+                          variant="ghost"
+                          size="sm"
+                          className="absolute right-2 top-2"
+                          onClick={() => setShowPrivateKey(!showPrivateKey)}
+                        >
+                          {showPrivateKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+                        </Button>
+                      </div>
+                    </FormControl>
+                    <FormDescription>从微信支付商户平台下载的私钥文件内容</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="certificateSerialNo"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      证书序列号
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入证书序列号" {...field} />
+                    </FormControl>
+                    <FormDescription>微信支付API证书序列号,40位字符串</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="apiV3Key"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      APIv3密钥
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <div className="relative">
+                        <Input 
+                          type={showApiV3Key ? 'text' : 'password'}
+                          placeholder="请输入APIv3密钥" 
+                          {...field} 
+                        />
+                        <Button
+                          type="button"
+                          variant="ghost"
+                          size="sm"
+                          className="absolute right-2 top-1/2 -translate-y-1/2"
+                          onClick={() => setShowApiV3Key(!showApiV3Key)}
+                        >
+                          {showApiV3Key ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+                        </Button>
+                      </div>
+                    </FormControl>
+                    <FormDescription>32位APIv3密钥,用于加密敏感信息</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : 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>
+                    <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" disabled={createMutation.isPending || updateMutation.isPending}>
+                  {isCreateForm ? '创建' : '更新'}
+                </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} disabled={deleteMutation.isPending}>
+              删除
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

+ 6 - 0
src/client/admin-shadcn/routes.tsx

@@ -12,6 +12,7 @@ import { default as CouponLogsPage } from './pages/CouponLogs';
 import { WechatCouponStocksPage } from './pages/WechatCouponStocks';
 import { WechatCouponsPage } from './pages/WechatCoupons';
 import { RedemptionCodesPage } from './pages/RedemptionCodes';
+import { WechatPayConfigPage } from './pages/WechatPayConfig';
 
 export const router = createBrowserRouter([
   {
@@ -69,6 +70,11 @@ export const router = createBrowserRouter([
         element: <RedemptionCodesPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'wechat-pay-config',
+        element: <WechatPayConfigPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,