# 用自己的介面填寫 OpenDashboard 表單

**新報名網站請先使用[流程接入與自訂 UI](workflow-ui.md)**：在流程內啟用專屬 API，不需要應用管理金鑰。本頁是單份連接應用表單的合約；已有此類表單或只需單份回覆的應用可繼續使用。它不是流程填表 API。


你的網站可以自行設計頁面、按鈕、分頁及品牌。從 OpenDashboard 取得表單 JSON，按欄位 key 收集答案，再由網站後端送回 OpenDashboard。同一份定義可以用 React、Vue、原生 HTML 或其他技術顯示；答案格式保持相同。

本教程只有虛構資料，可下載[表單定義 JSON](custom-ui-form.json)及[提交答案 JSON](custom-ui-response.json)。應用金鑰留在後端，瀏覽器只呼叫你自己的網站。先用[接入方式](integration-modes.md)選擇填表／查看入口；新流程由 OpenDashboard 保存正式答案。普通連接應用表單不自動產生公開填表頁；OpenDashboard 流程／競賽私人頁可與外站 UI 共用同一申請。原站回寫 intake 是另一種接法。

## 1．先選接入方式，再選資料模型

| 要完成的工作 | 使用哪套端點 | 保存方式 |
| --- | --- | --- |
| 需要草稿、審核補件及兩種 UI 共用同一流程 | workflows／workflow-runs | OpenDashboard 保存申請和版本，外站 UI 與私人流程頁操作同一 run，見多步流程教程。 |
| 一份普通表單，提交後可修改自己的答案 | forms／responses | 每位 respondent 每份表單保存一份最新正式回覆。 |
| 隊伍報名，再分階段保存草稿及提交作品 | competitions／entries／submissions | 一個 entry 連接報名、隊員及各階段的草稿／提交版本。 |

普通 responses API 沒有草稿、競賽期限或前置階段規則。競賽生成的報名／階段表單必須使用競賽端點；把它送到普通 responses 會得到 `409 competition_form_use_entry_api`。本頁第 2–7 節示範普通表單，第 8 節說明競賽的接法。

先由管理員建立連接應用，保存伺服器端金鑰及表單 id。forms API 只讀取同一應用擁有的表單；不能把一般管理後台建立的任意表單 id 當作應用自己的表單。[取得金鑰及建立定義](README.md)。

## 2．由後端取得表單 JSON

表單已建立時，使用它的 id 讀取定義：

```bash
curl --fail-with-body "$OPENDASHBOARD_BASE_URL/api/apps/v1/forms/$FORM_ID" \
  -H "Authorization: Bearer $OPENDASHBOARD_TEST_KEY"
```

回傳是表單物件本身，沒有外層 form 包裝。下方是節錄的合法 JSON 結構；實際 questions 包含該表單所有題目。

```json
{
  "id": "evt_demo_form_ui",
  "title": "Synthetic workshop signup",
  "description": "Only synthetic data is used in this example.",
  "test": true,
  "created_at": "2026-01-01T00:00:00.000Z",
  "questions": [
    { "key": "program", "title": "課程", "type": "short_text", "required": true, "personal": false },
    { "key": "session", "title": "場次", "type": "dropdown", "options": ["Demo A", "Demo B"], "required": true, "personal": false },
    { "key": "interest", "title": "興趣程度", "type": "number", "min": 0, "max": 5, "integer": true, "unit": "", "required": true, "personal": false },
    {
      "key": "participants", "title": "參加者", "type": "repeating_group",
      "min": 1, "max": 4, "required": false, "personal": false,
      "fields": [
        { "key": "participant_ref", "label": "匿名參加者編號", "type": "text", "required": true, "personal": false },
        { "key": "category", "label": "類別", "type": "single_choice", "options": ["Demo beginner", "Demo advanced"], "required": true, "personal": false }
      ]
    }
  ]
}
```

下載[完整建立表單 JSON](custom-ui-form.json)。它是 POST /forms 的正文，不是某個已存在表單的 id。先在測試環境執行，保存回傳 id，再 GET 取得補齊預設值的完整定義：

