Selaa lähdekoodia

📝 docs(story): add merchant management UI package story documentation

- create story documentation for single-tenant merchant management UI package
- define acceptance criteria for package creation and functionality
- outline task breakdown and implementation details
- document technical stack, architecture and testing requirements

✅ test(file-management): add file management UI tests

- add FileSelector component tests covering rendering, selection and filtering
- implement useFileManagement hook tests for file operations
- create utility function tests for file handling helpers
- add test setup configuration for consistent testing environment
yourname 1 kuukausi sitten
vanhempi
sitoutus
fc61e89f6d

+ 165 - 0
docs/stories/007.031.merchant-management-ui-package.story.md

@@ -0,0 +1,165 @@
+# 故事007.031: 单租户商户管理界面独立包实现
+
+## 状态
+
+Draft
+
+## 故事
+
+**作为** 系统管理员,
+**我想要** 有一个独立的单租户商户管理界面包,
+**以便** 可以在单租户系统中独立管理商户账户和状态,而不影响现有的多租户系统。
+
+## 验收标准
+
+1. **AC 1**: 成功创建单租户商户管理界面包 `@d8d/merchant-management-ui`,包含正确的包配置和依赖管理
+2. **AC 2**: 复制前端商户管理界面 `web/src/client/admin/pages/Merchants.tsx` 为单租户商户管理界面包
+3. **AC 3**: 实现完整的商户CRUD操作和状态管理
+4. **AC 4**: 基于React + TypeScript + TanStack Query + React Hook Form技术栈
+5. **AC 5**: 依赖共享UI组件包 `@d8d/shared-ui-components` 中的基础组件
+6. **AC 6**: 依赖商户模块包 `@d8d/merchant-module` 提供API客户端和类型定义
+7. **AC 7**: 提供workspace包依赖复用机制
+8. **AC 8**: 支持独立测试和部署
+9. **AC 9**: 验证现有功能无回归
+
+## 任务 / 子任务
+
+- [ ] 任务 1 (AC: 1, 7): 创建单租户商户管理界面包结构
+  - [ ] 创建包目录:`packages/merchant-management-ui/`
+  - [ ] 创建基础包结构:`src/`、`tests/`、`package.json`
+  - [ ] 配置包依赖和构建脚本
+
+- [ ] 任务 2 (AC: 1): 配置包依赖和构建
+  - [ ] 创建 `packages/merchant-management-ui/package.json` 包配置
+  - [ ] 添加依赖:`@d8d/shared-ui-components`、`@d8d/merchant-module`
+  - [ ] 配置构建脚本和TypeScript配置
+  - [ ] 安装包依赖:`cd packages/merchant-management-ui && pnpm install`
+
+- [ ] 任务 3 (AC: 2, 3): 复制并调整商户管理界面组件
+  - [ ] 复制 `web/src/client/admin/pages/Merchants.tsx` 为 `packages/merchant-management-ui/src/components/MerchantManagement.tsx`
+  - [ ] 更新组件导入路径,使用共享UI组件包
+  - [ ] 调整API客户端,使用商户模块包
+
+- [ ] 任务 4 (AC: 3, 6): 创建API客户端和类型定义
+  - [ ] 创建 `packages/merchant-management-ui/src/api/merchantClient.ts` API客户端
+  - [ ] 创建 `packages/merchant-management-ui/src/types/merchant.ts` 类型定义
+  - [ ] 确保所有类型定义与商户模块包对齐
+
+- [ ] 任务 5 (AC: 3, 4): 实现完整的商户管理功能
+  - [ ] 实现商户列表查询和分页功能
+  - [ ] 实现商户创建、编辑、删除功能
+  - [ ] 实现商户状态管理和搜索过滤功能
+  - [ ] 实现商户详情查看功能
+
+- [ ] 任务 6 (AC: 8): 创建测试套件
+  - [ ] 创建集成测试:`packages/merchant-management-ui/tests/integration/merchant-management.integration.test.tsx`
+  - [ ] 创建测试工具:`packages/merchant-management-ui/tests/test-utils.tsx`
+
+- [ ] 任务 7 (AC: 1, 7): 配置包导出接口
+  - [ ] 创建 `packages/merchant-management-ui/src/index.ts` 包导出主入口
+  - [ ] 确保所有导出组件、hook和类型定义正确
+  - [ ] 验证导出脚本正常工作
+
+- [ ] 任务 8 (AC: 9): 验证功能无回归
+  - [ ] 运行包构建:`cd packages/merchant-management-ui && pnpm build`
+  - [ ] 运行所有测试:`cd packages/merchant-management-ui && pnpm test`
+  - [ ] 验证商户管理功能正常
+  - [ ] 验证与现有系统兼容性
+
+- [ ] 任务 9 (新增任务): 实现RPC客户端架构和最佳实践
+  - [ ] 创建单例模式的商户客户端管理器
+  - [ ] 实现延迟初始化和客户端重置功能
+  - [ ] 使用Hono的InferRequestType和InferResponseType确保类型安全
+  - [ ] 提供全局唯一的客户端实例管理
+  - [ ] 验证RPC客户端在主应用中的正确集成
+  - [ ] 实现类型安全的API调用模式
+
+## Dev Notes
+
+### 技术栈和架构上下文
+- **技术栈**: React 19 + TypeScript + TanStack Query + React Hook Form [Source: architecture/tech-stack.md#现有技术栈维护]
+- **前端框架**: React 19.1.0 用于用户界面构建 [Source: architecture/tech-stack.md#现有技术栈维护]
+- **状态管理**: React Query 5.83.0 用于服务端状态管理 [Source: architecture/tech-stack.md#现有技术栈维护]
+- **构建工具**: Vite 7.0.0 用于开发服务器和构建 [Source: architecture/tech-stack.md#现有技术栈维护]
+
+### 项目结构
+- **包位置**: `packages/merchant-management-ui/` [Source: architecture/source-tree.md#实际项目结构]
+- **源码结构**:
+  - `src/components/` - React组件
+  - `src/hooks/` - 自定义React hooks
+  - `src/api/` - API客户端
+  - `src/types/` - TypeScript类型定义
+  - `tests/unit/` - 单元测试
+  - `tests/integration/` - 集成测试
+- **依赖管理**: 使用pnpm workspace依赖管理 [Source: architecture/source-tree.md#集成指南]
+
+### 依赖关系
+- **共享UI组件包**: `@d8d/shared-ui-components` - 提供基础UI组件 [Source: architecture/source-tree.md#实际项目结构]
+- **单租户商户模块**: `@d8d/merchant-module` - 提供商户管理API [Source: docs/prd/epic-007-multi-tenant-package-replication.md#商户管理界面包]
+
+### 从前一个故事吸取的经验教训
+- **useQuery测试策略**: 使用真实的QueryClientProvider而不是mock react-query,在TestWrapper中提供完整的react-query上下文 [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试策略关键发现]
+- **UI组件测试策略**: 使用data-testid进行元素定位比placeholder/role更准确稳定,避免因UI变化导致测试失败 [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试策略关键发现]
+- **React Hook Form处理**: 需要过滤React Hook Form的props避免React警告,改进mock策略 [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试策略关键发现]
+- **Router上下文**: 需要提供BrowserRouter上下文或mock useNavigate [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试策略关键发现]
+
+### 测试标准
+- **测试框架**: Vitest + Testing Library [Source: architecture/testing-strategy.md#单元测试]
+- **测试位置**: `packages/merchant-management-ui/tests/unit/` 和 `packages/merchant-management-ui/tests/integration/` [Source: architecture/testing-strategy.md#单元测试]
+- **测试覆盖率目标**: ≥ 80% 单元测试覆盖率 [Source: architecture/testing-strategy.md#各层覆盖率要求]
+- **测试执行**: 使用 `pnpm test` 运行所有测试 [Source: architecture/testing-strategy.md#本地开发测试]
+- **测试模式**: 遵循测试金字塔模型,包含单元测试、组件测试和集成测试 [Source: architecture/testing-strategy.md#测试金字塔策略]
+
+### 关键实施要点
+- **包命名**: 使用标准命名约定,不添加特殊后缀 [Source: docs/prd/epic-007-multi-tenant-package-replication.md#包命名约定]
+- **API客户端**: 使用Hono客户端调用单租户商户API [Source: docs/stories/007.015.auth-management-ui-package.story.md#任务-5]
+- **导出接口**: 提供完整的组件、hook和类型定义导出 [Source: docs/stories/007.015.auth-management-ui-package.story.md#任务-7]
+- **组件复用**: 基于现有商户管理界面实现,确保功能完整性和一致性
+
+### 商户管理功能特性
+- **商户列表**: 支持分页、搜索、过滤功能
+- **商户CRUD**: 完整的创建、读取、更新、删除操作
+- **状态管理**: 商户启用/禁用状态控制
+- **详情查看**: 商户详细信息展示
+- **表单验证**: 完整的表单验证和错误处理
+
+### 测试
+
+#### 测试标准和框架
+- **测试框架**: Vitest 3.2.4 + Testing Library 16.3.0 [Source: architecture/testing-strategy.md#工具版本]
+- **测试位置**:
+  - 集成测试: `packages/merchant-management-ui/tests/integration/**/*.test.tsx`
+  [Source: architecture/testing-strategy.md#单元测试]
+
+#### 测试模式和策略
+- **useQuery测试**: 使用真实的QueryClientProvider而不是mock react-query [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试策略关键发现]
+- **元素定位**: 使用data-testid进行元素定位,比placeholder/role更准确稳定 [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试策略关键发现]
+- **Mock策略**: 使用智能mock过滤React Hook Form props [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试架构改进]
+- **测试工具**: 提供QueryClientProvider和必要的上下文 [Source: docs/stories/007.015.auth-management-ui-package.story.md#测试架构改进]
+
+#### 特定测试要求
+- **商户CRUD测试**: 验证商户创建、读取、更新、删除功能
+- **状态管理测试**: 验证商户启用/禁用状态控制
+- **搜索过滤测试**: 验证搜索和过滤功能正常工作
+- **表单验证测试**: 验证表单验证和错误处理
+- **API集成测试**: 验证与商户模块的API集成
+
+#### 测试执行命令
+- 运行所有测试: `cd packages/merchant-management-ui && pnpm test`
+- 运行单元测试: `cd packages/merchant-management-ui && pnpm test:unit`
+- 运行集成测试: `cd packages/merchant-management-ui && pnpm test:integration`
+- 生成覆盖率报告: `cd packages/merchant-management-ui && pnpm test:coverage`
+
+## 变更日志
+
+| 日期 | 版本 | 描述 | 作者 |
+|------|------|------|------|
+| 2025-11-16 | 1.0 | 初始故事创建 | Bob (Scrum Master) |
+
+## Dev Agent Record
+
+*此部分将在开发代理实施过程中填充*
+
+## QA Results
+
+*此部分将在质量保证审查过程中由QA代理填充*

+ 194 - 0
packages/file-management-ui/tests/components/FileSelector.test.tsx

@@ -0,0 +1,194 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import FileSelector from '../../src/components/FileSelector';
+
+// Mock API客户端
+vi.mock('../../src/api/fileClient', () => ({
+  fileClient: {
+    $get: vi.fn(),
+    ':id': {
+      $get: vi.fn(),
+    },
+  },
+}));
+
+// Mock 文件上传组件
+vi.mock('../../src/components/MinioUploader', () => ({
+  default: () => <div data-testid="minio-uploader">MinioUploader</div>,
+}));
+
+describe('FileSelector', () => {
+  let queryClient: QueryClient;
+
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+
+    vi.clearAllMocks();
+  });
+
+  const renderWithQueryClient = (component: React.ReactElement) => {
+    return render(
+      <QueryClientProvider client={queryClient}>
+        {component}
+      </QueryClientProvider>
+    );
+  };
+
+  const mockFiles = [
+    {
+      id: 1,
+      name: 'test-image.jpg',
+      type: 'image/jpeg',
+      size: 1024,
+      fullUrl: 'http://example.com/test-image.jpg',
+      uploadTime: '2024-01-01T00:00:00Z',
+    },
+    {
+      id: 2,
+      name: 'test-document.pdf',
+      type: 'application/pdf',
+      size: 2048,
+      fullUrl: 'http://example.com/test-document.pdf',
+      uploadTime: '2024-01-01T00:00:00Z',
+    },
+  ];
+
+  it('应该渲染文件选择器', () => {
+    renderWithQueryClient(
+      <FileSelector value={null} onChange={() => {}} />
+    );
+
+    expect(screen.getByText('选择文件')).toBeInTheDocument();
+  });
+
+  it('应该打开选择对话框', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      })
+    });
+
+    renderWithQueryClient(
+      <FileSelector value={null} onChange={() => {}} />
+    );
+
+    const selectButton = screen.getByText('选择文件');
+    fireEvent.click(selectButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('选择文件')).toBeInTheDocument();
+      expect(screen.getByText('上传新文件或从已有文件中选择')).toBeInTheDocument();
+    });
+  });
+
+  it('应该显示已选文件预览', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient[':id'].$get.mockResolvedValue({
+      ok: true,
+      json: async () => mockFiles[0]
+    });
+
+    renderWithQueryClient(
+      <FileSelector value={1} onChange={() => {}} showPreview={true} />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('更换文件')).toBeInTheDocument();
+    });
+  });
+
+  it('应该支持多选模式', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      })
+    });
+
+    const onChange = vi.fn();
+    renderWithQueryClient(
+      <FileSelector
+        value={[1, 2]}
+        onChange={onChange}
+        allowMultiple={true}
+        showPreview={true}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('已选择 2 个文件')).toBeInTheDocument();
+    });
+  });
+
+  it('应该过滤文件类型', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      })
+    });
+
+    renderWithQueryClient(
+      <FileSelector
+        value={null}
+        onChange={() => {}}
+        filterType="image"
+      />
+    );
+
+    const selectButton = screen.getByText('选择文件');
+    fireEvent.click(selectButton);
+
+    await waitFor(() => {
+      expect(fileClient.$get).toHaveBeenCalledWith({
+        query: {
+          page: 1,
+          pageSize: 50,
+          keyword: 'image'
+        }
+      });
+    });
+  });
+
+  it('应该处理文件选择确认', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      })
+    });
+
+    const onChange = vi.fn();
+    renderWithQueryClient(
+      <FileSelector value={null} onChange={onChange} />
+    );
+
+    const selectButton = screen.getByText('选择文件');
+    fireEvent.click(selectButton);
+
+    await waitFor(() => {
+      const fileItems = screen.getAllByText('test-image.jpg');
+      fireEvent.click(fileItems[0]);
+    });
+
+    const confirmButton = screen.getByText('确认选择');
+    fireEvent.click(confirmButton);
+
+    expect(onChange).toHaveBeenCalledWith(1);
+  });
+});

