跳至主要内容

AppFuse Web 應用

本文檔說明如何在花店管理系統中使用 AppFuse Web 框架。

框架基礎

AppFuse Web 是本專案使用的前端框架。關於框架的基本概念與完整文檔,請參閱:

在花店系統中的應用

專案結構

app-web/src/
├── applets/ # 業務功能模組(Applet)
│ ├── product-applet/ # 商品管理 Applet
│ ├── order-applet/ # 訂單管理 Applet
│ ├── customer-applet/ # 客戶管理 Applet
│ ├── dashboard-applet/ # 儀表板 Applet
│ ├── delivery-applet/ # 配送管理 Applet
│ ├── design-applet/ # 設計管理 Applet
│ ├── sales-applet/ # 銷售管理 Applet
│ └── shared/ # Applet 共用組件
├── components/ # 專案共用組件
│ ├── applet-shell/ # Applet 容器組件
│ └── ... # 其他共用組件
├── conf/ # 應用程式配置
│ └── config.ts # 主配置(MSW、baseURL 等)
├── config/ # 功能配置
│ └── applet-registry.ts # Applet 註冊表
├── features/ # Redux slices
│ ├── iam/ # 身份驗證與授權狀態
│ ├── product/ # 商品狀態(含草稿管理)
│ └── ... # 其他功能狀態
├── services/ # API 服務層(依業務領域分類)
│ ├── api-client.ts # API 客戶端配置
│ ├── core/ # 核心服務(環境、參考資料、多語系)
│ ├── iam/ # 身份驗證服務
│ ├── sales/ # 銷售領域服務(商品、訂單)
│ ├── crm/ # CRM 領域服務(客戶)
│ └── dashboard/ # 儀表板服務
├── mocks/ # MSW mock handlers
│ ├── handlers/ # Mock API handlers
│ ├── data/ # Mock 資料與測試帳號
│ ├── middleware/ # Mock 中介軟體(認證檢查)
│ └── utils/ # Mock 工具
├── nls/ # 多語系資源(TypeScript)
│ ├── message/ # 訊息翻譯
│ ├── term/ # 術語翻譯
│ └── article/ # 文章翻譯
├── layouts/ # 佈局組件
├── pages/ # 獨立頁面(登入、錯誤等)
├── routes/ # 路由配置
│ └── index.tsx # 主路由配置
└── store/ # Redux store 配置

使用框架元件

AppFuse Web 提供豐富的 UI 元件,在花店系統中廣泛使用:

資料表格

import { DataTable } from '@appfuse/appfuse-web/components';

function ProductList() {
return (
<DataTable
data={products}
columns={[
{ key: 'name', header: '商品名稱' },
{ key: 'basePrice', header: '價格', render: (p) => `$${p.basePrice}` },
{ key: 'stock', header: '庫存' },
]}
onRowClick={(product) => navigate(`/products/${product.id}`)}
/>
);
}

查看完整範例:app-web/src/applets/product-applet/product-finder.tsx

表單元件

import { TextField, Select, Button } from '@appfuse/appfuse-web/components';

function ProductForm() {
return (
<form>
<TextField
label="商品名稱"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Select
label="分類"
options={categories}
value={category}
onChange={setCategory}
/>
<Button type="submit">儲存</Button>
</form>
);
}

查看完整範例:app-web/src/applets/product-applet/product-form.tsx

AppletShell

所有 Applet 都使用 <AppletShell> 提供標準佈局與導航:

import { useMemo } from 'react';
import { Routes, Route } from 'react-router';
import { List, Plus } from 'lucide-react';
import { useTranslation } from '@appfuse/appfuse-web/utils';
import { AppletShell, type AppletAction } from '@/components/applet-shell';
import { ProductFinder } from './product-finder';
import { ProductEditor } from './product-editor';
import { ProductDetail } from './product-detail';

const BASE_PATH = '/products';