```bash
curl --fail-with-body "$OPENDASHBOARD_BASE_URL/api/apps/v1/forms" \
  -H "Authorization: Bearer $OPENDASHBOARD_TEST_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @custom-ui-form.json
```

成功回傳 `201 { "id": "evt_…" }`。首次建立不是冪等操作；如果結果未知，先查核建立結果，不要盲目再次建立。

## 3．把定義對應到自己的 UI

| JSON 設定 | 你的 UI 要怎樣使用 |
| --- | --- |
| key | 答案的固定欄位名。用 program 傳課程答案，不用中文標題「課程」。 |
| title／label | 題目顯示 title；群組子欄位顯示 label，沒有時用 title／key。 |
| required | 標示必填並提示缺漏。群組 min 大於 0 也要求項目，即使 required 是 false。 |
| type | 決定輸入控制及送出的 JSON 型別，見下表。 |
| options | 選項值必須原樣送回；顯示文字相同也不能自行改成數字索引。 |
| min／max | number 是數值範圍；repeating_group 是項目數量。兩者不能混用。 |
| integer／unit | number 的整數要求及顯示單位。群組數值欄位的規則可能在 number 物件，讀 field.number 或 field 本身。 |
| fields | 群組每個項目／結果物件的子欄位。子欄位仍按自己的 key 收集。 |
| personal | 資料能否進入報表的分類；不表示瀏覽器可以把欄位公開給所有訪客。 |

| type | 可自行選擇的 UI | answers 中的值 |
| --- | --- | --- |
| short_text；群組內 text | 單行文字 | 字串，最多 500 字。 |
| paragraph | 多行文字 | 字串，最多 5000 字。 |
| number | 數值輸入 | JSON 數字；保留 0，未填留空或省略，不能把空值轉成 0。 |
| date | 日期輸入 | YYYY-MM-DD 字串。 |
| single_choice／dropdown | 單選按鈕／下拉 | 一個 options 字串。 |
| multiple_choice | 多選核取方塊 | options 字串陣列，沒有選擇用 []；必填時至少一個。 |
| repeating_group | 新增／移除項目的區塊或小表格 | 物件陣列，每個物件按 fields 的 key 保存。 |
| group，限群組子欄位 | 一組固定子欄位 | 一個物件，使用子欄位 key。 |
| labels，限群組／app_result 子欄位 | 多個標籤 | 字串陣列；完整限制見 API 參考。 |
| section／text_block | 標題／說明 | 不傳答案。 |
| hidden | 由網站後端設定 | 文字；不能讓訪客任意指定身份或來源值。 |
| app_result | 顯示應用計算的結果 | fields 定義的物件；成績／計算結果由可信後端產生。 |
| email／phone | 電郵／電話輸入 | 字串，需 identified mode；永遠屬個人資料。 |
| file_upload | 你的附件選擇及上傳流程 | HTTPS URL 字串，需 identified mode；不能直接傳 File、Base64 或 upload id。 |

這個 JSON 不是標準 JSON Schema，也沒有內建的版面、CSS 或 React 元件。可直接按 questions 生成控制，或設計固定頁面再對應 key。題目順序沿用 questions；unknown key 會被拒絕。表單標題、選項、說明及錯誤使用文字方式渲染，避免把內容直接插入 HTML。

hidden、app_result 及其答案只由可信後端設定或核對；只把訪客需要填寫及查看的定義／答案交給瀏覽器。對含 hidden 子欄位的群組也做相同處理，不要只隱藏最外層題目。

## 4．回傳答案的 JSON

下載[完整提交答案 JSON](custom-ui-response.json)。此檔供伺服器端測試，含由後端設定的 respondent 和 hidden 值；實際訪客瀏覽器不應自行選擇這兩項。

```json
{
  "respondent": "demo-contact-ui",
  "answers": {
    "program": "Synthetic workshop",
    "session": "Demo A",
    "interest": 0,
    "preferred_date": "2026-10-15",
    "topics": ["Demo X", "Demo Y"],
    "participants": [
      { "participant_ref": "demo-person-a", "category": "Demo beginner" },
      { "participant_ref": "demo-person-b", "category": "Demo advanced" }
    ],
    "notes": "Synthetic example only.",
    "portal_source": "demo-portal"
  },
  "submitted_at": "2026-01-01T00:00:00Z",
  "replace": true,
  "expected_version": 0
}
```

