問題描述

在鴻蒙應用開發中,如何實現優雅的表單驗證?如何處理各類用户輸入?本文以添加記錄頁面為例,講解完整的表單處理方案。

技術要點

  • TextInput 輸入控制
  • 實時驗證與提示
  • 正則表達式驗證
  • 輸入格式化
  • 表單狀態管理

完整實現代碼

/**
 * 表單驗證工具類
 */
export class FormValidator {
  /**
   * 驗證金額
   */
  public static validateAmount(amount: string): { valid: boolean; message: string } {
    if (!amount || amount.trim() === '') {
      return { valid: false, message: '請輸入金額' };
    }
​
    const num = parseFloat(amount);
    if (isNaN(num)) {
      return { valid: false, message: '請輸入有效的數字' };
    }
​
    if (num <= 0) {
      return { valid: false, message: '金額必須大於0' };
    }
​
    if (num > 999999) {
      return { valid: false, message: '金額不能超過999999' };
    }
​
    // 檢查小數位數
    const decimalPart = amount.split('.')[1];
    if (decimalPart && decimalPart.length > 2) {
      return { valid: false, message: '最多支持2位小數' };
    }
​
    return { valid: true, message: '' };
  }
​
  /**
   * 驗證姓名
   */
  public static validateName(name: string): { valid: boolean; message: string } {
    if (!name || name.trim() === '') {
      return { valid: false, message: '請輸入姓名' };
    }
​
    if (name.trim().length < 2) {
      return { valid: false, message: '姓名至少2個字符' };
    }
​
    if (name.trim().length > 20) {
      return { valid: false, message: '姓名不能超過20個字符' };
    }
​
    // 只允許中文、英文、數字
    const namePattern = /^[\u4e00-\u9fa5a-zA-Z0-9\s]+$/;
    if (!namePattern.test(name.trim())) {
      return { valid: false, message: '姓名只能包含中文、英文、數字' };
    }
​
    return { valid: true, message: '' };
  }
​
  /**
   * 驗證手機號
   */
  public static validatePhone(phone: string): { valid: boolean; message: string } {
    if (!phone || phone.trim() === '') {
      return { valid: true, message: '' }; // 手機號可選
    }
​
    const phonePattern = /^1[3-9]\d{9}$/;
    if (!phonePattern.test(phone.trim())) {
      return { valid: false, message: '請輸入正確的手機號' };
    }
​
    return { valid: true, message: '' };
  }
​
  /**
   * 驗證備註長度
   */
  public static validateRemark(remark: string): { valid: boolean; message: string } {
    if (remark && remark.length > 200) {
      return { valid: false, message: '備註不能超過200字' };
    }
​
    return { valid: true, message: '' };
  }
​
  /**
   * 格式化金額輸入
   */
  public static formatAmountInput(input: string): string {
    // 只保留數字和小數點
    let formatted = input.replace(/[^\d.]/g, '');
    
    // 只保留第一個小數點
    const parts = formatted.split('.');
    if (parts.length > 2) {
      formatted = parts[0] + '.' + parts.slice(1).join('');
    }
    
    // 限制小數位數為2位
    if (parts.length === 2 && parts[1].length > 2) {
      formatted = parts[0] + '.' + parts[1].substring(0, 2);
    }
    
    return formatted;
  }
​
  /**
   * 格式化手機號輸入
   */
  public static formatPhoneInput(input: string): string {
    // 只保留數字
    let formatted = input.replace(/\D/g, '');
    
    // 限制11位
    if (formatted.length > 11) {
      formatted = formatted.substring(0, 11);
    }
    
    return formatted;
  }
}
​
/**
 * 添加記錄頁面 - 完整表單驗證示例
 */
@Entry
@Component
struct AddRecordPage {
  // 表單字段
  @State amount: string = '';
  @State personName: string = '';
  @State phone: string = '';
  @State location: string = '';
  @State remark: string = '';
  