+ 242 - 0
packages/file-management-ui/tests/hooks/useFileManagement.test.ts

@@ -0,0 +1,242 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { useFileManagement } from '../../src/hooks/useFileManagement';
+
+// Mock API客户端
+vi.mock('../../src/api/fileClient', () => ({
+  fileClient: {
+    $get: vi.fn(),
+    ':id': {
+      $put: vi.fn(),
+      $delete: vi.fn(),
+    },
+  },
+}));
+
+// Mock toast
+vi.mock('sonner', () => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+    warning: vi.fn(),
+  },
+}));
+
+describe('useFileManagement', () => {
+  let queryClient: QueryClient;
+
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+
+    vi.clearAllMocks();
+  });
+
+  const wrapper = ({ children }: { children: React.ReactNode }) => (
+    <QueryClientProvider client={queryClient}>
+      {children}
+    </QueryClientProvider>
+  );
+
+  const mockFiles = [
+    {
+      id: 1,
+      name: 'test-file-1.jpg',
+      type: 'image/jpeg',
+      size: 1024,
+      fullUrl: 'http://example.com/test-file-1.jpg',
+      uploadTime: '2024-01-01T00:00:00Z',
+    },
+    {
+      id: 2,
+      name: 'test-file-2.pdf',
+      type: 'application/pdf',
+      size: 2048,
+      fullUrl: 'http://example.com/test-file-2.pdf',
+      uploadTime: '2024-01-01T00:00:00Z',
+    },
+  ];
+
+  it('应该初始化文件管理钩子', () => {
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    expect(result.current.files).toEqual([]);
+    expect(result.current.isLoading).toBe(true);
+    expect(result.current.searchText).toBe('');
+  });
+
+  it('应该获取文件列表', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 10, total: 2 }
+      })
+    });
+
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    await waitFor(() => {
+      expect(result.current.isLoading).toBe(false);
+    });
+
+    expect(result.current.files).toEqual(mockFiles);
+    expect(result.current.pagination.total).toBe(2);
+  });
+
+  it('应该处理搜索', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: [mockFiles[0]],
+        pagination: { current: 1, pageSize: 10, total: 1 }
+      })
+    });
+
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    result.current.handleSearch('test');
+
+    await waitFor(() => {
+      expect(result.current.searchText).toBe('test');
+    });
+
+    expect(fileClient.$get).toHaveBeenCalledWith({
+      query: {
+        page: 1,
+        pageSize: 10,
+        keyword: 'test'
+      }
+    });
+  });
+
+  it('应该处理分页', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    fileClient.$get.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        data: mockFiles,
+        pagination: { current: 2, pageSize: 5, total: 2 }
+      })
+    });
+
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    result.current.handlePageChange(2, 5);
+
+    await waitFor(() => {
+      expect(result.current.pagination.current).toBe(2);
+      expect(result.current.pagination.pageSize).toBe(5);
+    });
+  });
+
+  it('应该更新文件信息', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    const { toast } = require('sonner');
+
+    fileClient[':id'].$put.mockResolvedValue({
+      ok: true,
+      json: async () => ({
+        id: 1,
+        name: 'updated-file.jpg',
+        description: 'Updated description'
+      })
+    });
+
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    await result.current.updateFile({
+      id: 1,
+      data: {
+        name: 'updated-file.jpg',
+        description: 'Updated description'
+      }
+    });
+
+    expect(fileClient[':id'].$put).toHaveBeenCalledWith({
+      param: { id: 1 },
+      json: {
+        name: 'updated-file.jpg',
+        description: 'Updated description'
+      }
+    });
+
+    expect(toast.success).toHaveBeenCalledWith('文件信息更新成功');
+  });
+
+  it('应该删除文件', async () => {
+    const { fileClient } = require('../../src/api/fileClient');
+    const { toast } = require('sonner');
+
+    fileClient[':id'].$delete.mockResolvedValue({
+      ok: true
+    });
+
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    await result.current.deleteFile(1);
+
+    expect(fileClient[':id'].$delete).toHaveBeenCalledWith({
+      param: { id: 1 }
+    });
+
+    expect(toast.success).toHaveBeenCalledWith('文件删除成功');
+  });
+
+  it('应该检查文件是否可预览', () => {
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+
+    expect(result.current.isPreviewable('image/jpeg')).toBe(true);
+    expect(result.current.isPreviewable('video/mp4')).toBe(true);
+    expect(result.current.isPreviewable('application/pdf')).toBe(false);
+    expect(result.current.isPreviewable(null)).toBe(false);
+  });
+
+  it('应该处理文件预览', () => {
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+    const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+
+    const file = {
+      id: 1,
+      name: 'test.jpg',
+      type: 'image/jpeg',
+      fullUrl: 'http://example.com/test.jpg'
+    } as any;
+
+    result.current.handlePreview(file);
+
+    expect(windowOpenSpy).toHaveBeenCalledWith('http://example.com/test.jpg', '_blank');
+
+    windowOpenSpy.mockRestore();
+  });
+
+  it('应该处理文件下载', () => {
+    const { result } = renderHook(() => useFileManagement(), { wrapper });
+    const createElementSpy = vi.spyOn(document, 'createElement');
+    const appendChildSpy = vi.spyOn(document.body, 'appendChild');
+    const removeChildSpy = vi.spyOn(document.body, 'removeChild');
+
+    const file = {
+      id: 1,
+      name: 'test.jpg',
+      fullUrl: 'http://example.com/test.jpg'
+    } as any;
+
+    result.current.handleDownload(file);
+
+    expect(createElementSpy).toHaveBeenCalledWith('a');
+    expect(appendChildSpy).toHaveBeenCalled();
+    expect(removeChildSpy).toHaveBeenCalled();
+
+    createElementSpy.mockRestore();
+    appendChildSpy.mockRestore();
+    removeChildSpy.mockRestore();
+  });
+});