| 正文字段 | 誰提供／用途 |
| --- | --- |
| respondent | 網站後端從已驗證身份或存取憑證取得的穩定匿名編號，4–64 個英文字、數字、_ 或 -。每次更新沿用；不用姓名或電郵。 |
| answers | 按題目 key 收集的完整答案。array／object 保留結構，不能轉成一個 JSON 文字字串。 |
| submitted_at | 這次操作固定的 ISO 時間。範例時間是合成值；真實新操作記錄當下時間，重試保留原值。 |
| replace | true 表示可取代已有回覆。更新送完整答案，省略的舊答案會被移除。 |
| expected_version | 首次建立用 0；更新用讀回的目前版本。用來拒絕過時修改。 |

```bash
curl --fail-with-body "$OPENDASHBOARD_BASE_URL/api/apps/v1/forms/$FORM_ID/responses" \
  -H "Authorization: Bearer $OPENDASHBOARD_TEST_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @custom-ui-response.json
```

首次保存及新的取代操作成功回傳 `201 { "id": "reg_…", "version": 1 }`；更新後 version 增加。相同答案、submitted_at 及原 expected_version 的精確重試回傳 200 和原 id／version。

以上合成表單只有欄位型別及範圍驗證。額外業務規則，例如不同欄位之間的條件關係，需由你的可信後端處理或使用對應流程 API，不能只寫在瀏覽器。

## 5．瀏覽器和網站後端如何分工

使用者填寫 → 你的網站後端確認存取權、加入可信資料 → OpenDashboard 保存 → 你的網站顯示成功及 version。

可以在你自己的網站提供以下兩個介面；這些是網站自行實作的路徑，不是 OpenDashboard 已提供的端點：

| 自己網站的路徑例子 | 返回／接收甚麼 |
| --- | --- |
| GET /api/my-form | 可填寫的定義、目前使用者獲准查看的答案、目前 version；沒有回覆時 version 為 0。 |
| POST /api/my-form | 接收 answers、expected_version、submitted_at；後端核對身份並加入 respondent、可信 hidden／app_result，轉送 OpenDashboard。 |

網站後端固定允許使用的 FORM_ID；不得接受訪客傳入任意 form id、respondent、OpenDashboard URL 或金鑰。每次讀寫核對登入／私人存取憑證與該回覆的關係，保留原有 CSRF 防護及請求大小限制。應用金鑰識別網站，不會替你識別訪客。

下方是瀏覽器的提交參考。答案由自己的 UI 收集；csrfToken 由網站提供，後端也必須驗證。沒有把 OpenDashboard 金鑰或 respondent 放入前端。提交期間停用按鈕，保留答案；同一次失敗重試沿用同一 submittedAt 和 version。

```javascript
async function sendMyAnswers({ answers, version, submittedAt, csrfToken }) {
  const response = await fetch('/api/my-form', {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify({
      answers,
      expected_version: version,
      submitted_at: submittedAt
    })
  });
  const body = await response.json();
  if (!response.ok) {
    return { saved: false, status: response.status, error: body.error,
      fields: body.fields || {}, message: body.message };
  }
  return { saved: true, id: body.id, version: body.version };
}
```

自己的後端只有收到 OpenDashboard 保存成功才返回成功。網路錯誤／非 JSON 回應時，前端保留輸入及這次操作的時間／版本，提供查核或重試，不顯示已保存。

UI 可以在送出前檢查必填及格式，OpenDashboard 仍會在伺服器再驗證；讀取表單 JSON 不等於獲得其他人的答案。

## 6．修改及讀回自己的資料

後端用 `GET /forms/$FORM_ID/responses?respondent=$RESPONDENT` 讀取已授權的那一隊答案；不存在時 responses 是空陣列。不加 respondent 才回傳全體回覆，目前沒有 cursor 分頁。參數由已核對身份的後端提供，不能接受瀏覽器任意指定另一隊。

