跳至主要内容

表單處理

本指南說明如何在花店系統中處理表單。

表單工具

花店系統使用以下工具處理表單:

  • React Hook Form - 表單狀態管理
  • AppFuse Web Form Schema - 表單驗證(框架內建)
  • AppFuse Web 表單元件 - UI 元件(整合 React Hook Form)

Import 路徑

// 表單元件與驗證
import { Input, Select, Textarea, Toggle, MediaInput, validatorResolver, schema } from '@appfuse/appfuse-web/form';

// 布局與按鈕
import { Button, CollapsibleCard } from '@appfuse/appfuse-web/components';

// 工具函數
import { useTranslation, logger } from '@appfuse/appfuse-web/utils';

基本表單

簡單範例

import { useForm } from 'react-hook-form';
import { Input, validatorResolver, schema } from '@appfuse/appfuse-web/form';
import { Button } from '@appfuse/appfuse-web/components';
import { productService } from '@/services/sales/product-service';

// 定義 Schema(使用框架的 schema)
const productSchema = schema.object({
name: schema.string().required(),
basePrice: schema.number().required(),
stock: schema.number().required(),
});

// 從 Schema 推導類型
type ProductFormData = typeof productSchema.__infer;

function ProductForm() {
const {
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<ProductFormData>({
resolver: validatorResolver(productSchema),
});

const onSubmit = async (data: ProductFormData) => {
try {
await productService.create(data);
// 顯示成功訊息
} catch (error) {
// 處理錯誤
}
};

return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* 框架元件已整合 React Hook Form,直接傳 control 和 name */}
<Input
name="name"
control={control}
label="商品名稱"
placeholder="請輸入商品名稱"
required
/>

<Input
name="basePrice"
control={control}
type="number"
label="價格"
placeholder="0"
required
/>

<Input
name="stock"
control={control}
type="number"
label="庫存"
placeholder="0"
required
/>

<Button
type="submit"
variant="primary"
loading={isSubmitting}
disabled={isSubmitting}
>
儲存
</Button>
</form>
);
}

重要說明

  • 框架表單元件已內建 React Hook Form 整合
  • 不需要使用 {...register()}<Controller> wrapper
  • 直接傳遞 controlname props
  • 驗證錯誤會自動顯示在元件下方

複雜表單

下拉選單

import { Select, schema, validatorResolver } from '@appfuse/appfuse-web/form';
import { useTranslation } from '@appfuse/appfuse-web/utils';
import { ProductCategory } from '@/mocks/types';

const productSchema = schema.object({
name: schema.string().required(),
category: schema.string().required(),
});

type ProductFormData = typeof productSchema.__infer;

function ProductForm() {
const { t } = useTranslation();
const { control, handleSubmit } = useForm<ProductFormData>({
resolver: validatorResolver(productSchema),
});

// 分類選項
const categoryOptions = [
{ value: ProductCategory.BOUQUET, label: t('Bouquet') },
{ value: ProductCategory.POT, label: t('Potted Plant') },
{ value: ProductCategory.BASKET, label: t('Basket') },
];

return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 不需要 Controller wrapper,直接使用 Select */}
<Select
name="category"
control={control}
label="分類"
placeholder="請選擇分類"
options={categoryOptions}
valueAccessor="value"
textAccessor="label"
required
/>
</form>
);
}

查看完整範例:app-web/src/applets/product-applet/product-form.tsx

文字區域

import { Textarea, schema, validatorResolver } from '@appfuse/appfuse-web/form';

const productSchema = schema.object({
name: schema.string().required(),
description: schema.string(),
});

type ProductFormData = typeof productSchema.__infer;

function ProductForm() {
const { control } = useForm<ProductFormData>({
resolver: validatorResolver(productSchema),
});

return (
<form>
<Textarea
name="description"
control={control}
label="商品描述"
placeholder="請輸入商品描述"
minRows={3}
/>
</form>
);
}

圖片上傳(MediaInput)

框架提供強大的 MediaInput 元件,支援多檔案、拖放、預覽、縮放等功能。

import { MediaInput, schema, validatorResolver } from '@appfuse/appfuse-web/form';
import { useTranslation } from '@appfuse/appfuse-web/utils';

const productSchema = schema.object({
images: schema.array(schema.any()),
});

type ProductFormData = typeof productSchema.__infer;

function ProductForm() {
const { t } = useTranslation();
const { control } = useForm<ProductFormData>({
resolver: validatorResolver(productSchema),
});

return (
<form>
{/* MediaInput 自動處理預覽、拖放、多檔案等功能 */}
<MediaInput
name="images"
control={control}
multiple
maxFiles={6}
accept="image/jpeg,image/png,image/webp"
maxSize={10 * 1024 * 1024}
displayMode="thumbnail"
dragAndDrop
zoomable
fullscreenable
label={t('Product Images')}
helperText={t('First image will be the main image. Max 6 images.')}
uploadText={t('Click to upload or drag and drop')}
uploadHint={t('Supports JPEG, PNG, WebP (max 10MB)')}
/>
</form>
);
}