+ 36 - 0
packages/file-management-ui/tests/setup.ts

@@ -0,0 +1,36 @@
+import '@testing-library/jest-dom';
+
+// 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 = vi.fn().mockImplementation(() => ({
+  observe: vi.fn(),
+  unobserve: vi.fn(),
+  disconnect: vi.fn(),
+}));
+
+// Mock IntersectionObserver
+global.IntersectionObserver = vi.fn().mockImplementation(() => ({
+  observe: vi.fn(),
+  unobserve: vi.fn(),
+  disconnect: vi.fn(),
+}));
+
+// Mock URL.createObjectURL
+URL.createObjectURL = vi.fn();
+
+// Mock window.open
+window.open = vi.fn();

+ 98 - 0
packages/file-management-ui/tests/utils/index.test.ts

@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest';
+import {
+  formatFileSize,
+  isPreviewableFileType,
+  getFileIconType,
+  validateFileType,
+  validateFileSize
+} from '../../src/utils';
+
+describe('工具函数', () => {
+  describe('formatFileSize', () => {
+    it('应该格式化文件大小', () => {
+      expect(formatFileSize(0)).toBe('0 Bytes');
+      expect(formatFileSize(1024)).toBe('1 KB');
+      expect(formatFileSize(1048576)).toBe('1 MB');
+      expect(formatFileSize(1073741824)).toBe('1 GB');
+      expect(formatFileSize(1099511627776)).toBe('1 TB');
+    });
+
+    it('应该处理小数文件大小', () => {
+      expect(formatFileSize(1536)).toBe('1.5 KB');
+      expect(formatFileSize(1572864)).toBe('1.5 MB');
+    });
+  });
+
+  describe('isPreviewableFileType', () => {
+    it('应该检查文件类型是否可预览', () => {
+      expect(isPreviewableFileType('image/jpeg')).toBe(true);
+      expect(isPreviewableFileType('image/png')).toBe(true);
+      expect(isPreviewableFileType('video/mp4')).toBe(true);
+      expect(isPreviewableFileType('video/quicktime')).toBe(true);
+      expect(isPreviewableFileType('application/pdf')).toBe(false);
+      expect(isPreviewableFileType('text/plain')).toBe(false);
+      expect(isPreviewableFileType(null)).toBe(false);
+    });
+  });
+
+  describe('getFileIconType', () => {
+    it('应该返回正确的文件图标类型', () => {
+      expect(getFileIconType('image/jpeg')).toBe('image');
+      expect(getFileIconType('video/mp4')).toBe('video');
+      expect(getFileIconType('audio/mp3')).toBe('audio');
+      expect(getFileIconType('application/pdf')).toBe('pdf');
+      expect(getFileIconType('application/msword')).toBe('document');
+      expect(getFileIconType('application/vnd.ms-excel')).toBe('spreadsheet');
+      expect(getFileIconType('text/plain')).toBe('text');
+      expect(getFileIconType('application/octet-stream')).toBe('other');
+    });
+  });
+
+  describe('validateFileType', () => {
+    it('应该验证文件类型', () => {
+      const imageFile = new File([''], 'test.jpg', { type: 'image/jpeg' });
+      const pdfFile = new File([''], 'test.pdf', { type: 'application/pdf' });
+
+      // 接受所有文件类型
+      expect(validateFileType(imageFile, '*/*')).toBe(true);
+      expect(validateFileType(pdfFile, '*/*')).toBe(true);
+
+      // 接受特定MIME类型
+      expect(validateFileType(imageFile, 'image/*')).toBe(true);
+      expect(validateFileType(pdfFile, 'image/*')).toBe(false);
+
+      // 接受文件扩展名
+      expect(validateFileType(imageFile, '.jpg,.png')).toBe(true);
+      expect(validateFileType(pdfFile, '.jpg,.png')).toBe(false);
+
+      // 混合类型
+      expect(validateFileType(imageFile, 'image/*,.pdf')).toBe(true);
+      expect(validateFileType(pdfFile, 'image/*,.pdf')).toBe(true);
+    });
+
+    it('应该在没有accept参数时返回true', () => {
+      const file = new File([''], 'test.txt', { type: 'text/plain' });
+      expect(validateFileType(file)).toBe(true);
+    });
+  });
+
+  describe('validateFileSize', () => {
+    it('应该验证文件大小', () => {
+      const smallFile = new File([''], 'small.txt', { type: 'text/plain' });
+      Object.defineProperty(smallFile, 'size', { value: 1024 * 1024 }); // 1MB
+
+      const largeFile = new File([''], 'large.txt', { type: 'text/plain' });
+      Object.defineProperty(largeFile, 'size', { value: 10 * 1024 * 1024 }); // 10MB
+
+      expect(validateFileSize(smallFile, 5)).toBe(true); // 5MB限制
+      expect(validateFileSize(largeFile, 5)).toBe(false); // 5MB限制
+    });
+
+    it('应该处理边界情况', () => {
+      const exactSizeFile = new File([''], 'exact.txt', { type: 'text/plain' });
+      Object.defineProperty(exactSizeFile, 'size', { value: 5 * 1024 * 1024 }); // 5MB
+
+      expect(validateFileSize(exactSizeFile, 5)).toBe(true);
+    });
+  });
+});

