鸿蒙原生应用 ArkTS 表单工程:积分兑换页的实时算分与数量加减器

App 16「校园积分商城」商品 Tab(Func1Tab),是本系列交互联动最完整的页面——渐变兑换预览卡(积分随商品/数量实时计算)+ 6 商品 3 列 Grid(单选高亮)+ 数量加减器(1-10)+ 地址单选(宿舍/教学楼/自取,含默认标识)+ 收件信息表单 + 确认兑换按钮(动态显示消耗积分)。本篇基于 16-points-mall/entry/src/main/ets/pages/Func1Tab.ets(约 268 行)逐段拆解,附 4 张实机截图。

一、整体结构:兑换漏斗

Func1Tab 是 6 个 @Builder + 1 个辅助方法(getProduct())组成的"兑换漏斗":

build() {
  Column() {
    this.Header()
    Scroll() {
      Column({ space: 16 }) {
        this.PreviewCard()
        this.ProductSelector()
        this.QuantityCard()
        this.AddressSelector()
        this.FormCard()
        this.SubmitBtn()
      }
      .width('100%')
      .padding({ left: D.pad, right: D.pad, top: 16, bottom: D.pad + this.safeBottom + 20 })
    }
    .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
  }
  .width('100%').height('100%').backgroundColor(C.bg)
}

6 个块按"预览 → 选品 → 数量 → 地址 → 信息 → 确认"六步展开

  1. PreviewCard — 兑换预览(积分实时计算)
  2. ProductSelector — 选商品(6 个 3 列 Grid)
  3. QuantityCard — 选数量(减/数字/加)
  4. AddressSelector — 选地址(3 个单选)
  5. FormCard — 收件信息(姓名/手机/备注)
  6. SubmitBtn — 确认兑换(动态消耗积分)

"预览置顶"是电商兑换页的最佳实践——用户先看到"我要换什么、花多少积分",再往下操作,所有操作都实时反映到预览卡(所见即所得)。

校园积分商城商品页首屏 · 玫瑰红渐变预览卡+积分实时计算+6商品3列Grid单选

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

配图

二、PreviewCard:积分实时计算的渐变预览卡

PreviewCard 是整页的状态中枢——商品和数量的任何变化都实时反映在这里:

