跳至主要内容

專案前端模式

本文檔說明花店管理系統特定的前端設計模式與慣例。

目錄結構

app-web/src/
├── applets/ # 業務功能模組
│ ├── products/ # 商品管理
│ ├── orders/ # 訂單管理
│ ├── customers/ # 客戶管理
│ └── shared/ # 共用組件
├── components/ # 應用程式組件
│ ├── applet-shell/ # Applet 容器
│ ├── layout/ # 版面配置
│ └── common/ # 通用組件
├── config/ # 應用程式配置
├── features/ # Redux slices
├── hooks/ # 自定義 hooks
├── layouts/ # 佈局組件
├── mocks/ # MSW mock handlers
├── nls/ # 國際化資源
├── routes/ # 路由配置
├── services/ # API 服務層
├── store/ # Redux store
├── types/ # TypeScript 類型
└── utils/ # 工具函數

Applet 模式

什麼是 Applet?

Applet 是一個獨立的業務功能模組,包含:

  • 路由
  • 頁面組件
  • 服務層
  • 狀態管理(如需要)

Applet 結構

applets/products/
├── index.ts # 匯出點
├── routes.tsx # 路由定義
├── pages/ # 頁面組件
│ ├── ProductListPage.tsx
│ ├── ProductDetailPage.tsx
│ └── ProductFormPage.tsx
├── components/ # Applet 專用組件
│ ├── ProductCard.tsx
│ ├── ProductTable.tsx
│ └── ProductForm.tsx
├── hooks/ # Applet 專用 hooks
│ ├── useProducts.ts
│ └── useProductForm.ts
└── services/ # API 服務
└── productApi.ts

註冊 Applet

// applets/products/index.ts
import { ProductRoutes } from './routes';

export const ProductsApplet = {
id: 'products',
name: '商品管理',
icon: 'package',
routes: ProductRoutes,
permissions: ['PRODUCT_R'],
};

服務層模式

API 服務定義

// services/productApi.ts
import { http } from '@/utils/http';
import type { Product, ProductFilter, CreateProductRequest } from '@/types';

const BASE_URL = '/api/v1/products';

export const productApi = {
findAll: async (filter?: ProductFilter) => {
const response = await http.get<Product[]>(BASE_URL, { params: filter });
return response.data;
},

findById: async (id: string) => {
const response = await http.get<Product>(`${BASE_URL}/${id}`);
return response.data;
},

create: async (data: CreateProductRequest) => {
const response = await http.post<Product>(BASE_URL, data);
return response.data;
},

update: async (id: string, data: Partial<Product>) => {
const response = await http.patch<Product>(`${BASE_URL}/${id}`, data);
return response.data;
},

delete: async (id: string) => {
await http.delete(`${BASE_URL}/${id}`);
},
};

搭配 React Query

// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productApi } from '@/services/productApi';

export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filter: ProductFilter) => [...productKeys.lists(), filter] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};

export function useProducts(filter?: ProductFilter) {
return useQuery({
queryKey: productKeys.list(filter),
queryFn: () => productApi.findAll(filter),
});
}

export function useProduct(id: string) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => productApi.findById(id),
enabled: !!id,
});
}

表單模式

使用 React Hook Form

// components/ProductForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { productSchema, type ProductFormData } from './schema';

export function ProductForm({ defaultValues, onSubmit }: ProductFormProps) {
const form = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
defaultValues,
});

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>商品名稱</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 其他欄位 */}
<Button type="submit">儲存</Button>
</form>
</Form>
);
}

列表頁面模式

標準列表頁面

// pages/ProductListPage.tsx
export function ProductListPage() {
const [searchParams, setSearchParams] = useSearchParams();
const filter = parseFilterFromSearchParams(searchParams);

const { data, isLoading } = useProducts(filter);

const handleFilterChange = (newFilter: ProductFilter) => {
setSearchParams(filterToSearchParams(newFilter));
};

return (
<AppletShell title="商品管理">
<AppletShell.Toolbar>
<SearchBox
value={filter.search}
onChange={(search) => handleFilterChange({ ...filter, search })}
/>
<Button asChild>
<Link to="new">新增商品</Link>
</Button>
</AppletShell.Toolbar>

<AppletShell.Content>
{isLoading ? (
<TableSkeleton />
) : (
<ProductTable data={data} />
)}
</AppletShell.Content>

<AppletShell.Pagination
page={filter.page}
total={data?.totalPages}
onChange={(page) => handleFilterChange({ ...filter, page })}
/>
</AppletShell>
);
}

錯誤處理模式

全域錯誤邊界

// components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';

export class ErrorBoundary extends Component<
{ children: ReactNode; fallback?: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}

render() {
if (this.state.hasError) {
return this.props.fallback || <ErrorPage />;
}
return this.props.children;
}
}

API 錯誤處理

// utils/http.ts
import axios from 'axios';
import { toast } from '@/components/ui/toast';

const http = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});

http.interceptors.response.use(
(response) => response,
(error) => {
const message = error.response?.data?.message || '發生錯誤,請稍後再試';

if (error.response?.status === 401) {
// 導向登入頁
window.location.href = '/login';
} else if (error.response?.status === 403) {
toast.error('權限不足');
} else {
toast.error(message);
}

return Promise.reject(error);
}
);

export { http };

權限控制模式

權限檢查 Hook

// hooks/usePermission.ts
import { useAuth } from '@/features/auth';

export function usePermission(permission: string | string[]) {
const { user } = useAuth();

if (!user) return false;

const permissions = Array.isArray(permission) ? permission : [permission];
return permissions.some((p) => user.authorities.includes(p));
}

權限保護組件

// components/PermissionGuard.tsx
export function PermissionGuard({
permission,
fallback = null,
children,
}: PermissionGuardProps) {
const hasPermission = usePermission(permission);

if (!hasPermission) {
return fallback;
}

return children;
}

// 使用
<PermissionGuard permission="PRODUCT_W">
<Button>編輯</Button>
</PermissionGuard>

下一步