ADR-006: AppletShell 統一外殼架構
狀態
已接受 (2025-12-19)
背景 (Context)
問題陳述
隨著 Applet 數量增加,觀察到以下問題:
- 重複代碼 - 各 Applet 獨立實作側邊工具列邏輯
- 樣式不一致 - 導航按鈕的樣式和行為在不同 Applet 間略有差異
- 維護成本高 - 修改共同 UI 模式需在多個檔案中重複修改
- 草稿功能分散 - 訂單/客戶草稿管理邏輯分別實作
限制條件
- 需保持各 Applet 內部路由的自治性
- 需支援動態按鈕配置(不同 Applet 有不同的工具列按鈕)
- 需支援額外擴展按鈕(如草稿按鈕)
- 需與 DaisyUI 主題系統整合
關鍵利害關係人
- 前端團隊
- UX 設計團隊
決策 (Decision)
選擇的方案
建立 AppletShell 共用外殼組件,提供統一的 Applet 佈局結構。
實施細節
1. 組件結構
位於 src/components/applet-shell/applet-shell.tsx:
┌─────────────────────────────────────────┐
│ AppletShell │
│ ┌────────┬──────────────────────────┐ │
│ │ 側邊 │ │ │
│ │ 工具列 │ 主要內容區域 │ │
│ │ (w-16) │ (子路由渲染) │ │
│ │ │ │ │
│ │ [按鈕1]│ │ │
│ │ [按鈕2]│ │ │
│ │ │ │ │
│ │ [額外] │ │ │
│ │ [按鈕] │ │ │
│ └────────┴──────────────────────────┘ │
└─────────────────────────────────────────┘
2. Props 介面
interface AppletAction {
path: string; // 導航路徑(絕對路徑)
icon: LucideIcon; // 按鈕圖示
label: string; // 按鈕提示文字
}
interface AppletShellProps {
basePath: string; // Applet 基礎路徑
actions: AppletAction[]; // 工具列按鈕配置
extraButtons?: ReactNode; // 額外按鈕(如草稿)
children: ReactNode; // 子內容(Routes)
}
3. Active 狀態判斷邏輯
const isActive = (path: string): boolean => {
// 首頁(列表頁)需要精確匹配
if (path === basePath) {
return location.pathname === basePath;
}
// 其他頁面使用前綴匹配
return location.pathname.startsWith(path);
};
4. 使用範例
<AppletShell
basePath="/orders"
actions={[
{ path: '/orders', icon: List, label: t('Orders') },
{ path: '/orders/new', icon: Plus, label: t('Create Order') },
]}
extraButtons={<DraftOrderButtons />}
>
<Routes>
<Route index element={<OrderFinder />} />
<Route path="new" element={<OrderEditor />} />
<Route path=":id" element={<OrderDetail />} />
<Route path=":id/edit" element={<OrderEditor />} />
</Routes>
</AppletShell>
5. 草稿管理整合
透過 extraButtons prop 支援草稿按鈕的動態渲染:
function DraftOrderButtons() {
const createDrafts = useAppSelector(selectCreateDrafts);
const editDrafts = useAppSelector(selectEditDrafts);
return (
<>
{createDrafts.map((draft) => (
<DraftButton key={draft.id} draft={draft} type="create" />
))}
{editDrafts.map((draft) => (
<DraftButton key={draft.id} draft={draft} type="edit" />
))}
</>
);
}
考慮的替代方案 (Alternatives Considered)
方案 A: 繼續各 Applet 獨立實作
描述: 維持現狀,各 Applet 自行管理側邊工具列。
優點:
- 無需重構現有程式碼
- 各 Applet 完全自治
缺點:
- 重複代碼持續增加
- 樣式一致性難以保證
- 維護成本高
為何未採用: 隨著 Applet 增加,問題會越來越嚴重。
方案 B: 高階組件 (HOC) 模式
描述: 使用 HOC 包裝各 Applet,注入共用 UI 邏輯。
優點:
- 邏輯復用
- 不改變現有組件結構
缺點:
- HOC 嵌套可能導致 props 衝突
- 調試困難
- TypeScript 類型推斷複雜
為何未採用: 組合模式(Composition)更清晰、更易維護。
方案 C: Render Props 模式
描述: 使用 Render Props 提供側邊工具列的渲染邏輯。
優點:
- 靈活性高
- 可自定義渲染
缺點:
- 程式碼較冗長
- 每個 Applet 仍需處理部分重複邏輯
為何未採用: 組合模式更直觀、更符合 React 最佳實踐。
決策後果 (Consequences)
正面影響 (Positive)
- ✅ 消除重複 - 側邊工具列邏輯集中在一處
- ✅ 統一樣式 - 所有 Applet 的導航按鈕外觀一致
- ✅ 易於維護 - 修改共用邏輯只需改動一個檔案
- ✅ 靈活擴展 -
extraButtonsprop 支援自定義擴展 - ✅ 類型安全 - 完整的 TypeScript 類型定義
負面影響 (Negative)
- ⚠️ 輕微靈活性降低 - 特殊佈局需求可能無法滿足
- 緩解:保留不使用 AppletShell 的選項
- ⚠️ 學習成本 - 新成員需了解 AppletShell 的使用方式
- 緩解:提供完整的文檔和範例
風險 (Risks)
- 🚨 過度耦合風險(機率: 低,影響: 中)- 未來特殊需求可能需打破統一結構
- 緩解:保持 AppletShell 職責單一,不過度擴展功能
需要注意的事項 (Notes)
- 📌
basePath必須與路由配置一致 - 📌
actions中的path使用絕對路徑 - 📌 側邊工具列寬度固定為
w-16(4rem)
影響範圍 (Impact)
受影響的系統/模組
src/applets/order-applet/- 使用 AppletShellsrc/applets/customer-applet/- 使用 AppletShellsrc/applets/sales-applet/- 工作台不使用(單頁面)src/applets/design-applet/- 工作台不使用(單頁面)src/applets/delivery-applet/- 工作台不使用(單頁面)
需要的變更
- 建立
src/components/applet-shell/applet-shell.tsx - 重構 OrderApplet 使用 AppletShell
- 重構 CustomerApplet 使用 AppletShell
- 移除各 Applet 中重複的側邊工具列程式碼
適用場景
| Applet 類型 | 使用 AppletShell | 說明 |
|---|---|---|
| CRUD 應用 | ✅ 是 | 訂單、客戶管理等具有列表/詳情/編輯頁面 |
| 工作台 | ❌ 否 | 單頁面任務導向應用,無內部路由 |
參考資料 (References)
後續行動 (Follow-up Actions)
- 建立 AppletShell 組件
- 重構 OrderApplet
- 重構 CustomerApplet
- 未來:考慮添加可配置的工具列位置(左側/右側)
最後更新: 2025-12-19 決策者: Development Team