第38篇:通用个人信息组件——collect_personal_info

img

一、引言

个人信息编辑是几乎每个应用都需要的功能,包括头像、昵称、手机号、出生日期等信息的编辑和展示。DriverLicenseExam 项目的 collect_personal_info 组件将这些功能封装为可复用的组件集合,支持多种输入类型和表单校验。本文将深入解析其实现。

二、组件架构

2.1 目录结构

collect_personal_info/
  ├── src/main/ets/
  │   ├── common/
  │   │   ├── ConfigModel.ets        ← 配置模型
  │   │   ├── Constant.ets           ← 常量
  │   │   ├── CustomModifier.ets     ← 自定义修饰器
  │   │   ├── DataModel.ets          ← 数据模型
  │   │   ├── ErrorMsgMap.ets        ← 错误信息映射
  │   │   ├── FormModel.ets          ← 表单模型
  │   │   ├── GridRowColSetting.ets  ← 网格布局设置
  │   │   ├── Util.ets               ← 工具类
  │   │   └── WindowModel.ets        ← 窗口模型
  │   ├── components/
  │   │   ├── ChooseAvatar.ets       ← 头像选择
  │   │   ├── ChooseDate.ets         ← 日期选择
  │   │   ├── EnterPhoneNumber.ets   ← 手机号输入
  │   │   ├── EnterTextArea.ets      ← 多行文本输入
  │   │   ├── EnterTextInput.ets     ← 单行文本输入
  │   │   └── SelectDialog.ets       ← 选择弹窗
  │   ├── viewmodels/
  │   │   └── PersonalInfoViewVM.ets ← 个人信息 ViewModel
  │   └── views/
  │       ├── PersonalInfoView.ets   ← 个人信息视图
  │       └── ListCard.ets           ← 列表卡片
  ├── Index.ets                      ← 模块导出
  └── oh-package.json5               ← 模块配置

2.2 组件关系

PersonalInfoView(主视图)
  ├── ListCard(列表卡片容器)
  │   ├── ChooseAvatar(头像编辑)
  │   ├── EnterTextInput(昵称编辑)
  │   ├── ChooseDate(出生日期)
  │   ├── EnterPhoneNumber(手机号)
  │   └── EnterTextArea(个人简介)
  └── SelectDialog(选择弹窗)

三、头像编辑

3.1 ChooseAvatar 组件

// ChooseAvatar.ets
@ComponentV2
export struct ChooseAvatar {
  @Local avatar: Resource = $r('app.media.user_avatar');
  @Local showActionSheet: boolean = false;

  build() {
    Column() {
      Stack() {
        // 头像图片
        Image(this.avatar)
          .width(80).height(80)
          .borderRadius(40)
          .objectFit(ImageFit.Cover);

        // 编辑图标
        Image($r('app.media.ic_edit_avatar'))
          .width(24).height(24)
          .position({ right: 0, bottom: 0 });
      }
      .onClick(() => {
        this.showActionSheet = true;
      })
      .bindActionSheet(this.showActionSheet, {
        sheets: [
          {
            title: '拍照',
            action: () => {
              PickerUtil.takePhoto().then((uri: string) => {
                this.avatar = uri;
              });
            },
          },
          {
            title: '从相册选择',
            action: () => {
              PickerUtil.pickImage().then((uri: string) => {
                this.avatar = uri;
              });
            },
          },
          {
            title: '取消',
            action: () => {},
          },
        ],
      });

      Text('点击更换头像')
        .fontSize(12)
        .fontColor('#999')
        .margin({ top: 8 });
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center);
  }
}

3.2 PickerUtil 工具

// PickerUtil.ets
export class PickerUtil {
  // 从相册选择图片
  static async pickImage(): Promise<string> {
    try {
      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const result = await photoPicker.select({
        MIME_TYPE: ['image/jpeg', 'image/png'],
        maxSelectNumber: 1,
      });
      return result.photoUris[0];
    } catch (error) {
      Logger.error('PickerUtil', 'pickImage failed: ' + error.message);
      return '';
    }
  }

  // 拍照
  static async takePhoto(): Promise<string> {
    try {
      // 调用相机拍照
      // 返回图片 URI
      return '';
    } catch (error) {
      Logger.error('PickerUtil', 'takePhoto failed: ' + error.message);
      return '';
    }
  }
}

四、表单编辑组件

4.1 单行文本输入

// EnterTextInput.ets
@ComponentV2
export struct EnterTextInput {
  @Param label: string = '';           // 标签名
  @Param placeholder: string = '';     // 占位符
  @Param maxLength: number = 20;       // 最大长度
  @Param validator?: (value: string) => string;  // 校验函数
  @Local value: string = '';           // 输入值
  @Local errorMsg: string = '';        // 错误信息

  build() {
    Column() {
      Row() {
        // 标签
        Text(this.label)
          .fontSize(14)
          .width(80)
          .fontColor($r('sys.color.font_primary'));

        // 输入框
        TextInput({
          placeholder: this.placeholder,
          text: this.value,
        })
        .layoutWeight(1)
        .maxLength(this.maxLength)
        .onChange((value: string) => {
          this.value = value;
          this.validate();
        });
      }
      .padding(12)
      .height(46);

      // 错误提示
      if (this.errorMsg) {
        Text(this.errorMsg)
          .fontSize(12)
          .fontColor('#FF6B6B')
          .margin({ left: 92, bottom: 4 });
      }
    }
  }

