Applet 開發
本指南說明如何在花店系統中開發新的 Applet(業務功能模組)。
什麼是 Applet?
Applet 是花店系統中的業務功能模組,每個 Applet 代表一個獨立的功能領域。
現有 Applet
| Applet | 路徑 | 功能 | 參考檔案 |
|---|---|---|---|
| 儀表板 | / | 顯示業務指標、待處理任務、快速操作入口 | dashboard-applet/dashboard-applet.tsx |
| 商品管理 | /products/* | 商品 CRUD、分類管理、價格設定 | product-applet/product-applet.tsx |
| 訂單管理 | /orders/* | 訂單處理、狀態追蹤、訂單詳情 | order-applet/order-applet.tsx |
| 客戶管理 | /customers/* | 客戶資料管理、客戶歷史記錄 | customer-applet/customer-applet.tsx |
| 店員工作台 | /sales | 訂單確認、待處理訂單列表 | sales-applet/sales-applet.tsx |
| 設計師工作台 | /design | 設計任務管理、作品照片上傳 | design-applet/design-applet.tsx |
| 配送員工作台 | /delivery | 配送任務管理、簽收照片上傳 | delivery-applet/delivery-applet.tsx |
開發新 Applet
步驟 1:規劃 Applet
在開發前,確認:
- 業務需求明確(參考 User Story)
- API 規格已定義(參考 API Specs)
- 資料模型已設計(參考 Data Models)
- UI/UX 設計已完成(參考 Mockup)
步驟 2:建立目錄結構
mkdir -p src/applets/inventory-applet
cd src/applets/inventory-applet
建議的 Applet 結構(參考 product-applet/):
applets/inventory-applet/
├── inventory-applet.tsx # 主容器(含 Routes 配置)
├── inventory-finder.tsx # 列表/查找頁面
├── inventory-detail.tsx # 詳情頁面
├── inventory-editor.tsx # 編輯頁面(可選)
├── inventory-form.tsx # 表單組件
├── components/ # Applet 專用組件
│ ├── inventory-card.tsx
│ ├── stock-adjustment-form.tsx
│ └── index.ts
├── utils/ # Applet 專用工具(可選)
│ └── inventory-helpers.ts
├── types.ts # Applet 類型定義
└── index.ts # 導出
命名慣例:
- Applet 目錄使用
kebab-case並包含-applet後綴 - 所有檔案使用
kebab-case.tsx命名(不是 PascalCase) - 主容器命名為
{name}-applet.tsx - 列表頁面命名為
{name}-finder.tsx - 詳情頁面命名為
{name}-detail.tsx - 編輯頁面命名為
{name}-editor.tsx
步驟 3:定義類型
// applets/inventory/types.ts
export interface InventoryItem {
id: string;
productId: string;
productName: string;
quantity: number;
location: string;
lastUpdated: string;
}
export interface StockAdjustment {
itemId: string;
quantity: number;
reason: string;
notes?: string;
}
步驟 4:建立 API 服務
參考檔案:services/sales/product-service.ts
// services/inventory-service.ts
import { apiClient } from './api-client';
import type { InventoryItem, StockAdjustment } from '@/applets/inventory-applet/types';
import type { PagedResult, QueryParams } from '@/types';
/**
* 庫存管理服務
*/
class InventoryService {
/**
* 查詢庫存列表(支援分頁、過濾、排序)
*/
async query(params: QueryParams = {}): Promise<PagedResult<InventoryItem>> {
const response = await apiClient.get<InventoryItem[]>('/api/v1/inventory', {
params,
});
// 從 response headers 解析分頁資訊
const totalElements = parseInt(response.headers['x-total-count'] || '0', 10);
const page = parseInt(response.headers['x-page-number'] || '0', 10);
const size = parseInt(response.headers['x-page-size'] || '20', 10);
return {
content: response.data,
totalElements,
totalPages: Math.ceil(totalElements / size),
page,
size,
};
}
/**
* 取得單筆庫存資料
*/
async get(id: string): Promise<InventoryItem> {
const response = await apiClient.get<InventoryItem>(`/api/v1/inventory/${id}`);
return response.data;
}
/**
* 調整庫存
*/
async adjustStock(data: StockAdjustment): Promise<InventoryItem> {
const response = await apiClient.post<InventoryItem>('/api/v1/inventory/adjust', data);
return response.data;
}
}
export const inventoryService = new InventoryService();
重點說明:
- 使用 class 定義 Service(便於擴展和測試)
- 方法命名:
query()用於列表查詢、get()用於單筆查詢 - 使用
apiClient而非api(已配置 interceptors) - API 路徑使用
/api/v1/前綴 query()方法返回PagedResult類型,包含分頁資訊
步驟 5:建立 Mock Handlers(開發階段)
參考檔案:mocks/handlers/products.ts
// mocks/handlers/inventory.ts
import { http, HttpResponse } from 'msw';
import { checkAuth } from '@/mocks/middleware/auth-middleware';
import { db, tenantQuery, generateId } from '@/mocks/data/db';
import type { InventoryItem, StockAdjustment } from '@/mocks/types';
const API_BASE = '*';
export const inventoryHandlers = [
/**
* GET /api/v1/inventory
* 獲取庫存列表(支援搜尋、過濾、分頁)
*/
http.get(`${API_BASE}/api/v1/inventory`, ({ request }) => {
// 檢查認證與權限
const authCheck = checkAuth(request);
if (!authCheck.success) {
return authCheck.error!;
}
const { tenantId } = authCheck;
// 解析查詢參數
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1', 10);
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
// 獲取租戶的庫存資料
let items = tenantQuery.getByTenant(db.inventory, tenantId!);
// 分頁
const totalElements = items.length;
const startIndex = (page - 1) * limit;
const paginatedItems = items.slice(startIndex, startIndex + limit);
// 返回資料和分頁標頭
return HttpResponse.json(paginatedItems, {
headers: {
'X-Total-Count': totalElements.toString(),
'X-Page-Number': page.toString(),
'X-Page-Size': limit.toString(),
},
});
}),
/**
* GET /api/v1/inventory/:id
* 獲取單筆庫存資料
*/
http.get(`${API_BASE}/api/v1/inventory/:id`, ({ request, params }) => {
const authCheck = checkAuth(request);
if (!authCheck.success) {
return authCheck.error!;
}
const { tenantId } = authCheck;
const { id } = params;
const item = db.inventory.find((i) => i.id === id && i.tenantId === tenantId);
if (!item) {
return HttpResponse.json({ message: 'Item not found' }, { status: 404 });
}
return HttpResponse.json(item);
}),
/**
* POST /api/v1/inventory/adjust
* 調整庫存
*/
http.post(`${API_BASE}/api/v1/inventory/adjust`, async ({ request }) => {
const authCheck = checkAuth(request);
if (!authCheck.success) {
return authCheck.error!;
}
const { tenantId } = authCheck;
const adjustment = await request.json() as StockAdjustment;
const item = db.inventory.find(
(i) => i.id === adjustment.itemId && i.tenantId === tenantId
);
if (!item) {
return HttpResponse.json({ message: 'Item not found' }, { status: 404 });
}
// 調整庫存
item.quantity += adjustment.quantity;
item.lastUpdated = new Date().toISOString();
return HttpResponse.json(item);
}),
];
重點說明:
- 使用
checkAuth()middleware 驗證使用者身份 - 使用
tenantQuery.getByTenant()實現多租戶資料隔離 - API 路徑使用
/api/v1/前綴 - 分頁資訊透過 HTTP headers 返回(X-Total-Count、X-Page-Number、X-Page-Size)
- 錯誤處理返回適當的 HTTP 狀態碼
註冊 handler:
// mocks/handlers/index.ts
import { inventoryHandlers } from './inventory';
export const handlers = [
// ...其他 handlers
...inventoryHandlers,
];
步驟 6:建立 Applet 主容器
Applet 主容器負責處理路由和側邊欄導航。參考檔案:product-applet/product-applet.tsx
// applets/inventory-applet/inventory-applet.tsx
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 { InventoryFinder } from './inventory-finder';
import { InventoryEditor } from './inventory-editor';
import { InventoryDetail } from './inventory-detail';
const BASE_PATH = '/inventory';
export function InventoryApplet() {
const { t } = useTranslation();
// 定義側邊欄導航按鈕
const actions: AppletAction[] = useMemo(() => [
{ path: BASE_PATH, icon: List, label: t('Inventory') },
{ path: `${BASE_PATH}/adjust`, icon: Plus, label: t('Adjust Stock') },
], [t]);
return (
<AppletShell basePath={BASE_PATH} actions={actions}>
<Routes>
{/* 列表頁 */}
<Route index element={<InventoryFinder />} />
{/* 調整庫存頁 */}
<Route path="adjust" element={<InventoryEditor />} />
{/* 詳情頁 */}
<Route path=":id" element={<InventoryDetail />} />
</Routes>
</AppletShell>
);
}
重點說明:
basePath: Applet 的基礎路徑(必填)actions: 側邊欄導航按鈕陣列,包含path、icon、label- 使用
<Routes>配置子路由,<Route index>對應列表頁 - 使用
useMemo確保 actions 陣列在語言切換時重新計算
步驟 7:建立列表頁面(Finder)
列表頁面通常包含搜尋、過濾、分頁等功能。參考檔案:product-applet/product-finder.tsx
// applets/inventory-applet/inventory-finder.tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Eye } from 'lucide-react';
import { Button, VirtualTable } from '@appfuse/appfuse-web/components';
import { useTranslation } from '@appfuse/appfuse-web/utils';
import type { ColumnDef } from '@tanstack/react-table';
import { inventoryService } from '@/services/inventory-service';
import type { InventoryItem } from './types';
export function InventoryFinder() {
const { t } = useTranslation();
const navigate = useNavigate();
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadInventory();
}, []);
const loadInventory = async () => {
setLoading(true);
try {
const result = await inventoryService.query();
setItems(result.content);
} catch (error) {
console.error('Failed to load inventory:', error);
} finally {
setLoading(false);
}
};
const columns: ColumnDef<InventoryItem>[] = [
{
accessorKey: 'productName',
header: t('Product Name'),
},
{
accessorKey: 'quantity',
header: t('Stock'),
cell: ({ row }) => (
<span className={row.original.quantity < 10 ? 'text-error' : ''}>
{row.original.quantity}
</span>
),
},
{
accessorKey: 'location',
header: t('Location'),
},
{
id: 'actions',
header: t('Actions'),
cell: ({ row }) => (
<Button
size="sm"
variant="ghost"
onClick={() => navigate(row.original.id)}
>
<Eye className="w-4 h-4" />
</Button>
),
},
];
return (
<div className="p-4">
<VirtualTable
data={items}
columns={columns}
loading={loading}
estimatedRowHeight={60}
/>
</div>
);
}
步驟 8:建立詳情頁面(Detail)
詳情頁面顯示單筆資料的完整資訊。參考檔案:product-applet/product-detail.tsx
// applets/inventory-applet/inventory-detail.tsx
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router';
import { ArrowLeft, Edit } from 'lucide-react';
import { Button, CollapsibleCard } from '@appfuse/appfuse-web/components';
import { useTranslation } from '@appfuse/appfuse-web/utils';
import { inventoryService } from '@/services/inventory-service';
import type { InventoryItem } from './types';
export function InventoryDetail() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [item, setItem] = useState<InventoryItem | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (id) {
loadItem(id);
}
}, [id]);
const loadItem = async (itemId: string) => {
setLoading(true);
try {
const data = await inventoryService.get(itemId);
setItem(data);
} catch (error) {
console.error('Failed to load item:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="p-4">{t('Loading...')}</div>;
}
if (!item) {
return <div className="p-4">{t('Item not found')}</div>;
}
return (
<div className="p-4 space-y-4">
{/* 頁面標題與操作按鈕 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/inventory')}
>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold">{item.productName}</h1>
</div>
<Button
variant="primary"
onClick={() => navigate(`/inventory/${id}/adjust`)}
>
<Edit className="w-4 h-4 mr-2" />
{t('Adjust Stock')}
</Button>
</div>
{/* 基本資訊 */}
<CollapsibleCard title={t('Basic Information')} defaultOpen>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-base-content/60">
{t('Product Name')}
</label>
<p className="text-base-content">{item.productName}</p>
</div>
<div>
<label className="text-sm font-medium text-base-content/60">
{t('Stock')}
</label>
<p className={item.quantity < 10 ? 'text-error' : 'text-base-content'}>
{item.quantity}
</p>
</div>
<div>
<label className="text-sm font-medium text-base-content/60">
{t('Location')}
</label>
<p className="text-base-content">{item.location}</p>
</div>
<div>
<label className="text-sm font-medium text-base-content/60">
{t('Last Updated')}
</label>
<p className="text-base-content">
{new Date(item.lastUpdated).toLocaleString()}
</p>
</div>
</div>
</CollapsibleCard>
</div>
);
}
步驟 9:註冊路由
在 routes/index.tsx 中註冊 Applet。參考檔案:routes/index.tsx:132-141
// routes/index.tsx
import { lazy, Suspense } from 'react';
import { LoadingFallback } from '@/components/loading-fallback';
// Lazy load Applet
const InventoryApplet = lazy(() =>
import('@/applets/inventory-applet').then((m) => ({ default: m.InventoryApplet }))
);
// 在 children 中註冊路由
{
path: 'inventory/*', // 使用 /* wildcard 讓 Applet 處理子路由
element: (
<RoleGuard allowedRoles={BUSINESS_ROLES}>
<Suspense fallback={<LoadingFallback />}>
<InventoryApplet />
</Suspense>
</RoleGuard>
),
}
重點說明:
- 使用
inventory/*wildcard 模式,讓 Applet 內部的<Routes>處理子路由 - 使用
lazy()和Suspense實現 code splitting - 使用
RoleGuard控制權限(可選)
步驟 9:註冊到 Application Launcher
// config/applets.config.ts
export const appletRegistry = [
// 其他 Applets...
{
id: 'inventory',
name: '庫存管理',
icon: 'Package',
path: '/inventory',
description: '管理商品庫存',
category: 'operations',
},
];
Applet 設計模式
1. 列表-詳情模式
最常見的模式,用於大多數 CRUD 功能:
/inventory → InventoryListApplet
/inventory/:id → InventoryDetailApplet
/inventory/:id/edit → InventoryEditApplet
2. 嵌套子路由模式
複雜 Applet 可能需要子路由:
/orders → OrderListApplet
/orders/:id → OrderDetailApplet
/orders/:id/items → OrderItemsApplet
/orders/:id/shipment → OrderShipmentApplet
實作:
function OrderDetailApplet() {
return (
<AppletShell title="訂單詳情">
<Tabs>
<TabPanel label="基本資訊">
<Outlet /> {/* 預設內容 */}
</TabPanel>
<TabPanel label="訂單項目">
<Navigate to="items" />
</TabPanel>
<TabPanel label="配送資訊">
<Navigate to="shipment" />
</TabPanel>
</Tabs>
</AppletShell>
);
}
3. 多步驟流程模式
引導使用者完成多步驟操作:
function OrderCreateApplet() {
const [step, setStep] = useState(1);
return (
<AppletShell title="建立訂單">
<Stepper currentStep={step}>
<Step label="選擇客戶" />
<Step label="選擇商品" />
<Step label="確認訂單" />
</Stepper>
{step === 1 && <SelectCustomerStep onNext={() => setStep(2)} />}
{step === 2 && <SelectProductsStep onNext={() => setStep(3)} />}
{step === 3 && <ConfirmOrderStep />}
</AppletShell>
);
}
最佳實踐
1. 使用自訂 Hook 封裝邏輯
// applets/inventory/hooks/useInventory.ts
export function useInventory() {
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const loadInventory = async () => {
setLoading(true);
try {
const data = await inventoryService.findAll();
setItems(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadInventory();
}, []);
return { items, loading, reload: loadInventory };
}
// 使用
function InventoryListApplet() {
const { items, loading, reload } = useInventory();
return <DataTable data={items} loading={loading} />;
}
2. 錯誤處理
function InventoryListApplet() {
const [error, setError] = useState<string | null>(null);
const loadInventory = async () => {
try {
const data = await inventoryService.findAll();
setItems(data);
setError(null);
} catch (err) {
setError('載入庫存失敗,請稍後再試');
console.error(err);
}
};
if (error) {
return (
<Alert variant="error">
{error}
<Button onClick={loadInventory}>重試</Button>
</Alert>
);
}
return <DataTable data={items} />;
}
3. 載入狀態
function InventoryDetailApplet() {
const [loading, setLoading] = useState(true);
const [item, setItem] = useState<InventoryItem | null>(null);
if (loading) {
return (
<AppletShell title="庫存詳情">
<Skeleton count={5} />
</AppletShell>
);
}
return <AppletShell>{/* 內容 */}</AppletShell>;
}
測試
單元測試
// applets/inventory/InventoryListApplet.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { InventoryListApplet } from './InventoryListApplet';
import { inventoryService } from '@/services/inventory.service';
vi.mock('@/services/inventory.service');
describe('InventoryListApplet', () => {
it('renders inventory items', async () => {
const mockItems = [
{ id: '1', productName: 'Rose', quantity: 50 },
];
vi.mocked(inventoryService.findAll).mockResolvedValue(mockItems);
render(<InventoryListApplet />);
await waitFor(() => {
expect(screen.getByText('Rose')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
});
});