專案前端模式
本頁整理目前 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、可由使用者操作的input、select、textarea、dialog建立互動控制。 - 自行處理 keyboard、focus、disabled、expanded/selected 或 ARIA control 行為。
- 直接用 DaisyUI 的
btn、input、modal、dropdown、tabs等互動 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,沒有 title、Toolbar 或 Content 靜態子元件。
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 再呼叫 createHttpClient 或 axios.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;一般分頁列表才使用
DataTable 的 pagination、onPageChange 與 onPageSizeChange。
表單與部分更新
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。
列表畫面
VirtualTable 與 DataTable 都要求 data 與 columns。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')));
}