本章目標
建立一個新頁面時,能沿用 feature ownership、路由閘、SWR query、Zod schema、Ant Design table/form 和錯誤呈現模式,並在桌面、平板、手機都保留可操作的資訊。本文依目前程式碼整理,本輪未執行業務站瀏覽器驗收。
前置條件
先讀前端專案結構,能使用 React、TypeScript 和基本 HTTP 概念。
新頁面的 route path 必須已在後端 page tree 註冊:test/fixtures/routeRegistry.json 由 scripts/sync-page-tree.mjs 從 be-plus internal/route/ 同步,test/routes/gates.test.ts 會擋下未註冊的頁面。串接 API 前再核對 be-plus internal/route/testdata/registry.golden(路由與權限點的權威來源)、api/openapi.yaml(wire 形狀)與 GET /v1/me 形狀。
API 尚未註冊時,前端 AGENTS.md §0.1 與 ADR 0001 允許先做 presentational UI,資料放頁面 _fixtures/ 的 view-model,但不得建立 API hook 或串接;交付時標明尚未是真實後端整合,不用 fixture 補成事實。
Feature 與元件邊界
頁面 route 放在 app/,每條產品路由在同層 layout.tsx 掛 PageGate。業務頁程式目前有兩種既有放法:
features/<domain>/:API、schema、view-model、頁面 owner 與測試集中在同一業務域,例如 approval-roles、forms、hr-*、attendance-*。app/(platform)/<route>/_components/搭配hooks/api/:IAM 治理頁、/tasks、首頁與部分 workspace 頁採用。hooks/api/不只放跨頁 lookup,例如useIamRolesApi.ts的建立、更新、刪除 hook 只有/iam/roles使用。
前端 AGENTS.md §3、§7 與 docs/api-architecture-plan.md 目前的寫法是 hook 建在 hooks/api/、頁面私有 UI 與 view-model 放 app/<route>/_components/ 與 _fixtures/,尚未描述 features/。新增程式先沿用同一業務域的既有位置;規則衝突時以前端 AGENTS.md 為準。可重用的資料表、EmptyState、feedback、layout 放 components/,且依 AGENTS.md §6,要有第二個頁面使用相同的穩定 contract 才提升,避免某個 feature 的業務規則反向污染通用元件。
app/(platform)/workspace/approval-roles/layout.tsx # PageGate path="/workspace/approval-roles"
app/(platform)/workspace/approval-roles/page.tsx
→ features/approval-roles/ApprovalRolesPage.tsx
→ features/approval-roles/api.ts
→ hooks/api/useMeApi.ts + libs/permission/*
→ libs/api/request.ts + libs/api/pagination.ts + Zod schemaApprovalRolesPage.tsx 的畫面把讀取失敗、載入、列表、modal editor 與權限按鈕分開;頁面 local draft 留在 owner,不用 Jotai 儲存只在一個 modal 存在的欄位。layout.tsx 的 PageGate 是每條產品路由的必要檔案:path 字面值必須等於目錄路徑,test/routes/gates.test.ts 會機械比對,[...segments] catch-all 則刻意沒有閘。其餘 import chain 是閱讀路徑,不要求每個頁面都有同樣的檔案切分。
API hook 模式
新 hook 以前端 AGENTS.md §7 指定的 hooks/api/useExampleApi.ts 為範本:GET 用 fetcher 搭配 useSWR,命名 use[Resource];非 GET 用 mutator 搭配 useSWRMutation,命名 use[Action][Resource],silentError 寫成 arg.silentError ?? defaults?.silentError。分頁查詢的 fetcher 要寫成 async,pageQueryParams 同步丟出的 InvalidSortKeyError 才會變成 SWR error,而不是一直停在 loading。
每個 endpoint 集中宣告 path、wire schema、UI type 與 fetcher/mutator;SWR key 必須包含會影響資料的 tenant、account、filter、page、revision 或 object ID。範本的 key 不含身份,資料會隨租戶或帳號改變時,照 features/approval-roles/api.ts 把 tenant.id、account.id 放進 key。沒有身份或 action permission 時回 null key,不發一個後端必然拒絕的請求。
// 示例如下,對照 features/approval-roles/api.ts 簡化;請依實際 hook 型別與 endpoint 調整,不能直接當成現成函式複製執行。
const source = useSWR(
me && canRead ? [PATH, me.tenant.id, me.account.id, page, search] : null,
() =>
fetcher({
url: PATH,
schema: pageEnvelopeSchema(itemSchema),
config: { params: { page, pageSize: 20, sort, ...(search ? { q: search } : {}) } },
}),
retryOptions,
);重試由各 hook 自行設定,repo 沒有全域 SWRConfig。approval-roles 的 retryOptions 只對 10500/10501 重試,最多 3 次、間隔 5000ms;forms 的流程預覽與快照讀取設 shouldRetryOnError: false。
讀取錯誤交給共用 apiError 分類;mutation 失敗時保留使用者 draft 與 server field errors,不關閉 modal,也不在 feature 內複製一份 HTTP status 對照表。以 silentError 自行呈現錯誤時,遇到處理不了的錯誤要呼叫 surfaceApiError() 補回全域通知。注意 approval-roles 的寫入 saveApprovalRole 是一般 async 函式且固定 silentError: true,不是 §7 的 mutation hook 形式;新的寫入依範本實作。
Schema 與 wire casing
libs/api/client.ts 送出時 decamelize(body 與 params),回應時 camelize;FormData、responseType: 'blob' 與含 - 的 key 自動略過轉換。libs/api/request.ts 先驗證成功 envelope,再用 Zod safeParse,不符即丟 SchemaContractError。
需要保留表單動態欄位 key 時,僅在該 request 指定 custom.preserveKeyPaths,不要全域放寬 casing 轉換。同一組路徑同時套用在 request body 與完整的 response body,而 response 外層還有 { data } 信封,所以要同時列出 values 與 data.values,列表則是 data.items.*.values;features/forms/api.ts 的 FORM_VALUE_PRESERVE_KEY_PATHS 就是這三條。
// 對照 features/forms/api.ts 的 useUpdateFormDraft 簡化;實際以該檔為準。
const result = await mutator({
url: `${FORMS_PATH}/${formId}`,
schema: formInstanceSchema,
config: {
method: 'put',
// FormUpdateRequest 只接受 revision 與 values(additionalProperties: false),整包送 draft 會被拒。
data: { revision, values },
custom: { preserveKeyPaths: FORM_VALUE_PRESERVE_KEY_PATHS },
},
});Schema 預設用 .passthrough():hooks/api/useExampleApi.ts 與 test/contract/openapiShape.test.ts 的約定是 runtime 容忍後端 additive 欄位,欄位改名或移除由 openapiShape 契約測試對照 be-plus api/openapi.yaml 抓出(找不到同層 be-plus checkout 時該測試會 skip)。.strict() 目前主要集中在 features/forms/api.ts(整檔一律 strict,註解說明流程預覽 warning code 是封閉詞彙)、features/reviews/api.ts 與員工檔案 schema;新增 strict schema 前先確認契約確實封閉,否則後端多加一個欄位就會讓整個 hook 拋 SchemaContractError。錯誤 envelope 不能套成功 schema。
表單規範
以 AntD Form、Input、Select、DatePicker 等 public API 組合欄位;送出前做本地必填與格式檢查,但後端仍是規則 authority。request body 以白名單組裝,不帶 UI-only 欄位(後端遇到未知欄位回 400/10002)。欄位級錯誤用 apiFieldErrors 取出,其中 field 的值維持 wire 的 snake_case,只在一處用 formFieldName 轉成 UI field name;訊息直接使用後端的繁體中文 message,不維護第二份錯誤文案。
新增、編輯、刪除或審核 mutation 至少處理:
- disabled/loading 狀態,避免重複提交;
- 409 revision conflict(20202),保留目前 draft 並提示重新載入或合併;
- 422
field_errors,精確標在對應欄位; - 401/403,沿用全域 session/permission 行為;
- 503/transport failure,可重試但不清除使用者已填資料。
WorkflowPanel.tsx 也示範了預覽與持久化歷程分開;undetermined 顯示「填寫後決定」,不能把缺資料顯示為 rejected。
表格、分頁與空狀態
共用 DataTable 預設關閉 AntD 內建 pagination(呼叫端傳入 pagination 仍會覆蓋),由頁面在表格外渲染 components/data/TablePagination.tsx:只有一頁時整列不渲染,傳入 isLoading 時沿用上一個確認過的 total 並暫停翻頁。本文範例頁 ApprovalRolesPage 目前直接用 AntD Pagination。若欄位超過手機寬度,交給 .ant-table-content 橫向滾動。
伺服器分頁的 query 與回應形狀用 libs/api/pagination.ts 的 pageEnvelopeSchema、definePagePolicy 與 pageQueryParams,需要整份清單時用 fetchAllPages,細節見前端專案結構。每個頁面明確處理 loading、error、data.items.length === 0 與有資料四種狀態。
{
/* 示例如下;RequestError 是 ApprovalRolesPage.tsx 的檔內元件,不是共用元件,欄位與 callback 需依實際頁面契約實作。 */
}
{
source.error ? (
<RequestError error={source.error} retry={() => void source.mutate().catch(() => undefined)} />
) : (
<DataTable
rowKey="id"
loading={source.isLoading}
dataSource={source.data?.items ?? []}
scroll={{ x: 680 }}
columns={columns}
/>
);
}不要用固定 min-width 使整個 viewport 溢出,不要用 overflow: hidden 把操作列切掉,不要把「尚未載入」渲染成「查無資料」。對於具權限但合法零筆的結果,使用 components/data/EmptyState.tsx(ApprovalRolesPage 目前沿用 AntD Table 的預設空狀態,尚未示範這點);對於 error,提供可重試操作與可複製的 trace ID。整頁 403/404 用 components/feedback/ErrorPage.tsx,路由層的拒絕則由 PageGate 顯示 DeniedState。
權限與互動狀態
Can/PageGate 只控制 UI 是否能操作或進頁;後端仍必須重新驗證。按鈕隱藏與 disabled 只是體驗,不是安全邊界。Can 在權限資料未就緒時不渲染任何內容;PageGate 分開顯示載入中、選單取得失敗(MenuUnavailable)與拒絕(DeniedState)。權限點以 Set 精確比對;升權 session 不自動折入常駐 can(),因為它可能在會話中途撤銷。
操作元件要有可見 focus、鍵盤順序、aria label、至少 44px 的觸控目標;icon-only button 用 aria-label。不要只用顏色表示 error 或 status;Tag、文字和適當的 Alert 一起表達。
操作與驗證
- 確認 route 已在後端 page tree 註冊並建立同層
layout.tsx的PageGate,再建立 API schema/hook 與 loading、error、empty、success 四個畫面。 - 以 MSW handler 模擬成功、422、409、401、503、schema drift;handler 只寫
registry.golden已註冊的路由,不把 mock handler 宣稱成真實後端。 - 先跑受影響的測試;交付前依前端
AGENTS.md§15 執行 lint、typecheck、test:run與deadcode(knip,門檻寫在package.json)。UI 變更另以playwright-lanMCP 在 1440、1024、720、360 四個寬度截圖,並檢查 console、流程、表格自身橫捲與至少 44px 的觸控目標;改 theme、root layout 或 SSR 邊界還要做 production build 與 hydration 檢查。
# 工作目錄:nexus-pro-web-plus;本輪未跑以下業務測試。AGENTS.md 優先用 nub run,pnpm 亦可。
pnpm test:run features/approval-roles/ApprovalRolesPage.test.tsx
pnpm test:run libs/api/client.contract.test.ts
# 交付前整套
pnpm lint
pnpm typecheck
pnpm test:run
pnpm deadcode預期結果: 不相容的 schema 漂移在 request boundary 被拒絕;field error 留在正確欄位;重試不遺失 draft;窄螢幕仍能操作表格與 modal。實際後端租戶資料、登入與部署行為必須另行驗證。
常見錯誤與相關文件
錯誤包括:直接在 JSX 呼叫 API、無限 retry 503、把 401 當一般表單錯誤、把 permission key 做 substring match、對會新增欄位的 endpoint 預設用 .strict()、只列 values 而漏掉 response 的 data.values、新增頁面漏掛 layout.tsx 的 PageGate、只測成功 fixture、關閉 modal 清空失敗 draft、或新增第二套日期/表格元件。
相關閱讀:前端專案結構、測試策略、Repository 與資料存取、新增業務功能實戰。