跳至主要内容

專案前端模式

本頁整理目前 app-office 參考實作採用的應用層模式。它不是 @appfuse/appfuse-web 的公開 API 規格;消費端可以依自己的領域調整命名與畫面,但應保留 單一 HTTP client、資料驅動路由、框架表單元件與明確 query key 等邊界。

目錄與責任

app-office/src/
├── applets/ # 頁面與 applet 內部路由
│ ├── product-applet/
│ ├── order-applet/
│ └── shared/
├── components/ # 應用層共用元件(例如 AppletShell)
├── config/ # applet registry、應用設定
├── features/ # Redux:草稿、finder 狀態、登入狀態
├── routes/ # router、登入與角色 guard
├── services/ # API service、TanStack Query hooks
├── store/ # Redux store
├── types/ # 業務契約型別
└── mocks/ # MSW handlers 與 seed data

業務 HTTP 契約放在 services/{domain},共享 DTO 放在 types/。Applet 負責組合畫面,不應 另建 HTTP client 或重複實作驗證、token refresh。

基礎互動控制的選擇

前端採 appfuse-web first。開始寫控制元件前,先查目前安裝版本的公開 README、型別與 source,再依序嘗試公開元件、props/variants/slots,以及既有元件 composition。DaisyUI 是框架底層 styling technology,不是應用端可與 appfuse-web 並列選用的另一套元件庫。 公開元件清單與契約請參閱 AppFuse Web 元件

下列做法代表應用端正在自製 UI primitive,即使只在單頁使用一次也一樣:

  • 以原生 button、可由使用者操作的 inputselecttextareadialog 建立互動控制。
  • 自行處理 keyboard、focus、disabled、expanded/selected 或 ARIA control 行為。
  • 直接用 DaisyUI 的 btninputmodaldropdowntabs 等互動 component class。
  • 引入第三方 control,或建立重複框架互動責任的 wrapper。

靜態語意結構、layout/spacing/typography、業務頁面 composition,以及內部仍委派給框架 元件的 project component,不算新 primitive。React Router Link 與一般內容超連結也可 直接使用;把 anchor 做成按鈕或下載控制時仍應先查框架。

若框架與 composition 都無法滿足需求,AI 必須先讓人類在「調整需求並使用框架」、「擴充 appfuse-web 後再消費」、「批准專案內例外」三者中明確決策,不能自行選擇。專案內例外須在 primitive 定義或一次性使用點留下 @framework-primitive-exception,記錄批准日期、查核的 框架版本/公開 API 與例外理由。標記只涵蓋原批准責任,不可複製到另一個控制。

「是否跨產品重用」只決定最後應放進框架或留在專案;不決定它是不是基礎互動控制。

資料驅動 Applet 路由

Applet 的 launcher metadata 與 route metadata 集中於 config/applet-registry.ts。Router 迭代 registry 產生路由,因此新增 Applet 時不需要再維護另一份手寫 route switch。

{
id: 'products',
name: 'Product Management',
path: '/products',
allowedRoles: [Role.MANAGER, Role.FLORIST],
status: 'live',
route: {
routePattern: 'products/*',
guardRoles: BUSINESS_ROLES,
load: () =>
import('@/applets/product-applet').then((module) => ({
default: module.ProductApplet,
})),
},
}

Applet 內部再由一個 AppletShell 包住子路由。AppletShell 的現行契約是 basePath + actions + children,沒有 titleToolbarContent 靜態子元件。

const BASE_PATH = '/products';

export function ProductApplet() {
const { t } = useTranslation();
const actions = 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}>
<Routes>
<Route index element={<ProductFinder />} />
<Route path="new" element={<ProductEditor />} />
<Route path=":id" element={<ProductDetail />} />
<Route path=":id/edit" element={<ProductEditor />} />
</Routes>
</AppletShell>
);
}

單一 HTTP client

應用只在 services/api-client.ts 呼叫一次 createHttpClient。登入 token、401 refresh、檔案 上傳與部署時的 baseURL 都接到同一實例;業務 service 一律 import 這個實例。

// services/api-client.ts
import { createHttpClient } from '@appfuse/appfuse-web/utils';

export const apiClient = createHttpClient({
baseURL: '/',
fileUpload: {
strategy: 'binary',
endpoint: '/api/v1/staging/files/prepare',
},
auth: {
getAccessToken: () => selectAuthorization(store.getState())?.access_token ?? null,
refresh: refreshAccessToken,
onAuthFailed: () => store.dispatch(markSessionExpired()),
},
});