export function ProductApplet() {
const { t } = useTranslation();

// 定義 Applet 操作選單(使用 useMemo 確保語言切換時重新計算)
const actions: AppletAction[] = useMemo(() => [
{ path: BASE_PATH, icon: List, label: t('Products') },
{ path: `${BASE_PATH}/new`, icon: Plus, label: t('Add Product') },
], [t]);

return (
<AppletShell
basePath={BASE_PATH}
actions={actions}
extraButtons={
<>
{/* 側邊欄額外按鈕(如草稿、常用項目) */}
<DraftProductButtons />
<PinnedProductButtons />
</>
}
>
{/* Applet 內部路由 */}
<Routes>
<Route index element={<ProductFinder />} />
<Route path="new" element={<ProductEditor />} />
<Route path=":id" element={<ProductDetail />} />
<Route path=":id/edit" element={<ProductEditor />} />
</Routes>
</AppletShell>
);
}

查看完整範例:app-web/src/applets/product-applet/product-applet.tsx

API 整合

使用框架提供的 HTTP 客戶端進行 API 調用:

// services/api-client.ts - API 客戶端配置
import { createHttpClient } from '@appfuse/appfuse-web/utils';
import { store } from '@/store';
import { selectAuthorization } from '@/features/iam/me-slice';

/**
* 應用層 API 客戶端
* - 自動附加 Access Token
* - 401 時自動刷新 Token 並重試
* - 支援檔案上傳(binary-separate 模式)
*/
export const apiClient = createHttpClient({
baseURL: '/',
fileUpload: {
strategy: 'binary',
endpoint: '/api/v1/staging/files/prepare',
},
});

// 請求攔截器:自動附加 Access Token
apiClient.interceptors.request.use((config) => {
const state = store.getState();
const authorization = selectAuthorization(state);

if (authorization?.access_token) {
config.headers.Authorization = `Bearer ${authorization.access_token}`;
}

return config;
});

// 響應攔截器:處理 Token 刷新與錯誤
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

// 401 Unauthorized - 嘗試刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// ... Token 刷新邏輯
return apiClient(originalRequest);
}

throw error;
}
);
// services/sales/product-service.ts - 商品服務
import { apiClient } from '@/services/api-client';
import type { PagedResult } from '@appfuse/appfuse-web';
import type {
Product,
CreateProductRequest,
UpdateProductRequest,
UpdateProductStatusRequest,
} from '@/mocks/types';

class ProductService {
/**
* 查詢商品列表(支援搜尋、篩選、分頁)
*/
async query(params: ProductQueryParams = {}): Promise<PagedResult<Product>> {
const response = await apiClient.get<Product[]>('/api/v1/products', { params });

// 從 Header 讀取分頁資訊
const totalElements = Number(response.headers['x-total-count'] ?? '0');
const page = response.headers['x-page'] ? Number(response.headers['x-page']) : undefined;
const size = response.headers['x-per-page'] ? Number(response.headers['x-per-page']) : undefined;

return {
content: response.data,
totalElements,
page,
size,
};
}

/**
* 獲取商品詳情
*/
async get(id: string): Promise<Product> {
const response = await apiClient.get<Product>(`/api/v1/products/${id}`);
return response.data;
}

/**
* 創建商品
*/
async create(data: CreateProductRequest): Promise<Product> {
const response = await apiClient.post<Product>('/api/v1/products', data);
return response.data;
}

/**
* 更新商品(部分更新)
*/
async update(id: string, data: UpdateProductRequest): Promise<Product> {
const response = await apiClient.patch<Product>(`/api/v1/products/${id}`, data);
return response.data;
}

/**
* 更新商品狀態(上架/下架)
*/
async updateStatus(id: string, data: UpdateProductStatusRequest): Promise<Product> {
const response = await apiClient.patch<Product>(`/api/v1/products/${id}/status`, data);
return response.data;
}

/**
* 刪除商品
*/
async delete(id: string): Promise<void> {
await apiClient.delete(`/api/v1/products/${id}`);
}

/**
* 上傳商品圖片
*/
async uploadImage(file: File): Promise<{ url: string; id: string }> {
const formData = new FormData();
formData.append('image', file);
const response = await apiClient.post('/api/v1/products/upload-image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
}

export const productService = new ProductService();

查看完整範例:app-web/src/services/api-client.tsapp-web/src/services/sales/product-service.ts

狀態管理

使用 Redux Toolkit 管理全域狀態:

// features/auth/authSlice.ts
import { createSlice } from '@reduxjs/toolkit';

const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
token: null,
isAuthenticated: false,
},
reducers: {
setCredentials: (state, action) => {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
},
logout: (state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
},
},
});