+ 112 - 15
pnpm-lock.yaml

@@ -762,6 +762,106 @@ importers:
         specifier: ^3.2.4
         version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
 
+  packages/file-management-ui:
+    dependencies:
+      '@d8d/file-module':
+        specifier: workspace:*
+        version: link:../file-module
+      '@d8d/shared-types':
+        specifier: workspace:*
+        version: link:../shared-types
+      '@d8d/shared-ui-components':
+        specifier: workspace:*
+        version: link:../shared-ui-components
+      '@hookform/resolvers':
+        specifier: ^5.2.1
+        version: 5.2.2(react-hook-form@7.65.0(react@19.2.0))
+      '@tanstack/react-query':
+        specifier: ^5.90.9
+        version: 5.90.9(react@19.2.0)
+      axios:
+        specifier: ^1.7.9
+        version: 1.12.2(debug@4.4.3)
+      class-variance-authority:
+        specifier: ^0.7.1
+        version: 0.7.1
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
+      date-fns:
+        specifier: ^4.1.0
+        version: 4.1.0
+      dayjs:
+        specifier: ^1.11.13
+        version: 1.11.18
+      hono:
+        specifier: ^4.8.5
+        version: 4.8.5
+      lucide-react:
+        specifier: ^0.536.0
+        version: 0.536.0(react@19.2.0)
+      react:
+        specifier: ^19.1.0
+        version: 19.2.0
+      react-dom:
+        specifier: ^19.1.0
+        version: 19.2.0(react@19.2.0)
+      react-hook-form:
+        specifier: ^7.61.1
+        version: 7.65.0(react@19.2.0)
+      react-router:
+        specifier: ^7.1.3
+        version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+      sonner:
+        specifier: ^2.0.7
+        version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+      tailwind-merge:
+        specifier: ^3.3.1
+        version: 3.3.1
+      zod:
+        specifier: ^4.0.15
+        version: 4.1.12
+    devDependencies:
+      '@testing-library/jest-dom':
+        specifier: ^6.8.0
+        version: 6.9.1
+      '@testing-library/react':
+        specifier: ^16.3.0
+        version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+      '@testing-library/user-event':
+        specifier: ^14.6.1
+        version: 14.6.1(@testing-library/dom@10.4.1)
+      '@types/node':
+        specifier: ^22.10.2
+        version: 22.19.0
+      '@types/react':
+        specifier: ^19.2.2
+        version: 19.2.2
+      '@types/react-dom':
+        specifier: ^19.2.3
+        version: 19.2.3(@types/react@19.2.2)
+      '@typescript-eslint/eslint-plugin':
+        specifier: ^8.18.1
+        version: 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.8.3)
+      '@typescript-eslint/parser':
+        specifier: ^8.18.1
+        version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.8.3)
+      eslint:
+        specifier: ^9.17.0
+        version: 9.38.0(jiti@2.6.1)
+      jsdom:
+        specifier: ^26.0.0
+        version: 26.1.0
+      typescript:
+        specifier: ^5.8.3
+        version: 5.8.3
+      unbuild:
+        specifier: ^3.4.0
+        version: 3.6.1(sass@1.93.2)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))
+      vitest:
+        specifier: ^4.0.9
+        version: 4.0.9(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
+
   packages/file-module:
     dependencies:
       '@d8d/auth-module':
@@ -2171,9 +2271,6 @@ importers:
       '@d8d/user-module-mt':
         specifier: workspace:*
         version: link:../user-module-mt
-      '@hono/zod-openapi':
-        specifier: ^1.1.4
-        version: 1.1.4(hono@4.8.5)(zod@4.1.12)
       '@hookform/resolvers':
         specifier: ^5.2.1
         version: 5.2.2(react-hook-form@7.65.0(react@19.2.0))
@@ -16282,7 +16379,7 @@ snapshots:
       estree-walker: 2.0.2
       fdir: 6.5.0(picomatch@4.0.3)
       is-reference: 1.2.1
-      magic-string: 0.30.19
+      magic-string: 0.30.21
       picomatch: 4.0.3
     optionalDependencies:
       rollup: 4.52.5
@@ -16306,7 +16403,7 @@ snapshots:
   '@rollup/plugin-replace@6.0.3(rollup@4.52.5)':
     dependencies:
       '@rollup/pluginutils': 5.3.0(rollup@4.52.5)
-      magic-string: 0.30.19
+      magic-string: 0.30.21
     optionalDependencies:
       rollup: 4.52.5
 
@@ -17429,7 +17526,7 @@ snapshots:
 
   '@types/react@19.2.2':
     dependencies:
-      csstype: 3.1.3
+      csstype: 3.2.1
 
   '@types/resolve@1.20.2': {}
 
@@ -17778,7 +17875,7 @@ snapshots:
     dependencies:
       '@vitest/spy': 3.2.4
       estree-walker: 3.0.3
-      magic-string: 0.30.19
+      magic-string: 0.30.21
     optionalDependencies:
       vite: 7.1.11(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
 
@@ -17786,7 +17883,7 @@ snapshots:
     dependencies:
       '@vitest/spy': 3.2.4
       estree-walker: 3.0.3
-      magic-string: 0.30.19
+      magic-string: 0.30.21
     optionalDependencies:
       vite: 7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.64.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
 
@@ -17820,7 +17917,7 @@ snapshots:
   '@vitest/snapshot@3.2.4':
     dependencies:
       '@vitest/pretty-format': 3.2.4
-      magic-string: 0.30.19
+      magic-string: 0.30.21
       pathe: 2.0.3
 
   '@vitest/snapshot@4.0.9':
@@ -17890,7 +17987,7 @@ snapshots:
       '@vue/reactivity': 3.5.22
       '@vue/runtime-core': 3.5.22
       '@vue/shared': 3.5.22
-      csstype: 3.1.3
+      csstype: 3.2.1
 
   '@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.8.3))':
     dependencies:
@@ -19442,7 +19539,7 @@ snapshots:
   dom-helpers@5.2.1:
     dependencies:
       '@babel/runtime': 7.28.4
-      csstype: 3.1.3
+      csstype: 3.2.1
 
   dom-serializer@1.4.1:
     dependencies:
@@ -20305,7 +20402,7 @@ snapshots:
 
   fix-dts-default-cjs-exports@1.0.1:
     dependencies:
-      magic-string: 0.30.19
+      magic-string: 0.30.21
       mlly: 1.8.0
       rollup: 4.52.5
 
@@ -23813,7 +23910,7 @@ snapshots:
 
   rollup-plugin-dts@6.2.3(rollup@4.52.5)(typescript@5.8.3):
     dependencies:
-      magic-string: 0.30.19
+      magic-string: 0.30.21
       rollup: 4.52.5
       typescript: 5.8.3
     optionalDependencies:
@@ -24511,7 +24608,7 @@ snapshots:
       css-what: 6.2.2
       csso: 5.0.5
       picocolors: 1.1.1
-      sax: 1.4.1
+      sax: 1.4.3
 
   swiper@11.1.15: {}
 
@@ -24906,7 +25003,7 @@ snapshots:
       fix-dts-default-cjs-exports: 1.0.1
       hookable: 5.5.3
       jiti: 2.6.1
-      magic-string: 0.30.19
+      magic-string: 0.30.21
       mkdist: 2.4.1(sass@1.93.2)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))
       mlly: 1.8.0
       pathe: 2.0.3