Browse Source

✨ feat(goods): add goods category management module

- add GoodsCategories page with CRUD operations
- add goods category menu item with Package icon
- add route for goods categories management
- export ImageSelector component for public use

🐛 fix(component): export ImageSelector component

- add export statement to ImageSelector.tsx to make it usable in other components
yourname 4 months ago
parent
commit
61c4460f9b

+ 1 - 1
src/client/admin-shadcn/components/ImageSelector.tsx

@@ -30,7 +30,7 @@ interface ImageSelectorProps {
   onMultipleSelect?: (fileIds: number[]) => void;
   onMultipleSelect?: (fileIds: number[]) => void;
 }
 }
 
 
-const ImageSelector: React.FC<ImageSelectorProps> = ({
+export const ImageSelector: React.FC<ImageSelectorProps> = ({
   value,
   value,
   onChange,
   onChange,
   accept = 'image/*',
   accept = 'image/*',

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

@@ -10,7 +10,8 @@ import {
   LayoutDashboard,
   LayoutDashboard,
   File,
   File,
   Megaphone,
   Megaphone,
-  Tag
+  Tag,
+  Package
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 export interface MenuItem {
 export interface MenuItem {
@@ -123,6 +124,20 @@ export const useMenu = () => {
         }
         }
       ]
       ]
     },
     },
+    {
+      key: 'goods',
+      label: '商品管理',
+      icon: <Package className="h-4 w-4" />,
+      permission: 'goods:manage',
+      children: [
+        {
+          key: 'goods-categories',
+          label: '商品分类',
+          path: '/admin/goods-categories',
+          permission: 'goods:manage'
+        }
+      ]
+    },
     {
     {
       key: 'settings',
       key: 'settings',
       label: '系统设置',
       label: '系统设置',

+ 585 - 0
src/client/admin-shadcn/pages/GoodsCategories.tsx

@@ -0,0 +1,585 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { Plus, Search, Edit, Trash2, Folder } from 'lucide-react';
+import { toast } from 'sonner';
+
+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 { 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 { Badge } from '@/client/components/ui/badge';
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
+import { ImageSelector } from '@/client/admin-shadcn/components/ImageSelector';
+import { goodsCategoryClient } from '@/client/api';
+import { CreateGoodsCategoryDto, UpdateGoodsCategoryDto } from '@/server/modules/goods/goods-category.schema';
+
+import type { InferRequestType, InferResponseType } from 'hono/client';
+
+// 类型定义
+type CreateRequest = InferRequestType<typeof goodsCategoryClient.$post>['json'];
+type UpdateRequest = InferRequestType<typeof goodsCategoryClient[':id']['$put']>['json'];
+type GoodsCategoryResponse = InferResponseType<typeof goodsCategoryClient.$get, 200>['data'][0];
+
+// 表单Schema直接使用后端定义
+const createFormSchema = CreateGoodsCategoryDto;
+const updateFormSchema = UpdateGoodsCategoryDto;
+
+export const GoodsCategories = () => {
+  // 状态管理
+  const [searchParams, setSearchParams] = useState({ page: 1, limit: 10, search: '' });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingCategory, setEditingCategory] = useState<GoodsCategoryResponse | null>(null);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [categoryToDelete, setCategoryToDelete] = useState<number | null>(null);
+
+  // 表单实例
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      name: '',
+      parentId: 0,
+      imageFileId: null,
+      level: 0,
+      state: 1,
+    },
+  });
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(updateFormSchema),
+  });
+
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['goods-categories', searchParams],
+    queryFn: async () => {
+      const res = await goodsCategoryClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+        },
+      });
+      if (res.status !== 200) throw new Error('获取商品分类列表失败');
+      return await res.json();
+    },
+  });
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault();
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  // 处理创建商品分类
+  const handleCreateCategory = () => {
+    setIsCreateForm(true);
+    setEditingCategory(null);
+    createForm.reset({
+      name: '',
+      parentId: 0,
+      imageFileId: null,
+      level: 0,
+      state: 1,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理编辑商品分类
+  const handleEditCategory = (category: GoodsCategoryResponse) => {
+    setIsCreateForm(false);
+    setEditingCategory(category);
+    updateForm.reset({
+      name: category.name,
+      parentId: category.parentId,
+      imageFileId: category.imageFileId,
+      level: category.level,
+      state: category.state,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理删除商品分类
+  const handleDeleteCategory = (id: number) => {
+    setCategoryToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  // 确认删除
+  const confirmDelete = async () => {
+    if (!categoryToDelete) return;
+
+    try {
+      const res = await goodsCategoryClient[':id']['$delete']({
+        param: { id: categoryToDelete.toString() },
+      });
+
+      if (res.status === 204) {
+        toast.success('删除成功');
+        setDeleteDialogOpen(false);
+        refetch();
+      } else {
+        throw new Error('删除失败');
+      }
+    } catch (error) {
+      toast.error('删除失败,请重试');
+    }
+  };
+
+  // 处理表单提交
+  const handleCreateSubmit = async (data: CreateRequest) => {
+    try {
+      const res = await goodsCategoryClient.$post({ json: data });
+      if (res.status !== 201) throw new Error('创建失败');
+      toast.success('创建成功');
+      setIsModalOpen(false);
+      refetch();
+    } catch (error) {
+      toast.error('创建失败,请重试');
+    }
+  };
+
+  const handleUpdateSubmit = async (data: UpdateRequest) => {
+    if (!editingCategory) return;
+
+    try {
+      const res = await goodsCategoryClient[':id']['$put']({
+        param: { id: editingCategory.id.toString() },
+        json: data,
+      });
+      if (res.status !== 200) throw new Error('更新失败');
+      toast.success('更新成功');
+      setIsModalOpen(false);
+      refetch();
+    } catch (error) {
+      toast.error('更新失败,请重试');
+    }
+  };
+
+  // 获取状态显示文本
+  const getStateText = (state: number) => {
+    return state === 1 ? '可用' : '不可用';
+  };
+
+  const getStateBadgeVariant = (state: number) => {
+    return state === 1 ? 'default' : 'secondary';
+  };
+
+  // 格式化日期
+  const formatDate = (dateString: string) => {
+    return new Date(dateString).toLocaleDateString('zh-CN');
+  };
+
+  // 渲染骨架屏
+  if (isLoading) {
+    return (
+      <div className="space-y-4">
+        <div className="flex justify-between items-center">
+          <h1 className="text-2xl font-bold">商品分类管理</h1>
+          <Button disabled>
+            <Plus className="mr-2 h-4 w-4" />
+            创建分类
+          </Button>
+        </div>
+
+        <Card>
+          <CardHeader>
+            <div className="h-6 w-1/4 bg-gray-200 rounded animate-pulse" />
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2">
+              <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
+              <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
+              <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
+            </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>
+        <Button onClick={handleCreateCategory}>
+          <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>ID</TableHead>
+                  <TableHead>分类名称</TableHead>
+                  <TableHead>上级ID</TableHead>
+                  <TableHead>层级</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>图片</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data?.data.map((category) => (
+                  <TableRow key={category.id}>
+                    <TableCell className="font-medium">{category.id}</TableCell>
+                    <TableCell>
+                      <div className="flex items-center gap-2">
+                        <Folder className="h-4 w-4 text-muted-foreground" />
+                        <span>{category.name}</span>
+                      </div>
+                    </TableCell>
+                    <TableCell>{category.parentId}</TableCell>
+                    <TableCell>{category.level}</TableCell>
+                    <TableCell>
+                      <Badge variant={getStateBadgeVariant(category.state)}>
+                        {getStateText(category.state)}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>
+                      {category.imageFile?.fullUrl ? (
+                        <img
+                          src={category.imageFile.fullUrl}
+                          alt={category.name}
+                          className="w-10 h-10 object-cover rounded"
+                          onError={(e) => {
+                            e.currentTarget.src = '/placeholder.png';
+                          }}
+                        />
+                      ) : (
+                        <span className="text-muted-foreground text-xs">无图片</span>
+                      )}
+                    </TableCell>
+                    <TableCell>{formatDate(category.createdAt)}</TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditCategory(category)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeleteCategory(category.id)}
+                        >
+                          <Trash2 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>{isCreateForm ? '创建商品分类' : '编辑商品分类'}</DialogTitle>
+            <DialogDescription>
+              {isCreateForm ? '创建一个新的商品分类' : '编辑现有商品分类信息'}
+            </DialogDescription>
+          </DialogHeader>
+
+          {isCreateForm ? (
+            <Form {...createForm}>
+              <form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
+                <FormField
+                  control={createForm.control}
+                  name="name"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        分类名称 <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入分类名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="parentId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>上级分类ID</FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入上级分类ID,0表示顶级分类"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+                        />
+                      </FormControl>
+                      <FormDescription>顶级分类请填0</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="level"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>层级</FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入层级"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+                        />
+                      </FormControl>
+                      <FormDescription>顶级分类为0,依次递增</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="state"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>状态</FormLabel>
+                      <FormControl>
+                        <select
+                          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
+                        >
+                          <option value={1}>可用</option>
+                          <option value={2}>不可用</option>
+                        </select>
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="imageFileId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>分类图片</FormLabel>
+                      <FormControl>
+                        <ImageSelector
+                          value={field.value || undefined}
+                          onChange={(value) => field.onChange(value)}
+                          maxSize={2}
+                          uploadPath="/goods-categories"
+                          uploadButtonText="上传分类图片"
+                          previewSize="medium"
+                          placeholder="选择分类图片"
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit">创建</Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          ) : (
+            <Form {...updateForm}>
+              <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
+                <FormField
+                  control={updateForm.control}
+                  name="name"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        分类名称 <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入分类名称" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="parentId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>上级分类ID</FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入上级分类ID,0表示顶级分类"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+                        />
+                      </FormControl>
+                      <FormDescription>顶级分类请填0</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="level"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>层级</FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入层级"
+                          {...field}
+                          onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+                          value={field.value ?? ''}
+                        />
+                      </FormControl>
+                      <FormDescription>顶级分类为0,依次递增</FormDescription>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="state"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>状态</FormLabel>
+                      <FormControl>
+                        <select
+                          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                          value={field.value ?? 1}
+                          onChange={(e) => field.onChange(parseInt(e.target.value))}
+                        >
+                          <option value={1}>可用</option>
+                          <option value={2}>不可用</option>
+                        </select>
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="imageFileId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>分类图片</FormLabel>
+                      <FormControl>
+                        <ImageSelector
+                          value={field.value || undefined}
+                          onChange={(value) => field.onChange(value)}
+                          maxSize={2}
+                          uploadPath="/goods-categories"
+                          uploadButtonText="上传分类图片"
+                          previewSize="medium"
+                          placeholder="选择分类图片"
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit">更新</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}>
+              删除
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

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

@@ -10,6 +10,7 @@ import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import { FilesPage } from './pages/Files';
 import { AdvertisementsPage } from './pages/Advertisements';
 import { AdvertisementsPage } from './pages/Advertisements';
 import { AdvertisementTypesPage } from './pages/AdvertisementTypes';
 import { AdvertisementTypesPage } from './pages/AdvertisementTypes';
+import { GoodsCategories } from './pages/GoodsCategories';
 
 
 export const router = createBrowserRouter([
 export const router = createBrowserRouter([
   {
   {
@@ -57,6 +58,11 @@ export const router = createBrowserRouter([
         element: <AdvertisementTypesPage />,
         element: <AdvertisementTypesPage />,
         errorElement: <ErrorPage />
         errorElement: <ErrorPage />
       },
       },
+      {
+        path: 'goods-categories',
+        element: <GoodsCategories />,
+        errorElement: <ErrorPage />
+      },
       {
       {
         path: '*',
         path: '*',
         element: <NotFoundPage />,
         element: <NotFoundPage />,