鸿蒙原生应用 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 个块按"预览 → 选品 → 数量 → 地址 → 信息 → 确认"六步展开:
- PreviewCard— 兑换预览(积分实时计算)
- ProductSelector— 选商品(6 个 3 列 Grid)
- QuantityCard— 选数量(减/数字/加)
- AddressSelector— 选地址(3 个单选)
- FormCard— 收件信息(姓名/手机/备注)
- SubmitBtn— 确认兑换(动态消耗积分)
"预览置顶"是电商兑换页的最佳实践——用户先看到"我要换什么、花多少积分",再往下操作,所有操作都实时反映到预览卡(所见即所得)。
项目源码开源: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浅玫红 vsC.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 | 类型 | 用途 | 影响 |
|---|---|---|---|
productId | number | 当前商品(1-6) | 预览卡 emoji/name/desc/points、按钮积分 |
quantity | number | 数量(1-10) | 预览卡积分、按钮积分 |
addrId | number | 地址(1-3) | 地址单选高亮 |
name | string | 姓名 | 提交校验 |
phone | string | 手机号 | (展示) |
note | string | 备注 | (展示) |
productId+quantity是"计算型状态"——两者共同决定总积分(points * quantity),预览卡和按钮都从它们推导。"状态推导"(derived state)是响应式 UI 的核心:不额外存"总积分"变量(会不同步),而是每次从 productId/quantity 计算——"单一事实来源"。
getProduct()是"查表方法"——把"id → 商品"的映射封装,避免 4 处重复写查找逻辑。**"查表方法 + 派生计算"**是本页状态设计的精髓。
九、兑换流程的状态联动链
本页的状态联动链(一条完整的"响应式因果链"):
用户点击商品卡 → 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/getCategoryId | getProduct |
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 商品兑换页解析完毕。积分实时计算链(选品×数量=总价)+ 数量加减器(边界防护)+ 地址单选(●/○+默认)+ 动态账单按钮是四大亮点,配合玫瑰红主题,成为"兑换流程"页面的完整范本。