跳至主要内容

ADR-006: AppletShell 統一外殼架構

狀態

已接受 (2025-12-19)

背景 (Context)

問題陳述

隨著 Applet 數量增加,觀察到以下問題:

  1. 重複代碼 - 各 Applet 獨立實作側邊工具列邏輯
  2. 樣式不一致 - 導航按鈕的樣式和行為在不同 Applet 間略有差異
  3. 維護成本高 - 修改共同 UI 模式需在多個檔案中重複修改
  4. 草稿功能分散 - 訂單/客戶草稿管理邏輯分別實作

限制條件

  • 需保持各 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 的導航按鈕外觀一致
  • 易於維護 - 修改共用邏輯只需改動一個檔案
  • 靈活擴展 - extraButtons prop 支援自定義擴展
  • 類型安全 - 完整的 TypeScript 類型定義

負面影響 (Negative)

  • ⚠️ 輕微靈活性降低 - 特殊佈局需求可能無法滿足
    • 緩解:保留不使用 AppletShell 的選項
  • ⚠️ 學習成本 - 新成員需了解 AppletShell 的使用方式
    • 緩解:提供完整的文檔和範例

風險 (Risks)

  • 🚨 過度耦合風險(機率: 低,影響: 中)- 未來特殊需求可能需打破統一結構
    • 緩解:保持 AppletShell 職責單一,不過度擴展功能

需要注意的事項 (Notes)

  • 📌 basePath 必須與路由配置一致
  • 📌 actions 中的 path 使用絕對路徑
  • 📌 側邊工具列寬度固定為 w-16(4rem)

影響範圍 (Impact)

受影響的系統/模組

  • src/applets/order-applet/ - 使用 AppletShell
  • src/applets/customer-applet/ - 使用 AppletShell
  • src/applets/sales-applet/ - 工作台不使用(單頁面)
  • src/applets/design-applet/ - 工作台不使用(單頁面)
  • src/applets/delivery-applet/ - 工作台不使用(單頁面)

需要的變更

  1. 建立 src/components/applet-shell/applet-shell.tsx
  2. 重構 OrderApplet 使用 AppletShell
  3. 重構 CustomerApplet 使用 AppletShell
  4. 移除各 Applet 中重複的側邊工具列程式碼

適用場景

Applet 類型使用 AppletShell說明
CRUD 應用✅ 是訂單、客戶管理等具有列表/詳情/編輯頁面
工作台❌ 否單頁面任務導向應用,無內部路由

參考資料 (References)


後續行動 (Follow-up Actions)

  • 建立 AppletShell 組件
  • 重構 OrderApplet
  • 重構 CustomerApplet
  • 未來:考慮添加可配置的工具列位置(左側/右側)

最後更新: 2025-12-19 決策者: Development Team