ソースを参照

✨ feat(supplier-management-ui): 实现单租户供应商管理界面独立包

- 创建完整的供应商管理UI包结构
- 实现基于Hono RPC的单例模式供应商客户端管理器
- 复制并调整现有供应商管理界面组件
- 实现完整的供应商CRUD操作和联系人管理
- 创建集成测试套件,覆盖所有CRUD操作和错误处理
- 修复表单验证和骨架屏显示问题
- 更新故事文档和史诗文档进度

🤖 Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 ヶ月 前
コミット
03887dc08b

+ 6 - 3
docs/prd/epic-007-multi-tenant-package-replication.md

@@ -26,17 +26,18 @@
 - **Story 21:** 单租户广告分类管理界面独立包实现 - ✅ 已完成
 - **Story 23:** 单租户订单管理界面独立包实现 - ✅ 已完成
 - **Story 27:** 单租户商品分类管理界面独立包实现 - ✅ 已完成
+- **Story 29:** 单租户供应商管理界面独立包实现 - ✅ 已完成
 - **Story 37:** 单租户区域管理界面独立包实现 - ✅ 已完成
 
 ### 📊 完成统计
 - **阶段1完成度**: 5/5 故事 (100%)
 - **阶段2完成度**: 5/5 故事 (100%)
 - **阶段3完成度**: 3/3 故事 (100%)
-- **阶段4完成度**: 7/26 故事 (26.9%)
-- **总体完成度**: 20/39 故事 (51.3%)
+- **阶段4完成度**: 8/26 故事 (30.8%)
+- **总体完成度**: 21/39 故事 (53.8%)
 - **多租户包创建**: 10/11 包
 - **共享包创建**: 1/1 包
-- **前端包创建**: 5/26 包 (区分单租户和多租户版本)
+- **前端包创建**: 6/26 包 (区分单租户和多租户版本)
 - **测试通过率**: 100% (所有已创建包)
 - **构建状态**: 所有包构建成功
 
@@ -49,6 +50,8 @@
 - 成功创建广告分类管理界面包:`@d8d/advertisement-type-management-ui`,基于现有广告分类管理界面实现,依赖广告模块包 `@d8d/advertisements-module`
 - 成功创建订单管理界面包:`@d8d/order-management-ui`,基于现有订单管理界面实现,依赖订单模块包 `@d8d/orders-module`
 - 成功创建商品分类管理界面包:`@d8d/goods-category-management-ui`,基于现有商品分类管理界面实现,依赖商品模块包 `@d8d/goods-module`
+- 成功创建区域管理界面包:`@d8d/area-management-ui`,基于现有区域管理界面实现,依赖地理区域模块包 `@d8d/geo-areas`
+- 成功创建供应商管理界面包:`@d8d/supplier-management-ui`,基于现有供应商管理界面实现,依赖供应商模块包 `@d8d/supplier-module`
 - 规划创建13个管理界面独立包,区分单租户和多租户版本:
   - 单租户包:`@d8d/auth-management-ui`, `@d8d/user-management-ui`, `@d8d/advertisement-management-ui`, `@d8d/advertisement-type-management-ui`, `@d8d/order-management-ui`, `@d8d/goods-management-ui`, `@d8d/goods-category-management-ui`, `@d8d/supplier-management-ui`, `@d8d/merchant-management-ui`, `@d8d/file-management-ui`, `@d8d/delivery-address-management-ui`, `@d8d/area-management-ui`, `@d8d/tenant-config-management-ui`
   - 多租户包:`@d8d/auth-management-ui-mt`, `@d8d/user-management-ui-mt`, `@d8d/advertisement-management-ui-mt`, `@d8d/advertisement-type-management-ui-mt`, `@d8d/order-management-ui-mt`, `@d8d/goods-management-ui-mt`, `@d8d/goods-category-management-ui-mt`, `@d8d/supplier-management-ui-mt`, `@d8d/merchant-management-ui-mt`, `@d8d/file-management-ui-mt`, `@d8d/delivery-address-management-ui-mt`, `@d8d/area-management-ui-mt`, `@d8d/tenant-config-management-ui-mt`

+ 36 - 0
packages/supplier-management-ui/eslint.config.js

