| 版本 | 日期 | 描述 | 作者 |
|---|---|---|---|
| 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包引用。
packages/<module-name>-ui/
├── package.json # 包配置
├── tsconfig.json # TypeScript配置
├── vite.config.ts # Vite构建配置
├── src/
│ ├── index.ts # 主入口文件
│ ├── components/ # React组件
│ │ ├── <ComponentName>.tsx # 组件实现
│ │ ├── <ComponentName>.test.tsx # 组件测试
│ │ └── index.ts # 组件导出
│ ├── api/ # API客户端
│ │ ├── <module>Client.ts # RPC客户端管理器
│ │ └── index.ts # API导出
│ ├── hooks/ # 自定义Hooks
│ │ ├── use<HookName>.ts # Hook实现
│ │ └── index.ts # Hook导出
│ ├── types/ # TypeScript类型定义
│ │ ├── index.ts # 类型导出
│ │ └── <type>.ts # 具体类型定义
│ └── utils/ # 工具函数
│ └── index.ts # 工具导出
├── __tests__/ # 测试文件
│ └── integration/ # 集成测试
└── README.md # 包文档
{
"name": "@d8d/<module-name>-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-name>-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"
}
}
每个UI包必须实现一个ClientManager类来管理RPC客户端生命周期:
// src/api/<module>Client.ts
import { <module>Routes } from '@d8d/<module-name>-module';
import { rpcClient } from '@d8d/shared-ui-components/utils/hc'
export class <Module>ClientManager {
private static instance: <Module>ClientManager;
private client: ReturnType<typeof rpcClient<typeof <module>Routes>> | null = null;
private constructor() {}
public static getInstance(): <Module>ClientManager {
if (!<Module>ClientManager.instance) {
<Module>ClientManager.instance = new <Module>ClientManager();
}
return <Module>ClientManager.instance;
}
// 初始化客户端
public init(baseUrl: string = '/'): ReturnType<typeof rpcClient<typeof <module>Routes>> {
return this.client = rpcClient<typeof <module>Routes>(baseUrl);
}
// 获取客户端实例
public get(): ReturnType<typeof rpcClient<typeof <module>Routes>> {
if (!this.client) {
return this.init()
}
return this.client;
}
// 重置客户端(用于测试或重新初始化)
public reset(): void {
this.client = null;
}
}
// 导出单例实例
const <module>ClientManager = <Module>ClientManager.getInstance();
// 导出默认客户端实例(延迟初始化)
export const <module>Client = <module>ClientManager.get()
export {
<module>ClientManager
}
// src/api/index.ts
export {
<Module>ClientManager,
<module>ClientManager,
<module>Client
} from './<module>Client';
// src/components/<ComponentName>.tsx
import React from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { <module>ClientManager } from '../api/<module>Client';
import type { <Module>Response, <Module>SearchParams } from '../types';
interface <ComponentName>Props {
// 组件属性定义
}
export const <ComponentName>: React.FC<<ComponentName>Props> = (props) => {
// 使用客户端管理器获取客户端实例
const client = <module>ClientManager.get();
// 数据查询示例
const { data, isLoading } = useQuery({
queryKey: ['<module>', 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 <ComponentName>;
规范:当组件需要支持创建和编辑两种表单模式时,必须使用条件渲染两个独立的Form组件,避免在单个Form组件上动态切换props。
// ✅ 正确:条件渲染两个独立的Form组件
{isCreateForm ? (
<Form {...createForm}>
{/* 创建表单内容 */}
</Form>
) : (
<Form {...updateForm}>
{/* 编辑表单内容 */}
</Form>
)}
// ❌ 错误:在单个Form组件上动态切换props(可能导致类型不兼容)
<Form {...(isCreateForm ? createForm : updateForm)}>
{/* 表单内容 */}
</Form>
规范:参考PlatformManagement.tsx的表单处理模式,确保一致性。
规范:创建表单和编辑表单分别使用独立的useForm实例,避免状态混淆。
const createForm = useForm({
resolver: zodResolver(CreateSchema),
defaultValues: createDefaultValues
});
const updateForm = useForm({
resolver: zodResolver(UpdateSchema),
defaultValues: updateDefaultValues
});
// src/components/index.ts
export { <ComponentName> } from './<ComponentName>';
export type { <ComponentName>Props } from './<ComponentName>';
// src/types/index.ts
import type { InferResponseType, InferRequestType } from 'hono';
import type { <module>Routes } from '@d8d/<module-name>-module';
import { <module>Client } from '../api/<module>Client';
// 使用导出的client进行类型推导
export type <Module>Response = InferResponseType<typeof <module>Client.index.$get>;
export type Create<Module>Request = InferRequestType<typeof <module>Client.index.$post>;
export type Update<Module>Request = InferRequestType<typeof <module>Client[':id']['$put']>;
// 搜索参数类型
export interface <Module>SearchParams {
page: number;
limit: number;
search?: string;
// 其他搜索参数
}
// 组件属性类型
export interface <ComponentName>Props {
// 属性定义
}
规范:必须使用RPC推断类型,而不是直接导入schema类型,避免Date/string类型不匹配问题。
// ✅ 正确:使用RPC推断类型(推荐)
export type <Module>ListItem = <Module>ListResponse['data'][0];
// ❌ 错误:直接导入schema类型(可能导致Date/string不匹配)
import type { <Module> } from '@d8d/<module-name>-module/schemas';
规范:参考现有UI包(如广告管理UI)的类型定义模式,确保一致性。
// 广告管理UI模式参考
export type AdvertisementResponse = InferResponseType<typeof advertisementClient.index.$get, 200>['data'][0];
规范:当模块使用自定义路由与CRUD路由混合时,必须通过查看后端模块集成测试确认正确的路由结构。
// 示例:渠道模块的getChannel路由结构
// 根据后台集成测试,路由结构是 getChannel[':id'].$get
export type ChannelDetailResponse = InferResponseType<typeof channelClient.getChannel[':id']['$get'], 200>;
规范:使用简单的类型索引而不是复杂的条件类型,提高代码可读性。
// ✅ 正确:简单类型索引
export type <Module>ListItem = <Module>ListResponse['data'][0];
// ❌ 避免:复杂的条件类型
export type <Module>Item = <Module>ListResponse extends { data: infer T } ? T extends Array<infer U> ? U : never : never;
// 在组件中使用React Query进行状态管理
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// 查询示例
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['<module>', 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: ['<module>'] });
queryClient.invalidateQueries({ queryKey: ['<module>', id] });
}
});
packages/<module-name>-ui/
├── tests/
│ ├── integration/ # 集成测试
│ │ └── <component-name>.integration.test.tsx
│ ├── unit/ # 单元测试
│ │ └── <hook-name>.test.tsx
│ └── components/ # 组件测试
│ └── <ComponentName>.test.tsx
// 在测试文件中使用的标准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; }
});
规范:必须为关键交互元素添加data-testid属性,避免使用文本查找导致的测试冲突。
// 在组件中添加test ID
<DialogTitle data-testid="create-<module>-modal-title">创建<Module></DialogTitle>
<Button data-testid="search-button">搜索</Button>
// 在测试中使用test ID
const modalTitle = screen.getByTestId('create-<module>-modal-title');
const searchButton = screen.getByTestId('search-button');
规范:当页面中有多个相同文本元素时,必须使用test ID代替getByText()。
// ❌ 错误:可能找到错误的元素
const createButton = screen.getByText('创建');
// ✅ 正确:使用唯一的test ID
const createButton = screen.getByTestId('create-<module>-button');
规范:test ID命名使用kebab-case格式:{action}-{element}-{purpose}。
// 示例命名
data-testid="create-channel-modal-title"
data-testid="edit-channel-button-1"
data-testid="delete-confirm-dialog-title"
规范:在测试环境中使用Radix UI组件(特别是Select、DropdownMenu等)时,必须添加必要的DOM API mock。
问题:Radix UI组件在测试环境中可能缺少某些DOM API(如scrollIntoView),导致测试失败。
解决方案:在测试setup文件中添加必要的mock。
// 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,避免文本查找冲突。
// 在组件中为SelectItem添加test ID
<SelectContent>
<SelectItem value="all" data-testid="order-status-option-all">全部状态</SelectItem>
<SelectItem value={OrderStatus.DRAFT} data-testid="order-status-option-draft">草稿</SelectItem>
<SelectItem value={OrderStatus.CONFIRMED} data-testid="order-status-option-confirmed">已确认</SelectItem>
</SelectContent>
// 在测试中使用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();
// tests/integration/<component-name>.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 { <ComponentName> } from '../../src/components/<ComponentName>';
import { <module>ClientManager, <module>Client } from '../../src/api/<module>Client';
// Mock RPC客户端
vi.mock('../../src/api/<module>Client', () => {
const mock<Module>Client = {
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 mock<Module>ClientManager = {
get: vi.fn(() => mock<Module>Client),
};
return {
<module>ClientManager: mock<Module>ClientManager,
<module>Client: mock<Module>Client,
};
});
// Mock其他依赖
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}
}));
describe('<ComponentName>集成测试', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
beforeEach(() => {
vi.clearAllMocks();
queryClient.clear();
});
it('渲染组件并加载数据', async () => {
render(
<QueryClientProvider client={queryClient}>
<<ComponentName> />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('测试数据')).toBeInTheDocument();
});
});
it('创建新数据', async () => {
render(
<QueryClientProvider client={queryClient}>
<<ComponentName> />
</QueryClientProvider>
);
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(<module>Client.index.$post).toHaveBeenCalledWith({
json: expect.objectContaining({ name: '新数据' })
});
});
});
});
// tests/unit/use<HookName>.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<HookName> } from '../../src/hooks/use<HookName>';
import { <module>Client } from '../../src/api/<module>Client';
// Mock RPC客户端
vi.mock('../../src/api/<module>Client', () => {
const mock<Module>Client = {
index: {
$get: vi.fn(() => Promise.resolve(createMockResponse(200, {
data: [{ id: 1, name: '测试数据' }],
pagination: { total: 1 }
}))),
},
};
return {
<module>Client: mock<Module>Client,
};
});
describe('use<HookName>', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
queryClient.clear();
});
it('加载数据', async () => {
const { result } = renderHook(() => use<HookName>({ page: 1, limit: 10 }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual([
{ id: 1, name: '测试数据' }
]);
expect(<module>Client.index.$get).toHaveBeenCalledWith({
query: { page: 1, pageSize: 10 }
});
});
});
// tests/components/<ComponentName>.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { <ComponentName> } from '../../src/components/<ComponentName>';
// Mock子组件
vi.mock('../ChildComponent', () => ({
ChildComponent: () => <div>Mock子组件</div>
}));
describe('<ComponentName>', () => {
it('渲染组件', () => {
render(<<ComponentName> />);
expect(screen.getByText('组件标题')).toBeInTheDocument();
});
it('显示传入的属性', () => {
render(<<ComponentName> name="测试名称" />);
expect(screen.getByText('测试名称')).toBeInTheDocument();
});
});
UI包在PNPM工作空间中直接使用TypeScript源码,不需要构建步骤。主入口直接指向src/index.ts,TypeScript会自动处理类型检查和编译。
{
"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": 类型检查,不生成输出文件// 主应用中的使用示例
import { <ComponentName> } from '@d8d/<module-name>-ui';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<<ComponentName> />
</QueryClientProvider>
);
}
UI包应该支持以下环境变量配置:
VITE_API_BASE_URL: API基础URL(默认为/)VITE_APP_ENV: 应用环境(development/production)// 统一的错误处理模式
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;
}
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
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 || <div>组件加载失败</div>;
}
return this.props.children;
}
}
在开始UI包开发前,必须完成以下检查:
规范:必须验证故事中的API路径映射与实际后端路由定义的一致性。
# 检查后端模块的路由定义
cat allin-packages/<module-name>-module/src/routes/*.routes.ts
# 查看后端集成测试确认路由结构
cat allin-packages/<module-name>-module/tests/integration/*.test.ts
规范:必须通过查看后台模块集成测试确认正确的路由结构,特别是混合路由模式。
规范:必须参考现有UI包(如广告管理UI、平台管理UI)的实现模式。
规范:必须根据实际路由名称修正API调用,确保前端API调用与后端路由定义完全一致。
// ❌ 错误:使用故事中描述但实际不存在的路由
const res = await client.index.$get(...);
const res = await client.channels.$get(...);
// ✅ 正确:使用实际路由名称
const res = await client.getAll<Module>s.$get(...);
const res = await client.search<Module>s.$get(...);
# 复制模板
cp -r packages/template-ui packages/<module-name>-ui
# 更新包名和依赖
cd packages/<module-name>-ui
# 修改package.json中的name和dependencies
# 启动开发模式
pnpm dev
# 运行测试
pnpm test
# 类型检查
pnpm typecheck
# 代码检查
pnpm lint
# 构建包
pnpm build
# 本地测试
# 在主应用中引用本地构建的包进行测试
# 发布到npm(由CI/CD流程处理)
广告管理UI包: packages/advertisement-management-ui
src/components/AdvertisementManagement.tsxsrc/api/advertisementClient.tssrc/types/index.ts区域管理UI包: packages/area-management-ui
src/components/AreaManagement.tsxsrc/api/areaClient.ts// 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<typeof rpcClient<typeof advertisementRoutes>> | null = null;
public static getInstance(): AdvertisementClientManager {
if (!AdvertisementClientManager.instance) {
AdvertisementClientManager.instance = new AdvertisementClientManager();
}
return AdvertisementClientManager.instance;
}
public get(): ReturnType<typeof rpcClient<typeof advertisementRoutes>> {
if (!this.client) {
return this.init()
}
return this.client;
}
}
每个版本更新必须包含变更日志,记录:
// 使用React.lazy进行代码分割
const LazyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<React.Suspense fallback={<LoadingSpinner />}>
<LazyComponent />
</React.Suspense>
);
}
React.memo避免不必要的重渲染useMemo和useCallback缓存计算和函数dangerouslySetInnerHTML每个UI包必须包含:
本规范基于史诗008(AllIn UI模块移植)的经验总结,确保UI包开发的一致性和可维护性。