```javascript
// Only run on the website server after access.respondent has been authorized.
const response = await fetch(
  process.env.OPENDASHBOARD_BASE_URL + '/api/apps/v1/forms/' +
  encodeURIComponent(process.env.OPENDASHBOARD_FORM_ID) +
  '/responses?respondent=' + encodeURIComponent(access.respondent),
  { headers: { Authorization: 'Bearer ' + process.env.OPENDASHBOARD_APP_KEY } }
);
if (!response.ok) throw new Error('Unable to read the current response');
const payload = await response.json();
const own = payload.responses.find(item => item.respondent === access.respondent);
// Filter hidden/application-owned values before returning permitted answers.
const currentVersion = own ? own.version : 0;
```

access 由你自己的後端登入／存取驗證取得，不從請求正文或網址直接讀 respondent。這段是伺服器參考片段，需接到網站既有的存取檢查及錯誤處理。

修改前載入目前答案和 version。送完整新答案並以該版本作 expected_version；成功後採用返回的新版本。收到 response_changed 時，保存使用者未送出的內容，再讀最新資料讓他核對，不能只提高版本後自動覆寫。

普通表單更新只保存最新回覆。若需保留每次事件原文，可另外使用 append-only records；競賽草稿及作品修訂使用第 8 節的專用端點。

## 7．驗證錯誤及附件

以下錯誤會拒絕整次寫入：

```json
{
  "ok": false,
  "error": "invalid_answers",
  "message": "The response was not saved. Fix these and send it again.",
  "fields": {
    "participants.0.category": "Unknown choice."
  }
}
```

這個路徑對應第 1 個參加者的 category；序號由 0 開始。保留伺服器回傳的完整路徑，對應到自己的欄位，顯示文字錯誤並把焦點移到需修正的位置。message／fields 的文字可能是英文，網站可依 error code 顯示本地語言提示；不要只依英文句子判斷重試。

| 狀態／錯誤 | UI 處理 |
| --- | --- |
| 400 invalid_answers | 顯示 fields，保留答案，修正後重新提交。 |
| 401 invalid_app_key | 後端處理金鑰／應用設定；不能向訪客要求金鑰。 |
| 404 form_not_found | 核對固定表單 id、所屬應用及測試／正式模式。 |
| 409 form_closed／already_submitted | 顯示目前不能提交的原因；已有回覆需先讀取版本。 |
| 409 response_changed | 核對最新答案與自己的修改，不強行覆寫。 |
| 413 request_body_too_large | 縮減請求；JSON 上限 256 KB，檔案不放 JSON。 |
| 429 rate_limited | 後端按 Retry-After 等待，有限次重試。 |
| 5xx／逾時／網路中斷 | 保留操作內容，查核未知結果，再依冪等規則重試。 |