@@ -0,0 +1,36 @@
+import tseslint from '@typescript-eslint/eslint-plugin';
+import tsparser from '@typescript-eslint/parser';
+
+export default [
+  {
+    files: ['**/*.{ts,tsx}'],
+    ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
+    languageOptions: {
+      parser: tsparser,
+      ecmaVersion: 'latest',
+      sourceType: 'module',
+      parserOptions: {
+        ecmaFeatures: {
+          jsx: true,
+        },
+      },
+    },
+    plugins: {
+      '@typescript-eslint': tseslint,
+    },
+    rules: {
+      ...tseslint.configs.recommended.rules,
+
+      // TypeScript specific rules
+      '@typescript-eslint/no-unused-vars': 'error',
+      '@typescript-eslint/no-explicit-any': 'warn',
+      '@typescript-eslint/explicit-function-return-type': 'off',
+      '@typescript-eslint/explicit-module-boundary-types': 'off',
+
+      // General rules
+      'no-console': 'warn',
+      'prefer-const': 'error',
+      'no-var': 'error',
+    },
+  },
+];

+ 93 - 0
packages/supplier-management-ui/package.json

@@ -0,0 +1,93 @@
+{
+  "name": "@d8d/supplier-management-ui",
+  "version": "1.0.0",
+  "description": "供应商管理界面包 - 提供供应商管理的完整前端界面,包括供应商CRUD操作、联系人管理、状态管理等功能",
+  "type": "module",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "exports": {
+    ".": {
+      "types": "./src/index.ts",
+      "import": "./src/index.ts",
+      "require": "./src/index.ts"
+    },
+    "./components": {
+      "types": "./src/components/index.ts",
+      "import": "./src/components/index.ts",
+      "require": "./src/components/index.ts"
+    },
+    "./hooks": {
+      "types": "./src/hooks/index.ts",
+      "import": "./src/hooks/index.ts",
+      "require": "./src/hooks/index.ts"
+    },
+    "./api": {
+      "types": "./src/api/index.ts",
+      "import": "./src/api/index.ts",
+      "require": "./src/api/index.ts"
+    }
+  },
+  "files": [
+    "src"
+  ],
+  "scripts": {
+    "build": "unbuild",
+    "dev": "tsc --watch",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "test:coverage": "vitest run --coverage",
+    "lint": "eslint src --ext .ts,.tsx",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@d8d/shared-types": "workspace:*",
+    "@d8d/shared-ui-components": "workspace:*",
+    "@d8d/supplier-module": "workspace:*",
+    "@hookform/resolvers": "^5.2.1",
+    "@tanstack/react-query": "^5.90.9",
+    "axios": "^1.7.9",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "date-fns": "^4.1.0",
+    "dayjs": "^1.11.13",
+    "hono": "^4.8.5",
+    "lucide-react": "^0.536.0",
+    "react": "^19.1.0",
+    "react-dom": "^19.1.0",
+    "react-hook-form": "^7.61.1",
+    "react-router": "^7.1.3",
+    "sonner": "^2.0.7",
+    "tailwind-merge": "^3.3.1",
+    "zod": "^4.0.15"
+  },
+  "devDependencies": {
+    "@testing-library/jest-dom": "^6.8.0",
+    "@testing-library/react": "^16.3.0",
+    "@testing-library/user-event": "^14.6.1",
+    "@types/node": "^22.10.2",
+    "@types/react": "^19.2.2",
+    "@types/react-dom": "^19.2.3",
+    "@typescript-eslint/eslint-plugin": "^8.18.1",
+    "@typescript-eslint/parser": "^8.18.1",
+    "eslint": "^9.17.0",
+    "jsdom": "^26.0.0",
+    "typescript": "^5.8.3",
+    "unbuild": "^3.4.0",
+    "vitest": "^4.0.9"
+  },
+  "peerDependencies": {
+    "react": "^19.1.0",
+    "react-dom": "^19.1.0"
+  },
+  "keywords": [
+    "supplier",
+    "management",
+    "admin",
+    "ui",
+    "react",
+    "crud",
+    "contacts"
+  ],
+  "author": "D8D Team",
+  "license": "MIT"
+}

+ 1 - 0
packages/supplier-management-ui/src/api/index.ts