  validate() {
    if (this.value.length === 0) {
      this.errorMsg = '请输入' + this.label;
    } else if (this.validator) {
      this.errorMsg = this.validator(this.value);
    } else {
      this.errorMsg = '';
    }
  }
}

4.2 手机号输入

// EnterPhoneNumber.ets
@ComponentV2
export struct EnterPhoneNumber {
  @Local phoneNumber: string = '';
  @Local errorMsg: string = '';

  build() {
    Column() {
      Row() {
        Text('手机号').fontSize(14).width(80);
        TextInput({
          placeholder: '请输入手机号',
          text: this.phoneNumber,
        })
        .layoutWeight(1)
        .type(InputType.PhoneNumber)  // 手机号输入类型
        .maxLength(11)
        .onChange((value: string) => {
          this.phoneNumber = value;
          this.validatePhone();
        });
      }
      .padding(12)
      .height(46);

      if (this.errorMsg) {
        Text(this.errorMsg).fontSize(12).fontColor('#FF6B6B');
      }
    }
  }

  validatePhone() {
    const phoneRegex = /^1[3-9]\d{9}$/;
    if (this.phoneNumber.length === 0) {
      this.errorMsg = '请输入手机号';
    } else if (!phoneRegex.test(this.phoneNumber)) {
      this.errorMsg = '请输入正确的手机号格式';
    } else {
      this.errorMsg = '';
    }
  }
}

4.3 日期选择

// ChooseDate.ets
@ComponentV2
export struct ChooseDate {
  @Param label: string = '出生日期';
  @Local selectedDate: Date = new Date();
  @Local showDatePicker: boolean = false;

  build() {
    Row() {
      Text(this.label).fontSize(14).width(80);
      Text(this.formatDate(this.selectedDate))
        .fontSize(14)
        .fontColor($r('sys.color.font_primary'))
        .layoutWeight(1);
    }
    .padding(12)
    .height(46)
    .onClick(() => {
      this.showDatePicker = true;
    })
    .bindDatePicker($$this.showDatePicker, {
      start: new Date('1900-01-01'),
      end: new Date(),
      selected: this.selectedDate,
    });
  }

  formatDate(date: Date): string {
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    return year + '-' + month + '-' + day;
  }
}

五、个人信息主视图

5.1 PersonalInfoView 组件

// PersonalInfoView.ets
@ComponentV2
export struct PersonalInfoView {
  @Local userInfo: UserInfoModel = AccountUtil.getUserInfo();

  build() {
    NavDestination() {
      Scroll() {
        Column({ space: 12 }) {
          // 头像模块
          ListCard({
            content: ChooseAvatar({ avatar: this.userInfo.avatar }),
          });

          // 信息编辑模块
          ListCard({
            content: Column({ space: 0 }) {
              EnterTextInput({
                label: '昵称',
                placeholder: '请输入昵称',
                value: this.userInfo.nickname as string,
                maxLength: 12,
              });

              Divider().height(0.5).width('100%');

              ChooseDate({ label: '出生日期' });

              Divider().height(0.5).width('100%');

              EnterPhoneNumber({ phoneNumber: this.userInfo.phone });
            },
          });

          // 保存按钮
          Button('保存')
            .width('90%')
            .height(44)
            .backgroundColor('#64BB5C')
            .borderRadius(22)
            .fontColor('#fff')
            .fontSize(16)
            .margin({ top: 24 })
            .onClick(() => {
              this.saveUserInfo();
            });
        }
        .padding(16);
      }
    }
    .title('个人信息');
  }

  saveUserInfo() {
    // 保存用户信息
    AccountUtil.setUserInfo(this.userInfo);
    showToast('保存成功', this.getUIContext());
    this.vm.navStack.pop();
  }
}

六、表单校验与错误处理

6.1 错误信息映射

// ErrorMsgMap.ets
export const ErrorMsgMap: Record<string, string> = {
  'empty_nickname': '昵称不能为空',
  'invalid_phone': '手机号格式不正确',
  'invalid_date': '请选择有效的日期',
  'nickname_too_long': '昵称长度不能超过12个字符',
  'phone_exists': '该手机号已被绑定',
};

6.2 表单提校验

// 表单提交前校验
validateForm(): boolean {
  let isValid = true;
  // 校验所有字段
  if (!this.userInfo.nickname) {
    showToast('请输入昵称', this.getUIContext());
    isValid = false;
  }
  if (!this.isValidPhone(this.userInfo.phone)) {
    showToast('请输入正确的手机号', this.getUIContext());
    isValid = false;
  }
  return isValid;
}

七、总结

个人信息组件通过多个子组件的组合实现了完整的个人信息编辑功能:

  1. 头像编辑:支持拍照和相册选择
  2. 文本输入:支持单行和多行文本
  3. 手机号输入:专门的手机号格式校验
  4. 日期选择:使用 DatePicker 组件
  5. 表单校验:实时的输入校验和错误提示
  6. 保存交互:保存成功后的 Toast 提示

关键源码文件:

  • components/collect_personal_info/src/main/ets/views/PersonalInfoView.ets — 主视图
  • components/collect_personal_info/src/main/ets/components/ChooseAvatar.ets — 头像选择
  • components/collect_personal_info/src/main/ets/components/EnterTextInput.ets — 文本输入
  • components/collect_personal_info/src/main/ets/components/ChooseDate.ets — 日期选择
  • components/collect_personal_info/src/main/ets/components/EnterPhoneNumber.ets — 手机号输入
  • commons/commonLib/src/main/ets/utils/PickerUtil.ets — 图片选择工具
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