👉 動態表單 (Schema-driven Forms) 自動生成適用場景
這個不一定是後台系統就會用到,有時候後台系統為了加速查詢,可能會不同查詢頁加上不同的快速帶入功能,這種類型的表單,硬要用動態表單帶入的會就會有無限的特規,所以使用前請確認清楚,如果使用者在驗證方式跟排版比較多樣化,這個就不是你的最佳解。
像是服務評分、心理測驗或是E學院的測驗就可以考慮使用。
換句話說,Schema 就是一份「表單藍圖」,表單只是照著藍圖渲染出來。
假設我們要設計一個問卷表單:
簡易版:
後續的範例會以簡單版的展示
interface SimpleSchema {
type: 'object';
title?: string;
properties: Record<string, {
ui: UIType;
type: 'integer' | 'string' | 'array' | 'boolean';
title: string;
enum?: (number | string)[]; // single/multi/likert 用來渲染選項
description?: string;
weight?: number; // 題目權重,預設 1
scoreValues?: number[]; // 跟 enum 對齊(index 對 index)
// - boolean:若未提供,預設 [0,1] 對應 [false,true]
// - single:選到第 i 個 → scoreValues[i]
// - multi:選到多個 → 全部相加
// - likert:若 enum 是數字,直接取值 * weight;若提供 scoreValues 則優先用 scoreValues
}>;
required?: string[];
}
複雜板:
這個版本差在可以花式驗證,大家有興趣可以自己擴增來試試
type UIType = 'likert' | 'boolean' | 'single' | 'multi';
type ValidatorConfig = {
// 基本
required?: boolean;
email?: boolean;
minLength?: number;
maxLength?: number;
pattern?: string | RegExp;
// 數值/量表
min?: number;
max?: number;
// 複選數量
minSelected?: number;
maxSelected?: number;
// 條件式
requiredIf?: { dependsOn: string; equals: any };
enableIf?: { dependsOn: string; equals: any };
// 自訂 / 非同步(名稱映射到 registry)
customValidators?: string[];
asyncValidators?: string[];
// 覆寫訊息(鍵名對應 Angular 的錯誤 key 或自訂)
messages?: Partial<Record<
| 'required' | 'email' | 'minLength' | 'maxLength' | 'pattern'
| 'min' | 'max' | 'minSelected' | 'maxSelected'
| 'requiredIf' | 'custom' | 'async',
string
>>;
};
interface SimpleSchemaV2 {
type: 'object';
title?: string;
properties: Record<string, {
ui: UIType;
type: 'integer' | 'string' | 'array' | 'boolean';
title: string;
enum?: (number | string)[];
description?: string;
// scoring
weight?: number;
scoreValues?: number[];
// 驗證規則
validators?: ValidatorConfig;
}>;
required?: string[];
// 表單層級驗證(可選)
groupValidators?: Array<{ name: string; options?: any }>;
}
實際專案使用的時候,把資料整理成 properties 裡面的格式,就可以帶入使用了。
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormGroup, FormBuilder, Validators, ReactiveFormsModule, FormControl, ValidatorFn, ValidationErrors, AbstractControl, FormArray } from '@angular/forms';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzRadioModule } from 'ng-zorro-antd/radio';
import { NzSelectModule } from 'ng-zorro-antd/select';
type UIType = 'likert' | 'boolean' | 'single' | 'multi';
interface SimpleSchema {
type: 'object';
title?: string;
properties: Record<string, {
ui: UIType;
type: 'integer' | 'string' | 'array' | 'boolean';
title: string;
enum?: (number | string)[]; // single/multi/likert 用來渲染選項
description?: string;
weight?: number; // 題目權重,預設 1
scoreValues?: number[]; // 跟 enum 對齊(index 對 index)
// - boolean:若未提供,預設 [0,1] 對應 [false,true]
// - single:選到第 i 個 → scoreValues[i]
// - multi:選到多個 → 全部相加
// - likert:若 enum 是數字,直接取值 * weight;若提供 scoreValues 則優先用 scoreValues
}>;
required?: string[];
}
const quizSchema: SimpleSchema = {
type: 'object',
title: '自我管理與專注量表(隨便設的題目)',
properties: {
// Likert:直接取 1~5
q1: { ui: 'likert', type: 'integer', title: '我對未來感到樂觀。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
q2: { ui: 'likert', type: 'integer', title: '面對壓力時,我能快速恢復。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
q3: { ui: 'likert', type: 'integer', title: '我能長時間專注在單一任務上。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
q4: { ui: 'likert', type: 'integer', title: '我擅長清楚表達需求與想法。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
q5: { ui: 'likert', type: 'integer', title: '我會主動尋求資源或協助。', enum: [1, 2, 3, 4, 5], description: '1=非常不同意,5=非常同意' },
// Boolean:未提供 scoreValues → 預設 [false=0, true=1]
q6: { ui: 'boolean', type: 'boolean', title: '我固定在一天中的同一時段處理深度工作。' },
// Single:用 scoreValues 對齊 enum
q7: {
ui: 'single', type: 'string', title: '我安排每日任務的方式',
enum: ['臨時想到才做', '簡單待辦清單', '有優先順序的待辦', '有時間區塊與緩衝'],
scoreValues: [0, 1, 2, 3]
},
// Multi:每勾一項加相對應分數(簡單相加)
q8: {
ui: 'multi', type: 'array', title: '我常用哪些提升專注的方法(可複選)',
enum: ['番茄鐘', '關閉通知', '單一待辦板', '固定安靜場域'],
scoreValues: [1, 1, 1, 1]
},
},
required: ['q1', 'q2', 'q3', 'q4', 'q5', 'q6', 'q8']
};
@Component({
selector: 'app-day25',
standalone: true,
imports: [
CommonModule,
NzCardModule,
NzButtonModule,
NzAlertModule,
ReactiveFormsModule,
NzRadioModule,
NzDividerModule,
NzSelectModule,
NzCheckboxModule,
NzFormModule
],
templateUrl: './day25.component.html',
styleUrl: './day25.component.scss'
})
export class Day25Component {
schema = quizSchema;
propKeys = Object.keys(this.schema.properties);
form: FormGroup;
submitted = false;
total = 0;
resultText = '';
constructor(private fb: FormBuilder) {
this.form = this.buildForm(this.schema);
}
// 讓複選題至少選 1 個的驗證器
private atLeastOneSelected(): ValidatorFn {
return (ctrl: AbstractControl): ValidationErrors | null => {
const v = ctrl.value as unknown;
return Array.isArray(v) && (v as boolean[]).some(Boolean)
? null
: { atLeastOne: true };
};
}
// 依 Schema 建 FormControl(支援 boolean/single/multi/likert)
private buildForm(schema: SimpleSchema): FormGroup {
const group: Record<string, any> = {};
for (const key of Object.keys(schema.properties)) {
const prop = this.schema.properties[key];
const isRequired = !!this.schema.required?.includes(key);
switch (prop.ui) {
case 'multi': {
const opts = (prop.enum || []) as any[];
const controls = opts.map(() => this.fb.nonNullable.control<boolean>(false));
const validators = isRequired ? [this.atLeastOneSelected()] : [];
group[key] = new FormArray<FormControl<boolean>>(controls, validators);
break;
}
case 'boolean': {
group[key] = new FormControl<boolean | null>(
null,
isRequired ? [Validators.required] : []
);
break;
}
case 'single':
case 'likert': {
group[key] = new FormControl<number | string | null>(
null,
isRequired ? [Validators.required] : []
);
break;
}
}
}
return this.fb.group(group);
}
isRequired(key: string): boolean {
return this.schema.required?.includes(key) ?? false;
}
isInvalid(key: string) {
const c = this.form.get(key);
return !!c && c.invalid && (c.dirty || this.submitted);
}
onSubmit() {
this.submitted = true;
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const values = this.form.value as Record<string, any>;
const total = this.propKeys.reduce((acc, k) => acc + this.scoreOf(k, values[k]), 0);
const maxTotal = this.getMaxTotal();
this.total = total;
const pct = maxTotal > 0 ? (total / maxTotal) * 100 : 0;
if (pct >= 80) this.resultText = '整體狀態良好,韌性與自我效能感較高。';
else if (pct >= 60) this.resultText = '中性偏佳,建議持續關注壓力管理與支持系統。';
else this.resultText = '近期可能壓力較高或資源不足,建議適度休息並尋求支持。';
}
private scoreOf(key: string, raw: any): number {
const prop = this.schema.properties[key];
const weight = prop.weight ?? 1;
switch (prop.ui) {
case 'likert': {
// 若有 scoreValues 就用它;否則用 enum 的數值(1~5…)
if (Array.isArray(prop.scoreValues) && prop.enum) {
const idx = (prop.enum as any[]).findIndex(v => v === raw);
const sc = idx >= 0 ? (prop.scoreValues[idx] ?? 0) : 0;
return sc * weight;
}
const n = Number(raw ?? 0);
return (isNaN(n) ? 0 : n) * weight;
}
case 'boolean': {
// 若有 scoreValues(2 個值)就用它;否則預設 false=0, true=1
if (Array.isArray(prop.scoreValues) && prop.scoreValues.length >= 2) {
const idx = raw === true ? 1 : 0; // 假設 scoreValues 對應 [false, true]
return (prop.scoreValues[idx] ?? 0) * weight;
}
return (raw === true ? 1 : 0) * weight;
}
case 'single': {
// 用 scoreValues 對應 enum;沒有就 0 分
if (Array.isArray(prop.scoreValues) && prop.enum) {
const idx = (prop.enum as any[]).findIndex(v => v === raw);
const sc = idx >= 0 ? (prop.scoreValues[idx] ?? 0) : 0;
return sc * weight;
}
return 0;
}
case 'multi': {
const enumVals = (prop.enum || []) as any[];
const sv = prop.scoreValues;
// 1) raw 可能是 boolean[](FormArray),把 true 的索引挑出
let selectedIdxs: number[] = [];
if (Array.isArray(raw) && raw.length && typeof raw[0] === 'boolean') {
selectedIdxs = (raw as boolean[])
.map((b, i) => (b ? i : -1))
.filter(i => i >= 0);
}
// 2) 或者 raw 可能是值的陣列(少見,但保險處理)
else if (Array.isArray(raw)) {
selectedIdxs = (raw as any[])
.map(v => enumVals.findIndex(e => e === v))
.filter(i => i >= 0);
}
// 有 scoreValues:加總被勾選的分數
if (Array.isArray(sv) && sv.length) {
const subtotal = selectedIdxs.reduce((sum, i) => sum + (sv[i] ?? 0), 0);
return subtotal * weight;
}
// 沒定義 scoreValues:預設一個選項 1 分
return selectedIdxs.length * weight;
}
}
return 0;
}
private getMaxTotal(): number {
let max = 0;
for (const key of this.propKeys) {
const prop = this.schema.properties[key];
const weight = prop.weight ?? 1;
switch (prop.ui) {
case 'likert': {
if (Array.isArray(prop.scoreValues) && prop.scoreValues.length) {
max += Math.max(...prop.scoreValues) * weight;
} else {
const nums = (prop.enum || []).filter(e => typeof e === 'number') as number[];
const m = nums.length ? Math.max(...nums) : 0;
max += m * weight;
}
break;
}
case 'boolean': {
if (Array.isArray(prop.scoreValues) && prop.scoreValues.length >= 2) {
max += Math.max(prop.scoreValues[0] ?? 0, prop.scoreValues[1] ?? 0) * weight;
} else {
max += 1 * weight; // 預設 true=1
}
break;
}
case 'single': {
if (Array.isArray(prop.scoreValues) && prop.scoreValues.length) {
max += Math.max(...prop.scoreValues) * weight;
}
// 沒有 scoreValues 則視為資訊題,最大分 0
break;
}
case 'multi': {
if (Array.isArray(prop.scoreValues) && prop.scoreValues.length) {
// 預設最大分:全選
max += prop.scoreValues.reduce((a, b) => a + b, 0) * weight;
} else {
// 沒定義 scoreValues 時,每個選項 1 分 → 全選
max += (prop.enum?.length ?? 0) * weight;
}
break;
}
}
}
return max;
}
// 取得複選題的選項(字串陣列)
getMultiOptions(key: string): string[] {
return (this.schema.properties[key].enum || []) as string[];
}
// 勾選/取消勾選時更新 FormControl(型別是 string[])
onToggleMulti(key: string, val: string, checked: boolean) {
const ctrl = this.form.get(key) as FormControl<string[]>;
const arr = ctrl.value ?? [];
const next = checked ? Array.from(new Set([...arr, val])) : arr.filter(x => x !== val);
ctrl.setValue(next);
ctrl.markAsDirty();
ctrl.updateValueAndValidity();
}
}
<div>
<nz-card [nzTitle]="schema.title">
<form nz-form [formGroup]="form" (ngSubmit)="onSubmit()">
<ng-container *ngFor="let key of propKeys">
<nz-form-item>
<!-- 標籤區:標題 + 必填星號 + 描述 -->
<nz-form-label [nzRequired]="isRequired(key)" [nzFor]="key">
{{ schema.properties[key].title }}
<div *ngIf="schema.properties[key].description">
{{ schema.properties[key].description }}
</div>
</nz-form-label>
<!-- 控制區:綁定驗證狀態與錯誤提示 -->
<nz-form-control [nzErrorTip]="'此題必填'" [nzValidateStatus]="form.controls[key]">
<ng-container [ngSwitch]="schema.properties[key].ui">
<!-- Likert:1~5 -->
<ng-container *ngSwitchCase="'likert'">
<nz-radio-group [formControlName]="key" [id]="key">
<label nz-radio *ngFor="let v of schema.properties[key].enum" [nzValue]="v">
{{ v }}
</label>
</nz-radio-group>
</ng-container>
<!-- 是非題 -->
<ng-container *ngSwitchCase="'boolean'">
<nz-radio-group [formControlName]="key" [id]="key">
<label nz-radio [nzValue]="true">是</label>
<label nz-radio [nzValue]="false">否</label>
</nz-radio-group>
</ng-container>
<!-- 單選題 -->
<ng-container *ngSwitchCase="'single'">
<nz-radio-group [formControlName]="key" [id]="key">
<label nz-radio *ngFor="let v of schema.properties[key].enum" [nzValue]="v">
{{ v }}
</label>
</nz-radio-group>
</ng-container>
<!-- 複選題(FormArray) -->
<ng-container *ngSwitchCase="'multi'">
<div [formArrayName]="key" [id]="key">
<label nz-checkbox *ngFor="let v of getMultiOptions(key); let i = index" [formControlName]="i">
{{ v }}
</label>
</div>
</ng-container>
</ng-container>
</nz-form-control>
</nz-form-item>
<nz-divider></nz-divider>
</ng-container>
<div>
<button nz-button nzType="primary" [disabled]="form.invalid">送出</button>
</div>
</form>
<nz-divider></nz-divider>
<nz-alert *ngIf="submitted && form.valid" nzType="success" nzShowIcon [nzMessage]="'總分:' + total + ' 分'"
[nzDescription]="resultText">
</nz-alert>
</nz-card>
</div>