@@ -0,0 +1 @@
+export { supplierClient, supplierClientManager } from './supplierClient';

+ 44 - 0
packages/supplier-management-ui/src/api/supplierClient.ts

@@ -0,0 +1,44 @@
+import { adminSupplierRoutes as supplierRoutes } from '@d8d/supplier-module';
+import { rpcClient } from '@d8d/shared-ui-components/utils/hc'
+
+class SupplierClientManager {
+  private static instance: SupplierClientManager;
+  private client: ReturnType<typeof rpcClient<typeof supplierRoutes>> | null = null;
+
+  private constructor() {}
+
+  public static getInstance(): SupplierClientManager {
+    if (!SupplierClientManager.instance) {
+      SupplierClientManager.instance = new SupplierClientManager();
+    }
+    return SupplierClientManager.instance;
+  }
+
+  // 初始化客户端
+  public init(baseUrl: string = '/'): ReturnType<typeof rpcClient<typeof supplierRoutes>> {
+    return this.client = rpcClient<typeof supplierRoutes>(baseUrl);
+  }
+
+  // 获取客户端实例
+  public get(): ReturnType<typeof rpcClient<typeof supplierRoutes>> {
+    if (!this.client) {
+      return this.init()
+    }
+    return this.client;
+  }
+
+  // 重置客户端(用于测试或重新初始化)
+  public reset(): void {
+    this.client = null;
+  }
+}
+
+// 导出单例实例
+const supplierClientManager = SupplierClientManager.getInstance();
+
+// 导出默认客户端实例(延迟初始化)
+export const supplierClient = supplierClientManager.get()
+
+export {
+  supplierClientManager
+}

+ 670 - 0
packages/supplier-management-ui/src/components/SupplierManagement.tsx