使用狀態:

import { useSelector, useDispatch } from 'react-redux';
import { selectAuth } from '@/features/auth/authSlice';

function UserProfile() {
const { user } = useSelector(selectAuth);
return <div>{user?.name}</div>;
}

Mock API(開發階段)

使用 MSW (Mock Service Worker) 提供 Mock API:

// mocks/handlers/products.ts
import { http, HttpResponse } from 'msw';
import { checkAuth } from '@/mocks/middleware/auth-middleware';
import { db, tenantQuery } from '@/mocks/data/db';
import type { Product, CreateProductRequest } from '@/mocks/types';

const API_BASE = '*';

export const productHandlers = [
/**
* GET /api/v1/products
* 獲取商品列表(支援搜尋、篩選、分頁)
*/
http.get(`${API_BASE}/api/v1/products`, ({ request }) => {
// 檢查認證與權限
const authCheck = checkAuth(request);
if (!authCheck.success) {
return authCheck.error!;
}

const { tenantId } = authCheck;

// 解析查詢參數
const url = new URL(request.url);
const searchQuery = url.searchParams.get('search') || '';
const page = parseInt(url.searchParams.get('page') || '1', 10);
const limit = parseInt(url.searchParams.get('limit') || '20', 10);

// 1. 獲取租戶的商品
let products = tenantQuery.getByTenant(db.products, tenantId!);

// 2. 搜尋邏輯
if (searchQuery) {
products = products.filter((product) =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}

// 3. 分頁
const start = (page - 1) * limit;
const end = start + limit;
const pagedProducts = products.slice(start, end);

// 4. 回傳結果與分頁資訊
return HttpResponse.json(pagedProducts, {
headers: {
'X-Total-Count': String(products.length),
'X-Page': String(page),
'X-Per-Page': String(limit),
},
});
}),

/**
* POST /api/v1/products
* 創建商品
*/
http.post(`${API_BASE}/api/v1/products`, async ({ request }) => {
const authCheck = checkAuth(request);
if (!authCheck.success) {
return authCheck.error!;
}

const data = await request.json() as CreateProductRequest;
const newProduct: Product = {
...data,
id: generateId(),
sku: generateProductSku(data.category),
tenantId: authCheck.tenantId!,
createdAt: new Date(),
updatedAt: new Date(),
};

db.products.push(newProduct);
return HttpResponse.json(newProduct, { status: 201 });
}),
];

查看完整範例:app-web/src/mocks/handlers/products.ts

詳細說明:Mock → 真實 API 切換

路由配置

使用 React Router 配置路由:

// routes/index.tsx
import { Routes, Route, Navigate } from 'react-router';
import { ProductApplet } from '@/applets/product-applet';
import { OrderApplet } from '@/applets/order-applet';
import { CustomerApplet } from '@/applets/customer-applet';
import { DashboardApplet } from '@/applets/dashboard-applet';
import { ProtectedRoute } from './protected-route';

export function AppRoutes() {
return (
<Routes>
{/* 預設重導向至儀表板 */}
<Route index element={<Navigate to="/dashboard" replace />} />

{/* 儀表板 Applet */}
<Route path="/dashboard/*" element={
<ProtectedRoute>
<DashboardApplet />
</ProtectedRoute>
} />

{/* 商品管理 Applet - 處理所有 /products/* 路由 */}
<Route path="/products/*" element={
<ProtectedRoute>
<ProductApplet />
</ProtectedRoute>
} />

{/* 訂單管理 Applet - 處理所有 /orders/* 路由 */}
<Route path="/orders/*" element={
<ProtectedRoute>
<OrderApplet />
</ProtectedRoute>
} />

{/* 客戶管理 Applet - 處理所有 /customers/* 路由 */}
<Route path="/customers/*" element={
<ProtectedRoute>
<CustomerApplet />
</ProtectedRoute>
} />
</Routes>
);
}

注意:Applet 內部使用 <Routes> 處理子路由,因此在主路由使用 /* 匹配所有子路徑。

國際化

使用框架提供的多語系功能:

// 使用框架的 useTranslation hook
import { useTranslation } from '@appfuse/appfuse-web/utils';
// 或從根路徑導入
import { useTranslation } from '@appfuse/appfuse-web';

function ProductList() {
const { t } = useTranslation();

return (
<div>
<h1>{t('Products')}</h1>
{products.length === 0 && <p>{t('No products found')}</p>}
</div>
);
}

多語系檔案結構:

app-web/src/nls/
├── message/ # 訊息翻譯
│ └── zh-TW.ts # 繁體中文訊息
├── term/ # 術語翻譯
│ └── zh-TW.ts # 繁體中文術語
├── article/ # 文章翻譯
└── types.ts # 類型定義

翻譯檔案範例nls/message/zh-TW.ts):

export default {
'Products': '商品管理',
'Add Product': '新增商品',
'No products found': '目前沒有商品',
'Save': '儲存',
'Cancel': '取消',
// ...
};

專案特定慣例

檔案命名

實際專案使用的命名慣例:

  • Applet 組件kebab-case.tsx(如 product-applet.tsxproduct-finder.tsx
  • 一般組件PascalCase.tsxkebab-case.tsx(依團隊慣例)
  • Servicekebab-case-service.ts(如 product-service.tscustomer-service.ts
  • 類型定義types.ts(放在模組目錄下)
  • Hooksuse*.ts(如 useProduct.tsuseAuth.ts
  • Mock handlerskebab-case.ts(如 products.tsorders.ts

Import 順序

// 1. React 相關
import { useState, useEffect, useMemo } from 'react';

// 2. 第三方套件
import { useNavigate } from 'react-router';
import { User } from 'lucide-react';

// 3. AppFuse Web 框架
import { Button } from '@appfuse/appfuse-web/components';
import { useTranslation } from '@appfuse/appfuse-web/utils';
// 或從根路徑導入常用功能
import { useTranslation, environ } from '@appfuse/appfuse-web';

// 4. 專案內部(使用 @/ alias)
import { productService } from '@/services/sales/product-service';
import { useAppSelector } from '@/store';
import type { Product } from '@/mocks/types';

組件結構

// 1. Imports
import { ... } from '...';

// 2. Types
interface Props {
productId: string;
}

// 3. Component
export function ProductDetail({ productId }: Props) {
// 4. Hooks
const [product, setProduct] = useState<Product | null>(null);

// 5. Effects
useEffect(() => {
loadProduct();
}, [productId]);

// 6. Handlers
const handleEdit = () => {
// ...
};

// 7. Render
return (
<div>
{/* ... */}
</div>
);
}

