iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

30天打造一套企業PLM系列 第 8

Day 8:動態表單引擎——前後端如何一起渲染

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260825/20161290mRKHYrzyf0.jpg

系列:30 天打造企業級 PLM|面向:全端

問題場景

Day 4 把欄位定義存進了資料庫,今天讓顯示出來:同一個前端元件,渲染出幾十種不同的表單。測試表單三個欄位、承認表單二十個欄位帶表格與附件,程式碼都是同一份。這就是動態表單引擎,沒有任何一張表單是寫死的。

實機畫面,建立完成後的 Form 直接依 Form Type metadata 渲染出多種欄位與群組:

https://ithelp.ithome.com.tw/upload/images/20260825/20161290ff1RG1HSyr.png

商業邏輯設計

  • 表單的業務規則是活的:同一張表單,欄位必填與否隨流程關卡改變(step 欄位綁定),可見與可編輯隨角色改變
  • 欄位級權限有真實的業務場景:成本欄位只有採購看得到、機密規格只開放特定群組。這不是錦上添花,是報價單類表單的硬需求

技術選型與取捨

架構演進:從 JSP 拼接 / Java Client 雙軌制到 React 宣告式渲染

以前在 Oracle Agile PLM 中,動態表單的渲染是出了名的歷史包袱:

  1. Web Client 的 JSP 拼接:伺服器端依靠 Struts、JSP 與自訂 Taglib 逐行拼裝 HTML。每次欄位連動或分頁切換,都要觸發整頁或 Frame 重載,畫面閃爍、體驗僵硬。
  2. Java Client 的 Swing 繪製:為了提供流暢操作,Agile 開發了另一套基於 Java Web Start 的 Swing 胖客戶端。這意味著同一張表單的動態行為、權限控制要在 JSP 與 Swing 兩套完全不同的技術棧各自實作一次!當後來主流瀏覽器全面廢除 Java Applet / Web Start 時,這套胖客戶端直接成了企業無法更新 JRE 的資安重災區。

Mini-PLM 徹底統一為現代前端架構:後端只提供 JSON Schema(mp_config_form_field)與資料,前端單一 React 19 SPA 透過 Ant Design ProComponents 進行宣告式動態渲染

https://ithelp.ithome.com.tw/upload/images/20260825/20161290sV51t967Q2.png

渲染管線三段式:

後端欄位定義 API(mp_config_form_field)
        ↓ useMetaStore 全域快取(避免每開一張表單抓一次 meta)
前端組 columns(valueType 驅動)
        ↓ ProForm / EditableProTable 渲染
使用者畫面

https://ithelp.ithome.com.tw/upload/images/20260825/20161290nsZVVjdle9.png

meta 走全域快取,因為欄位定義變動頻率低、讀取頻率高,是最典型的快取對象。代價是管理員改了定義、使用者還看到舊的,這正是後面 SSE 失效通知要解決的問題。
valueType 驅動元件選擇:text/textarea/digit/date/select/multilist/radio/checkbox/image/upload/html/table/annotateddoc 各對應一種 antd 元件,新增欄位型態等於在對映表加一條,不動任何表單。驗證則有三道防線:前端 rules 顧體驗、後端 Start Gate 守門、DB 約束兜底。

Dynamic Form 到底動態在哪裡?

Dynamic Form 不是「一個很大的表單元件」,而是把欄位定義欄位值拆開。Form Type 描述這類表單有哪些欄位、欄位順序、群組、顯示方式與權限;使用者建立的 Form instance 只保存這次文件的實際值。前端拿到兩者後,才把 metadata 組成 columns,交給同一個 MPForm 元件渲染。

例如後端回傳的欄位定義可以長這樣:

{
  "dataIndex": "description",
  "label": "說明",
  "valueType": "textarea",
  "required": true,
  "canRead": true
}

dataIndex 決定值放在哪裡,label 決定使用者看到什麼,valueType 決定要用哪一種輸入元件,requiredcanRead 則同時參與驗證和權限過濾。也就是說,真正的動態不是 JSX 裡寫滿 if (formType === ...),而是新增一個 Form Type 或欄位後,既有 renderer 能讀懂新的 metadata:

metadata 的 valueType renderer 使用者看到的效果
text Input 單行文字欄位
select / multilist Select 單選或多選清單
table EditableProTable 可編輯的明細表格
upload / image Upload 附件或圖片控制項

