鸿蒙实战:PhoneLoginPage 6位验证码 Cell 设计与 60 秒倒计时

鸿蒙实战:PhoneLoginPage 6位验证码 Cell 设计与 60 秒倒计时

前言

图:鸿蒙实战第90篇:PhoneLoginPage 6位验证码 Cell 设计与 60 秒倒计时 运行效果截图(HarmonyOS NEXT)

手机验证码登录是移动 App 最常见的登录方式之一,但在 ArkUI 中实现"6 格验证码输入框"并不简单------没有现成的 PinInputOtpInput 组件。本篇完整拆解 PhoneLoginPage 的 514 行代码,重点解析:

  1. 隐藏 TextInput + 6 个 Stack Cell 的组合设计(系统键盘 + 自定义视觉)
  2. Cell 三态渲染(空白 / 光标 / 数字)
  3. 60 秒倒计时setInterval + 状态切换)
  4. 手机号正则校验与双输入联合按钮控制

系列导航:89 LoginPage 华为一键登录 · 91 AnalyzingPage 分析数据流
相关文档:TextInput API · setInterval


一、页面整体架构

1.1 状态变量

typescript 复制代码
@Entry
@Component
struct PhoneLoginPage {
  @State phoneNumber: string = ''       // 手机号(11位)
  @State verifyCode: string = ''        // 验证码(6位)
  @State phoneFocus: boolean = false    // 手机号输入框聚焦
  @State codeFocus: boolean = false     // 验证码聚焦
  @State isCountingDown: boolean = false// 倒计时中
  @State countdown: number = 60         // 剩余秒数
  @State isLoggingIn: boolean = false   // 登录中
  private countdownTimer: number = -1  // setInterval 返回的 ID
}

1.2 完整交互流程

UserDao + AppStorage SMS 服务(调试:Mock) PhoneLoginPage 用户 UserDao + AppStorage SMS 服务(调试:Mock) PhoneLoginPage 用户 #mermaid-svg-8kY9ZeBknklcYfsv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8kY9ZeBknklcYfsv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8kY9ZeBknklcYfsv .error-icon{fill:#552222;}#mermaid-svg-8kY9ZeBknklcYfsv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8kY9ZeBknklcYfsv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8kY9ZeBknklcYfsv .marker.cross{stroke:#333333;}#mermaid-svg-8kY9ZeBknklcYfsv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8kY9ZeBknklcYfsv p{margin:0;}#mermaid-svg-8kY9ZeBknklcYfsv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8kY9ZeBknklcYfsv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8kY9ZeBknklcYfsv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-8kY9ZeBknklcYfsv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv .sequenceNumber{fill:white;}#mermaid-svg-8kY9ZeBknklcYfsv #sequencenumber{fill:#333;}#mermaid-svg-8kY9ZeBknklcYfsv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv .messageText{fill:#333;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8kY9ZeBknklcYfsv .labelText,#mermaid-svg-8kY9ZeBknklcYfsv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .loopText,#mermaid-svg-8kY9ZeBknklcYfsv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8kY9ZeBknklcYfsv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8kY9ZeBknklcYfsv .noteText,#mermaid-svg-8kY9ZeBknklcYfsv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8kY9ZeBknklcYfsv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8kY9ZeBknklcYfsv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8kY9ZeBknklcYfsv .actorPopupMenu{position:absolute;}#mermaid-svg-8kY9ZeBknklcYfsv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-8kY9ZeBknklcYfsv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8kY9ZeBknklcYfsv .actor-man circle,#mermaid-svg-8kY9ZeBknklcYfsv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-8kY9ZeBknklcYfsv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入手机号(11位) 点击"获取验证码" 正则校验 ^13-9\d{9} sendVerifyCode(phone) 返回成功(Mock: 延迟800ms) 启动 60s setInterval 倒计时 输入 6 位验证码 正则校验 \^\\d{6} 点击"登录" verifyCode(phone, code) 验证通过 UserDao.ensureLocalUser DemoSeeder.seedIfNeeded AppStorage.setOrCreate replaceUrl → HomePage


二、6 位验证码 Cell 组件设计

2.1 核心思路

ArkUI 没有内置 OTP/Pin 输入组件,需要用以下组合实现:

  • 隐藏的 TextInputopacity: 0height: 0,只负责接收系统键盘输入
  • 6 个可见 Stack Cell:只负责展示当前输入内容
  • $$this.verifyCode 双向绑定:隐藏输入框的值实时同步到状态变量

