Browse Source

✨ feat(goods): 新增商品管理功能

- 创建商品分类选择器组件 GoodsCategorySelector
- 创建供应商选择器组件 SupplierSelector
- 新增商品管理页面,支持商品的增删改查
- 在菜单中添加商品列表入口
- 添加商品页面路由配置
- 修复商品schema中价格字段的类型定义
yourname 4 months ago
parent
commit
616bf75409

+ 69 - 0
src/client/admin-shadcn/components/GoodsCategorySelector.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/client/components/ui/select';
+import { goodsCategoryClient } from '@/client/api';
+
+interface GoodsCategorySelectorProps {
+  value?: number;
+  onChange?: (value: number) => void;
+  placeholder?: string;
+  disabled?: boolean;
+  level?: 1 | 2 | 3;
+  parentId?: number;
+}
+
+const GoodsCategorySelector: React.FC<GoodsCategorySelectorProps> = ({
+  value,
+  onChange,
+  placeholder = "请选择商品分类",
+  disabled = false,
+  level = 1,
+  parentId,
+}) => {
+  const { data: categories, isLoading } = useQuery({
+    queryKey: ['goods-categories', level, parentId],
+    queryFn: async () => {
+      const res = await goodsCategoryClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          filters: JSON.stringify({
+            level,
+            parentId: parentId || undefined,
+          })
+        }
+      });
+      if (res.status !== 200) throw new Error('获取商品分类失败');
+      const data = await res.json();
+      return data.data;
+    },
+    enabled: !disabled,
+  });
+
+  return (
+    <Select
+      value={value?.toString()}
+      onValueChange={(v) => onChange?.(parseInt(v))}
+      disabled={disabled || isLoading}
+    >
+      <SelectTrigger>
+        <SelectValue placeholder={isLoading ? "加载中..." : placeholder} />
+      </SelectTrigger>
+      <SelectContent>
+        {categories?.map((category) => (
+          <SelectItem key={category.id} value={category.id.toString()}>
+            {category.name}
+          </SelectItem>
+        ))}
+      </SelectContent>
+    </Select>
+  );
+};
+
+export default GoodsCategorySelector;

+ 58 - 0
src/client/admin-shadcn/components/SupplierSelector.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/client/components/ui/select';
+import { supplierClient } from '@/client/api';
+
+interface SupplierSelectorProps {
+  value?: number;
+  onChange?: (value: number) => void;
+  placeholder?: string;
+  disabled?: boolean;
+}
+
+const SupplierSelector: React.FC<SupplierSelectorProps> = ({
+  value,
+  onChange,
+  placeholder = "请选择供应商",
+  disabled = false,
+}) => {
+  const { data: suppliers, isLoading } = useQuery({
+    queryKey: ['suppliers'],
+    queryFn: async () => {
+      const res = await supplierClient.$get({
+        query: { page: 1, pageSize: 100 }
+      });
+      if (res.status !== 200) throw new Error('获取供应商失败');
+      const data = await res.json();
+      return data.data;
+    },
+    enabled: !disabled,
+  });
+
+  return (
+    <Select
+      value={value?.toString()}
+      onValueChange={(v) => onChange?.(parseInt(v))}
+      disabled={disabled || isLoading}
+    >
+      <SelectTrigger>
+        <SelectValue placeholder={isLoading ? "加载中..." : placeholder} />
+      </SelectTrigger>
+      <SelectContent>
+        {suppliers?.map((supplier) => (
+          <SelectItem key={supplier.id} value={supplier.id.toString()}>
+            {supplier.name}
+          </SelectItem>
+        ))}
+      </SelectContent>
+    </Select>
+  );
+};
+
+export default SupplierSelector;

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

