# UI包开发规范 ## 版本信息 | 版本 | 日期 | 描述 | 作者 | |------|------|------|------| | 1.1 | 2025-12-04 | 添加Radix UI组件测试环境修复规范(基于故事008.007经验) | James | | 1.0 | 2025-12-03 | 基于史诗008经验创建UI包规范 | Claude Code | ## 概述 UI包是独立的前端模块包,用于封装特定业务功能的React组件、API客户端和状态管理逻辑。每个UI包作为一个独立的npm包发布,可以被主应用或其他UI包引用。 ## 包结构规范 ### 标准目录结构 ```text packages/-ui/ ├── package.json # 包配置 ├── tsconfig.json # TypeScript配置 ├── vite.config.ts # Vite构建配置 ├── src/ │ ├── index.ts # 主入口文件 │ ├── components/ # React组件 │ │ ├── .tsx # 组件实现 │ │ ├── .test.tsx # 组件测试 │ │ └── index.ts # 组件导出 │ ├── api/ # API客户端 │ │ ├── Client.ts # RPC客户端管理器 │ │ └── index.ts # API导出 │ ├── hooks/ # 自定义Hooks │ │ ├── use.ts # Hook实现 │ │ └── index.ts # Hook导出 │ ├── types/ # TypeScript类型定义 │ │ ├── index.ts # 类型导出 │ │ └── .ts # 具体类型定义 │ └── utils/ # 工具函数 │ └── index.ts # 工具导出 ├── __tests__/ # 测试文件 │ └── integration/ # 集成测试 └── README.md # 包文档 ``` ### package.json配置 ```json { "name": "@d8d/-ui", "version": "1.0.0", "description": "UI包描述", "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/-module": "workspace:*", "@hookform/resolvers": "^5.2.1", "@tanstack/react-query": "^5.90.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "hono": "^4.8.5", "lucide-react": "^0.536.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.61.1", "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" } } ``` ## RPC客户端实现规范 ### 客户端管理器模式 每个UI包必须实现一个`ClientManager`类来管理RPC客户端生命周期: ```typescript // src/api/Client.ts import { Routes } from '@d8d/-module'; import { rpcClient } from '@d8d/shared-ui-components/utils/hc' export class ClientManager { private static instance: ClientManager; private client: ReturnTypeRoutes>> | null = null; private constructor() {} public static getInstance(): ClientManager { if (!ClientManager.instance) { ClientManager.instance = new ClientManager(); } return ClientManager.instance; } // 初始化客户端 public init(baseUrl: string = '/'): ReturnTypeRoutes>> { return this.client = rpcClientRoutes>(baseUrl); } // 获取客户端实例 public get(): ReturnTypeRoutes>> { if (!this.client) { return this.init() } return this.client; } // 重置客户端(用于测试或重新初始化) public reset(): void { this.client = null; } } // 导出单例实例 const ClientManager = ClientManager.getInstance(); // 导出默认客户端实例(延迟初始化) export const Client = ClientManager.get() export { ClientManager } ``` ### API导出文件 ```typescript // src/api/index.ts export { ClientManager, ClientManager, Client } from './Client'; ``` ## 组件开发规范 ### 组件结构 ```typescript // src/components/.tsx import React from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { ClientManager } from '../api/Client'; import type { Response, SearchParams } from '../types'; interface Props { // 组件属性定义 } export const : React.FC<Props> = (props) => { // 使用客户端管理器获取客户端实例 const client = ClientManager.get(); // 数据查询示例 const { data, isLoading } = useQuery({ queryKey: ['', searchParams], queryFn: async () => { const res = await client.index.$get({ query: { page: searchParams.page, pageSize: searchParams.limit, keyword: searchParams.search } }); if (res.status !== 200) throw new Error('获取数据失败'); return await res.json(); } }); // 数据变更示例 const mutation = useMutation({ mutationFn: async (data: CreateRequest) => { const res = await client.index.$post({ json: data }); if (res.status !== 201) throw new Error('创建失败'); return await res.json(); }, onSuccess: () => { // 成功处理 }, onError: (error) => { // 错误处理 } }); return ( // 组件JSX ); }; export default ; ``` ### 表单组件模式规范(基于史诗008经验) #### 1. 条件渲染独立Form组件 **规范**:当组件需要支持创建和编辑两种表单模式时,必须使用条件渲染两个独立的Form组件,避免在单个Form组件上动态切换props。 ```typescript // ✅ 正确:条件渲染两个独立的Form组件 {isCreateForm ? (
{/* 创建表单内容 */}
) : (
{/* 编辑表单内容 */}
)} // ❌ 错误:在单个Form组件上动态切换props(可能导致类型不兼容)
{/* 表单内容 */}
``` #### 2. 参考现有模式 **规范**:参考PlatformManagement.tsx的表单处理模式,确保一致性。 #### 3. 表单状态管理 **规范**:创建表单和编辑表单分别使用独立的useForm实例,避免状态混淆。 ```typescript const createForm = useForm({ resolver: zodResolver(CreateSchema), defaultValues: createDefaultValues }); const updateForm = useForm({ resolver: zodResolver(UpdateSchema), defaultValues: updateDefaultValues }); ``` ### 组件导出 ```typescript // src/components/index.ts export { } from './'; export type { Props } from './'; ``` ## 类型定义规范 ### 类型文件结构 ```typescript // src/types/index.ts import type { InferResponseType, InferRequestType } from 'hono'; import type { Routes } from '@d8d/-module'; import { Client } from '../api/Client'; // 使用导出的client进行类型推导 export type Response = InferResponseTypeClient.index.$get>; export type CreateRequest = InferRequestTypeClient.index.$post>; export type UpdateRequest = InferRequestTypeClient[':id']['$put']>; // 搜索参数类型 export interface SearchParams { page: number; limit: number; search?: string; // 其他搜索参数 } // 组件属性类型 export interface Props { // 属性定义 } ``` ### 类型推断最佳实践(基于史诗008经验) #### 1. 使用RPC推断类型 **规范**:必须使用RPC推断类型,而不是直接导入schema类型,避免Date/string类型不匹配问题。 ```typescript // ✅ 正确:使用RPC推断类型(推荐) export type ListItem = ListResponse['data'][0]; // ❌ 错误:直接导入schema类型(可能导致Date/string不匹配) import type { } from '@d8d/-module/schemas'; ``` #### 2. 参考现有UI包模式 **规范**:参考现有UI包(如广告管理UI)的类型定义模式,确保一致性。 ```typescript // 广告管理UI模式参考 export type AdvertisementResponse = InferResponseType['data'][0]; ``` #### 3. 处理混合路由模式 **规范**:当模块使用自定义路由与CRUD路由混合时,必须通过查看后端模块集成测试确认正确的路由结构。 ```typescript // 示例:渠道模块的getChannel路由结构 // 根据后台集成测试,路由结构是 getChannel[':id'].$get export type ChannelDetailResponse = InferResponseType; ``` #### 4. 避免复杂的条件类型 **规范**:使用简单的类型索引而不是复杂的条件类型,提高代码可读性。 ```typescript // ✅ 正确:简单类型索引 export type ListItem = ListResponse['data'][0]; // ❌ 避免:复杂的条件类型 export type Item = ListResponse extends { data: infer T } ? T extends Array ? U : never : never; ``` ## 状态管理规范 ### React Query配置 ```typescript // 在组件中使用React Query进行状态管理 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // 查询示例 const { data, isLoading, error, refetch } = useQuery({ queryKey: ['', id], queryFn: async () => { const res = await client[':id'].$get({ param: { id } }); if (res.status !== 200) throw new Error('获取详情失败'); return await res.json(); }, enabled: !!id, // 条件查询 }); // 变更示例 const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (data: UpdateRequest) => { const res = await client[':id']['$put']({ param: { id }, json: data }); if (res.status !== 200) throw new Error('更新失败'); return await res.json(); }, onSuccess: () => { // 使相关查询失效 queryClient.invalidateQueries({ queryKey: [''] }); queryClient.invalidateQueries({ queryKey: ['', id] }); } }); ``` ## 测试规范 ### 测试文件结构 ```text packages/-ui/ ├── tests/ │ ├── integration/ # 集成测试 │ │ └── .integration.test.tsx │ ├── unit/ # 单元测试 │ │ └── .test.tsx │ └── components/ # 组件测试 │ └── .test.tsx ``` ### Mock响应工具函数 ```typescript // 在测试文件中使用的标准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; } }); ``` ### 测试选择器优化规范(基于史诗008经验) #### 1. 优先使用test ID **规范**:必须为关键交互元素添加`data-testid`属性,避免使用文本查找导致的测试冲突。 ```typescript // 在组件中添加test ID 创建 // 在测试中使用test ID const modalTitle = screen.getByTestId('create--modal-title'); const searchButton = screen.getByTestId('search-button'); ``` #### 2. 避免文本选择器冲突 **规范**:当页面中有多个相同文本元素时,必须使用test ID代替`getByText()`。 ```typescript // ❌ 错误:可能找到错误的元素 const createButton = screen.getByText('创建'); // ✅ 正确:使用唯一的test ID const createButton = screen.getByTestId('create--button'); ``` #### 3. 命名约定 **规范**:test ID命名使用kebab-case格式:`{action}-{element}-{purpose}`。 ```typescript // 示例命名 data-testid="create-channel-modal-title" data-testid="edit-channel-button-1" data-testid="delete-confirm-dialog-title" ``` #### 4. Radix UI组件测试环境修复(基于故事008.007经验) **规范**:在测试环境中使用Radix UI组件(特别是Select、DropdownMenu等)时,必须添加必要的DOM API mock。 **问题**:Radix UI组件在测试环境中可能缺少某些DOM API(如`scrollIntoView`),导致测试失败。 **解决方案**:在测试setup文件中添加必要的mock。 ```typescript // tests/setup.ts import '@testing-library/jest-dom'; import { vi } from 'vitest'; // Mock sonner vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } })); // Mock scrollIntoView for Radix UI components Element.prototype.scrollIntoView = vi.fn(); ``` **Select组件test ID规范**:为Radix UI Select组件的选项添加test ID,避免文本查找冲突。 ```typescript // 在组件中为SelectItem添加test ID 全部状态 草稿 已确认 // 在测试中使用test ID查找Select选项 expect(screen.getByTestId('order-status-option-all')).toBeInTheDocument(); expect(screen.getByTestId('order-status-option-draft')).toBeInTheDocument(); expect(screen.getByTestId('order-status-option-confirmed')).toBeInTheDocument(); ``` ### 组件集成测试 ```typescript // tests/integration/.integration.test.tsx 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 { } from '../../src/components/'; import { ClientManager, Client } from '../../src/api/Client'; // Mock RPC客户端 vi.mock('../../src/api/Client', () => { const mockClient = { index: { $get: vi.fn(() => Promise.resolve(createMockResponse(200, { data: [ { id: 1, name: '测试数据', // 其他字段 } ], pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } }))), $post: vi.fn(() => Promise.resolve(createMockResponse(201, { id: 2, name: '新建数据' }))), }, ':id': { $get: vi.fn(() => Promise.resolve(createMockResponse(200, { id: 1, name: '测试数据详情' }))), $put: vi.fn(() => Promise.resolve(createMockResponse(200, { id: 1, name: '更新后的数据' }))), $delete: vi.fn(() => Promise.resolve(createMockResponse(204))), }, }; const mockClientManager = { get: vi.fn(() => mockClient), }; return { ClientManager: mockClientManager, Client: mockClient, }; }); // Mock其他依赖 vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), } })); describe('集成测试', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); beforeEach(() => { vi.clearAllMocks(); queryClient.clear(); }); it('渲染组件并加载数据', async () => { render( < /> ); await waitFor(() => { expect(screen.getByText('测试数据')).toBeInTheDocument(); }); }); it('创建新数据', async () => { render( < /> ); const createButton = screen.getByText('新建'); fireEvent.click(createButton); const nameInput = screen.getByLabelText('名称'); fireEvent.change(nameInput, { target: { value: '新数据' } }); const submitButton = screen.getByText('提交'); fireEvent.click(submitButton); await waitFor(() => { expect(Client.index.$post).toHaveBeenCalledWith({ json: expect.objectContaining({ name: '新数据' }) }); }); }); }); ``` ### Hook单元测试 ```typescript // tests/unit/use.test.tsx import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { use } from '../../src/hooks/use'; import { Client } from '../../src/api/Client'; // Mock RPC客户端 vi.mock('../../src/api/Client', () => { const mockClient = { index: { $get: vi.fn(() => Promise.resolve(createMockResponse(200, { data: [{ id: 1, name: '测试数据' }], pagination: { total: 1 } }))), }, }; return { Client: mockClient, }; }); describe('use', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); beforeEach(() => { vi.clearAllMocks(); queryClient.clear(); }); it('加载数据', async () => { const { result } = renderHook(() => use({ page: 1, limit: 10 }), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toEqual([ { id: 1, name: '测试数据' } ]); expect(Client.index.$get).toHaveBeenCalledWith({ query: { page: 1, pageSize: 10 } }); }); }); ``` ### 组件单元测试 ```typescript // tests/components/.test.tsx import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { } from '../../src/components/'; // Mock子组件 vi.mock('../ChildComponent', () => ({ ChildComponent: () =>
Mock子组件
})); describe('', () => { it('渲染组件', () => { render(< />); expect(screen.getByText('组件标题')).toBeInTheDocument(); }); it('显示传入的属性', () => { render(< name="测试名称" />); expect(screen.getByText('测试名称')).toBeInTheDocument(); }); }); ``` ## 构建和发布规范 ### 构建配置 UI包在PNPM工作空间中直接使用TypeScript源码,不需要构建步骤。主入口直接指向`src/index.ts`,TypeScript会自动处理类型检查和编译。 ### TypeScript配置 ```json { "compilerOptions": { "target": "ES2020", "lib": ["DOM", "DOM.Iterable", "ES2020"], "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 }, "include": ["src"] } ``` ### 构建脚本说明 - `"build": "unbuild"`: 使用unbuild进行构建(可选,用于生产环境发布) - `"dev": "tsc --watch"`: 开发模式下监听TypeScript文件变化 - `"typecheck": "tsc --noEmit"`: 类型检查,不生成输出文件 ## 集成规范 ### 在主应用中使用 ```typescript // 主应用中的使用示例 import { } from '@d8d/-ui'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); function App() { return ( < /> ); } ``` ### 环境配置 UI包应该支持以下环境变量配置: - `VITE_API_BASE_URL`: API基础URL(默认为`/`) - `VITE_APP_ENV`: 应用环境(development/production) ## 错误处理规范 ### API错误处理 ```typescript // 统一的错误处理模式 try { const res = await client.index.$get({ query: params }); if (res.status !== 200) { const errorData = await res.json(); throw new Error(errorData.message || '请求失败'); } return await res.json(); } catch (error) { if (error instanceof Error) { toast.error(error.message); } else { toast.error('未知错误'); } throw error; } ``` ### 组件错误边界 ```typescript import React, { Component, ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; fallback?: ReactNode; } interface State { hasError: boolean; error?: Error; } export class ErrorBoundary extends Component { public state: State = { hasError: false }; public static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('UI组件错误:', error, errorInfo); } public render() { if (this.state.hasError) { return this.props.fallback ||
组件加载失败
; } return this.props.children; } } ``` ## 开发流程规范 ### 1. 开发前检查清单(基于史诗008经验) 在开始UI包开发前,必须完成以下检查: #### 1.1 API路径映射验证 **规范**:必须验证故事中的API路径映射与实际后端路由定义的一致性。 ```bash # 检查后端模块的路由定义 cat allin-packages/-module/src/routes/*.routes.ts # 查看后端集成测试确认路由结构 cat allin-packages/-module/tests/integration/*.test.ts ``` #### 1.2 路由结构确认 **规范**:必须通过查看后台模块集成测试确认正确的路由结构,特别是混合路由模式。 #### 1.3 参考现有UI包 **规范**:必须参考现有UI包(如广告管理UI、平台管理UI)的实现模式。 ### 2. API调用一致性规范 **规范**:必须根据实际路由名称修正API调用,确保前端API调用与后端路由定义完全一致。 ```typescript // ❌ 错误:使用故事中描述但实际不存在的路由 const res = await client.index.$get(...); const res = await client.channels.$get(...); // ✅ 正确:使用实际路由名称 const res = await client.getAlls.$get(...); const res = await client.searchs.$get(...); ``` ### 3. 创建新UI包 ```bash # 复制模板 cp -r packages/template-ui packages/-ui # 更新包名和依赖 cd packages/-ui # 修改package.json中的name和dependencies ``` ### 2. 开发组件 ```bash # 启动开发模式 pnpm dev # 运行测试 pnpm test # 类型检查 pnpm typecheck # 代码检查 pnpm lint ``` ### 3. 构建和发布 ```bash # 构建包 pnpm build # 本地测试 # 在主应用中引用本地构建的包进行测试 # 发布到npm(由CI/CD流程处理) ``` ## 参考实现 ### 现有UI包参考 - **广告管理UI包**: `packages/advertisement-management-ui` - 组件: `src/components/AdvertisementManagement.tsx` - API客户端: `src/api/advertisementClient.ts` - 类型定义: `src/types/index.ts` - **区域管理UI包**: `packages/area-management-ui` - 组件: `src/components/AreaManagement.tsx` - API客户端: `src/api/areaClient.ts` ### 关键代码片段 ```typescript // RPC客户端管理器实现参考 import { advertisementRoutes } from '@d8d/advertisements-module'; import { rpcClient } from '@d8d/shared-ui-components/utils/hc' export class AdvertisementClientManager { private static instance: AdvertisementClientManager; private client: ReturnType> | null = null; public static getInstance(): AdvertisementClientManager { if (!AdvertisementClientManager.instance) { AdvertisementClientManager.instance = new AdvertisementClientManager(); } return AdvertisementClientManager.instance; } public get(): ReturnType> { if (!this.client) { return this.init() } return this.client; } } ``` ## 版本管理 ### 版本号规则 - **主版本号**: 不兼容的API变更 - **次版本号**: 向后兼容的功能性新增 - **修订号**: 向后兼容的问题修正 ### 变更日志 每个版本更新必须包含变更日志,记录: 1. 新增功能 2. 问题修复 3. 破坏性变更 4. 依赖更新 ## 性能优化 ### 代码分割 ```typescript // 使用React.lazy进行代码分割 const LazyComponent = React.lazy(() => import('./HeavyComponent')); function App() { return ( }> ); } ``` ### 组件优化 - 使用`React.memo`避免不必要的重渲染 - 使用`useMemo`和`useCallback`缓存计算和函数 - 实现虚拟列表处理大量数据 ## 安全规范 ### 输入验证 - 所有用户输入必须在前端进行验证 - 使用Zod schema进行表单验证 - 敏感数据不存储在客户端状态中 ### XSS防护 - 使用React的自动转义机制 - 避免使用`dangerouslySetInnerHTML` - 对动态内容进行清理 ## 文档要求 每个UI包必须包含: 1. **README.md**: 包概述、安装、使用示例 2. **API文档**: 组件Props和API接口说明 3. **示例代码**: 完整的使用示例 4. **变更日志**: 版本更新记录 --- *本规范基于史诗008(AllIn UI模块移植)的经验总结,确保UI包开发的一致性和可维护性。*