#mermaid-svg-WqipWrOlyEkvHx44{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WqipWrOlyEkvHx44 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WqipWrOlyEkvHx44 .error-icon{fill:#552222;}#mermaid-svg-WqipWrOlyEkvHx44 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WqipWrOlyEkvHx44 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 .marker.cross{stroke:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WqipWrOlyEkvHx44 p{margin:0;}#mermaid-svg-WqipWrOlyEkvHx44 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster-label text{fill:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster-label span{color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster-label span p{background-color:transparent;}#mermaid-svg-WqipWrOlyEkvHx44 .label text,#mermaid-svg-WqipWrOlyEkvHx44 span{fill:#333;color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .node rect,#mermaid-svg-WqipWrOlyEkvHx44 .node circle,#mermaid-svg-WqipWrOlyEkvHx44 .node ellipse,#mermaid-svg-WqipWrOlyEkvHx44 .node polygon,#mermaid-svg-WqipWrOlyEkvHx44 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .rough-node .label text,#mermaid-svg-WqipWrOlyEkvHx44 .node .label text,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape .label,#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape .label{text-anchor:middle;}#mermaid-svg-WqipWrOlyEkvHx44 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .rough-node .label,#mermaid-svg-WqipWrOlyEkvHx44 .node .label,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape .label,#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape .label{text-align:center;}#mermaid-svg-WqipWrOlyEkvHx44 .node.clickable{cursor:pointer;}#mermaid-svg-WqipWrOlyEkvHx44 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 .arrowheadPath{fill:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WqipWrOlyEkvHx44 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WqipWrOlyEkvHx44 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WqipWrOlyEkvHx44 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WqipWrOlyEkvHx44 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WqipWrOlyEkvHx44 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WqipWrOlyEkvHx44 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster text{fill:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster span{color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WqipWrOlyEkvHx44 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WqipWrOlyEkvHx44 rect.text{fill:none;stroke-width:0;}#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape p,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape .label rect,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WqipWrOlyEkvHx44 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WqipWrOlyEkvHx44 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WqipWrOlyEkvHx44 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户点击 Cell 区域
隐藏 TextInput 获取焦点

focusable + defaultFocus
系统键盘弹出
用户输入数字
verifyCode 更新
ForEach 重新渲染 6 个 Cell

三态逻辑

2.2 隐藏输入框

typescript 复制代码
TextInput({ text: $$this.verifyCode })
  .type(InputType.Number)
  .maxLength(6)
  .fontSize(0)          // ← 文字大小为 0,实际上"不可见"
  .caretColor(Color.Transparent)  // ← 光标也透明
  .backgroundColor(Color.Transparent)
  .width('100%')
  .height(0)            // ← 高度为 0,不占布局空间
  .opacity(0)           // ← 完全透明
  .focusable(true)

为什么不用 visibility: Hidden visibility 隐藏后组件无法获取焦点,无法触发系统键盘。用 opacity: 0 + height: 0 是兼顾"键盘可弹出"和"视觉不可见"的折中方案。

2.3 6 个 Cell 的三态渲染

typescript 复制代码
@Builder
VerifyCodeCells() {
  Row({ space: 10 }) {
    ForEach([0, 1, 2, 3, 4, 5], (index: number) => {
      Stack() {
        // 背景格子
        Column()
          .width(44)
          .height(56)
          .borderRadius(8)
          .backgroundColor(AppColors.CARD)
          .border({
            width: 2,
            color: index === this.verifyCode.length
              ? AppColors.PRIMARY  // 当前位:主色高亮
              : AppColors.BORDER   // 其他位:默认灰色
          })

        // 三态内容渲染
        if (index < this.verifyCode.length) {
          // 状态 ①:已输入数字
          Text(this.verifyCode[index])
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor(AppColors.TEXT)
        } else if (index === this.verifyCode.length) {
          // 状态 ②:当前输入位,显示光标
          Column()
            .width(2)
            .height(24)
            .backgroundColor(AppColors.PRIMARY)
            .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
        }
        // 状态 ③:未到达的位,什么都不显示(空格子)
      }
    })
  }
  .onClick(() => {
    // 点击 Cell 区域,触发隐藏 TextInput 获取焦点
    this.codeFocus = true
  })
}

2.4 状态流转图

Cell 索引 i 条件 显示内容 边框颜色
i < length 已输入 对应数字字符 默认灰色
i === length 当前位 闪烁光标(2×24 竖线) 主色高亮
i > length 未到达 空(空格子) 默认灰色

三、60 秒倒计时实现

3.1 启动倒计时

typescript 复制代码
private startCountdown(): void {
  this.isCountingDown = true
  this.countdown = 60
  this.countdownTimer = setInterval(() => {
    this.countdown -= 1
    if (this.countdown <= 0) {
      this.stopCountdown()
    }
  }, 1000)
}

