Explorar el Código

✨ feat(admin): add merchant management module

- add merchant menu item in navigation
- create merchant management page with complete CRUD functionality
- implement merchant list with pagination and search
- add create, edit, view and delete operations for merchants
- add form validation and error handling
- add merchant detail view dialog
- add confirmation dialog for deletion
- add loading state with skeletons
- add routes for merchant management page
yourname hace 4 meses
padre
commit
9f661fd72f

+ 7 - 0
src/client/admin-shadcn/menu.tsx

@@ -160,6 +160,13 @@ export const useMenu = () => {
       path: '/admin/suppliers',
       permission: 'supplier:manage'
     },
+    {
+      key: 'merchants',
+      label: '商户管理',
+      icon: <Building className="h-4 w-4" />,
+      path: '/admin/merchants',
+      permission: 'merchant:manage'
+    },
     {
       key: 'agents',
       label: '代理商管理',

+ 681 - 0
src/client/admin-shadcn/pages/Merchants.tsx

@@ -0,0 +1,681 @@
+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 { toast } from 'sonner'
+import type { InferRequestType, InferResponseType } from 'hono/client'
+import { Plus, Search, Edit, Trash2, Eye } from 'lucide-react'
+
+import { Button } from '@/client/components/ui/button'
+import { Input } from '@/client/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table'
+import { Badge } from '@/client/components/ui/badge'
+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 { Separator } from '@/client/components/ui/separator'
+import { Switch } from '@/client/components/ui/switch'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select'
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination'
+
+import { merchantClient } from '@/client/api'
+import { CreateMerchantDto, UpdateMerchantDto } from '@/server/modules/merchant/merchant.schema'
+import { Skeleton } from '@/client/components/ui/skeleton'
+
+type CreateRequest = InferRequestType<typeof merchantClient.$post>['json']
+type UpdateRequest = InferRequestType<typeof merchantClient[':id']['$put']>['json']
+type MerchantResponse = InferResponseType<typeof merchantClient.$get, 200>['data'][0]
+
+const createFormSchema = CreateMerchantDto
+const updateFormSchema = UpdateMerchantDto
+
+export const MerchantsPage = () => {
+  const queryClient = useQueryClient()
+  
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
+  })
+  
+  const [isModalOpen, setIsModalOpen] = useState(false)
+  const [editingMerchant, setEditingMerchant] = useState<MerchantResponse | null>(null)
+  const [isCreateForm, setIsCreateForm] = useState(true)
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+  const [merchantToDelete, setMerchantToDelete] = useState<number | null>(null)
+  const [detailDialogOpen, setDetailDialogOpen] = useState(false)
+  const [detailMerchant, setDetailMerchant] = useState<MerchantResponse | null>(null)
+
+  // 创建表单
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      name: '',
+      username: '',
+      password: '',
+      phone: '',
+      realname: '',
+      state: 2,
+      rsaPublicKey: '',
+      aesKey: '',
+    },
+  })
+
+  // 更新表单
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(updateFormSchema),
+  })
+
+  // 获取商户列表
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['merchants', searchParams],
+    queryFn: async () => {
+      const res = await merchantClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+        }
+      })
+      if (res.status !== 200) throw new Error('获取商户列表失败')
+      return await res.json()
+    }
+  })
+
+  // 创建商户
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateRequest) => {
+      const res = await merchantClient.$post({ json: data })
+      if (res.status !== 201) throw new Error('创建商户失败')
+      return await res.json()
+    },
+    onSuccess: () => {
+      toast.success('商户创建成功')
+      setIsModalOpen(false)
+      createForm.reset()
+      refetch()
+    },
+    onError: (error: Error) => {
+      toast.error(error.message || '创建失败')
+    }
+  })
+
+  // 更新商户
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateRequest }) => {
+      const res = await merchantClient[':id']['$put']({
+        param: { id: id.toString() },
+        json: data
+      })
+      if (res.status !== 200) throw new Error('更新商户失败')
+      return await res.json()
+    },
+    onSuccess: () => {
+      toast.success('商户更新成功')
+      setIsModalOpen(false)
+      setEditingMerchant(null)
+      refetch()
+    },
+    onError: (error: Error) => {
+      toast.error(error.message || '更新失败')
+    }
+  })
+
+  // 删除商户
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const res = await merchantClient[':id']['$delete']({
+        param: { id: id.toString() }
+      })
+      if (res.status !== 204) throw new Error('删除商户失败')
+      return res
+    },
+    onSuccess: () => {
+      toast.success('商户删除成功')
+      setDeleteDialogOpen(false)
+      setMerchantToDelete(null)
+      refetch()
+    },
+    onError: (error: Error) => {
+      toast.error(error.message || '删除失败')
+    }
+  })
+
+  // 搜索处理
+  const handleSearch = (e?: React.FormEvent) => {
+    e?.preventDefault()
+    setSearchParams(prev => ({ ...prev, page: 1 }))
+  }
+
+  // 创建商户
+  const handleCreateMerchant = () => {
+    setIsCreateForm(true)
+    setEditingMerchant(null)
+    createForm.reset()
+    setIsModalOpen(true)
+  }
+
+  // 编辑商户
+  const handleEditMerchant = (merchant: MerchantResponse) => {
+    setIsCreateForm(false)
+    setEditingMerchant(merchant)
+    updateForm.reset({
+      name: merchant.name || '',
+      username: merchant.username,
+      phone: merchant.phone || '',
+      realname: merchant.realname || '',
+      state: merchant.state,
+      rsaPublicKey: merchant.rsaPublicKey || '',
+      aesKey: merchant.aesKey || '',
+    })
+    setIsModalOpen(true)
+  }
+
+  // 查看详情
+  const handleViewDetail = (merchant: MerchantResponse) => {
+    setDetailMerchant(merchant)
+    setDetailDialogOpen(true)
+  }
+
+  // 删除商户
+  const handleDeleteMerchant = (id: number) => {
+    setMerchantToDelete(id)
+    setDeleteDialogOpen(true)
+  }
+
+  // 确认删除
+  const confirmDelete = () => {
+    if (merchantToDelete) {
+      deleteMutation.mutate(merchantToDelete)
+    }
+  }
+
+  // 提交表单
+  const handleSubmit = (data: CreateRequest | UpdateRequest) => {
+    if (isCreateForm) {
+      createMutation.mutate(data as CreateRequest)
+    } else if (editingMerchant) {
+      updateMutation.mutate({ id: editingMerchant.id, data: data as UpdateRequest })
+    }
+  }
+
+  // 状态文本
+  const getStateText = (state: number) => {
+    return state === 1 ? '启用' : '禁用'
+  }
+
+  const getStateBadgeVariant = (state: number) => {
+    return state === 1 ? 'default' : 'secondary'
+  }
+
+  // 渲染加载骨架
+  if (isLoading) {
+    return (
+      <div className="space-y-4">
+        <div className="flex justify-between items-center">
+          <Skeleton className="h-8 w-48" />
+          <Skeleton className="h-10 w-32" />
+        </div>
+        
+        <Card>
+          <CardContent className="pt-6">
+            <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 w-20" />
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* 页面标题 */}
+      <div className="flex justify-between items-center">
+        <h1 className="text-2xl font-bold">商户管理</h1>
+        <Button onClick={handleCreateMerchant}>
+          <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="搜索商户名称、用户名、手机号..."
+                value={searchParams.search}
+                onChange={(e) => setSearchParams(prev => ({ ...prev, search: 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>用户名</TableHead>
+                  <TableHead>姓名</TableHead>
+                  <TableHead>手机号</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>登录次数</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data?.data.map((merchant) => (
+                  <TableRow key={merchant.id}>
+                    <TableCell>{merchant.name || '-'}</TableCell>
+                    <TableCell>{merchant.username}</TableCell>
+                    <TableCell>{merchant.realname || '-'}</TableCell>
+                    <TableCell>{merchant.phone || '-'}</TableCell>
+                    <TableCell>
+                      <Badge variant={getStateBadgeVariant(merchant.state)}>
+                        {getStateText(merchant.state)}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>{merchant.loginNum}</TableCell>
+                    <TableCell>
+                      {format(new Date(merchant.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleViewDetail(merchant)}
+                          title="查看详情"
+                        >
+                          <Eye className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditMerchant(merchant)}
+                          title="编辑"
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeleteMerchant(merchant.id)}
+                          title="删除"
+                          className="text-destructive hover:text-destructive"
+                        >
+                          <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(handleSubmit)} className="space-y-4">
+                <FormField
+                  control={createForm.control}
+                  name="name"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>商户名称</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入商户名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="username"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>用户名 <span className="text-red-500">*</span></FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入用户名" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="password"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>密码 <span className="text-red-500">*</span></FormLabel>
+                      <FormControl>
+                        <Input type="password" placeholder="请输入密码" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="phone"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>手机号</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入手机号" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="realname"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>姓名</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入姓名" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="state"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>状态</FormLabel>
+                      <Select onValueChange={(value) => field.onChange(parseInt(value))} defaultValue={field.value.toString()}>
+                        <FormControl>
+                          <SelectTrigger>
+                            <SelectValue placeholder="请选择状态" />
+                          </SelectTrigger>
+                        </FormControl>
+                        <SelectContent>
+                          <SelectItem value="1">启用</SelectItem>
+                          <SelectItem value="2">禁用</SelectItem>
+                        </SelectContent>
+                      </Select>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit" disabled={createMutation.isPending}>
+                    创建
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          ) : (
+            <Form {...updateForm}>
+              <form onSubmit={updateForm.handleSubmit(handleSubmit)} className="space-y-4">
+                <FormField
+                  control={updateForm.control}
+                  name="name"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>商户名称</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入商户名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="username"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>用户名</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入用户名" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="phone"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>手机号</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入手机号" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="realname"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>姓名</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入姓名" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="password"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>密码(留空则不修改)</FormLabel>
+                      <FormControl>
+                        <Input type="password" placeholder="请输入新密码" {...field} />
+                      </FormControl>
+                      <FormDescription>如果不修改密码,请留空</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="state"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>状态</FormLabel>
+                      <Select onValueChange={(value) => field.onChange(parseInt(value))} value={field.value?.toString()}>
+                        <FormControl>
+                          <SelectTrigger>
+                            <SelectValue placeholder="请选择状态" />
+                          </SelectTrigger>
+                        </FormControl>
+                        <SelectContent>
+                          <SelectItem value="1">启用</SelectItem>
+                          <SelectItem value="2">禁用</SelectItem>
+                        </SelectContent>
+                      </Select>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit" disabled={updateMutation.isPending}>
+                    更新
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 详情对话框 */}
+      <Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
+        <DialogContent className="sm:max-w-[500px]">
+          <DialogHeader>
+            <DialogTitle>商户详情</DialogTitle>
+            <DialogDescription>查看商户详细信息</DialogDescription>
+          </DialogHeader>
+          
+          {detailMerchant && (
+            <div className="space-y-4">
+              <div className="grid grid-cols-2 gap-4">
+                <div>
+                  <label className="text-sm font-medium">商户名称</label>
+                  <p className="text-sm text-muted-foreground">{detailMerchant.name || '-'}</p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">用户名</label>
+                  <p className="text-sm text-muted-foreground">{detailMerchant.username}</p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">姓名</label>
+                  <p className="text-sm text-muted-foreground">{detailMerchant.realname || '-'}</p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">手机号</label>
+                  <p className="text-sm text-muted-foreground">{detailMerchant.phone || '-'}</p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">状态</label>
+                  <p className="text-sm">
+                    <Badge variant={getStateBadgeVariant(detailMerchant.state)}>
+                      {getStateText(detailMerchant.state)}
+                    </Badge>
+                  </p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">登录次数</label>
+                  <p className="text-sm text-muted-foreground">{detailMerchant.loginNum}</p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">创建时间</label>
+                  <p className="text-sm text-muted-foreground">
+                    {format(new Date(detailMerchant.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                  </p>
+                </div>
+                <div>
+                  <label className="text-sm font-medium">更新时间</label>
+                  <p className="text-sm text-muted-foreground">
+                    {format(new Date(detailMerchant.updatedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                  </p>
+                </div>
+              </div>
+              
+              {detailMerchant.lastLoginTime > 0 && (
+                <div>
+                  <label className="text-sm font-medium">最后登录时间</label>
+                  <p className="text-sm text-muted-foreground">
+                    {format(new Date(detailMerchant.lastLoginTime * 1000), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                  </p>
+                </div>
+              )}
+              
+              {detailMerchant.lastLoginIp && (
+                <div>
+                  <label className="text-sm font-medium">最后登录IP</label>
+                  <p className="text-sm text-muted-foreground">{detailMerchant.lastLoginIp}</p>
+                </div>
+              )}
+            </div>
+          )}
+          
+          <DialogFooter>
+            <Button onClick={() => setDetailDialogOpen(false)}>关闭</Button>
+          </DialogFooter>
+        </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

@@ -14,6 +14,7 @@ import { GoodsCategories } from './pages/GoodsCategories';
 import { GoodsPage } from './pages/Goods';
 import { ExpressCompaniesPage } from './pages/ExpressCompanies';
 import { SuppliersPage } from './pages/Suppliers';
+import { MerchantsPage } from './pages/Merchants'
 import { AgentsPage } from './pages/Agents';
 
 export const router = createBrowserRouter([
@@ -82,6 +83,11 @@ export const router = createBrowserRouter([
         element: <SuppliersPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'merchants',
+        element: <MerchantsPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: 'agents',
         element: <AgentsPage />,