MediaInput 功能:

  • ✅ 多檔案上傳(multiple
  • ✅ 拖放上傳(dragAndDrop
  • ✅ 自動預覽(displayMode="thumbnail"
  • ✅ 圖片縮放(zoomable
  • ✅ 全螢幕檢視(fullscreenable
  • ✅ 檔案大小限制(maxSize
  • ✅ 檔案類型限制(accept
  • ✅ 自動與 API 客戶端整合(支援 binary-separate 上傳模式)

查看完整範例:app-web/src/applets/product-applet/product-form.tsx

動態欄位

陣列欄位

import { useFieldArray } from 'react-hook-form';
import { Input, Select, schema, validatorResolver } from '@appfuse/appfuse-web/form';
import { Button } from '@appfuse/appfuse-web/components';

const orderSchema = schema.object({
items: schema.array(
schema.object({
productId: schema.string().required(),
quantity: schema.number().required(),
})
),
});

type OrderFormData = typeof orderSchema.__infer;

function OrderForm() {
const { control } = useForm<OrderFormData>({
resolver: validatorResolver(orderSchema),
});

const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});

return (
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4">
{/* 框架元件不需要 Controller,直接傳 control 和 name */}
<Select
name={`items.${index}.productId`}
control={control}
label="商品"
options={products}
valueAccessor="value"
textAccessor="label"
/>

<Input
name={`items.${index}.quantity`}
control={control}
type="number"
label="數量"
placeholder="1"
/>

<Button
type="button"
color="danger"
onClick={() => remove(index)}
>
移除
</Button>
</div>
))}

<Button
type="button"
color="secondary"
onClick={() => append({ productId: '', quantity: 1 })}
>
新增商品
</Button>
</div>
);
}

表單驗證

驗證策略

花店系統採用前端輕量驗證策略:

/**
* 商品表單驗證 Schema
*
* 設計原則:前端只做類型檢查與必填驗證,格式/長度/範圍驗證由後端處理
*/
export const productFormSchema = schema.object({
name: schema.string().required(), // ✅ 只檢查必填
category: schema.string().required(), // ✅ 只檢查必填
basePrice: schema.number().required(), // ✅ 只檢查類型與必填
costPrice: schema.number(), // ✅ 選填
stock: schema.number().required(), // ✅ 只檢查類型與必填
// ... 其他欄位
});

原因:

  • 前端驗證可能與後端不同步
  • 後端擁有完整的業務規則與資料庫約束
  • 減少前端程式碼複雜度
  • 錯誤訊息由後端統一提供(多語系、一致性)

處理後端驗證錯誤

當後端回傳驗證錯誤時,使用 setError 顯示在表單中:

import { useForm } from 'react-hook-form';
import { Input, validatorResolver, schema } from '@appfuse/appfuse-web/form';
import { Button } from '@appfuse/appfuse-web/components';
import { ErrorResponse } from '@appfuse/appfuse-web/utils';

function ProductForm() {
const { control, handleSubmit, setError } = useForm<ProductFormData>({
resolver: validatorResolver(productSchema),
});

const onSubmit = async (data: ProductFormData) => {
try {
await productService.create(data);
} catch (error) {
if (error instanceof ErrorResponse) {
// 處理後端驗證錯誤
if (error.status === 400 && error.fieldErrors) {
// 將後端欄位錯誤設定到對應表單欄位
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field as keyof ProductFormData, {
type: 'server',
message: message as string,
});
});
} else if (error.status === 409) {
// 資源衝突(如 SKU 已存在)
setError('sku', {
type: 'server',
message: error.message || 'SKU 已存在',
});
}
}
}
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input
name="name"
control={control}
label="商品名稱"
required
/>
{/* 錯誤會自動顯示在元件下方 */}
</form>
);
}

表單狀態管理

預設值

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { productService } from '@/services/sales/product-service';

function ProductEditForm({ productId }: { productId: string }) {
const { control, reset } = useForm<ProductFormData>();

useEffect(() => {
const loadProduct = async () => {
const product = await productService.get(productId);
reset({
name: product.name,
category: product.category,
basePrice: product.basePrice,
stock: product.stock,
});
};
loadProduct();
}, [productId, reset]);

return <form>{/* ... */}</form>;
}

查看完整範例:app-web/src/applets/product-applet/product-editor.tsx

髒值檢測

function ProductForm() {
const { formState: { isDirty, dirtyFields } } = useForm();

const handleCancel = () => {
if (isDirty) {
const confirm = window.confirm('有未儲存的變更,確定要離開嗎?');
if (!confirm) return;
}
navigate('/products');
};

return (
<form>
{/* ... */}
<Button onClick={handleCancel}>取消</Button>
</form>
);
}

表單重置

