大規模程式碼重構
上週我重構了一個18000行程式碼的老專案,技術債很重,架構混亂。用Claude Code處理後,效率提升了20倍。這個案例會展示整個重構過程。
專案背景
專案什麼情況
這是個跑了3年的電商管理系統,問題一堆:
- 18,000+行React程式碼
- 技術棧老掉牙: React 15.6, Redux 3.7
- 元件耦合嚴重
- 首屏要12秒,慢得要命
- 改一個bug出三個新bug
- 沒有測試
重構目標
- 升級到React 18, Redux Toolkit
- 元件解耦,重新組織目錄
- 首屏降到2秒以內
- 加TypeScript和測試
- 工具鏈現代化
需要多久
- 傳統方式: 4-6周
- 用Claude Code: 1-2周
- 效率提升: 4-6倍
準備工作
專案分析
開始前得先搞清楚專案什麼情況。啟動Claude Code:
cd ecommerce-admin-system
claude
第一輪對話
我需要重构这个大项目,帮我分析一下:
项目路径: /path/to/ecommerce-admin-system
重点看:
1. 项目结构怎么组织的
2. 依赖版本老不老
3. 哪里有代码重复
4. 性能瓶颈在哪
Claude Code會深入分析:
📊 分析中...
✓ 读取package.json
✓ 扫描src/目录(127个文件)
✓ 分析组件层级
✓ 识别依赖关系
✓ 计算代码指标
## 分析结果
### 项目结构
src/ ├── components/ # 73個元件,職責混亂 ├── containers/ # 15個容器元件 ├── redux/ # Redux配置 ├── utils/ # 工具函式 ├── api/ # API呼叫 └── styles/ # CSS檔案
主要問題
🔴 嚴重問題:
- 依賴過時(React 15.6, Redux 3.7)
- 沒有型別檢查
- 沒有測試
- 沒有程式碼分割(包大小2.8MB)
- 23處直接操作DOM
🟡 中等優先順序:
- 狀態管理混亂(Redux和本地state混用)
- 18個元件有重複程式碼
- 沒有錯誤邊界
- API URL硬編碼
重構策略
第一週: 依賴和基礎設施 第二週: 架構和元件 第三週: 效能和測試
開始第一週?
## 第一阶段:依赖升级
### 制定计划
建立詳細的重構計劃,包括:
- 每個階段的具體任務
- 怎麼回滾
- 測試策略
- 風險評估
**REFACTORING_PLAN.md**:
```markdown
# 電商管理系統重構計劃
## 專案資訊
- 當前程式碼行數: 18,247行
- 元件數量: 88個
- 重構開始日期: 2024-01-15
- 預計完成日期: 2024-02-15
## 階段一:依賴升級 (Day 1-3)
### 任務清單
- [ ] 升級React到18.x
- [ ] 升級Redux到Redux Toolkit
- [ ] 升級React Router到v6
- [ ] 升級Webpack到5.x
- [ ] 配置TypeScript
- [ ] 設定ESLint和Prettier
### 回滾策略
- 使用Git分支: `refactor/phase1-dependencies`
- 保留原package.json為package.json.backup
- 每個升級點建立commit
## 階段二:架構重構 (Day 4-10)
### 任務清單
- [ ] 重新設計目錄結構
- [ ] 提取公共元件
- [ ] 重構狀態管理
- [ ] API層抽象
- [ ] 路由重構
## 階段三:效能最佳化 (Day 11-15)
### 任務清單
- [ ] 程式碼分割和懶載入
- [ ] 元件memoization
- [ ] 圖片最佳化
- [ ] 快取策略
- [ ] 新增測試
### 風險評估
🔴 高風險:
- 依賴升級可能導致的不相容
- 狀態管理遷移可能引入資料流問題
🟡 中風險:
- 元件重構可能影響業務邏輯
- 效能最佳化可能改變使用者行為
🟢 低風險:
- 程式碼規範調整
- 文件更新
### 回滾預案
1. Git版本控制 - 每個階段獨立分支
2. 資料庫遷移指令碼 - 保留rollback指令碼
3. 環境變數備份 - .env.backup
4. 部署配置保留 - k8s-config.backup/
升级依赖
開始第一階段:
1. 建立新分支
2. 升級React和核心依賴
3. 更新構建配置
4. 確保專案能跑起來
Claude Code会执行升级流程:
# Git操作
git checkout -b refactor/phase1-dependencies
git checkout -b backup/before-refactor
# 依賴升級流程
package.json (升级后):
{
"name": "ecommerce-admin-system",
"version": "2.0.0",
"description": "E-commerce Admin System - Refactored",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"test:coverage": "react-scripts test --coverage --watchAll=false",
"eject": "react-scripts eject",
"lint": "eslint src/**/*.{js,jsx,ts,tsx}",
"lint:fix": "eslint src/**/*.{js,jsx,ts,tsx} --fix",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"@reduxjs/toolkit": "^2.0.1",
"react-redux": "^9.0.4",
"axios": "^1.6.2",
"@mui/material": "^5.15.0",
"@mui/icons-material": "^5.15.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"date-fns": "^3.0.0",
"react-hook-form": "^7.49.0",
"react-query": "^3.39.3"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3",
"react-scripts": "5.0.1",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.5.1"
}
}
TypeScript迁移
配置TypeScript:
1. 建立tsconfig.json,開啟嚴格模式
2. 設定路徑別名(@components, @utils)
3. Jest支援TypeScript
4. 漸進式遷移,別一次性全改
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": "src",
"paths": {
"@components/*": ["components/*"],
"@containers/*": ["containers/*"],
"@utils/*": ["utils/*"],
"@api/*": ["api/*"],
"@hooks/*": ["hooks/*"],
"@types/*": ["types/*"],
"@constants/*": ["constants/*"],
"@services/*": ["services/*"],
"@store/*": ["store/*"],
"@assets/*": ["assets/*"]
}
},
"include": [
"src/**/*",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"build",
"dist"
]
}
重构工具配置 - .eslintrc.js:
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
project: './tsconfig.json',
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
rules: {
'react/react-in-jsx-scope': 'off', // React 18不需要
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'react/prop-types': 'off', // 使用TypeScript代替
},
settings: {
react: {
version: 'detect',
},
},
};
.prettierrc:
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}
第二阶段:架构重构
重新组织目录
重新設計專案結構:
1. 按功能模組組織
2. 業務邏輯和UI元件分開
3. 層次清晰
4. 方便團隊協作
新目录结构:
src/
├── api/ # API介面層
│ ├── client.ts # Axios配置
│ ├── endpoints.ts # API端點定義
│ ├── products.ts # 產品相關API
│ ├── orders.ts # 訂單相關API
│ └── customers.ts # 客戶相關API
├── assets/ # 靜態資源
│ ├── images/
│ ├── fonts/
│ └── icons/
├── components/ # 通用UI元件
│ ├── ui/ # 基礎UI元件
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Modal/
│ │ ├── Table/
│ │ └── Form/
│ └── layout/ # 佈局元件
│ ├── Header/
│ ├── Sidebar/
│ └── Footer/
├── features/ # 功能模組
│ ├── products/
│ │ ├── components/ # 產品相關元件
│ │ ├── hooks/ # 自定義hooks
│ │ ├── services/ # 業務邏輯
│ │ ├── types.ts # 型別定義
│ │ └── index.tsx # 入口
│ ├── orders/
│ ├── customers/
│ ├── dashboard/
│ └── auth/
├── hooks/ # 全域性自定義hooks
│ ├── useAuth.ts
│ ├── usePagination.ts
│ └── useDebounce.ts
├── store/ # Redux store
│ ├── index.ts
│ ├── slices/
│ │ ├── authSlice.ts
│ │ ├── productSlice.ts
│ │ └── orderSlice.ts
│ └── middleware/
├── types/ # 全域性型別定義
│ ├── api.ts
│ ├── models.ts
│ └── index.ts
├── utils/ # 工具函式
│ ├── formatters.ts
│ ├── validators.ts
│ └── constants.ts
├── App.tsx
├── main.tsx
└── vite-env.d.ts
API层重构
src/api/client.ts:
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { store } from '@/store';
import { logout } from '@/store/slices/authSlice';
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// 請求攔截器
apiClient.interceptors.request.use(
(config) => {
const token = store.getState().auth.token;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers['X-Request-ID'] = generateRequestId();
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 響應攔截器
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
},
(error) => {
const { response } = error;
// 處理認證錯誤
if (response?.status === 401) {
store.dispatch(logout());
window.location.href = '/login';
return Promise.reject(new Error('Session expired'));
}
// 處理其他錯誤
const errorMessage = response?.data?.message || error.message || 'An error occurred';
// 記錄錯誤(可以整合錯誤監控服務)
console.error('API Error:', {
url: error.config?.url,
status: response?.status,
message: errorMessage,
});
return Promise.reject(new Error(errorMessage));
}
);
// 生成請求ID
function generateRequestId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// 通用API方法
export const api = {
get: <T>(url: string, config?: AxiosRequestConfig): Promise<T> => {
return apiClient.get(url, config);
},
post: <T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
return apiClient.post(url, data, config);
},
put: <T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
return apiClient.put(url, data, config);
},
patch: <T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
return apiClient.patch(url, data, config);
},
delete: <T>(url: string, config?: AxiosRequestConfig): Promise<T> => {
return apiClient.delete(url, config);
},
};
export default apiClient;
__CODE_BLOCK_15__typescript
// API端点集中管理
export const ENDPOINTS = {
// 认证
AUTH: {
LOGIN: '/auth/login',
LOGOUT: '/auth/logout',
REFRESH: '/auth/refresh',
REGISTER: '/auth/register',
},
// 产品
PRODUCTS: {
LIST: '/products',
DETAIL: (id: string) => __INLINE_CODE_55__,
CREATE: '/products',
UPDATE: (id: string) => __INLINE_CODE_56__,
DELETE: (id: string) => __INLINE_CODE_57__,
SEARCH: '/products/search',
CATEGORIES: '/products/categories',
},
// 订单
ORDERS: {
LIST: '/orders',
DETAIL: (id: string) => __INLINE_CODE_58__,
CREATE: '/orders',
UPDATE: (id: string) => __INLINE_CODE_59__,
DELETE: (id: string) => __INLINE_CODE_60__,
STATS: '/orders/stats',
},
// 客户
CUSTOMERS: {
LIST: '/customers',
DETAIL: (id: string) => __INLINE_CODE_61__,
CREATE: '/customers',
UPDATE: (id: string) => __INLINE_CODE_62__,
DELETE: (id: string) => __INLINE_CODE_63__,
},
} as const;
__CODE_BLOCK_16__typescript
__IMPORT_91__
__IMPORT_92__
__IMPORT_93__
export const productsApi = {
// 获取产品列表
getProducts: async (params: ProductFilters): Promise<PaginatedResponse<Product>> => {
return api.get(ENDPOINTS.PRODUCTS.LIST, { params });
},
// 获取产品详情
getProduct: async (id: string): Promise<Product> => {
return api.get(ENDPOINTS.PRODUCTS.DETAIL(id));
},
// 创建产品
createProduct: async (data: ProductFormData): Promise<Product> => {
return api.post(ENDPOINTS.PRODUCTS.CREATE, data);
},
// 更新产品
updateProduct: async (id: string, data: Partial<ProductFormData>): Promise<Product> => {
return api.put(ENDPOINTS.PRODUCTS.UPDATE(id), data);
},
// 删除产品
deleteProduct: async (id: string): Promise<void> => {
return api.delete(ENDPOINTS.PRODUCTS.DELETE(id));
},
// 搜索产品
searchProducts: async (query: string): Promise<Product[]> => {
return api.get(ENDPOINTS.PRODUCTS.SEARCH, { params: { q: query } });
},
// 获取产品分类
getCategories: async (): Promise<string[]> => {
return api.get(ENDPOINTS.PRODUCTS.CATEGORIES);
},
};
__CODE_BLOCK_17__
重构Redux状态管理:
1. 迁移到Redux Toolkit
2. 使用RTK Query进行数据获取
3. 创建类型安全的slices
4. 实现持久化存储
__CODE_BLOCK_18__typescript
__IMPORT_94__
__IMPORT_95__
__IMPORT_96__
__IMPORT_97__
__IMPORT_98__
__IMPORT_99__
__IMPORT_100__
// Redux Persist配置
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth'], // 只持久化auth reducer
};
// 配置store
export const store = configureStore({
reducer: {
auth: persistReducer(persistConfig, authReducer),
[productsApi.reducerPath]: productsApi.reducer,
[ordersApi.reducerPath]: ordersApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}).concat(productsApi.middleware, ordersApi.middleware),
devTools: process.env.NODE_ENV !== 'production',
});
// 启用查询自动-refetch
setupListeners(store.dispatch);
export const persistor = persistStore(store);
// 类型导出
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
__CODE_BLOCK_19__typescript
__IMPORT_101__
__IMPORT_102__
__IMPORT_103__
interface User {
id: string;
username: string;
email: string;
role: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
loading: boolean;
error: string | null;
}
const initialState: AuthState = {
user: null,
token: localStorage.getItem('token'),
isAuthenticated: false,
loading: false,
error: null,
};
// 异步thunks
export const login = createAsyncThunk(
'auth/login',
async (credentials: { email: string; password: string }) => {
const response = await authApi.login(credentials);
// 存储token到localStorage
localStorage.setItem('token', response.token);
return response;
}
);
export const logout = createAsyncThunk('auth/logout', async () => {
await authApi.logout();
localStorage.removeItem('token');
});
export const register = createAsyncThunk(
'auth/register',
async (userData: { username: string; email: string; password: string }) => {
const response = await authApi.register(userData);
localStorage.setItem('token', response.token);
return response;
}
);
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
setUser: (state, action: PayloadAction<User>) => {
state.user = action.payload;
state.isAuthenticated = true;
},
},
extraReducers: (builder) => {
// 登录
builder
.addCase(login.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Login failed';
});
// 登出
builder.addCase(logout.fulfilled, (state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
});
// 注册
builder
.addCase(register.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(register.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
})
.addCase(register.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Registration failed';
});
},
});
export const { clearError, setUser } = authSlice.actions;
// Selectors
export const selectAuth = (state: RootState) => state.auth;
export const selectUser = (state: RootState) => state.auth.user;
export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated;
export const selectAuthLoading = (state: RootState) => state.auth.loading;
export default authSlice.reducer;
__CODE_BLOCK_20__typescript
__IMPORT_104__
__IMPORT_105__
__IMPORT_106__
__IMPORT_107__
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_BASE_URL || '/api',
prepareHeaders: (headers, { getState }) => {
// 从Redux store获取token
const token = (getState() as RootState).auth.token;
if (token) {
headers.set('authorization', __INLINE_CODE_64__);
}
return headers;
},
}),
tagTypes: ['Product', 'ProductsList'],
endpoints: (builder) => ({
// 获取产品列表
getProducts: builder.query<PaginatedResponse<Product>, ProductFilters>({
query: (params) => ({
url: ENDPOINTS.PRODUCTS.LIST,
params,
}),
providesTags: ['ProductsList'],
}),
// 获取产品详情
getProduct: builder.query<Product, string>({
query: (id) => ENDPOINTS.PRODUCTS.DETAIL(id),
providesTags: (result, error, id) => [{ type: 'Product', id }],
}),
// 创建产品
createProduct: builder.mutation<Product, ProductFormData>({
query: (data) => ({
url: ENDPOINTS.PRODUCTS.CREATE,
method: 'POST',
body: data,
}),
invalidatesTags: ['ProductsList'],
}),
// 更新产品
updateProduct: builder.mutation<Product, { id: string; data: Partial<ProductFormData> }>({
query: ({ id, data }) => ({
url: ENDPOINTS.PRODUCTS.UPDATE(id),
method: 'PUT',
body: data,
}),
invalidatesTags: (result, error, { id }) => [
{ type: 'Product', id },
'ProductsList',
],
}),
// 删除产品
deleteProduct: builder.mutation<void, string>({
query: (id) => ({
url: ENDPOINTS.PRODUCTS.DELETE(id),
method: 'DELETE',
}),
invalidatesTags: ['ProductsList'],
}),
// 搜索产品
searchProducts: builder.query<Product[], string>({
query: (query) => ({
url: ENDPOINTS.PRODUCTS.SEARCH,
params: { q: query },
}),
}),
// 获取分类
getCategories: builder.query<string[], void>({
query: () => ENDPOINTS.PRODUCTS.CATEGORIES,
}),
}),
});
// 导出hooks
export const {
useGetProductsQuery,
useGetProductQuery,
useCreateProductMutation,
useUpdateProductMutation,
useDeleteProductMutation,
useSearchProductsQuery,
useGetCategoriesQuery,
} = productsApi;
__CODE_BLOCK_21__
创建可复用UI组件库:
1. Button - 支持多种样式和尺寸
2. Input - 带验证的输入框
3. Modal - 对话框组件
4. Table - 数据表格
5. Form - 表单组件
所有组件使用TypeScript,支持主题定制
__CODE_BLOCK_22__typescript
__IMPORT_108__
__IMPORT_109__
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'medium',
children,
loading = false,
disabled = false,
startIcon,
endIcon,
fullWidth = false,
...props
}) => {
return (
<StyledButton
variant={variant}
size={size}
disabled={disabled || loading}
fullWidth={fullWidth}
startIcon={startIcon}
endIcon={endIcon}
{...props}
>
{loading && <span className="spinner" />}
{children}
</StyledButton>
);
};
export default Button;
src/components/ui/Button/Button.styles.ts:
import styled, { css } from 'styled-components';
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'ghost';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
const variantStyles = {
primary: css__INLINE_CODE_65__,
secondary: css__INLINE_CODE_66__,
success: css__INLINE_CODE_67__,
danger: css__INLINE_CODE_68__,
ghost: css__INLINE_CODE_69__,
};
const sizeStyles = {
small: css__INLINE_CODE_70__,
medium: css__INLINE_CODE_71__,
large: css__INLINE_CODE_72__,
};
export const StyledButton = styled.button<ButtonProps>__INLINE_CODE_73__
width: 100%;
__INLINE_CODE_74__;
__CODE_BLOCK_24__typescript
__IMPORT_111__
__IMPORT_112__
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
error,
helperText,
leftIcon,
rightIcon,
fullWidth = false,
...props
},
ref
) => {
return (
<InputWrapper fullWidth={fullWidth}>
{label && <Label>{label}</Label>}
<StyledInput
ref={ref}
hasError={!!error}
hasLeftIcon={!!leftIcon}
hasRightIcon={!!rightIcon}
fullWidth={fullWidth}
{...props}
/>
{error && <ErrorText>{error}</ErrorText>}
{helperText && !error && <HelperText>{helperText}</HelperText>}
</InputWrapper>
);
}
);
Input.displayName = 'Input';
export default Input;
__CODE_BLOCK_25__typescript
__IMPORT_113__
__IMPORT_114__
__IMPORT_115__
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Checkbox,
IconButton,
Typography,
Box,
Chip,
Menu,
MenuItem,
} from '@mui/material';
__IMPORT_116__
MoreVert as MoreVertIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Visibility as ViewIcon,
} from '@mui/icons-material';
__IMPORT_117__
__IMPORT_118__
__IMPORT_119__
__IMPORT_120__
__IMPORT_121__
__IMPORT_122__
__IMPORT_123__
__IMPORT_124__
export const ProductList: React.FC = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
// 状态管理
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selected, setSelected] = useState<readonly string[]>([]);
const [filters, setFilters] = useState<ProductFilters>({
page: page + 1,
limit: rowsPerPage,
});
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
// RTK Query hook
const { data, isLoading, isError, refetch } = useGetProductsQuery(filters, {
refetchOnMountOrArgChange: true,
});
const products = data?.data || [];
const total = data?.total || 0;
// 处理分页变化
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
setFilters((prev) => ({ ...prev, page: newPage + 1 }));
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
const newRowsPerPage = parseInt(event.target.value, 10);
setRowsPerPage(newRowsPerPage);
setPage(0);
setFilters((prev) => ({ ...prev, page: 1, limit: newRowsPerPage }));
};
// 处理选择
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = products.map((product) => product.id);
setSelected(newSelected);
return;
}
setSelected([]);
};
const handleClick = (event: React.MouseEvent<unknown>, id: string) => {
const selectedIndex = selected.indexOf(id);
let newSelected: readonly string[] = [];
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, id);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1)
);
}
setSelected(newSelected);
};
const isSelected = (id: string) => selected.indexOf(id) !== -1;
// 菜单操作
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, product: Product) => {
setAnchorEl(event.currentTarget);
setSelectedProduct(product);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedProduct(null);
};
const handleView = () => {
navigate(__INLINE_CODE_75__);
handleMenuClose();
};
const handleEdit = () => {
navigate(__INLINE_CODE_76__);
handleMenuClose();
};
const handleDelete = async () => {
if (selectedProduct && window.confirm(__INLINE_CODE_77__)) {
try {
await dispatch(deleteProduct(selectedProduct.id)).unwrap();
refetch();
} catch (error) {
console.error('删除失败:', error);
}
}
handleMenuClose();
};
const handleBulkDelete = async () => {
if (window.confirm(__INLINE_CODE_78__)) {
// 批量删除逻辑
console.log('批量删除:', selected);
}
};
// 加载状态
if (isLoading) {
return <Loader />;
}
// 錯誤狀態
if (isError) {
return (
<EmptyState
title="加载失败"
description="无法加载产品列表,请稍后重试"
action={
<Button variant="primary" onClick={() => refetch()}>
重新加载
</Button>
}
/>
);
}
// 空狀態
if (products.length === 0) {
return (
<EmptyState
title="暂无产品"
description="开始添加您的第一个产品"
action={
<Button variant="primary" onClick={() => navigate('/products/new')}>
添加产品
</Button>
}
/>
);
}
return (
<Box>
{/* 工具栏 */}
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">产品列表</Typography>
{selected.length > 0 && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="danger" size="small" onClick={handleBulkDelete}>
删除选中 ({selected.length})
</Button>
</Box>
)}
</Box>
{/* 表格 */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selected.length > 0 && selected.length < products.length}
checked={products.length > 0 && selected.length === products.length}
onChange={handleSelectAllClick}
/>
</TableCell>
<TableCell>产品名称</TableCell>
<TableCell>分类</TableCell>
<TableCell align="right">价格</TableCell>
<TableCell align="center">库存</TableCell>
<TableCell>状态</TableCell>
<TableCell>创建时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{products.map((product) => {
const isItemSelected = isSelected(product.id);
return (
<TableRow
key={product.id}
hover
onClick={(event) => handleClick(event, product.id)}
role="checkbox"
aria-checked={isItemSelected}
selected={isItemSelected}
>
<TableCell padding="checkbox">
<Checkbox checked={isItemSelected} />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{product.image && (
<Box
component="img"
src={product.image}
alt={product.name}
sx={{ width: 40, height: 40, borderRadius: 1, objectFit: 'cover' }}
/>
)}
<Typography variant="body2" fontWeight={500}>
{product.name}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={product.category} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight={600}>
{formatPrice(product.price)}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={product.stock}
size="small"
color={product.stock < 10 ? 'error' : 'default'}
/>
</TableCell>
<TableCell>
<Chip
label={product.status === 'active' ? '上架' : '下架'}
size="small"
color={product.status === 'active' ? 'success' : 'default'}
/>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatDate(product.createdAt)}
</Typography>
</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<IconButton
onClick={(e) => handleMenuOpen(e, product)}
size="small"
>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50]}
component="div"
count={total}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage="每页行数:"
labelDisplayedRows={({ from, to, count }) =>
__INLINE_CODE_79__超过 ${to}__INLINE_CODE_80__
}
/>
</Paper>
{/* 操作選單 */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={handleView}>
<ViewIcon fontSize="small" sx={{ mr: 1 }} />
檢視
</MenuItem>
<MenuItem onClick={handleEdit}>
<EditIcon fontSize="small" sx={{ mr: 1 }} />
編輯
</MenuItem>
<MenuItem onClick={handleDelete} sx={{ color: 'error.main' }}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
刪除
</MenuItem>
</Menu>
</Box>
);
};
export default ProductList;
第四阶段:性能优化
代码分割和懒加载
實現程式碼分割和路由懶載入:
1. 使用React.lazy進行元件懶載入
2. 配置Suspense fallback
3. 分析bundle大小
4. 最佳化首屏載入時間
src/App.tsx:
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { store, persistor } from '@/store';
import { theme } from '@/theme';
import { Layout } from '@/components/layout/Layout';
import { Loader } from '@/components/ui/Loader';
import { AuthGuard } from '@/components/auth/AuthGuard';
import { GuestGuard } from '@/components/auth/GuestGuard';
// 懶載入頁面元件
const LoginPage = lazy(() => import('@/features/auth/LoginPage'));
const RegisterPage = lazy(() => import('@/features/auth/RegisterPage'));
const Dashboard = lazy(() => import('@/features/dashboard/Dashboard'));
const ProductList = lazy(() => import('@/features/products/ProductList'));
const ProductDetail = lazy(() => import('@/features/products/ProductDetail'));
const ProductForm = lazy(() => import('@/features/products/ProductForm'));
const OrderList = lazy(() => import('@/features/orders/OrderList'));
const OrderDetail = lazy(() => import('@/features/orders/OrderDetail'));
const CustomerList = lazy(() => import('@/features/customers/CustomerList'));
const Settings = lazy(() => import('@/features/settings/Settings'));
const NotFound = lazy(() => import('@/components/error/NotFound'));
// 載入元件
const PageLoader: React.FC = () => (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<Loader size="large" />
</Box>
);
function App() {
return (
<Provider store={store}>
<PersistGate loading={<PageLoader />} persistor={persistor}>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
{/* 公开路由 */}
<Route
path="/login"
element={
<GuestGuard>
<LoginPage />
</GuestGuard>
}
/>
<Route
path="/register"
element={
<GuestGuard>
<RegisterPage />
</GuestGuard>
}
/>
{/* 受保護的路由 */}
<Route
path="/"
element={
<AuthGuard>
<Layout />
</AuthGuard>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
{/* 產品路由 */}
<Route path="products">
<Route index element={<ProductList />} />
<Route path="new" element={<ProductForm />} />
<Route path=":id" element={<ProductDetail />} />
<Route path=":id/edit" element={<ProductForm />} />
</Route>
{/* 訂單路由 */}
<Route path="orders">
<Route index element={<OrderList />} />
<Route path=":id" element={<OrderDetail />} />
</Route>
{/* 客戶路由 */}
<Route path="customers">
<Route index element={<CustomerList />} />
</Route>
{/* 設定 */}
<Route path="settings" element={<Settings />} />
</Route>
{/* 404 */}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
</ThemeProvider>
</PersistGate>
</Provider>
);
}
export default App;
性能监控和优化
src/utils/performance.ts:
// 效能監控工具
export class PerformanceMonitor {
private marks: Map<string, number> = new Map();
// 開始標記
start(label: string): void {
this.marks.set(label, performance.now());
}
// 結束標記並返回耗時
end(label: string): number {
const startTime = this.marks.get(label);
if (!startTime) {
console.warn(`No start mark found for: ${label}`);
return 0;
}
const endTime = performance.now();
const duration = endTime - startTime;
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
this.marks.delete(label);
return duration;
}
// 測量非同步操作
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
this.start(label);
try {
const result = await fn();
this.end(label);
return result;
} catch (error) {
this.end(label);
throw error;
}
}
// 测量同步操作
measureSync<T>(label: string, fn: () => T): T {
this.start(label);
try {
const result = fn();
this.end(label);
return result;
} catch (error) {
this.end(label);
throw error;
}
}
// 获取Web Vitals
getWebVitals() {
if (!('PerformanceObserver' in window)) {
return null;
}
return new Promise((resolve) => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const vitals: any = {};
entries.forEach((entry: any) => {
if (entry.entryType === 'navigation') {
vitals.domContentLoaded = entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart;
vitals.loadComplete = entry.loadEventEnd - entry.loadEventStart;
vitals.domReady = entry.domComplete - entry.domInteractive;
}
});
resolve(vitals);
observer.disconnect();
});
observer.observe({ entryTypes: ['navigation'] });
});
}
}
// 全局性能监控实例
export const perfMonitor = new PerformanceMonitor();
// React性能优化hook
__IMPORT_137__
export function useRenderCount(componentName: string) {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
console.log(__INLINE_CODE_83__);
});
}
export function usePerformanceLog(componentName: string) {
const renderStartTime = useRef<number>();
useEffect(() => {
renderStartTime.current = performance.now();
return () => {
if (renderStartTime.current) {
const renderTime = performance.now() - renderStartTime.current;
if (renderTime > 16) { // 超过一帧(16ms)
console.warn(__INLINE_CODE_84__);
}
}
};
});
}
__CODE_BLOCK_29__typescript
__IMPORT_138__
__IMPORT_139__
__IMPORT_140__
__IMPORT_141__
__IMPORT_142__
export default defineConfig({
plugins: [
react(),
// Gzip压缩
compression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240, // 只压缩大于10KB的文件
}),
// Brotli压缩
compression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240,
}),
// Bundle分析
visualizer({
open: false,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html',
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@features': path.resolve(__dirname, './src/features'),
'@utils': path.resolve(__dirname, './src/utils'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@store': path.resolve(__dirname, './src/store'),
'@types': path.resolve(__dirname, './src/types'),
'@api': path.resolve(__dirname, './src/api'),
},
},
build: {
// 代码分割
rollupOptions: {
output: {
manualChunks: {
// React核心库
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// UI库
'mui-vendor': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
// Redux
'redux-vendor': ['@reduxjs/toolkit', 'react-redux', 'redux-persist'],
// 其他第三方库
'vendor': ['axios', 'date-fns'],
},
},
},
// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
// Chunk大小警告阈值
chunkSizeWarningLimit: 1000,
// Source map
sourcemap: false,
},
server: {
port: 3000,
open: true,
},
});
__CODE_BLOCK_30__typescript
__IMPORT_143__
__IMPORT_144__
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
loading?: 'lazy' | 'eager';
className?: string;
}
const ImageWrapper = styled.div<{ width?: number; height?: number }>__INLINE_CODE_85__;
const StyledImage = styled.img<{ loaded: boolean }>__INLINE_CODE_86__;
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
src,
alt,
width,
height,
loading = 'lazy',
className,
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
// Intersection Observer for lazy loading
useEffect(() => {
if (loading === 'eager') {
setIsInView(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [loading]);
const handleLoad = () => {
setIsLoaded(true);
};
return (
<ImageWrapper width={width} height={height} className={className}>
{isInView && (
<StyledImage
ref={imgRef}
src={src}
alt={alt}
loading={loading}
loaded={isLoaded}
onLoad={handleLoad}
/>
)}
</ImageWrapper>
);
};
第五阶段:测试和质量保障
单元测试
為關鍵元件和工具函式新增單元測試:
1. 使用Jest和React Testing Library
2. 測試覆蓋率 > 80%
3. 包含邊界情況和錯誤處理
4. 整合到CI/CD流程
src/components/ui/Button/tests/Button.test.tsx:
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Button } from '../Button';
describe('Button Component', () => {
it('renders correctly with default props', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('primary');
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders different variants', () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('primary');
rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('secondary');
rerender(<Button variant="danger">Danger</Button>);
expect(screen.getByRole('button')).toHaveClass('danger');
});
it('renders different sizes', () => {
const { rerender } = render(<Button size="small">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('small');
rerender(<Button size="medium">Medium</Button>);
expect(screen.getByRole('button')).toHaveClass('medium');
rerender(<Button size="large">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('large');
});
it('is disabled when loading', () => {
render(<Button loading>Loading</Button>);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
it('does not call onClick when disabled', () => {
const handleClick = jest.fn();
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
it('renders with icons', () => {
const StartIcon = () => <span data-testid="start-icon">Start</span>;
const EndIcon = () => <span data-testid="end-icon">End</span>;
render(
<Button startIcon={<StartIcon />} endIcon={<EndIcon />}>
With Icons
</Button>
);
expect(screen.getByTestId('start-icon')).toBeInTheDocument();
expect(screen.getByTestId('end-icon')).toBeInTheDocument();
});
it('renders as full width when fullWidth is true', () => {
render(<Button fullWidth>Full Width</Button>);
const button = screen.getByRole('button');
expect(button).toHaveStyle({ width: '100%' });
});
});
__CODE_BLOCK_33__typescript
__IMPORT_149__
describe('Format Utilities', () => {
describe('formatPrice', () => {
it('formats positive numbers correctly', () => {
expect(formatPrice(100)).toBe('¥100.00');
expect(formatPrice(1000)).toBe('¥1,000.00');
expect(formatPrice(1000000)).toBe('¥1,000,000.00');
});
it('handles decimal values', () => {
expect(formatPrice(99.9)).toBe('¥99.90');
expect(formatPrice(99.999)).toBe('¥100.00');
});
it('handles zero', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it('handles negative numbers', () => {
expect(formatPrice(-100)).toBe('-¥100.00');
});
});
describe('formatDate', () => {
it('formats date strings correctly', () => {
const date = '2024-01-15T10:30:00Z';
const formatted = formatDate(date);
expect(formatted).toMatch(/2024/);
expect(formatted).toMatch(/01/);
expect(formatted).toMatch(/15/);
});
it('handles invalid dates', () => {
expect(formatDate('invalid-date')).toBe('Invalid Date');
});
it('uses custom format', () => {
const date = '2024-01-15T10:30:00Z';
const formatted = formatDate(date, 'yyyy-MM-dd');
expect(formatted).toBe('2024-01-15');
});
});
describe('formatPhoneNumber', () => {
it('formats Chinese phone numbers', () => {
expect(formatPhoneNumber('13800138000')).toBe('138-0013-8000');
expect(formatPhoneNumber('18912345678')).toBe('189-1234-5678');
});
it('handles invalid phone numbers', () => {
expect(formatPhoneNumber('123')).toBe('123');
expect(formatPhoneNumber('')).toBe('');
});
});
describe('formatCurrency', () => {
it('formats currency for different locales', () => {
expect(formatCurrency(1000, 'USD')).toBe('$1,000.00');
expect(formatCurrency(1000, 'EUR')).toBe('€1,000.00');
expect(formatCurrency(1000, 'CNY')).toBe('¥1,000.00');
});
it('handles different currencies', () => {
expect(formatCurrency(100, 'JPY')).toBe('¥100');
});
});
});
__CODE_BLOCK_34__typescript
__IMPORT_150__
__IMPORT_151__
__IMPORT_152__
__IMPORT_153__
__IMPORT_154__
__IMPORT_155__
__IMPORT_156__
__IMPORT_157__
__IMPORT_158__
// Mock Service Worker setup
const server = setupServer(
rest.get('/api/products', (req, res, ctx) => {
return res(
ctx.json({
data: [
{
id: '1',
name: 'Product 1',
price: 100,
category: 'Electronics',
stock: 50,
status: 'active',
createdAt: '2024-01-15T10:00:00Z',
},
{
id: '2',
name: 'Product 2',
price: 200,
category: 'Books',
stock: 5,
status: 'active',
createdAt: '2024-01-14T10:00:00Z',
},
],
total: 2,
page: 1,
limit: 10,
})
);
}),
rest.delete('/api/products/:id', (req, res, ctx) => {
return res(ctx.status(204));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const renderWithProviders = (component: React.ReactElement) => {
return render(
<Provider store={store}>
<BrowserRouter>{component}</BrowserRouter>
</Provider>
);
};
describe('ProductList Integration Tests', () => {
it('fetches and displays products', async () => {
renderWithProviders(<ProductList />);
// 等待產品載入
await waitFor(() => {
expect(screen.getByText('Product 1')).toBeInTheDocument();
expect(screen.getByText('Product 2')).toBeInTheDocument();
});
});
it('displays product details correctly', async () => {
renderWithProviders(<ProductList />);
await waitFor(() => {
const product1 = screen.getByText('Product 1').closest('tr');
expect(product1).toBeInTheDocument();
// 檢查價格
expect(within(product1!).getByText('¥100.00')).toBeInTheDocument();
// 檢查分類
expect(within(product1!).getByText('Electronics')).toBeInTheDocument();
// 檢查庫存
expect(within(product1!).getByText('50')).toBeInTheDocument();
});
});
it('handles product deletion', async () => {
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true);
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByText('Product 1')).toBeInTheDocument();
});
// 點選刪除按鈕
const deleteButton = screen.getAllByLabelText(/delete/i)[0];
fireEvent.click(deleteButton);
await waitFor(() => {
expect(screen.queryByText('Product 1')).not.toBeInTheDocument();
});
confirmSpy.mockRestore();
});
it('displays empty state when no products', async () => {
server.use(
rest.get('/api/products', (req, res, ctx) => {
return res(
ctx.json({
data: [],
total: 0,
})
);
})
);
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByText(/暫無產品/i)).toBeInTheDocument();
});
});
it('handles API errors gracefully', async () => {
server.use(
rest.get('/api/products', (req, res, ctx) => {
return res(ctx.status(500));
})
);
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByText(/載入失敗/i)).toBeInTheDocument();
});
});
});