news 2026/9/4 20:19:49

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
鸿蒙原生应用 ArkTS 表单工程:积分兑换页的实时算分与数量加减器

鸿蒙原生应用 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— 确认兑换(动态消耗积分)

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

项目源码开源: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类型用途影响
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 处重复写查找逻辑。**"查表方法 + 派生计算"**是本页状态设计的精髓。

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

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 20:18:49

运动跟踪基准全面解析:从评估框架到工程实现

最近在调研人体运动分析与视频理解方向时&#xff0c;被 HumanTracker 这个题目吸引了。它并不是又一个姿态估计模型&#xff0c;而是一类更值得仔细拆解的工作&#xff1a; Motion Tracking Benchmark 。放在当前的研究节奏里&#xff0c;其实很多团队的模型精度已经不低&am…

作者头像 李华
网站建设 2026/9/4 20:18:42

从print(list)到PDF打印:拆解“打印列表”三类场景与排查思路

如果你在一个技术群里问“Printing Lists”&#xff0c;大概率会收到两种回答。一种人以为你问的是 Python 里怎么用print函数打印一个列表&#xff1b;另一种人以为是打印机、PDF、设备列表之类的东西出了问题。这两个回答都有道理&#xff0c;但也说明“打印列表”这个词从一…

作者头像 李华
网站建设 2026/9/4 20:18:18

Python轨道交通客流预测系统:从数据到模型的全流程实践

简介&#xff1a;本资源是一套面向本科毕业设计与课程实践的轨道交通客流预测系统完整实现&#xff0c;基于Python构建&#xff0c;聚焦城市轨交场景下的短时客流趋势建模与可视化分析&#xff0c;适用于计算机、交通工程、智能科学等专业学生开展毕业设计、期末大作业或课程设…

作者头像 李华
网站建设 2026/9/4 20:17:55

C#船舶升级设计源码框架:工业通信、协议解析与船级社认证实践

简介&#xff1a;本资源是一套面向船舶工程技术人员与C#开发者的船舶现代化升级设计源码解决方案&#xff0c;聚焦老旧船舶动力、导航、安全及通信系统的软件层面技术改造与功能增强。压缩包共307个文件&#xff0c;总大小21.09MB&#xff0c;包含137个核心C#源文件&#xff08…

作者头像 李华
网站建设 2026/9/4 20:16:16

DeepSeek Agent开发实战:从Function Calling到前端集成排错

之前在接 DeepSeek 的 Agent 类需求时&#xff0c;很多同学会遇到一个很现实的问题&#xff1a;官方文档和开源仓库都看了&#xff0c;模型接口也能通&#xff0c;但一到“让模型自己决定调用哪个工具、把工具结果再喂回去”这一步就卡住了。尤其是想参考 deepseek-ai 相关的 a…

作者头像 李华
网站建设 2026/9/4 20:15:17

定时器原理与工作模式全解析:从51到STM32的实战指南

我做了十年嵌入式开发&#xff0c;从51单片机一路折腾到Cortex-M系列&#xff0c;踩过最多的坑基本都集中在定时器上。很多人觉得定时器不就是“数数”嘛&#xff0c;但真正做项目时才发现&#xff0c;同一个TIM外设&#xff0c;有人用来输出电机PWM&#xff0c;有人用来捕获编…

作者头像 李华