private stopCountdown(): void {
  if (this.countdownTimer !== -1) {
    clearInterval(this.countdownTimer)
    this.countdownTimer = -1
  }
  this.isCountingDown = false
  this.countdown = 60
}

3.2 按钮的两种状态

typescript 复制代码
Button(this.isCountingDown
  ? `${this.countdown}s 后重发`   // 倒计时中:显示剩余秒数
  : '获取验证码'                   // 倒计时结束:可重新发送
)
.enabled(!this.isCountingDown && this.isPhoneValid())
.backgroundColor(
  (!this.isCountingDown && this.isPhoneValid())
    ? AppColors.PRIMARY
    : AppColors.TEXT_3
)
.animation({ duration: 250, curve: Curve.EaseOut })
.onClick(() => {
  if (!this.isCountingDown && this.isPhoneValid()) {
    this.sendVerifyCode()
  }
})

3.3 组件销毁时清理定时器

typescript 复制代码
aboutToDisappear(): void {
  this.stopCountdown()  // ← 防止页面销毁后 setInterval 仍在执行
}

内存泄漏风险 :如果不在 aboutToDisappearclearInterval,即使页面已销毁,计时器仍会每秒触发 setState,导致"更新已销毁的组件"异常。这是 setInterval 使用的必须注意事项。


四、表单校验设计

4.1 正则校验函数

typescript 复制代码
private isPhoneValid(): boolean {
  return /^1[3-9]\d{9}$/.test(this.phoneNumber)
}

private isCodeValid(): boolean {
  return /^\d{6}$/.test(this.verifyCode)
}
正则 含义 示例
^1[3-9]\d{9}$ 以 1 开头,第二位 3-9,共 11 位 13812345678 ✓
^\d{6}$ 恰好 6 位纯数字 123456 ✓

4.2 登录按钮联合控制

登录按钮需要手机号和验证码同时有效才可用:

typescript 复制代码
Button('登录')
  .enabled(this.isPhoneValid() && this.isCodeValid() && !this.isLoggingIn)
  .opacity(
    (this.isPhoneValid() && this.isCodeValid()) ? 1.0 : 0.5
  )
  .animation({ duration: 300 })

.opacity(0.5) 而非 .backgroundColor 变色------视觉上"变暗变淡"的方式对用户更友好,不会让他们以为按钮"消失了"。


五、输入框聚焦高亮

5.1 手机号输入框聚焦

typescript 复制代码
TextInput({ placeholder: '请输入手机号' })
  .type(InputType.PhoneNumber)
  .maxLength(11)
  .border({
    width: 1.5,
    color: this.phoneFocus ? AppColors.PRIMARY : AppColors.BORDER
  })
  .animation({ duration: 250, curve: Curve.EaseOut })
  .onFocus(() => { this.phoneFocus = true })
  .onBlur(() => { this.phoneFocus = false })

聚焦时边框颜色从 BORDER(灰色)过渡到 PRIMARY(棕色),.animation 让颜色变化有 250ms 的平滑过渡。


六、调试模式与生产模式

6.1 发送验证码(双模式)

typescript 复制代码
private async sendVerifyCode(): Promise<void> {
  try {
    // ===== 生产代码(预留)=====
    // import { cloudSms } from '@hmscore/sms'
    // await cloudSms.sendVerificationCode(this.phoneNumber)

    // ===== 调试代码(当前运行)=====
    await new Promise<void>(resolve => setTimeout(resolve, 800))
    hilog.info(0x0000, TAG, '调试模式:验证码已"发送"到 %{private}s', this.phoneNumber)

    this.startCountdown()
    promptAction.showToast({ message: '验证码已发送(调试:任意6位数字均可)' })
  } catch (e) {
    promptAction.showToast({ message: '发送失败,请重试' })
  }
}

注意手机号日志安全hilog.info 中手机号使用 %{private}s 而非 %{public}s,确保手机号在 Release 构建的日志中被隐藏。

6.2 验证码校验(双模式)

typescript 复制代码
private async handleLogin(): Promise<void> {
  this.isLoggingIn = true
  try {
    // 生产:
    // const result = await cloudSms.verifyCode(this.phoneNumber, this.verifyCode)
    // if (!result.success) throw new Error('验证码错误')

    // 调试:任何 6 位数字均视为正确
    await new Promise<void>(resolve => setTimeout(resolve, 600))

    await this.initUserAfterLogin(
      'PHONE_' + this.phoneNumber.slice(-4),
      '鹿鹿用户'
    )
  } finally {
    this.isLoggingIn = false
  }
}