所以這次截圖要看的是「建立完成後的 Form」:畫面上的欄位與群組是由 7555 這個 Form Type 的 metadata 生成,不是截圖工具另外寫死一張展示頁。換一個 Form Type,資料值與欄位數量不同,仍然走同一條渲染管線;這才是 Dynamic Form 對企業 PLM 的價值。

SSE 搭配 cache 更新:讓 metadata 不會過期

快取解決的是「每次開 Form 都不必重新抓欄位定義」,卻帶來另一個問題:管理員在後台修改欄位後,已經開啟的瀏覽器仍可能拿著舊 metadata。這裡 SSE(Server-Sent Events)不負責把整份表單資料推給前端,它只負責通知「哪一種共用資源變了」;收到通知後,前端再讓對應 cache 失效並重新取值。

以 Form Type 為例,預期的事件鏈是:

管理員更新 Form Type / 欄位
        ↓
後端發布 SystemUpdateEvent(FORM_TYPE, UPDATE, resourceId)
        ↓
SSE /api/v1/events/system 推送 formtype-update
        ↓
SystemEventService → useSystemEventListener
        ↓
讓 meta/formTypies 與 meta/formFields cache 失效
        ↓
下一次渲染重新抓取最新 metadata

這個設計有兩個重要界線。第一,SSE payload 只帶 resourceTypeeventTyperesourceId 和時間,不把所有欄位值廣播給每個人,避免通知通道變成第二個資料 API。第二,失效不等於立刻重繪整個網站:只清掉受影響的 Form Type 與欄位 query,下一次需要時再抓,既保持畫面一致,也控制網路流量。

目前專案已經有 SystemUpdateEventformtype-update 事件名稱、統一 SSE 服務,以及 useMetaStore 使用 queryClient 管理 formTypies / formFields 的基礎;把事件 handler 精準對到 Query invalidation,還需要處理重連、重複通知、多人同時編輯與正在輸入中的表單。這些不應在 Day 8 偷塞成一個看似完成的 callback,而是留給後面的即時同步章節驗證。

Day 19 預告:即時事件與 cache invalidation

Day 19 會把今天的兩條線接起來:後台儲存成功後發布事件,前端依 resourceType 對應 query key,收到 formtype-update 後只失效受影響的 metadata。屆時還要驗證 SSE 斷線自動重連、事件遺失時的補抓、通知抵達和重新抓取之間的競態,以及使用者正在編輯時不能被背景更新覆蓋。Day 8 先建立「metadata 驅動渲染」的模型,Day 19 再讓這份模型在多人在線時保持新鮮。

核心內容:沒權限的欄位,API 該傳什麼?

這是動態表單最容易做錯的設計。欄位有權限後,API 傳輸有三條路,全是坑:

  1. 不傳:前端表單結構缺一塊,版面錯位、Form 值對不上
  2. 傳 null:使用者一存檔,null 蓋掉別人填的真值,靜默資料毀損
  3. 原值照傳:前端「隱藏」了,但 DevTools 網路面板裡看得一清二楚,權限形同虛設

Mini-PLM 的解法是讀寫兩端都處理。讀取端:後端在欄位定義上標 canRead,值在出口就過濾,無權限的欄位值根本不出後端(結構還在、值不在,版面不會壞)。寫入端:前端 submit 前把無權限欄位從送出值裡清掉(MPForm.tsx 實碼):

// 送出前清洗:無讀取權限的欄位一律自 submit values 移除,
// 避免以 undefined/null 蓋掉他人填寫的真值
const dataIndex = typeof column?.dataIndex === 'string' ? column.dataIndex : undefined;
if (dataIndex && column?.canRead === false) {
  delete sanitizedValues[dataIndex];
}

後端存檔時同樣忽略無權限欄位。前端清洗是防呆,後端忽略才是防線,惡意 payload 直接打 API 也蓋不掉。一句話總結:欄位權限是值的過濾,不是元件的隱藏,而且必須在後端完成

https://ithelp.ithome.com.tw/upload/images/20260825/20161290fEw1KeEwJf.png

實機畫面,無權限欄位顯示為「No Privilege」遮罩。結構還在、值不出後端,品項頁走的是同一套機制:

https://ithelp.ithome.com.tw/upload/images/20260825/20161290sj8OZsojoJ.png

必填守門:前端擋一次,後端再擋一次

前端 rules 擋得住正常使用者,擋不住繞過 UI 直接送 API 的人。後端 Start Gate 在表單推進(送簽)前重驗必填,缺漏時回 Day 5 講過的結構化錯誤:

400 REQUIRED_FIELDS_MISSING + missingFields: ["說明", "選擇自動編號"]

前端拿到 missingFields 直接在對應欄位標紅。兩端用的是同一份 metadata 的 required 定義,永遠不會前端說必填、後端說隨便。

label 與 raw 的雙路徑

同一個 select 欄位的值,兩種場景要的東西不同。listing 與 search 的出口要轉成 label,使用者要看「量產中」,不是 PHASE_MP;detail 與 edit 路徑要保留 raw key,下拉選單的預設值要用 key 去 match option。這兩條路徑在 buildFormResponses 分流。

沒分清楚的下場踩過:編輯頁下拉預設值直接掛掉,你塞給它一個 label,它在 options 裡找不到對應的 key。multilist 更麻煩,存的是 JSON 陣列字串,出口轉 label 要先 JSON 解析再逐一對映。

踩坑記錄:「我的 Item Type 不見了!」

本系列排行榜第一名的坑,案發現場就在表單儲存。使用者回報 Item Type 消失,我們在型態設定翻了半天,最後發現真正被清空的是登入者自己的帳號資料

鏈路是這樣的:Form 實體的 @CreatedBy 審計欄位關聯到帳號實體,而這個關聯多掛了一個 cascade。存表單時,audit 機制塞進來的是一個只有 ID 的帳號空殼物件,cascade 讓 JPA 把這個空殼 merge 回帳號表,整列帳號資料被空值覆蓋。至於 Item Type 消失?那是帳號列被清掉後一連串查詢失敗的連鎖假象。症狀在東邊,病灶在西邊。

修法一行(拿掉 cascade),教訓一輩子:審計欄位的關聯嚴禁 cascade。@CreatedBy 指向的物件永遠是參照,不是聚合的一部分。cascade 的語意是「我擁有它的生命週期」,你的表單並不擁有登入者的帳號。

https://ithelp.ithome.com.tw/upload/images/20260825/20161290iEgclJKc9F.png

小結

動態表單這天講的四件事,其實都繞著同一份 metadata 打轉:快取它(記得配失效)、用它過濾權限值、用它做兩端一致的必填驗證、用它分流 label 與 raw。至於 cascade 那個坑,跟 metadata 無關,純粹是 JPA 給每個過路人的見面禮。

明日 Day 9:簽核流程引擎——Workflow / Step / Action 三層狀態機,以及退回與駁回的商業語意差異。


上一篇
Day 7:LDAP 整合與 RBAC 設計
系列文
30天打造一套企業PLM8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
jeff377
iT邦新手 3 級 ‧ 2026-08-25 09:15:08

凱文大叔你好,我這系列 Day 7 剛好也在談渲染層(FormLayout 與多前端畫面產生),提供另一種做法參考。

差別在我做的是 ERP 框架、不是應用系統,所以刻意把 UI 與邏輯脫鉤,拆成 FormSchema 與 FormLayout 兩份定義:欄位語意、型別、關連、驗證在 FormSchema,版面只交代有哪些欄位、什麼順序、分幾群、用哪一種控件。同一份 FormLayout 要同時渲染桌面(macOS / Windows)、瀏覽器、iOS、Android,所以定義裡只能留抽象的控件種類,翻成原生控件那一段各端自己有渲染層——代價是詞彙被壓得很少(連座標都不寫),好處是多一個端就是多一個渲染層,不必回頭改上千份定義。

你講「欄位權限是值的過濾,不是元件的隱藏,而且必須在後端完成」我完全同意。前端做的事都是體驗,不是防線——欄位權限、必填、值的過濾這些只要在前端成立,繞過 UI 直接打 API 就全部失效。後端才是最後一道,而且它跟前端讀的必須是同一份定義,否則兩邊遲早各說各話。

好酷, 我們一個做ERP一個做PLM
我自己寫這套產品本來是純人工開發,後來才轉為AI開發
進度及功能也大為提升

我要留言

立即登入留言