function ProductForm() {
const { reset } = useForm();

const onSubmit = async (data: ProductFormData) => {
await productService.create(data);
reset(); // 清空表單
};

return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
}

錯誤處理

顯示 API 錯誤

import { ErrorResponse } from '@appfuse/appfuse-web/utils';

function ProductForm() {
const { control, handleSubmit, setError } = useForm<ProductFormData>();

const onSubmit = async (data: ProductFormData) => {
try {
await productService.create(data);
} catch (error) {
if (error instanceof ErrorResponse) {
if (error.status === 409) {
// 資源衝突
setError('root', {
type: 'server',
message: error.message || '資料衝突,請稍後再試',
});
} else if (error.status === 400 && error.fieldErrors) {
// 欄位驗證錯誤
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field as keyof ProductFormData, {
type: 'server',
message: message as string,
});
});
}
}
}
};

return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
}

對話框中的表單

import { prompt } from '@appfuse/appfuse-web';
import { Input, validatorResolver, schema } from '@appfuse/appfuse-web/form';
import { Button } from '@appfuse/appfuse-web/components';

const productSchema = schema.object({
name: schema.string().required(),
basePrice: schema.number().required(),
});

type ProductFormData = typeof productSchema.__infer;

function ProductCreateDialog({ open, onClose }: DialogProps) {
const { control, handleSubmit, reset } = useForm<ProductFormData>({
resolver: validatorResolver(productSchema),
});

const onSubmit = async (data: ProductFormData) => {
try {
await productService.create(data);
reset();
onClose();
} catch (error) {
// 處理錯誤
}
};

return (
<prompt.Dialog
open={open}
onClose={onClose}
title="新增商品"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
name="name"
control={control}
label="商品名稱"
required
/>

<Input
name="basePrice"
control={control}
type="number"
label="價格"
required
/>

<div className="flex justify-end gap-2">
<Button color="secondary" onClick={onClose}>
取消
</Button>
<Button type="submit" color="primary">
儲存
</Button>
</div>
</form>
</prompt.Dialog>
);
}

最佳實踐

1. 使用 Schema 型別推論

推薦

import { schema } from '@appfuse/appfuse-web/form';

const productSchema = schema.object({
name: schema.string().required()
});

type FormData = typeof productSchema.__infer;

避免

interface FormData { name: string; }
const productSchema = schema.object({ name: schema.string() });

2. 集中管理 Schema

// applets/product-applet/types.ts
import { schema } from '@appfuse/appfuse-web/form';

export const productFormSchema = schema.object({
name: schema.string().required(),
basePrice: schema.number().required(),
// ...
});

export type ProductFormData = typeof productFormSchema.__infer;

3. 復用表單組件

// applets/product-applet/product-form.tsx
import { Input, Select } from '@appfuse/appfuse-web/form';
import { Control } from 'react-hook-form';

interface ProductFormFieldsProps {
control: Control<ProductFormData>;
}

export function ProductFormFields({ control }: ProductFormFieldsProps) {
return (
<>
<Input
name="name"
control={control}
label="商品名稱"
required
/>
<Input
name="basePrice"
control={control}
type="number"
label="價格"
required
/>
</>
);
}

// 在不同表單中使用
function ProductCreateForm() {
const { control } = useForm<ProductFormData>();

return (
<form>
<ProductFormFields control={control} />
</form>
);
}

4. 使用 CollapsibleCard 組織表單

import { CollapsibleCard } from '@appfuse/appfuse-web/components';
import { Input } from '@appfuse/appfuse-web/form';

function ProductForm() {
return (
<form className="space-y-2">
{/* 基本資訊 */}
<CollapsibleCard title="基本資訊">
<Input name="name" control={control} label="商品名稱" />
<Input name="category" control={control} label="分類" />
</CollapsibleCard>

{/* 價格設定 */}
<CollapsibleCard title="價格設定">
<Input name="basePrice" control={control} label="售價" />
<Input name="costPrice" control={control} label="成本價" />
</CollapsibleCard>
</form>
);
}

查看完整範例:app-web/src/applets/product-applet/product-form.tsx

實際範例

查看專案中的完整表單實作:

表單組件:

  • app-web/src/applets/product-applet/product-form.tsx - 完整的商品表單實作
    • 使用 CollapsibleCard 組織表單區塊
    • MediaInput 多圖片上傳
    • 支援草稿自動儲存
    • 支援 dirtyFields 追蹤變更

表單編輯器:

  • app-web/src/applets/product-applet/product-editor.tsx - 表單編輯頁面
    • 創建與編輯模式
    • 草稿機制整合
    • 錯誤處理

Schema 定義:

  • app-web/src/applets/product-applet/types.ts - Schema 與類型定義

其他範例:

  • app-web/src/applets/order-applet/components/order-form.tsx - 訂單表單
  • app-web/src/applets/customer-applet/components/individual-customer-form.tsx - 客戶表單

下一步