  // 驗證錯誤信息
  @State amountError: string = '';
  @State nameError: string = '';
  @State phoneError: string = '';
  @State remarkError: string = '';
  
  // 表單狀態
  @State formValid: boolean = false;
  @State submitDisabled: boolean = true;
  @State loading: boolean = false;
​
  /**
   * 金額輸入變化
   */
  private onAmountChange(value: string) {
    // 格式化輸入
    const formatted = FormValidator.formatAmountInput(value);
    this.amount = formatted;
    
    // 實時驗證
    const result = FormValidator.validateAmount(formatted);
    this.amountError = result.message;
    
    // 更新表單狀態
    this.updateFormValidity();
  }
​
  /**
   * 姓名輸入變化
   */
  private onNameChange(value: string) {
    this.personName = value;
    
    // 實時驗證
    const result = FormValidator.validateName(value);
    this.nameError = result.message;
    
    this.updateFormValidity();
  }
​
  /**
   * 手機號輸入變化
   */
  private onPhoneChange(value: string) {
    // 格式化輸入
    const formatted = FormValidator.formatPhoneInput(value);
    this.phone = formatted;
    
    // 實時驗證
    const result = FormValidator.validatePhone(formatted);
    this.phoneError = result.message;
    
    this.updateFormValidity();
  }
​
  /**
   * 備註輸入變化
   */
  private onRemarkChange(value: string) {
    this.remark = value;
    
    // 實時驗證
    const result = FormValidator.validateRemark(value);
    this.remarkError = result.message;
    
    this.updateFormValidity();
  }
​
  /**
   * 更新表單有效性
   */
  private updateFormValidity() {
    const amountValid = FormValidator.validateAmount(this.amount).valid;
    const nameValid = FormValidator.validateName(this.personName).valid;
    const phoneValid = FormValidator.validatePhone(this.phone).valid;
    const remarkValid = FormValidator.validateRemark(this.remark).valid;
    
    this.formValid = amountValid && nameValid && phoneValid && remarkValid;
    this.submitDisabled = !this.formValid;
  }
​
  /**
   * 提交表單
   */
  private async submitForm() {
    // 最終驗證
    if (!this.validateForm()) {
      promptAction.showToast({
        message: '請檢查表單填寫',
        duration: 2000
      });
      return;
    }
​
    try {
      this.loading = true;
      
      // 保存數據邏輯
      await this.saveRecord();
      
      promptAction.showToast({
        message: '保存成功',
        duration: 2000
      });
      
      setTimeout(() => {
        router.back();
      }, 1000);
    } catch (error) {
      promptAction.showToast({
        message: '保存失敗',
        duration: 2000
      });
    } finally {
      this.loading = false;
    }
  }
​
  /**
   * 驗證整個表單
   */
  private validateForm(): boolean {
    // 驗證金額
    const amountResult = FormValidator.validateAmount(this.amount);
    if (!amountResult.valid) {
      this.amountError = amountResult.message;
      return false;
    }
​
    // 驗證姓名
    const nameResult = FormValidator.validateName(this.personName);
    if (!nameResult.valid) {
      this.nameError = nameResult.message;
      return false;
    }
​
    // 驗證手機號
    const phoneResult = FormValidator.validatePhone(this.phone);
    if (!phoneResult.valid) {
      this.phoneError = phoneResult.message;
      return false;
    }
​
    // 驗證備註
    const remarkResult = FormValidator.validateRemark(this.remark);
    if (!remarkResult.valid) {
      this.remarkError = remarkResult.message;
      return false;
    }
​
    return true;
  }
​
  private async saveRecord() {
    // 保存邏輯
  }
​
  build() {
    Column() {
      // 導航欄
      this.buildHeader()
​
      // 表單內容
      Scroll() {
        Column() {
          // 金額輸入
          this.buildAmountInput()
​
          // 姓名輸入
          this.buildNameInput()
​
          // 手機號輸入
          this.buildPhoneInput()
​
          // 地點輸入
          this.buildLocationInput()
​
          // 備註輸入
          this.buildRemarkInput()
        }
        .padding(16)
      }
      .layoutWeight(1)
​
      // 提交按鈕
      this.buildSubmitButton()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
​
  @Builder
  buildHeader() {
    Row() {
      Image($r('app.media.ic_back'))
        .width(24)
        .height(24)
        .onClick(() => router.back())
​
      Text('添加記錄')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin({ left: 16 })
    }
    .width('100%')
    .height(60)
    .padding({ left: 20, right: 20 })
    .backgroundColor('#FA8C16')
  }
​
  /**
   * 金額輸入框
   */
  @Builder
  buildAmountInput() {
    Column() {
      Row() {
        Text('金額')
          .fontSize(16)
          .fontColor('#262626')
        
        Text('*')
          .fontSize(16)
          .fontColor('#FF4D4F')
          .margin({ left: 4 })
      }
      .margin({ bottom: 8 })
​
      TextInput({ text: this.amount, placeholder: '請輸入金額' })
        .type(InputType.Number)
        .fontSize(16)
        .placeholderColor('#BFBFBF')
        .backgroundColor('#FFFFFF')
        .borderRadius(8)
        .padding({ left: 12, right: 12 })
        .height(48)
        .onChange((value: string) => {
          this.onAmountChange(value);
        })
        .onSubmit(() => {
          // 回車時驗證
          const result = FormValidator.validateAmount(this.amount);
          this.amountError = result.message;
        })
​
      // 錯誤提示
      if (this.amountError) {
        Text(this.amountError)
          .fontSize(12)
          .fontColor('#FF4D4F')
          .margin({ top: 4 })
      }
​
      // 快捷金額
      this.buildQuickAmounts()
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 16 })
  }
​
  /**
   * 快捷金額選擇
   */
  @Builder
  buildQuickAmounts() {
    Row() {
      ForEach([100, 200, 500, 1000], (amount: number) => {
        Button(amount.toString())
          .fontSize(14)
          .fontColor('#595959')
          .backgroundColor('#F5F5F5')
          .borderRadius(16)
          .padding({ left: 16, right: 16, top: 6, bottom: 6 })
          .onClick(() => {
            this.amount = amount.toString();
            this.onAmountChange(this.amount);
          })
      })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .margin({ top: 12 })
  }
​
  /**
   * 姓名輸入框
   */
  @Builder
  buildNameInput() {
    Column() {
      Row() {
        Text('姓名')
          .fontSize(16)
          .fontColor('#262626')
        
        Text('*')
          .fontSize(16)
          .fontColor('#FF4D4F')
          .margin({ left: 4 })
      }
      .margin({ bottom: 8 })
​
      TextInput({ text: this.personName, placeholder: '請輸入姓名' })
        .fontSize(16)
        .placeholderColor('#BFBFBF')
        .backgroundColor('#FFFFFF')
        .borderRadius(8)
        .padding({ left: 12, right: 12 })
        .height(48)
        .maxLength(20)
        .onChange((value: string) => {
          this.onNameChange(value);
        })
​
      if (this.nameError) {
        Text(this.nameError)
          .fontSize(12)
          .fontColor('#FF4D4F')
          .margin({ top: 4 })
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 16 })
  }
​
  /**
   * 手機號輸入框
   */
  @Builder
  buildPhoneInput() {
    Column() {
      Text('手機號')
        .fontSize(16)
        .fontColor('#262626')
        .margin({ bottom: 8 })
​
      TextInput({ text: this.phone, placeholder: '請輸入手機號(可選)' })
        .type(InputType.PhoneNumber)
        .fontSize(16)
        .placeholderColor('#BFBFBF')
        .backgroundColor('#FFFFFF')
        .borderRadius(8)
        .padding({ left: 12, right: 12 })
        .height(48)
        .maxLength(11)
        .onChange((value: string) => {
          this.onPhoneChange(value);
        })
​
      if (this.phoneError) {
        Text(this.phoneError)
          .fontSize(12)
          .fontColor('#FF4D4F')
          .margin({ top: 4 })
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 16 })
  }
​
  /**
   * 地點輸入框
   */
  @Builder
  buildLocationInput() {
    Column() {
      Text('地點')
        .fontSize(16)
        .fontColor('#262626')
        .margin({ bottom: 8 })
​
      TextInput({ text: this.location, placeholder: '請輸入地點(可選)' })
        .fontSize(16)
        .placeholderColor('#BFBFBF')
        .backgroundColor('#FFFFFF')
        .borderRadius(8)
        .padding({ left: 12, right: 12 })
        .height(48)
        .onChange((value: string) => {
          this.location = value;
        })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 16 })
  }
​
  /**
   * 備註輸入框
   */
  @Builder
  buildRemarkInput() {
    Column() {
      Row() {
        Text('備註')
          .fontSize(16)
          .fontColor('#262626')
        
        Text(`${this.remark.length}/200`)
          .fontSize(12)
          .fontColor('#8C8C8C')
          .margin({ left: 8 })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 8 })
​
      TextArea({ text: this.remark, placeholder: '請輸入備註(可選)' })
        .fontSize(16)
        .placeholderColor('#BFBFBF')
        .backgroundColor('#FFFFFF')
        .borderRadius(8)
        .padding(12)
        .height(100)
        .maxLength(200)
        .onChange((value: string) => {
          this.onRemarkChange(value);
        })
​
      if (this.remarkError) {
        Text(this.remarkError)
          .fontSize(12)
          .fontColor('#FF4D4F')
          .margin({ top: 4 })
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 16 })
  }
​
  /**
   * 提交按鈕
   */
  @Builder
  buildSubmitButton() {
    Button(this.loading ? '保存中...' : '保存')
      .width('90%')
      .height(48)
      .fontSize(16)
      .fontColor('#FFFFFF')
      .backgroundColor(this.submitDisabled ? '#D9D9D9' : '#FA8C16')
      .borderRadius(24)
      .margin({ bottom: 16 })
      .enabled(!this.submitDisabled && !this.loading)
      .onClick(() => {
        this.submitForm();
      })
  }
}

