Explorar o código

✨ feat(admin): add order management module

- add order management menu item in sidebar
- create Orders.tsx page with order list display
- implement order search, filtering and pagination
- add order detail view and edit functionality
- add order status and payment status management
- add order routes configuration
yourname hai 4 meses
pai
achega
f07c8029ae

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

@@ -153,6 +153,20 @@ export const useMenu = () => {
           label: '快递公司',
           path: '/admin/express-companies',
           permission: 'goods:manage'
+        },
+      ]
+    },
+    {
+      key: 'orders',
+      label: '订单管理',
+      icon: <Truck className="h-4 w-4" />,
+      permission: 'order:manage',
+      children: [
+        {
+          key: 'orders-list',
+          label: '订单列表',
+          path: '/admin/orders',
+          permission: 'order:manage'
         }
       ]
     },

+ 527 - 0
src/client/admin-shadcn/pages/Orders.tsx

@@ -0,0 +1,527 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { format } from 'date-fns';
+import { toast } from 'sonner';
+import { Search, Edit, Eye } from 'lucide-react';
+import { Skeleton } from '@/client/components/ui/skeleton';
+
+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, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
+import { Textarea } from '@/client/components/ui/textarea';
+
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
+import { orderClient } from '@/client/api';
+import type { InferRequestType, InferResponseType } from 'hono/client';
+import { UpdateOrderDto } from '@/server/modules/orders/order.schema';
+
+// 类型定义
+type OrderResponse = InferResponseType<typeof orderClient.$get, 200>['data'][0];
+type UpdateRequest = InferRequestType<typeof orderClient[':id']['$put']>['json'];
+
+// 状态映射
+const orderStatusMap = {
+  0: { label: '未发货', color: 'warning' },
+  1: { label: '已发货', color: 'info' },
+  2: { label: '收货成功', color: 'success' },
+  3: { label: '已退货', color: 'destructive' },
+} as const;
+
+const payStatusMap = {
+  0: { label: '未支付', color: 'warning' },
+  1: { label: '支付中', color: 'info' },
+  2: { label: '支付成功', color: 'success' },
+  3: { label: '已退款', color: 'secondary' },
+  4: { label: '支付失败', color: 'destructive' },
+  5: { label: '订单关闭', color: 'destructive' },
+} as const;
+
+const orderTypeMap = {
+  1: { label: '实物订单', color: 'default' },
+  2: { label: '虚拟订单', color: 'secondary' },
+} as const;
+
+export const OrdersPage = () => {
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
+    status: '',
+    payStatus: '',
+  });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingOrder, setEditingOrder] = useState<OrderResponse | null>(null);
+  const [detailModalOpen, setDetailModalOpen] = useState(false);
+  const [selectedOrder, setSelectedOrder] = useState<OrderResponse | null>(null);
+
+  // 表单实例
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(UpdateOrderDto),
+    defaultValues: {},
+  });
+
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['orders', searchParams],
+    queryFn: async () => {
+      const res = await orderClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+          ...(searchParams.status && { filters: JSON.stringify({ state: parseInt(searchParams.status) }) }),
+          ...(searchParams.payStatus && { filters: JSON.stringify({ payState: parseInt(searchParams.payStatus) }) }),
+        }
+      });
+      if (res.status !== 200) throw new Error('获取订单列表失败');
+      return await res.json();
+    }
+  });
+
+  // 处理搜索
+  const handleSearch = () => {
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  // 处理编辑订单
+  const handleEditOrder = (order: OrderResponse) => {
+    setEditingOrder(order);
+    updateForm.reset({
+      state: order.state,
+      payState: order.payState,
+      remark: order.remark || '',
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理查看详情
+  const handleViewDetails = (order: OrderResponse) => {
+    setSelectedOrder(order);
+    setDetailModalOpen(true);
+  };
+
+  // 处理更新订单
+  const handleUpdateSubmit = async (data: UpdateRequest) => {
+    if (!editingOrder) return;
+
+    try {
+      const res = await orderClient[':id']['$put']({
+        param: { id: editingOrder.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) {
+      console.error('更新订单失败:', error);
+      toast.error('更新失败,请重试');
+    }
+  };
+
+  // 格式化金额
+  const formatAmount = (amount: number) => {
+    return `¥${amount.toFixed(2)}`;
+  };
+
+  // 获取状态颜色
+  const getStatusBadge = (status: number, type: 'order' | 'pay') => {
+    const map = type === 'order' ? orderStatusMap : payStatusMap;
+    const config = map[status as keyof typeof map] || { label: '未知', color: 'default' };
+    
+    return <Badge variant={config.color as any}>{config.label}</Badge>;
+  };
+
+  // 骨架屏
+  if (isLoading) {
+    return (
+      <div className="space-y-4">
+        <div className="flex justify-between items-center">
+          <div>
+            <h1 className="text-2xl font-bold">订单管理</h1>
+            <p className="text-muted-foreground">管理所有订单信息</p>
+          </div>
+        </div>
+        
+        <Card>
+          <CardHeader>
+            <Skeleton className="h-6 w-1/4" />
+          </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 w-20" />
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* 页面标题 */}
+      <div className="flex justify-between items-center">
+        <div>
+          <h1 className="text-2xl font-bold">订单管理</h1>
+          <p className="text-muted-foreground">管理所有订单信息</p>
+        </div>
+      </div>
+
+      {/* 搜索区域 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>订单列表</CardTitle>
+          <CardDescription>查看和管理所有订单</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="flex gap-4 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>
+            <Select
+              value={searchParams.status}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, status: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-32">
+                <SelectValue placeholder="订单状态" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部</SelectItem>
+                <SelectItem value="0">未发货</SelectItem>
+                <SelectItem value="1">已发货</SelectItem>
+                <SelectItem value="2">收货成功</SelectItem>
+                <SelectItem value="3">已退货</SelectItem>
+              </SelectContent>
+            </Select>
+            <Select
+              value={searchParams.payStatus}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, payStatus: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-32">
+                <SelectValue placeholder="支付状态" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部</SelectItem>
+                <SelectItem value="0">未支付</SelectItem>
+                <SelectItem value="1">支付中</SelectItem>
+                <SelectItem value="2">支付成功</SelectItem>
+                <SelectItem value="3">已退款</SelectItem>
+                <SelectItem value="4">支付失败</SelectItem>
+                <SelectItem value="5">订单关闭</SelectItem>
+              </SelectContent>
+            </Select>
+            <Button onClick={handleSearch}>
+              <Search className="h-4 w-4 mr-2" />
+              搜索
+            </Button>
+          </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((order) => (
+                  <TableRow key={order.id}>
+                    <TableCell>
+                      <div>
+                        <p className="font-medium">{order.orderNo}</p>
+                        <p className="text-sm text-muted-foreground">
+                          {orderTypeMap[order.orderType as keyof typeof orderTypeMap]?.label}
+                        </p>
+                      </div>
+                    </TableCell>
+                    <TableCell>
+                      <div>
+                        <p>{order.user?.username || '-'}</p>
+                        <p className="text-sm text-muted-foreground">{order.userPhone}</p>
+                      </div>
+                    </TableCell>
+                    <TableCell>
+                      <div>
+                        <p>{order.recevierName || '-'}</p>
+                        <p className="text-sm text-muted-foreground">{order.receiverMobile}</p>
+                      </div>
+                    </TableCell>
+                    <TableCell>
+                      <div>
+                        <p className="font-medium">{formatAmount(order.payAmount)}</p>
+                        <p className="text-sm text-muted-foreground">{formatAmount(order.amount)}</p>
+                      </div>
+                    </TableCell>
+                    <TableCell>{getStatusBadge(order.state, 'order')}</TableCell>
+                    <TableCell>{getStatusBadge(order.payState, 'pay')}</TableCell>
+                    <TableCell>
+                      {format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm')}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleViewDetails(order)}
+                        >
+                          <Eye className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditOrder(order)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+          </div>
+
+          {data?.data.length === 0 && !isLoading && (
+            <div className="text-center py-8">
+              <p className="text-muted-foreground">暂无订单数据</p>
+            </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-[500px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>编辑订单</DialogTitle>
+            <DialogDescription>更新订单状态和备注信息</DialogDescription>
+          </DialogHeader>
+          
+          <Form {...updateForm}>
+            <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
+              <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="0">未发货</SelectItem>
+                        <SelectItem value="1">已发货</SelectItem>
+                        <SelectItem value="2">收货成功</SelectItem>
+                        <SelectItem value="3">已退货</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={updateForm.control}
+                name="payState"
+                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="0">未支付</SelectItem>
+                        <SelectItem value="1">支付中</SelectItem>
+                        <SelectItem value="2">支付成功</SelectItem>
+                        <SelectItem value="3">已退款</SelectItem>
+                        <SelectItem value="4">支付失败</SelectItem>
+                        <SelectItem value="5">订单关闭</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={updateForm.control}
+                name="remark"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>管理员备注</FormLabel>
+                    <FormControl>
+                      <Textarea
+                        placeholder="输入管理员备注信息..."
+                        className="resize-none"
+                        {...field}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                  取消
+                </Button>
+                <Button type="submit">保存</Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+
+      {/* 订单详情模态框 */}
+      <Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
+        <DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>订单详情</DialogTitle>
+            <DialogDescription>查看订单的详细信息</DialogDescription>
+          </DialogHeader>
+          
+          {selectedOrder && (
+            <div className="space-y-4">
+              <div className="grid grid-cols-2 gap-4">
+                <div>
+                  <h4 className="font-medium mb-2">订单信息</h4>
+                  <div className="space-y-2 text-sm">
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">订单号:</span>
+                      <span>{selectedOrder.orderNo}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">订单类型:</span>
+                      <span>{orderTypeMap[selectedOrder.orderType as keyof typeof orderTypeMap]?.label}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">订单金额:</span>
+                      <span>{formatAmount(selectedOrder.amount)}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">实付金额:</span>
+                      <span>{formatAmount(selectedOrder.payAmount)}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">运费:</span>
+                      <span>{formatAmount(selectedOrder.freightAmount)}</span>
+                    </div>
+                  </div>
+                </div>
+                
+                <div>
+                  <h4 className="font-medium mb-2">状态信息</h4>
+                  <div className="space-y-2 text-sm">
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">订单状态:</span>
+                      <span>{getStatusBadge(selectedOrder.state, 'order')}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">支付状态:</span>
+                      <span>{getStatusBadge(selectedOrder.payState, 'pay')}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">支付方式:</span>
+                      <span>{selectedOrder.payType === 1 ? '积分' : selectedOrder.payType === 2 ? '礼券' : '未选择'}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">创建时间:</span>
+                      <span>{format(new Date(selectedOrder.createdAt), 'yyyy-MM-dd HH:mm')}</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+              <div className="grid grid-cols-2 gap-4">
+                <div>
+                  <h4 className="font-medium mb-2">用户信息</h4>
+                  <div className="space-y-2 text-sm">
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">用户名:</span>
+                      <span>{selectedOrder.user?.username || '-'}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">手机号:</span>
+                      <span>{selectedOrder.userPhone || '-'}</span>
+                    </div>
+                  </div>
+                </div>
+                
+                <div>
+                  <h4 className="font-medium mb-2">收货信息</h4>
+                  <div className="space-y-2 text-sm">
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">收货人:</span>
+                      <span>{selectedOrder.recevierName || '-'}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">手机号:</span>
+                      <span>{selectedOrder.receiverMobile || '-'}</span>
+                    </div>
+                    <div className="flex justify-between">
+                      <span className="text-muted-foreground">地址:</span>
+                      <span>{selectedOrder.address || '-'}</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+              {selectedOrder.remark && (
+                <div>
+                  <h4 className="font-medium mb-2">管理员备注</h4>
+                  <p className="text-sm bg-muted p-3 rounded-md">{selectedOrder.remark}</p>
+                </div>
+              )}
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

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

@@ -18,6 +18,7 @@ import { MerchantsPage } from './pages/Merchants'
 import { AgentsPage } from './pages/Agents';
 import { UserCardsPage } from './pages/UserCards';
 import { UserCardBalanceRecordsPage } from './pages/UserCardBalanceRecords';
+import { OrdersPage } from './pages/Orders';
 import { DeliveryAddressesPage } from './pages/DeliveryAddresses';
 
 export const router = createBrowserRouter([
@@ -111,6 +112,11 @@ export const router = createBrowserRouter([
         element: <DeliveryAddressesPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'orders',
+        element: <OrdersPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,