學習資源

框架文檔

專案範例

實際參考實作檔案:

Applet 範例:

  • app-web/src/applets/product-applet/ - 商品管理 Applet 完整範例
    • product-applet.tsx - Applet 容器與路由配置
    • product-finder.tsx - 商品列表與搜尋頁面
    • product-editor.tsx - 商品編輯器(創建/編輯)
    • product-detail.tsx - 商品詳情頁面
    • product-form.tsx - 商品表單組件
    • components/ - Applet 專用組件
  • app-web/src/applets/order-applet/ - 訂單管理 Applet 範例
  • app-web/src/applets/customer-applet/ - 客戶管理 Applet 範例
  • app-web/src/applets/dashboard-applet/ - 儀表板 Applet 範例

Service 範例:

  • app-web/src/services/api-client.ts - API 客戶端配置與 Token 處理
  • app-web/src/services/sales/product-service.ts - 商品服務
  • app-web/src/services/sales/product-queries.ts - 商品 React Query hooks
  • app-web/src/services/sales/order-service.ts - 訂單服務
  • app-web/src/services/crm/customer-service.ts - 客戶服務

Mock 範例:

  • app-web/src/mocks/handlers/products.ts - 商品 Mock API
  • app-web/src/mocks/handlers/orders.ts - 訂單 Mock API
  • app-web/src/mocks/middleware/auth-middleware.ts - 認證中介軟體
  • app-web/src/mocks/data/test-accounts.ts - 測試帳號資料

配置範例:

  • app-web/src/conf/config.ts - 應用程式主配置(MSW、baseURL)
  • app-web/src/config/applet-registry.ts - Applet 註冊表
  • app-web/src/routes/index.tsx - 主路由配置

組件範例:

  • app-web/src/components/applet-shell/ - AppletShell 組件實作
  • app-web/src/applets/shared/ - Applet 間共用組件

下一步