核心技術點

1. TextInput 類型

.type(InputType.Number)      // 數字鍵盤
.type(InputType.PhoneNumber)  // 電話鍵盤
.type(InputType.Email)        // 郵箱鍵盤

2. 輸入限制

.maxLength(20)           // 最大長度
.onChange((value) => {}) // 實時監聽
.onSubmit(() => {})      // 回車提交

3. 正則表達式驗證

// 手機號
/^1[3-9]\d{9}$/
​
// 姓名(中英文數字)
/^[\u4e00-\u9fa5a-zA-Z0-9\s]+$/
​
// 金額(最多2位小數)
/^\d+(\.\d{1,2})?$/

最佳實踐

1. 實時驗證 + 提交驗證

// 實時驗證: 輸入時提示
.onChange((value) => {
  this.validate(value);
})
​
// 提交驗證: 最終檢查
submitForm() {
  if (!this.validateForm()) {
    return;
  }
}

2. 輸入格式化

// 自動格式化,提升用户體驗
formatAmountInput(input: string): string {
  return input.replace(/[^\d.]/g, '');
}

3. 錯誤提示

if (this.amountError) {
  Text(this.amountError)
    .fontSize(12)
    .fontColor('#FF4D4F')
}

4. 按鈕狀態控制

.enabled(!this.submitDisabled && !this.loading)
.backgroundColor(this.submitDisabled ? '#D9D9D9' : '#FA8C16')

總結

完整的表單驗證方案:

  • ✅ 實時驗證與提示
  • ✅ 輸入格式化
  • ✅ 正則表達式驗證
  • ✅ 表單狀態管理
  • ✅ 用户體驗優化

相關資源

  • 鴻蒙學習資源