七、常见问题

7.1 键盘弹出后 Cell 被遮挡

现象:输入验证码时,软键盘弹出后 6 个 Cell 消失在键盘下方

解决 :在根 Column 外套 Scroll,并设置 .padding({ bottom: 300 }),让内容区有足够的滚动空间:

typescript 复制代码
Scroll() {
  Column() {
    // ... 所有内容
  }
  .padding({ bottom: 300 })
}

7.2 光标动画不闪烁

现象:当前 Cell 显示的光标不闪烁,只是一条静止的竖线

解决 :光标需要 iterations: -1(无限循环)+ playMode: PlayMode.Alternate(来回播放):

typescript 复制代码
Column()
  .width(2).height(24)
  .backgroundColor(AppColors.PRIMARY)
  .animation({
    duration: 600,
    iterations: -1,
    playMode: PlayMode.Alternate,
    curve: Curve.Linear
  })
  .opacity(1)  // 通过 opacity 来回 0→1 实现闪烁效果

八、注意事项与常见问题

8.1 开发注意事项

在实际开发过程中,需特别注意以下几点:

  • API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
  • 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
  • 生命周期 :合理使用 aboutToAppear()aboutToDisappear() 管理资源初始化与释放
  • 状态同步 :跨页面数据通过 AppStorage 共享,组件内状态使用 @State / @Prop / @Link 装饰器

8.2 常见错误与解决方案

开发过程中的高频问题汇总:

错误现象 可能原因 解决方案
权限被系统拒绝 未在 module.json5 中静态声明 添加 requestPermissions 权限配置项
页面跳转后白屏 路由路径未在 main_pages.json 注册 检查并补充路由配置文件
UI 状态不刷新 状态变量未添加响应式装饰器 为变量加 @State / @Observed 装饰器
数据库查询返回空 查询条件与存储数据格式不匹配 使用 hilog.debug 打印 SQL 条件排查
相机预览黑屏 运行时相机权限未获取 先调用 requestPermissionsFromUser 申请

总结

PhoneLoginPage 的 514 行代码的核心亮点:

  1. 隐藏输入框 + 自定义 Cell 组合:最优雅的 ArkUI 原生 6 位验证码实现方案
  2. 三态 Cell 渲染i < len / i === len / i > len 三种分支,代码清晰逻辑无歧义
  3. setInterval + aboutToDisappear 联动:计时器必须在页面销毁时清理
  4. 双输入联合校验:手机号正则 + 验证码正则 AND 组合控制按钮状态
  5. %{private}s 手机号隐私:生产日志不暴露用户隐私信息

下一篇预告第91篇 AnalyzingPage------AI 分析数据流水线与五步进度动画

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

PhoneLoginPage 技术亮点 :在没有系统级 OtpInput 组件的 ArkUI 中,通过 ForEach 渲染 6 个独立 TextInput + 焦点自动跳转 + 键盘监听,手工实现了媲美原生的 PIN 码输入体验。

关键技巧总结

  • 验证码框:TextInput({ placeholder: '' }).maxLength(1) × 6
  • 焦点自动跳转:focusControl.requestFocus('pin_${index + 1}')
  • 60 秒倒计时:setInterval + @State countdown 驱动 UI 更新
  • 防抖提交:isSubmitting 标志位防止重复触发验证逻辑

延伸阅读:第89篇 LoginPage 华为一键登录 · 第88篇 UserDao

相关推荐
xd1855785551 小时前
敲门砖工坊-求职信定制的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:ModalBottomSheet 实战
android·javascript·学习·flutter·华为·harmonyos
tyqtyq222 小时前
求职信生成:AI 智能求职信撰写系统的鸿蒙实现
人工智能·学习·华为·生活·harmonyos
l134062082353 小时前
鸿蒙实战:RDB 数据库设计与 DatabaseService
数据库·华为·harmonyos·鸿蒙系统
ZZZMMM.zip3 小时前
数据侦探社-数据趋势分析的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
星释4 小时前
鸿蒙智能体开发实战:34.鸿蒙壁纸大师 - 提示词工程与优化
android·华为·harmonyos·鸿蒙
千逐685 小时前
React Native 性能优化深度解析:从原理到实战
flutter·华为·鸿蒙
ujainu小5 小时前
并发性能测试:心跳监测与主线程阻塞分析
华为·性能优化·harmonyos
●VON6 小时前
HarmonyKit | 鸿蒙开发:项目目录结构与多模块架构最佳实践
华为·架构·单元测试·harmonyos·鸿蒙