file_upload 的 HTTPS 字串只保存一個檔案網址，不會自動備份檔案，也不是通用上傳元件。要由 OpenDashboard 保存私人 bytes，使用[私人 uploads API](reference.md#section-9)，完成檔案驗證後保存固定 upload id 的關聯，讀取時由已授權後端取得短效下載 URL。upload id 目前不能直接當作普通 file_upload 答案；可在受控的 hidden 欄位或私人 records 中保存關聯。

不要把短效下載 URL 當作永久附件識別，也不要把一般管理表單的檔案 metadata 物件直接搬到 Connected Apps API。兩者格式不同。瀏覽器使用簽名上傳 URL 時，不帶應用金鑰。

## 8．自己的 UI 接競賽及階段提交

競賽仍可使用自己的頁面，但讀寫 entry／submission，不能用普通 responses。所有路徑仍以 /api/apps/v1 開始，由你的網站後端使用應用金鑰呼叫。

| 操作 | 端點及正文 |
| --- | --- |
| 取得報名欄位、隊員及階段 | GET /competitions/:id；讀 entry.fields、entry.members.fields 和 stages 的 questions／window／prerequisites。 |
| 建立隊伍草稿 | POST /competitions/:id/entries；送 respondent、division、answers、members。 |
| 讀回隊伍及各階段狀態 | GET /entries/:id。 |
| 修改隊伍資料 | PUT /entries/:id；送 division、完整 answers、完整 members、上次 revision。 |
| 正式提交報名 | POST /entries/:id/submit；送 revision。 |
| 讀回某階段草稿／作品 | GET /entries/:id/submissions/:stage_key。 |
| 儲存階段草稿 | PUT /entries/:id/submissions/:stage_key；送 answers、revision，首次用 0。 |
| 正式提交作品 | POST /entries/:id/submissions/:stage_key/submit；送剛保存後的 revision。 |
| 改用 OpenDashboard 私人填寫頁 | POST /entries/:id/links；取得 url 後私下交給該隊聯絡人，可設定 expires_at。 |

建立隊伍草稿的正文例子：

```json
{
  "respondent": "demo-contact-ui",
  "division": "demo",
  "answers": { "team_name": "Synthetic team" },
  "members": [
    { "alias": "demo-person-a" },
    { "alias": "demo-person-b" }
  ]
}
```

division、team_name、alias 必須先在該競賽定義宣告，不是可以套在任意競賽的固定欄位。報名 UI 顯示隊伍 fields 和隊員 members.fields，作品 UI 顯示該階段 questions。入口及成員模型與普通 form 的 answers 不同。

保存某階段草稿的正文例子：

```json
{
  "answers": { "work": "Synthetic proposal text." },
  "revision": 0
}
```

work 必須是該階段宣告的 question key。成功保存回傳 submission id 和 revision，再以返回 revision 正式提交。同一 entry 持續更新，網站後端每次核對使用者可管理這支隊伍。

競賽 API 在伺服器檢查報名狀態、開放時間、已提交的前置階段及版本。前置條件未完成時，連保存該階段草稿也會被拒絕。草稿放寬必填答案檢查，但隊伍草稿仍需符合設定隊員人數；提交時檢查完整必填資料。衝突用 entry_changed／submission_changed，而不是普通回覆的 expected_version；需重新讀取核對。私人競賽附件用該 entry 的 uploads API，file 答案保存 upl_ id，不能用普通 file_upload URL 格式。

現有版本同一 respondent 在同一競賽限一隊。可撤銷私人 link 是持有者的存取憑證；自助開始報名、驗證電郵、自動寄出／找回連結仍需另行實作。不要把產生私人連結當作已提供整套登入或自助報名入口。

## 9．交付前驗收

用測試金鑰驗證：GET 的每個 key 對應正確 UI；必填、數值 0、多選及兩位參加者的物件陣列能保存；錯誤指向正確項目；更新沿用 respondent 和版本；重試不增加重複回覆；陌生訪客不能選另一份回覆；金鑰沒有出現在瀏覽器或下載檔案中。

再檢查只有適當的非個人欄位進報表。資料分類、表單建立重試、回覆讀取限制及私人檔案完整規則，見[API 參考](reference.md)及[常見問題](FAQ.md)。每個網站仍需實作自己的身份核對、欄位對應及伺服器接入層；不用為它重新修改 OpenDashboard。

## 哪些情況需要把資料送回原站？

既有整合若仍由原站確認正式答案，維護方式見[原站回寫](intakes.md)。新流程即使外站需要評審，也應由 OpenDashboard 保存答案，再用[流程資料同步](workflow-sync.md)提供副本；不必改用舊式回寫。以 OpenDashboard 為主時，兩個 UI 直接使用同一中央 API，通常不需要回寫；先看[接入方式](integration-modes.md)。公開入口只建立新隊伍，再以私人連結修改；既有資料不會按姓名或電郵被認領。需要配置原站回寫程序及通知，單純匯入或建立表單不會自動啟用。

## 公開入口及管理位置

兩份以上表單需要按步驟串聯時，使用[多步流程](workflows.md#section-8)。在流程管理選擇流程，再到「網站接入」取得公開新申請連結，或由可信後端建立同一 run 的私人連結。原站為正式資料來源時使用[原站回寫入口](intakes.md)。表單管理保留每張表的回覆查看，關聯表單可直接返回所屬流程。