@Builder
PreviewCard() {
  Row({ space: 14 }) {
    Row() {
      Text(this.getProduct().emoji).fontSize(36)
    }
    .width(64).height(64)
    .backgroundColor('#FFFFFF').opacity(0.25).borderRadius(D.rMd)
    .justifyContent(FlexAlign.Center)

    Column({ space: 6 }) {
      Text('兑换预览').fontSize(11).fontColor('#FFFFFF').opacity(0.85)
      Text(this.getProduct().name).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text(this.getProduct().desc).fontSize(12).fontColor('#FFFFFF').opacity(0.9)
    }
    .alignItems(HorizontalAlign.Start).layoutWeight(1)

    Column({ space: 4 }) {
      Text(this.getProduct().points * this.quantity + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('所需积分').fontSize(10).fontColor('#FFFFFF').opacity(0.85)
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(16)
  .borderRadius(D.rLg)
  .linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
}

this.getProduct() 是核心辅助方法

private getProduct(): Template {
  for (let i = 0; i < this.templates.length; i++) {
    if (this.templates[i].id === this.productId) return this.templates[i];
  }
  return this.templates[0];
}

"根据 productId 查当前商品"——线性遍历 + 命中返回 + 兜底返回第一个(templates[0] 蓝牙耳机)。getProduct() 被 4 处调用(预览卡 emoji/name/desc/points、提交按钮积分)——**"查表方法 + 多处复用"**是状态驱动 UI 的标准姿势。

关键:积分实时计算

Column({ space: 4 }) {
  Text(this.getProduct().points * this.quantity + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
  Text('所需积分').fontSize(10).fontColor('#FFFFFF').opacity(0.85)
}

points * quantity——商品单价 × 数量 = 总积分。@State productId@State quantity 一变 → getProduct() 重算 → 预览卡积分自动更新——"一处状态、两处联动"(选商品联动积分、加数量联动积分)。用户点加号,预览卡的积分立刻 +800(蓝牙耳机),"花了多少"一目了然。

预览卡三区:左 emoji 图标(64×64 半透明白底)+ 中"商品名 + 描述" + 右"所需积分大字"。"积分大字"是全卡最重的信息(电商:价格最重)。

三、ProductSelector:6 商品 3 列 Grid

ProductSelector 是 6 个商品的 3 列 Grid,单选高亮:

@Builder
ProductSelector() {
  Column({ space: 12 }) {
    Text('选择商品').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    Grid() {
      ForEach(this.templates, (t: Template) => {
        GridItem() {
          Column({ space: 4 }) {
            Text(t.emoji).fontSize(28)
            Text(t.name).fontSize(11).fontColor(this.productId === t.id ? C.primary : C.text)
              .fontWeight(this.productId === t.id ? FontWeight.Bold : FontWeight.Normal)
            Text(t.points + '积分').fontSize(10).fontColor(C.textDim)
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(this.productId === t.id ? C.primarySoft : C.cardSoft)
          .borderRadius(D.rSm)
          .border({ width: this.productId === t.id ? 2 : 1, color: this.productId === t.id ? C.primary : C.stroke })
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.productId = t.id; })
        }
      }, (t: Template) => t.id.toString())
    }
    .columnsTemplate('1fr 1fr 1fr')
    .columnsGap(10)
    .rowsGap(10)
    .width('100%')
  }
  .width('100%')
  .padding(14)
  .backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

3.1 6 商品数据

private templates: Template[] = [
  { id: 1, emoji: '🎧', name: '蓝牙耳机', points: 800, desc: '高品质无线耳机' },
  { id: 2, emoji: '☕', name: '保温杯', points: 500, desc: '316不锈钢内胆' },
  { id: 3, emoji: '📚', name: '学习笔记本', points: 200, desc: 'B5加厚精装本' },
  { id: 4, emoji: '🎒', name: '校园背包', points: 1200, desc: '防水大容量' },
  { id: 5, emoji: '🎮', name: '游戏手柄', points: 600, desc: '蓝牙无线手柄' },
  { id: 6, emoji: '💡', name: '台灯', points: 400, desc: 'LED护眼台灯' }
];

6 商品积分梯度:200(笔记本)→ 400(台灯)→ 500(保温杯)→ 600(手柄)→ 800(耳机)→ 1200(背包)——"不同价位覆盖不同积分段用户"(积分少换笔记本、积分多换背包)。积分与首页商品一致(蓝牙耳机 800 等 5 个相同,台灯是新增第 6 个)。

每个商品卡:emoji + 名称 + 积分价三行。选中态三件套:背景(C.primarySoft 浅玫红 vs C.cardSoft 浅灰)、边框(2vp 主色 vs 1vp 浅灰)、文字(主色加粗 vs 深色常规)。

onClick(() => { this.productId = t.id; }) 一行更新选中——productId 一变,预览卡 getProduct() 重算 → 名称/描述/积分全更新。"单选联动预览"是电商选品的核心交互

四、QuantityCard:数量加减器(1-10)

QuantityCard 是"减号 / 数字 / 加号"的数量选择器,范围 1-10

@Builder
QuantityCard() {
  Row({ space: 12 }) {
    Text('兑换数量').fontSize(14).fontColor(C.text).layoutWeight(1)
    Row({ space: 12 }) {
      Row() {
        Text('−').fontSize(18).fontColor(C.textSub)
      }
      .width(32).height(32)
      .backgroundColor(C.cardSoft).borderRadius(D.rSm)
      .justifyContent(FlexAlign.Center)
      .onClick(() => { if (this.quantity > 1) this.quantity--; })

      Text(this.quantity + '').fontSize(16).fontColor(C.text).fontWeight(FontWeight.Bold)

      Row() {
        Text('+').fontSize(18).fontColor(C.textSub)
      }
      .width(32).height(32)
      .backgroundColor(C.cardSoft).borderRadius(D.rSm)
      .justifyContent(FlexAlign.Center)
      .onClick(() => { if (this.quantity < 10) this.quantity++; })
    }
  }
  .width('100%')
  .padding(14)
  .backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

加减器的边界防护

.onClick(() => { if (this.quantity > 1) this.quantity--; })
...
.onClick(() => { if (this.quantity < 10) this.quantity++; })
  • 减号if (this.quantity > 1) 才减——下限 1(不能 0 或负数)
  • 加号if (this.quantity < 10) 才加——上限 10(不能无限加)

"if 边界判断"是数量选择器的标准防护——没有 if,用户连点减号会变成 0/-1/-2,连点加号会无限大。边界值(1 和 10)是业务规则(一次最多换 10 个,防止刷积分)。

布局:左"兑换数量"标签 + 右"32×32 减号块 + 数字 + 32×32 加号块"。减号/加号块是"伪按钮"C.cardSoft 浅灰底 + 圆角),Text('−') 用减号字符(非连字符)。数字居中加粗(16sp)显示当前数量。

数量变化 → 预览卡积分联动points * quantity——加一次,总积分 +800(蓝牙耳机)。"数量驱动总价"是电商计算的核心(单价 × 数量 = 总价)。

五、AddressSelector:地址单选(●/○ + 默认标识)

AddressSelector 是 3 个收货地址(宿舍/教学楼/自取)的单选列表:

@Builder
AddressSelector() {
  Column({ space: 12 }) {
    Text('收货地址').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    ForEach(this.addresses, (a: AddrItem) => {
      Row({ space: 10 }) {
        Row() {
          Text(this.addrId === a.id ? '●' : '○').fontSize(16)
            .fontColor(this.addrId === a.id ? C.primary : C.textDim)
        }
        .width(24)
        Column({ space: 2 }) {
          Row({ space: 6 }) {
            Text(a.label).fontSize(14).fontColor(C.text).fontWeight(FontWeight.Medium)
            if (a.isDefault) {
              Text('默认').fontSize(9).fontColor('#FFFFFF')
                .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                .backgroundColor(C.primary).borderRadius(3)
            }
          }
          Text(a.detail).fontSize(12).fontColor(C.textDim)
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
      .backgroundColor(this.addrId === a.id ? C.primarySoft : C.cardSoft)
      .borderRadius(D.rSm)
      .border({ width: this.addrId === a.id ? 1 : 0, color: C.primary })
      .onClick(() => { this.addrId = a.id; })
    }, (a: AddrItem) => a.id.toString())
  }
  .width('100%')
  .padding(14)
  .backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

5.1 3 个地址数据

private addresses: AddrItem[] = [
  { id: 1, label: '宿舍', detail: '7 号楼 305 室', isDefault: true },
  { id: 2, label: '教学楼', detail: 'A 栋 302 教室', isDefault: false },
  { id: 3, label: '自取', detail: '校门口快递站', isDefault: false }
];

3 个地址覆盖校园场景:宿舍(默认)、教学楼(上课自取)、校门口快递站(驿站自取)——"校园地址模板"

5.2 单选圆点(●/○)

/ 字符实现单选圆点(比用 Radio 组件更轻量):

Row() {
  Text(this.addrId === a.id ? '●' : '○').fontSize(16)
    .fontColor(this.addrId === a.id ? C.primary : C.textDim)
}
.width(24)
  • 选中:实心圆 + 主色(玫瑰红)
  • 未选:空心圆 + 灰色

●/○ 是"字符画单选按钮"——ArkUI 有原生 Radio 组件,但字符方式更可控(大小/颜色/间距全自定)。选中的视觉(实心主色)比系统 Radio 更贴合主题色。

5.3 默认标识

if (a.isDefault) {
  Text('默认').fontSize(9).fontColor('#FFFFFF')
    .padding({ left: 4, right: 4, top: 1, bottom: 1 })
    .backgroundColor(C.primary).borderRadius(3)
}

"默认"标签isDefault 字段驱动,主色底白字小胶囊)——"默认地址"是电商的标准概念(免去每次选择)。@State addrId = 1 初始选中宿舍(默认地址),"默认 = 初始选中"

5.4 选中卡背景

.backgroundColor(this.addrId === a.id ? C.primarySoft : C.cardSoft)
.borderRadius(D.rSm)
.border({ width: this.addrId === a.id ? 1 : 0, color: C.primary })

选中卡:浅玫红底 + 1vp 主色描边;未选卡:浅灰底无边框——"背景 + 描边"双选中标识,配合左侧 ● 圆点,三重确认"这个地址被选中"。

六、FormCard:收件信息表单

FormCard 是"姓名/手机/备注"三行输入(与 App 14 预约页表单同构):

@Builder
FormCard() {
  Column({ space: 12 }) {
    Text('收件信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    Row({ space: 8 }) {
      Text('姓名').fontSize(13).fontColor(C.textDim).width(50)
      TextInput({ placeholder: '请输入姓名', text: this.name })
        .onChange((val: string) => { this.name = val; })
        .backgroundColor(C.cardSoft).borderRadius(D.rSm).height(40)
        .layoutWeight(1).placeholderColor(C.textDim).placeholderFont({ size: 13 })
    }
    .width('100%')
    Row({ space: 8 }) {
      Text('手机').fontSize(13).fontColor(C.textDim).width(50)
      TextInput({ placeholder: '请输入手机号', text: this.phone })
        .onChange((val: string) => { this.phone = val; })
        .backgroundColor(C.cardSoft).borderRadius(D.rSm).height(40)
        .layoutWeight(1).placeholderColor(C.textDim).placeholderFont({ size: 13 })
        .type(InputType.Number)
    }
    .width('100%')
    Row({ space: 8 }) {
      Text('备注').fontSize(13).fontColor(C.textDim).width(50)
      TextInput({ placeholder: '选填', text: this.note })
        .onChange((val: string) => { this.note = val; })
        .backgroundColor(C.cardSoft).borderRadius(D.rSm).height(40)
        .layoutWeight(1).placeholderColor(C.textDim).placeholderFont({ size: 13 })
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

与 App 14 场地页 FormCard 完全同构(标签 50vp 固定宽 + 输入框 layoutWeight + 手机号 InputType.Number 数字键盘)——"收件表单"模板第 2 次复用(App 14 预约信息 → App 16 收件信息)。3 个 @State(name/phone/note)双向绑定。

七、SubmitBtn:动态积分的确认按钮

SubmitBtn 是本页最精彩的部分——按钮文案动态显示当前消耗积分

@Builder
SubmitBtn() {
  Button('确认兑换(消耗 ' + this.getProduct().points * this.quantity + ' 积分)')
    .width('100%').height(48)
    .fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
    .borderRadius(D.rMd)
    .onClick(() => {
      if (this.name.length === 0) {
        promptAction.showToast({ message: '请输入姓名' });
        return;
      }
      promptAction.showToast({ message: '兑换成功!消耗 ' + this.getProduct().points * this.quantity + ' 积分' });
    })
}

动态文案

Button('确认兑换(消耗 ' + this.getProduct().points * this.quantity + ' 积分)')

按钮文字 = "确认兑换(消耗 X 积分)"——X 随商品/数量实时变化(选蓝牙耳机数量 2 → "消耗 1600 积分")。"按钮即账单"——用户在点击前就明确知道"这次要花多少"。

与 App 13 的动态按钮对比:App 13 是"标题为空 → 请填写标题"(表单校验态),App 16 是"积分实时显示"(账单态)——动态按钮的两种形态:校验提示 / 实时账单。

校验name.length === 0 → "请输入姓名"(单校验 + 早返回)——"姓名是必填"(收件人必须知道)。

成功提示'兑换成功!消耗 ' + points * quantity + ' 积分'——成功回显消耗积分,用户确认"扣了 1600 没错"。

八、@State 的 6 状态设计

Func1Tab 有 6 个 @State(与 App 15 识别页并列最多):

@State类型用途影响
productIdnumber当前商品(1-6)预览卡 emoji/name/desc/points、按钮积分
quantitynumber数量(1-10)预览卡积分、按钮积分
addrIdnumber地址(1-3)地址单选高亮
namestring姓名提交校验
phonestring手机号(展示)
notestring备注(展示)

productId + quantity 是"计算型状态"——两者共同决定总积分(points * quantity),预览卡和按钮都从它们推导。"状态推导"(derived state)是响应式 UI 的核心:不额外存"总积分"变量(会不同步),而是每次从 productId/quantity 计算——"单一事实来源"

getProduct() 是"查表方法"——把"id → 商品"的映射封装,避免 4 处重复写查找逻辑。**"查表方法 + 派生计算"**是本页状态设计的精髓。

校园积分商城商品页底部 · 确认兑换按钮+安全区留白+底部Tab栏

九、兑换流程的状态联动链

本页的状态联动链(一条完整的"响应式因果链"):

用户点击商品卡 → productId 更新
  → getProduct() 重算 → 预览卡名称/描述/积分更新
    → 按钮文案积分更新
用户点击 + 号 → quantity 更新
  → 预览卡积分更新(points × quantity)
    → 按钮文案积分更新
用户点击地址 → addrId 更新 → 地址单选高亮(● + 背景 + 描边)
用户输入姓名 → name 更新 → 提交校验可用
用户点击提交 → 校验 name → Toast 成功 + 消耗积分

6 个 @State、5 步交互、全程联动——从"选商品"到"提交成功",每一步都有 UI 反馈,这是本系列"交互完整度"最高的页面(App 15 识别页有 doSearch 但无数量联动,App 16 商品页有完整的价格计算链)。

十、与 App 15 识别页的对比

维度App 15 识别查询App 16 商品兑换
核心搜索分类选品 + 数量 + 地址
选择器四色分类 2 列6 商品 3 列
特殊组件快捷查询 Grid数量加减器 + 地址单选
实时计算points × quantity 总积分
动态按钮无(固定文案)"消耗 X 积分"实时更新
辅助方法doSearch/getCategoryIdgetProduct

App 16 的进步:① 数量加减器(边界防护 1-10);② 地址单选(●/○ + 默认标识);③ 积分实时计算链(选品+数量 → 总积分 → 预览卡/按钮联动)。"电商兑换流程"模板可复用到:积分商城、兑换中心、购物车结算、点餐下单——任何"选品 → 定数量 → 填地址 → 确认"的场景。

校园积分商城商品页下部 · 收件信息表单完整+确认兑换按钮(动态消耗积分)

十一、兑换表单的"所见即所得"设计

本页最值得学习的交互哲学是**"所见即所得"(WYSIWYG)**——用户每一步操作,预览卡和按钮都实时反馈:

1. 选商品 → 预览卡立即更新:emoji/名称/描述/积分全部切换(getProduct() 重算)。

2. 加数量 → 积分立即翻倍points × quantity——点一下加号,总积分 +800(蓝牙耳机),用户立刻知道"买 2 个要 1600"。

3. 按钮即账单:"确认兑换(消耗 1600 积分)"——点击前就知道花多少,无"结算时吓一跳"的意外。

"所见即所得"的 3 层价值

  • 信任感:用户全程知道"自己在花什么",不会被"隐藏费用"背刺
  • 操作效率:不用"选完再算总价",边选边看
  • 防误操作:数量点多了,预览卡积分立刻警示(1600 一看就知道"点多了")

实现要点"总积分"不存 @State,而是每次从 productId/quantity 派生计算getProduct().points * this.quantity)——"派生状态"(derived state)保证永远同步。如果存 totalPoints 变量,选商品/加数量时都要手动更新,漏一处就不同步。

对比传统命令式 UI:命令式要"取值 → 计算 → 写回 DOM",声明式只需"状态变 → 计算函数重跑 → UI 自动更新"——"状态派生"是声明式 UI 的核心红利

校园积分商城商品页中段 · 数量加减器+收货地址单选(宿舍默认)+收件信息表单

十二、兑换流程的边界与异常处理

本页的"边界防护"值得总结:

1. 数量边界quantity > 1 / quantity < 10)——数量锁定 1-10。

2. 商品查找兜底getProduct() 返回 templates[0])——productId 非法时回退第一个商品,不会 undefined 崩溃

3. 姓名必填校验name.length === 0 → 拦截)——收件人必填。

4. 未覆盖的边界(真实项目必须补):

  • 积分不足:可用 2,860 但选"校园背包 x3 = 3600"——应校验 points × quantity ≤ 可用积分,不足时按钮置灰"积分不足"
  • 库存校验:数量超过库存(游戏手柄 stock=0 不可选)
  • 手机号格式:11 位数字校验(当前只限制数字键盘)
  • 重复提交:防连点(提交后按钮 loading)

"边界即业务规则"——demo 只实现了"数量/姓名"两个边界,真实项目要把"积分余额/库存/格式/防刷"全补齐。"边界防护"是 demo 到产品的关键差距(demo 防崩溃,产品防损失)。

十三、小结

App 16 商品兑换页解析完毕。积分实时计算链(选品×数量=总价)+ 数量加减器(边界防护)+ 地址单选(●/○+默认)+ 动态账单按钮是四大亮点,配合玫瑰红主题,成为"兑换流程"页面的完整范本。

Logo

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

更多推荐