@@ -0,0 +1,670 @@
+import React, { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import { zhCN } from 'date-fns/locale';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { toast } from 'sonner';
+import {
+  Search,
+  Plus,
+  Edit,
+  Trash2,
+  Eye,
+  EyeOff
+} from 'lucide-react';
+
+import { supplierClient } from '../api/supplierClient';
+import type { InferRequestType, InferResponseType } from 'hono/client';
+import { CreateAdminSupplierDto, UpdateAdminSupplierDto } from '@d8d/supplier-module/schemas';
+
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
+import { Input } from '@d8d/shared-ui-components/components/ui/input';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@d8d/shared-ui-components/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@d8d/shared-ui-components/components/ui/table';
+import { Badge } from '@d8d/shared-ui-components/components/ui/badge';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@d8d/shared-ui-components/components/ui/dialog';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@d8d/shared-ui-components/components/ui/form';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@d8d/shared-ui-components/components/ui/select';
+import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
+import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@d8d/shared-ui-components/components/ui/pagination';
+
+// 类型定义
+type SupplierResponse = InferResponseType<typeof supplierClient.index.$get, 200>;
+type SupplierItem = SupplierResponse['data'][0];
+type CreateRequest = InferRequestType<typeof supplierClient.index.$post>['json'];
+type UpdateRequest = InferRequestType<typeof supplierClient[':id']['$put']>['json'];
+
+// 表单Schema直接使用后端定义
+const createFormSchema = CreateAdminSupplierDto;
+const updateFormSchema = UpdateAdminSupplierDto;
+
+export const SupplierManagement = () => {
+  // 状态管理
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
+  });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingSupplier, setEditingSupplier] = useState<SupplierItem | null>(null);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [supplierToDelete, setSupplierToDelete] = useState<number | null>(null);
+  const [showPassword, setShowPassword] = useState(false);
+
+  // 表单实例
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      name: '',
+      username: '',
+      password: '',
+      phone: '',
+      realname: '',
+      state: 2,
+    },
+  });
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(updateFormSchema),
+  });
+
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['suppliers', searchParams],
+    queryFn: async () => {
+      const res = await supplierClient.index.$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 handleCreateSupplier = () => {
+    setIsCreateForm(true);
+    setEditingSupplier(null);
+    createForm.reset({
+      name: '',
+      username: '',
+      password: '',
+      phone: '',
+      realname: '',
+      state: 2,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 编辑供应商
+  const handleEditSupplier = (supplier: SupplierItem) => {
+    setIsCreateForm(false);
+    setEditingSupplier(supplier);
+    updateForm.reset({
+      name: supplier.name,
+      username: supplier.username,
+      phone: supplier.phone,
+      realname: supplier.realname,
+      state: supplier.state,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 删除供应商
+  const handleDeleteSupplier = (id: number) => {
+    setSupplierToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  const confirmDelete = async () => {
+    if (!supplierToDelete) return;
+
+    try {
+      const res = await supplierClient[':id']['$delete']({
+        param: { id: Number(supplierToDelete) },
+      });
+
+      if (res.status === 204) {
+        toast.success('删除成功');
+        setDeleteDialogOpen(false);
+        refetch();
+      } else {
+        throw new Error('删除失败');
+      }
+    } catch (error) {
+      console.error('删除失败:', error);
+      toast.error('删除失败,请重试');
+    }
+  };
+
+  // 表单提交处理
+  const handleCreateSubmit = async (data: CreateRequest) => {
+    try {
+      const res = await supplierClient.index.$post({ json: data });
+      if (res.status === 201) {
+        toast.success('创建成功');
+        setIsModalOpen(false);
+        refetch();
+      } else {
+        const error = await res.json();
+        toast.error(error.message || '创建失败');
+      }
+    } catch (error) {
+      console.error('创建失败:', error);
+      toast.error('操作失败,请重试');
+    }
+  };
+
+  const handleUpdateSubmit = async (data: UpdateRequest) => {
+    if (!editingSupplier) return;
+
+    try {
+      const updateData = {
+        ...data,
+        ...(data.password === '' && { password: undefined }),
+      };
+
+      const res = await supplierClient[':id']['$put']({
+        param: { id: Number(editingSupplier.id) },
+        json: updateData,
+      });
+
+      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 renderTableSkeleton = () => (
+    <div className="space-y-2">
+      {Array.from({ length: 5 }).map((_, index) => (
+        <div key={index} className="flex space-x-4">
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 flex-1" />
+          <Skeleton className="h-4 w-16" />
+        </div>
+      ))}
+    </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={handleCreateSupplier} data-testid="create-supplier-button">
+          <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"
+                data-testid="search-input"
+              />
+            </div>
+            <Button type="submit" variant="outline" data-testid="search-button">
+              搜索
+            </Button>
+          </form>
+
+          {/* 数据表格 */}
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>ID</TableHead>
+                  <TableHead>供应商名称</TableHead>
+                  <TableHead>用户名</TableHead>
+                  <TableHead>联系人</TableHead>
+                  <TableHead>手机号码</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  // 显示表格骨架屏
+                  <TableRow>
+                    <TableCell colSpan={7} className="p-4">
+                      {renderTableSkeleton()}
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  // 显示实际供应商数据
+                  data?.data.map((supplier) => (
+                  <TableRow key={supplier.id}>
+                    <TableCell>{supplier.id}</TableCell>
+                    <TableCell>{supplier.name || '-'}</TableCell>
+                    <TableCell>{supplier.username}</TableCell>
+                    <TableCell>{supplier.realname || '-'}</TableCell>
+                    <TableCell>{supplier.phone || '-'}</TableCell>
+                    <TableCell>
+                      <Badge variant={supplier.state === 1 ? 'default' : 'secondary'}>
+                        {supplier.state === 1 ? '启用' : '禁用'}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>
+                      {format(new Date(supplier.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={() => handleEditSupplier(supplier)}
+                          data-testid={`edit-supplier-${supplier.id}`}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeleteSupplier(supplier.id)}
+                          data-testid={`delete-supplier-${supplier.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>
+          )}
+
+          {/* 分页 */}
+          <Pagination>
+            <PaginationContent>
+              <PaginationItem>
+                <PaginationPrevious
+                  onClick={() => searchParams.page > 1 && setSearchParams(prev => ({ ...prev, page: prev.page - 1 }))}
+                  className={searchParams.page <= 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
+                />
+              </PaginationItem>
+
+              {Array.from({ length: Math.ceil((data?.pagination.total || 0) / searchParams.limit) }, (_, i) => i + 1)
+                .filter(page =>
+                  page === 1 ||
+                  page === Math.ceil((data?.pagination.total || 0) / searchParams.limit) ||
+                  Math.abs(page - searchParams.page) <= 1
+                )
+                .map((page, index, array) => (
+                  <React.Fragment key={page}>
+                    {index > 0 && array[index - 1] !== page - 1 && (
+                      <PaginationItem>
+                        <span className="px-2">...</span>
+                      </PaginationItem>
+                    )}
+                    <PaginationItem>
+                      <PaginationLink
+                        onClick={() => setSearchParams(prev => ({ ...prev, page }))}
+                        isActive={page === searchParams.page}
+                        className="cursor-pointer"
+                      >
+                        {page}
+                      </PaginationLink>
+                    </PaginationItem>
+                  </React.Fragment>
+                ))}
+
+              <PaginationItem>
+                <PaginationNext
+                  onClick={() => searchParams.page < Math.ceil((data?.pagination.total || 0) / searchParams.limit) && setSearchParams(prev => ({ ...prev, page: prev.page + 1 }))}
+                  className={searchParams.page >= Math.ceil((data?.pagination.total || 0) / searchParams.limit) ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
+                />
+              </PaginationItem>
+            </PaginationContent>
+          </Pagination>
+        </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} data-testid="supplier-name-input" />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="username"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        用户名
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入用户名" {...field} data-testid="supplier-username-input" />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="password"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        密码
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <div className="relative">
+                          <Input
+                            type={showPassword ? 'text' : 'password'}
+                            placeholder="请输入密码"
+                            {...field}
+                            data-testid="supplier-password-input"
+                          />
+                          <Button
+                            type="button"
+                            variant="ghost"
+                            size="sm"
+                            className="absolute right-0 top-0 h-full px-3"
+                            onClick={() => setShowPassword(!showPassword)}
+                          >
+                            {showPassword ? (
+                              <EyeOff className="h-4 w-4" />
+                            ) : (
+                              <Eye className="h-4 w-4" />
+                            )}
+                          </Button>
+                        </div>
+                      </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="phone"
+                  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" data-testid="create-supplier-submit-button">创建</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="username"
+                  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="password"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>密码(留空不修改)</FormLabel>
+                      <FormControl>
+                        <div className="relative">
+                          <Input
+                            type={showPassword ? 'text' : 'password'}
+                            placeholder="不修改请留空"
+                            {...field}
+                          />
+                          <Button
+                            type="button"
+                            variant="ghost"
+                            size="sm"
+                            className="absolute right-0 top-0 h-full px-3"
+                            onClick={() => setShowPassword(!showPassword)}
+                          >
+                            {showPassword ? (
+                              <EyeOff className="h-4 w-4" />
+                            ) : (
+                              <Eye className="h-4 w-4" />
+                            )}
+                          </Button>
+                        </div>
+                      </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="phone"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>手机号码</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入手机号码" {...field} />
+                      </FormControl>
+                      <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" data-testid="update-supplier-submit-button">更新</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} data-testid="confirm-delete-button">
+              删除
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

+ 1 - 0
packages/supplier-management-ui/src/components/index.ts

@@ -0,0 +1 @@
+export { SupplierManagement } from './SupplierManagement';

+ 16 - 0
packages/supplier-management-ui/src/index.ts

@@ -0,0 +1,16 @@
+// 主包导出
+
+// 组件导出
+export { SupplierManagement } from './components';
+
+// API导出
+export { supplierClient, supplierClientManager } from './api';
+
+// 类型导出
+export type {
+  SupplierResponse,
+  SupplierItem,
+  CreateRequest,
+  UpdateRequest,
+  SearchParams
+} from './types/supplier';

+ 21 - 0
packages/supplier-management-ui/src/types/supplier.ts

@@ -0,0 +1,21 @@
+import type { InferRequestType, InferResponseType } from 'hono/client';
+import type { supplierClient } from '../api/supplierClient';
+
+// 供应商响应类型
+export type SupplierResponse = InferResponseType<typeof supplierClient.index.$get, 200>;
+
+// 供应商项类型
+export type SupplierItem = SupplierResponse['data'][0];
+
+// 创建请求类型
+export type CreateRequest = InferRequestType<typeof supplierClient.index.$post>['json'];
+
+// 更新请求类型
+export type UpdateRequest = InferRequestType<typeof supplierClient[':id']['$put']>['json'];
+
+// 搜索参数类型
+export interface SearchParams {
+  page: number;
+  limit: number;
+  search: string;
+}

+ 320 - 0
packages/supplier-management-ui/tests/integration/supplier-management.integration.test.tsx

@@ -0,0 +1,320 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SupplierManagement } from '../../src/components/SupplierManagement';
+import { supplierClient } from '../../src/api/supplierClient';
+
+// 完整的mock响应对象
+const createMockResponse = (status: number, data?: any) => ({
+  status,
+  ok: status >= 200 && status < 300,
+  body: null,
+  bodyUsed: false,
+  statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
+  headers: new Headers(),
+  url: '',
+  redirected: false,
+  type: 'basic' as ResponseType,
+  json: async () => data || {},
+  text: async () => '',
+  blob: async () => new Blob(),
+  arrayBuffer: async () => new ArrayBuffer(0),
+  formData: async () => new FormData(),
+  clone: function() { return this; }
+});
+
+// Mock API client
+vi.mock('../../src/api/supplierClient', () => {
+  const mockSupplierClient = {
+    index: {
+      $get: vi.fn(() => Promise.resolve({ status: 200, body: null })),
+      $post: vi.fn(() => Promise.resolve({ status: 201, body: null })),
+    },
+    ':id': {
+      $put: vi.fn(() => Promise.resolve({ status: 200, body: null })),
+      $delete: vi.fn(() => Promise.resolve({ status: 204, body: null })),
+    },
+  };
+
+  const mockSupplierClientManager = {
+    get: vi.fn(() => mockSupplierClient),
+  };
+
+  return {
+    supplierClientManager: mockSupplierClientManager,
+    supplierClient: mockSupplierClient,
+  };
+});
+
+// Mock toast
+vi.mock('sonner', () => ({
+  toast: {
+    success: vi.fn(() => {}),
+    error: vi.fn(() => {}),
+  },
+}));
+
+const createTestQueryClient = () =>
+  new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+      },
+    },
+  });
+
+const renderWithProviders = (component: React.ReactElement) => {
+  const queryClient = createTestQueryClient();
+  return render(
+    <QueryClientProvider client={queryClient}>
+      {component as any}
+    </QueryClientProvider>
+  );
+};
+
+describe('供应商管理集成测试', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('应该完成完整的供应商CRUD流程', async () => {
+    const user = userEvent.setup();
+    const mockSuppliers = {
+      data: [
+        {
+          id: 1,
+          name: '测试供应商',
+          username: 'testsupplier',
+          realname: '张经理',
+          phone: '13800138000',
+          state: 1,
+          createdAt: '2024-01-01T00:00:00Z',
+        },
+      ],
+      pagination: {
+        total: 1,
+        page: 1,
+        pageSize: 10,
+      },
+    };
+
+    const { toast } = await import('sonner');
+
+    // Mock initial supplier list
+    (supplierClient.index.$get as any).mockResolvedValue(createMockResponse(200, mockSuppliers));
+
+    renderWithProviders(<SupplierManagement />);
+
+    // Wait for initial data to load
+    await waitFor(() => {
+      expect(screen.getByText('测试供应商')).toBeInTheDocument();
+    });
+
+    // Test create supplier
+    const createButton = screen.getByTestId('create-supplier-button');
+    fireEvent.click(createButton);
+
+    // Wait for dialog to open
+    await waitFor(() => {
+      expect(screen.getByTestId('create-supplier-submit-button')).toBeInTheDocument();
+    });
+
+
+    // Fill create form using placeholder text with fireEvent.change
+    const nameInput = screen.getByPlaceholderText('请输入供应商名称');
+    const usernameInput = screen.getByPlaceholderText('请输入用户名');
+    const passwordInput = screen.getByPlaceholderText('请输入密码');
+    const phoneInput = screen.getByPlaceholderText('请输入手机号码');
+
+    fireEvent.change(nameInput, { target: { value: '新供应商' } });
+    fireEvent.change(usernameInput, { target: { value: 'newsupplier' } });
+    fireEvent.change(passwordInput, { target: { value: 'password123' } });
+    fireEvent.change(phoneInput, { target: { value: '13800138000' } });
+
+    // Mock successful creation
+    (supplierClient.index.$post as any).mockResolvedValue(createMockResponse(201, { id: 2, name: '新供应商' }));
+
+    const submitButton = screen.getByTestId('create-supplier-submit-button');
+
+    await user.click(submitButton);
+
+    await waitFor(() => {
+      expect(supplierClient.index.$post).toHaveBeenCalledWith({
+        json: {
+          name: '新供应商',
+          username: 'newsupplier',
+          password: 'password123',
+          phone: '13800138000',
+          realname: '',
+          state: 2,
+        },
+      });
+      expect(toast.success).toHaveBeenCalledWith('创建成功');
+    });
+
+    // Test edit supplier
+    const editButton = screen.getByTestId('edit-supplier-1');
+    fireEvent.click(editButton);
+
+    // Verify edit form is populated
+    await waitFor(() => {
+      expect(screen.getByDisplayValue('测试供应商')).toBeInTheDocument();
+    });
+
+    // Update supplier
+    const updateNameInput = screen.getByDisplayValue('测试供应商');
+    fireEvent.change(updateNameInput, { target: { value: '更新供应商' } });
+
+    // Mock successful update
+    (supplierClient[':id']['$put'] as any).mockResolvedValue(createMockResponse(200));
+
+    const updateButton = screen.getByTestId('update-supplier-submit-button');
+    await user.click(updateButton);
+
+    await waitFor(() => {
+      expect(supplierClient[':id']['$put']).toHaveBeenCalledWith({
+        param: { id: 1 },
+        json: {
+          name: '更新供应商',
+          username: 'testsupplier',
+          phone: '13800138000',
+          realname: '张经理',
+          password: undefined,
+          state: 1,
+        },
+      });
+      expect(toast.success).toHaveBeenCalledWith('更新成功');
+    });
+
+    // Test delete supplier
+    const deleteButton = screen.getByTestId('delete-supplier-1');
+    fireEvent.click(deleteButton);
+
+    // Confirm deletion
+    expect(screen.getByText('确认删除')).toBeInTheDocument();
+
+    // Mock successful deletion
+    (supplierClient[':id']['$delete'] as any).mockResolvedValue({
+      status: 204,
+    });
+
+    const confirmDeleteButton = screen.getByTestId('confirm-delete-button');
+    await user.click(confirmDeleteButton);
+
+    await waitFor(() => {
+      expect(supplierClient[':id']['$delete']).toHaveBeenCalledWith({
+        param: { id: 1 },
+      });
+      expect(toast.success).toHaveBeenCalledWith('删除成功');
+    });
+  });
+
+  it('应该优雅处理API错误', async () => {
+    const { supplierClient } = await import('../../src/api/supplierClient');
+    const { toast } = await import('sonner');
+    const user = userEvent.setup();
+
+    // Mock API error
+    (supplierClient.index.$get as any).mockRejectedValue(new Error('API Error'));
+
+    renderWithProviders(<SupplierManagement />);
+
+    // Should handle error without crashing
+    await waitFor(() => {
+      expect(screen.getByText('供应商管理')).toBeInTheDocument();
+    });
+
+    // Test create supplier error
+    const createButton = screen.getByText('创建供应商');
+    fireEvent.click(createButton);
+
+    const nameInput = screen.getByPlaceholderText('请输入供应商名称');
+    const usernameInput = screen.getByPlaceholderText('请输入用户名');
+    const passwordInput = screen.getByPlaceholderText('请输入密码');
+    const phoneInput = screen.getByPlaceholderText('请输入手机号码');
+
+    fireEvent.change(nameInput, { target: { value: '测试供应商' } });
+    fireEvent.change(usernameInput, { target: { value: 'testsupplier' } });
+    fireEvent.change(passwordInput, { target: { value: 'password' } });
+    fireEvent.change(phoneInput, { target: { value: '13800138000' } });
+
+    // Mock creation error
+    (supplierClient.index.$post as any).mockRejectedValue(new Error('Creation failed'));
+
+    const submitButton = screen.getByTestId('create-supplier-submit-button');
+    await user.click(submitButton);
+
+    await waitFor(() => {
+      expect(toast.error).toHaveBeenCalledWith('操作失败,请重试');
+    });
+  });
+
+  it('应该处理搜索功能', async () => {
+    const { supplierClient } = await import('../../src/api/supplierClient');
+    const mockSuppliers = {
+      data: [],
+      pagination: { total: 0, page: 1, pageSize: 10 },
+    };
+
+    (supplierClient.index.$get as any).mockResolvedValue(createMockResponse(200, mockSuppliers));
+
+    renderWithProviders(<SupplierManagement />);
+
+    // Test search
+    const searchInput = screen.getByTestId('search-input');
+    fireEvent.change(searchInput, { target: { value: '搜索关键词' } });
+
+    const searchButton = screen.getByTestId('search-button');
+    fireEvent.click(searchButton);
+
+    await waitFor(() => {
+      expect(supplierClient.index.$get).toHaveBeenCalledWith({
+        query: {
+          page: 1,
+          pageSize: 10,
+          keyword: '搜索关键词',
+        },
+      });
+    });
+  });
+
+  it('应该显示供应商状态', async () => {
+    const mockSuppliers = {
+      data: [
+        {
+          id: 1,
+          name: '启用供应商',
+          username: 'enabledsupplier',
+          realname: '李经理',
+          phone: '13900139000',
+          state: 1,
+          createdAt: '2024-01-01T00:00:00Z',
+        },
+        {
+          id: 2,
+          name: '禁用供应商',
+          username: 'disabledsupplier',
+          realname: '王经理',
+          phone: '13700137000',
+          state: 2,
+          createdAt: '2024-01-02T00:00:00Z',
+        },
+      ],
+      pagination: {
+        total: 2,
+        page: 1,
+        pageSize: 10,
+      },
+    };
+
+    (supplierClient.index.$get as any).mockResolvedValue(createMockResponse(200, mockSuppliers));
+
+    renderWithProviders(<SupplierManagement />);
+
+    await waitFor(() => {
+      expect(screen.getByText('启用')).toBeInTheDocument();
+      expect(screen.getByText('禁用')).toBeInTheDocument();
+    });
+  });
+});

+ 43 - 0
packages/supplier-management-ui/tests/setup.ts

@@ -0,0 +1,43 @@
+import '@testing-library/jest-dom';
+import { vi } from 'vitest';
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: vi.fn().mockImplementation(query => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: vi.fn(), // deprecated
+    removeListener: vi.fn(), // deprecated
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  })),
+});
+
+// Mock ResizeObserver
+global.ResizeObserver = class MockResizeObserver {
+  constructor(callback: ResizeObserverCallback) {
+    // Store callback for testing
+    (this as any).callback = callback;
+  }
+  observe = vi.fn();
+  unobserve = vi.fn();
+  disconnect = vi.fn();
+};
+
+// Mock IntersectionObserver
+global.IntersectionObserver = class MockIntersectionObserver {
+  constructor(callback: IntersectionObserverCallback) {
+    // Store callback for testing
+    (this as any).callback = callback;
+  }
+  observe = vi.fn();
+  unobserve = vi.fn();
+  disconnect = vi.fn();
+  root: Element | null = null;
+  rootMargin: string = '';
+  thresholds: ReadonlyArray<number> = [];
+  takeRecords = vi.fn();
+};

+ 36 - 0
packages/supplier-management-ui/tsconfig.json

@@ -0,0 +1,36 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "outDir": "./dist",
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": [
+    "src/**/*",
+    "tests/**/*"
+  ],
+  "exclude": [
+    "node_modules",
+    "dist"
+  ]
+}

+ 24 - 0
packages/supplier-management-ui/vitest.config.ts

@@ -0,0 +1,24 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: 'jsdom',
+    setupFiles: ['./tests/setup.ts'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'json', 'html'],
+      exclude: [
+        'node_modules/',
+        'tests/',
+        '**/*.d.ts',
+        '**/*.config.*'
+      ]
+    }
+  },
+  resolve: {
+    alias: {
+      '@': './src'
+    }
+  }
+});