建立新 session 的公開認證 request 必須在呼叫點明確排除既有 session,而不是在 client 配置以 URL matcher 猜測。這可避免 session 過期後 login/Email OTP 請求又被舊 Bearer token 或 refresh 流程攔住:

await apiClient.post('/api/v1/auth/login', credentials, { authMode: 'none' });
await apiClient.post('/api/v1/auth/email-otp/challenges', request, {
authMode: 'none',
});

authMode: 'none' 只控制前端 HTTP client:不附加 session Bearer、不主動 refresh、401 時也不 refresh/retry;它不是 request body 或 HTTP header。

// services/sales/product-service.ts
import { apiClient } from '@/services/api-client';

export const productService = {
async get(id: string): Promise<Product> {
const response = await apiClient.get<Product>(`/api/v1/products/${id}`);
return response.data;
},

async query(params: ProductQueryParams = {}): Promise<PagedResult<Product>> {
const response = await apiClient.get<Product[]>('/api/v1/products', { params });
return toPagedResult(response);
},
};

不要在其他 service 再呼叫 createHttpClientaxios.create。那會漏掉應用啟動時設定的 context path、認證 callback 與檔案上傳策略。

TanStack Query 邊界

Service 處理 HTTP 契約;query hook 處理 query key、快取與失效。列表 filter 與 sorting 必須納入 query key,mutation 成功後失效同一 key family。

export function useProduct(id: string) {
return useQuery({
queryKey: queryKeys.products.detail(id),
queryFn: () => productService.get(id),
enabled: id.length > 0,
});
}

export function useActiveProducts() {
return useQuery({
queryKey: [...queryKeys.products.lists(), 'active-options'],
queryFn: async () => {
const result = await productService.query({ status: ['active'] });
return result.content;
},
});
}

長列表使用框架的 useInfiniteList 搭配 VirtualTable;一般分頁列表才使用 DataTablepaginationonPageChangeonPageSizeChange

表單與部分更新

React Hook Form 應搭配 @appfuse/appfuse-web/form 的整合元件與 validatorResolver。前端只做型別與必填檢查;格式、範圍與業務規則由 Server 驗證。

import { useForm } from 'react-hook-form';
import { Button } from '@appfuse/appfuse-web/components';
import {
Input,
Select,
schema,
validatorResolver,
} from '@appfuse/appfuse-web/form';

type ProductFormData = {
name: string | null;
category: string | null;
};

const productFormSchema = schema.object({
name: schema.string().required(),
category: schema.string().required(),
});

function ProductForm({ onSubmit }: ProductFormProps) {
const {
control,
handleSubmit,
formState: { dirtyFields },
} = useForm<ProductFormData>({
resolver: validatorResolver(productFormSchema),
defaultValues: { name: null, category: null },
});

return (
<form onSubmit={handleSubmit((data) => onSubmit(data, dirtyFields))}>
<Input name="name" control={control} label="Product Name" />
<Select
name="category"
control={control}
label="Category"
options={categoryOptions}
/>
<Button type="submit" color="primary">儲存</Button>
</form>
);
}

編輯 API 使用 PATCH 時,只從 dirtyFields 萃取實際修改的欄位。不要把完整表單覆蓋回 Server,也不要引入另一套 Zod/Yup resolver。

列表畫面

VirtualTableDataTable 都要求 datacolumns。Finder 可以把搜尋、filter drawer 與列表放在普通 layout 中;工具列不是 AppletShell 的靜態子元件。

function ProductFinder() {
const { data, isLoading, infiniteScroll, fetchNextPage } = useProductList(
filters,
sorting,
);

return (
<div className="space-y-4 p-4">
<SearchBar value={searchTerm} onChange={setSearchTerm} />
<VirtualTable
data={data}
columns={columns}
loading={isLoading}
infiniteScroll={infiniteScroll}
onFetchNextPage={fetchNextPage}
/>
</div>
);
}

錯誤與使用者訊息

HTTP client 會拋出框架的 ErrorResponse。頁面使用 getApiErrorMessage 取得安全訊息, 再透過 prompt 呈現;refresh session 明確失效與 reauthentication-required 流程留給共用 client。

try {
await updateProduct(id, changes);
prompt.success(t('Product updated successfully'));
} catch (error) {
logger.error('Failed to update product', error);
prompt.error(t(getApiErrorMessage(error, 'Failed to update product')));
}

下一步