@@ -132,6 +132,12 @@ export const useMenu = () => {
       icon: <Package className="h-4 w-4" />,
       permission: 'goods:manage',
       children: [
+        {
+          key: 'goods-list',
+          label: '商品列表',
+          path: '/admin/goods',
+          permission: 'goods:manage'
+        },
         {
           key: 'goods-categories',
           label: '商品分类',

+ 686 - 0
src/client/admin-shadcn/pages/Goods.tsx

@@ -0,0 +1,686 @@
+import React, { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import { zhCN } from 'date-fns/locale';
+import { toast } from 'sonner';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import type { InferRequestType, InferResponseType } from 'hono/client';
+
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Label } from '@/client/components/ui/label';
+import { Badge } from '@/client/components/ui/badge';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+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 { Textarea } from '@/client/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
+
+import { goodsClient } from '@/client/api';
+import { CreateGoodsDto, UpdateGoodsDto } from '@/server/modules/goods/goods.schema';
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
+import ImageSelector from '@/client/admin-shadcn/components/ImageSelector';
+import GoodsCategorySelector from '@/client/admin-shadcn/components/GoodsCategorySelector';
+import SupplierSelector from '@/client/admin-shadcn/components/SupplierSelector';
+import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
+
+type CreateRequest = InferRequestType<typeof goodsClient.$post>['json'];
+type UpdateRequest = InferRequestType<typeof goodsClient[':id']['$put']>['json'];
+type GoodsResponse = InferResponseType<typeof goodsClient.$get, 200>['data'][0];
+
+const createFormSchema = CreateGoodsDto;
+const updateFormSchema = UpdateGoodsDto;
+
+export const GoodsPage = () => {
+  const queryClient = useQueryClient();
+  const [searchParams, setSearchParams] = useState({ page: 1, limit: 10, search: '' });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingGoods, setEditingGoods] = useState<GoodsResponse | null>(null);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [goodsToDelete, setGoodsToDelete] = useState<number | null>(null);
+
+  // 创建表单
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      name: '',
+      price: 0,
+      costPrice: 0,
+      categoryId1: 0,
+      categoryId2: 0,
+      categoryId3: 0,
+      goodsType: 1,
+      supplierId: null,
+      imageFileId: null,
+      slideImageIds: [],
+      detail: '',
+      instructions: '',
+      sort: 0,
+      state: 1,
+      stock: 0,
+      lowestBuy: 1,
+    },
+  });
+
+  // 更新表单
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(updateFormSchema),
+  });
+
+  // 获取商品列表
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['goods', searchParams],
+    queryFn: async () => {
+      const res = await goodsClient.$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 goodsClient.$post({ json: data });
+      if (res.status !== 201) throw new Error('创建商品失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('商品创建成功');
+      setIsModalOpen(false);
+      createForm.reset();
+      refetch();
+    },
+    onError: (error) => {
+      toast.error(error.message || '创建商品失败');
+    }
+  });
+
+  // 更新商品
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateRequest }) => {
+      const res = await goodsClient[':id']['$put']({
+        param: { id: id.toString() },
+        json: data
+      });
+      if (res.status !== 200) throw new Error('更新商品失败');
+      return await res.json();
+    },
+    onSuccess: () => {
+      toast.success('商品更新成功');
+      setIsModalOpen(false);
+      setEditingGoods(null);
+      refetch();
+    },
+    onError: (error) => {
+      toast.error(error.message || '更新商品失败');
+    }
+  });
+
+  // 删除商品
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const res = await goodsClient[':id']['$delete']({
+        param: { id: id.toString() }
+      });
+      if (res.status !== 204) throw new Error('删除商品失败');
+      return id;
+    },
+    onSuccess: () => {
+      toast.success('商品删除成功');
+      setDeleteDialogOpen(false);
+      setGoodsToDelete(null);
+      refetch();
+    },
+    onError: (error) => {
+      toast.error(error.message || '删除商品失败');
+    }
+  });
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault();
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  // 处理创建
+  const handleCreateGoods = () => {
+    setIsCreateForm(true);
+    setEditingGoods(null);
+    createForm.reset();
+    setIsModalOpen(true);
+  };
+
+  // 处理编辑
+  const handleEditGoods = (goods: GoodsResponse) => {
+    setIsCreateForm(false);
+    setEditingGoods(goods);
+    
+    updateForm.reset({
+      name: goods.name,
+      price: goods.price,
+      costPrice: goods.costPrice,
+      categoryId1: goods.categoryId1,
+      categoryId2: goods.categoryId2,
+      categoryId3: goods.categoryId3,
+      goodsType: goods.goodsType,
+      supplierId: goods.supplierId,
+      imageFileId: goods.imageFileId,
+      slideImageIds: goods.slideImages?.map(img => img.id) || [],
+      detail: goods.detail || '',
+      instructions: goods.instructions || '',
+      sort: goods.sort,
+      state: goods.state,
+      stock: goods.stock,
+      lowestBuy: goods.lowestBuy,
+    });
+    
+    setIsModalOpen(true);
+  };
+
+  // 处理删除
+  const handleDeleteGoods = (id: number) => {
+    setGoodsToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  // 确认删除
+  const confirmDelete = () => {
+    if (goodsToDelete) {
+      deleteMutation.mutate(goodsToDelete);
+    }
+  };
+
+  // 提交表单
+  const handleSubmit = (data: CreateRequest | UpdateRequest) => {
+    if (isCreateForm) {
+      createMutation.mutate(data as CreateRequest);
+    } else if (editingGoods) {
+      updateMutation.mutate({ id: editingGoods.id, data: data as UpdateRequest });
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <h1 className="text-2xl font-bold">商品管理</h1>
+        <Button onClick={handleCreateGoods}>
+          <Plus className="mr-2 h-4 w-4" />
+          创建商品
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>商品列表</CardTitle>
+          <CardDescription>管理您的商品信息</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleSearch} className="mb-4">
+            <div className="flex gap-2">
+              <div className="relative flex-1 max-w-sm">
+                <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+                <Input
+                  placeholder="搜索商品名称..."
+                  value={searchParams.search}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                  className="pl-8"
+                />
+              </div>
+              <Button type="submit" variant="outline">
+                搜索
+              </Button>
+            </div>
+          </form>
+
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>商品图片</TableHead>
+                  <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((goods) => (
+                  <TableRow key={goods.id}>
+                    <TableCell>
+                      {goods.imageFile?.fullUrl ? (
+                        <img
+                          src={goods.imageFile.fullUrl}
+                          alt={goods.name}
+                          className="w-12 h-12 object-cover rounded"
+                        />
+                      ) : (
+                        <div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
+                          <Package className="h-6 w-6 text-gray-400" />
+                        </div>
+                      )}
+                    </TableCell>
+                    <TableCell className="font-medium">{goods.name}</TableCell>
+                    <TableCell>¥{goods.price.toFixed(2)}</TableCell>
+                    <TableCell>{goods.stock}</TableCell>
+                    <TableCell>{goods.salesNum}</TableCell>
+                    <TableCell>{goods.supplier?.name || '-'}</TableCell>
+                    <TableCell>
+                      <Badge variant={goods.state === 1 ? 'default' : 'secondary'}>
+                        {goods.state === 1 ? '可用' : '不可用'}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>
+                      {format(new Date(goods.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditGoods(goods)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeleteGoods(goods.id)}
+                        >
+                          <Trash2 className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+
+            {data?.data.length === 0 && !isLoading && (
+              <div className="text-center py-8">
+                <p className="text-muted-foreground">暂无商品数据</p>
+              </div>
+            )}
+          </div>
+
+          <DataTablePagination
+            currentPage={searchParams.page}
+            pageSize={searchParams.limit}
+            totalCount={data?.pagination.total || 0}
+            onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
+          />
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑对话框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[700px] 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>商品名称 <span className="text-red-500">*</span></FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入商品名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <div className="grid grid-cols-2 gap-4">
+                  <FormField
+                    control={createForm.control}
+                    name="price"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>售卖价 <span className="text-red-500">*</span></FormLabel>
+                        <FormControl>
+                          <Input type="number" step="0.01" placeholder="0.00" {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={createForm.control}
+                    name="costPrice"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>成本价 <span className="text-red-500">*</span></FormLabel>
+                        <FormControl>
+                          <Input type="number" step="0.01" placeholder="0.00" {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <div className="grid grid-cols-3 gap-4">
+                  <FormField
+                    control={createForm.control}
+                    name="categoryId1"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>一级分类</FormLabel>
+                        <FormControl>
+                          <GoodsCategorySelector
+                            value={field.value || undefined}
+                            onChange={field.onChange}
+                            level={1}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={createForm.control}
+                    name="categoryId2"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>二级分类</FormLabel>
+                        <FormControl>
+                          <GoodsCategorySelector
+                            value={field.value || undefined}
+                            onChange={field.onChange}
+                            level={2}
+                            parentId={createForm.watch('categoryId1')}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={createForm.control}
+                    name="categoryId3"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>三级分类</FormLabel>
+                        <FormControl>
+                          <GoodsCategorySelector
+                            value={field.value || undefined}
+                            onChange={field.onChange}
+                            level={3}
+                            parentId={createForm.watch('categoryId2')}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <div className="grid grid-cols-2 gap-4">
+                  <FormField
+                    control={createForm.control}
+                    name="supplierId"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>供应商</FormLabel>
+                        <FormControl>
+                          <SupplierSelector
+                            value={field.value || undefined}
+                            onChange={field.onChange}
+                          />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={createForm.control}
+                    name="goodsType"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>商品类型</FormLabel>
+                        <Select
+                          value={field.value?.toString()}
+                          onValueChange={(value) => field.onChange(parseInt(value))}
+                        >
+                          <FormControl>
+                            <SelectTrigger>
+                              <SelectValue placeholder="选择商品类型" />
+                            </SelectTrigger>
+                          </FormControl>
+                          <SelectContent>
+                            <SelectItem value="1">实物产品</SelectItem>
+                            <SelectItem value="2">虚拟产品</SelectItem>
+                          </SelectContent>
+                        </Select>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <FormField
+                  control={createForm.control}
+                  name="stock"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>库存 <span className="text-red-500">*</span></FormLabel>
+                      <FormControl>
+                        <Input type="number" placeholder="0" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="imageFileId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>商品主图</FormLabel>
+                      <FormControl>
+                        <ImageSelector
+                          value={field.value || undefined}
+                          onChange={field.onChange}
+                          maxSize={2}
+                          uploadPath="/goods"
+                          uploadButtonText="上传商品主图"
+                          previewSize="medium"
+                          placeholder="选择商品主图"
+                        />
+                      </FormControl>
+                      <FormDescription>推荐尺寸:800x800px</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="instructions"
+                  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" disabled={createMutation.isPending}>
+                    {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>商品名称 <span className="text-red-500">*</span></FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入商品名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <div className="grid grid-cols-2 gap-4">
+                  <FormField
+                    control={updateForm.control}
+                    name="price"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>售卖价</FormLabel>
+                        <FormControl>
+                          <Input type="number" step="0.01" {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={updateForm.control}
+                    name="costPrice"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>成本价</FormLabel>
+                        <FormControl>
+                          <Input type="number" step="0.01" {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <div className="grid grid-cols-2 gap-4">
+                  <FormField
+                    control={updateForm.control}
+                    name="stock"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>库存</FormLabel>
+                        <FormControl>
+                          <Input type="number" {...field} />
+                        </FormControl>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+
+                  <FormField
+                    control={updateForm.control}
+                    name="state"
+                    render={({ field }) => (
+                      <FormItem>
+                        <FormLabel>状态</FormLabel>
+                        <Select
+                          value={field.value?.toString()}
+                          onValueChange={(value) => field.onChange(parseInt(value))}
+                        >
+                          <FormControl>
+                            <SelectTrigger>
+                              <SelectValue />
+                            </SelectTrigger>
+                          </FormControl>
+                          <SelectContent>
+                            <SelectItem value="1">可用</SelectItem>
+                            <SelectItem value="2">不可用</SelectItem>
+                          </SelectContent>
+                        </Select>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </div>
+
+                <DialogFooter>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={() => setIsModalOpen(false)}
+                  >
+                    取消
+                  </Button>
+                  <Button type="submit" disabled={updateMutation.isPending}>
+                    {updateMutation.isPending ? '更新中...' : '更新'}
+                  </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}
+            >
+              {deleteMutation.isPending ? '删除中...' : '删除'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

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

@@ -11,6 +11,7 @@ import { FilesPage } from './pages/Files';
 import { AdvertisementsPage } from './pages/Advertisements';
 import { AdvertisementTypesPage } from './pages/AdvertisementTypes';
 import { GoodsCategories } from './pages/GoodsCategories';
+import { GoodsPage } from './pages/Goods';
 import { ExpressCompaniesPage } from './pages/ExpressCompanies';
 import { SuppliersPage } from './pages/Suppliers';
 
@@ -65,6 +66,11 @@ export const router = createBrowserRouter([
         element: <GoodsCategories />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'goods',
+        element: <GoodsPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: 'express-companies',
         element: <ExpressCompaniesPage />,

+ 6 - 6
src/server/modules/goods/goods.schema.ts

@@ -129,11 +129,11 @@ export const CreateGoodsDto = z.object({
     description: '商品名称',
     example: 'iPhone 15'
   }),
-  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').default(0).openapi({
+  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').default(0).openapi({
     description: '售卖价',
     example: 5999.99
   }),
-  costPrice: z.coerce.number().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').default(0).openapi({
+  costPrice: z.coerce.number<number>().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').default(0).openapi({
     description: '成本价',
     example: 4999.99
   }),
@@ -181,7 +181,7 @@ export const CreateGoodsDto = z.object({
     description: '状态 1可用 2不可用',
     example: 1
   }),
-  stock: z.coerce.number().int().nonnegative('库存必须为非负数').default(0).openapi({
+  stock: z.coerce.number<number>().int().nonnegative('库存必须为非负数').default(0).openapi({
     description: '库存',
     example: 100
   }),
@@ -204,11 +204,11 @@ export const UpdateGoodsDto = z.object({
     description: '商品名称',
     example: 'iPhone 15'
   }),
-  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').optional().openapi({
+  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').optional().openapi({
     description: '售卖价',
     example: 5999.99
   }),
-  costPrice: z.coerce.number().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').optional().openapi({
+  costPrice: z.coerce.number<number>().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').optional().openapi({
     description: '成本价',
     example: 4999.99
   }),
@@ -256,7 +256,7 @@ export const UpdateGoodsDto = z.object({
     description: '状态 1可用 2不可用',
     example: 1
   }),
-  stock: z.coerce.number().int().nonnegative('库存必须为非负数').optional().openapi({
+  stock: z.coerce.number<number>().int().nonnegative('库存必须为非负数').optional().openapi({
     description: '库存',
     example: 100
   }),