鸿蒙ArkTS实战解析:健康积分商城的声明式UI架构与交互系统
鸿蒙开发背景与ArkTS语言生态
在当今万物互联的时代,华为推出的HarmonyOS(鸿蒙操作系统)以其独特的分布式架构和跨设备协同能力,正在重塑移动应用的开发范式。鸿蒙操作系统不仅仅是一个手机操作系统,更是一个面向全场景的分布式操作系统,它支持智能手机、平板电脑、智能手表、智慧屏、车机等多种设备形态。在这种多设备协同的背景下,传统的UI开发方式已经难以满足快速迭代和多端适配的需求,因此华为推出了ArkUI框架和ArkTS语言,为开发者提供了一套全新的声明式UI开发体系。
ArkTS是鸿蒙生态中推荐的主力开发语言,它在TypeScript的基础上进行了深度定制和扩展,增加了@Entry、@Component、@State、@Builder等一系列装饰器语法,使得开发者可以用更加简洁、直观的方式描述界面结构和状态逻辑。ArkTS继承了TypeScript的静态类型检查能力,在编译阶段就能发现潜在的类型错误,大大提升了代码的健壮性和可维护性。同时,ArkTS还通过V8引擎的深度优化,保证了运行时的性能表现,使得声明式UI不仅开发效率高,执行效率同样出色。
声明式UI范式是ArkUI框架的核心设计理念。与传统的命令式UI(如Android的findViewById + 手动操作View)不同,声明式UI要求开发者"描述界面应该是什么样子",而不是"告诉系统怎么一步步构建界面"。在ArkTS中,开发者通过链式调用的方式描述组件的属性和层级关系,框架会自动负责UI的创建、更新和销毁。当状态数据发生变化时,框架会自动重新执行build方法,只更新受影响的部分,这种机制被称为"状态驱动UI更新"。这种范式极大地简化了状态管理的复杂度,让开发者可以专注于业务逻辑本身,而不是繁琐的UI同步操作。
ArkUI组件体系是鸿蒙声明式UI的基础设施。它提供了丰富的内置组件,包括容器组件(Column、Row、Stack、Flex、Scroll等)和基础组件(Text、Image、Button、TextInput、Toggle、Progress等)。容器组件负责子组件的布局排列,基础组件负责内容的展示和交互。此外,ArkUI还提供了ForEach列表渲染、if/else条件渲染等渲染控制语法,以及animateTo动画API、aboutToAppear生命周期钩子等高级能力。这些组件和API的组合使用,可以构建出极其复杂的界面效果和交互逻辑,而代码却保持简洁和可读。
核心概念:声明式UI的本质是"状态即UI"。 开发者只需关心数据的正确性,框架自动完成从数据到界面的映射。这种范式彻底改变了前端开发的思维方式,从"操作DOM"转向"管理状态"。
项目概览:健康积分商城的业务架构
本项目是一个完整的"健康积分商城"应用,采用电商多巴胺浅色风格设计,具备7个Tab页面的双排底部导航(4+3布局)、6个功能弹框、金刚区头部、双列商品瀑布流、签到日历、积分收支横条和夺宝进度条等丰富的功能模块。所有交互都是真实生效的,包括Tab切换、兑换扣积分、每日签到、完成任务、夺宝参与、编辑目标和删除记录等操作。整个应用展示了鸿蒙ArkTS在构建复杂电商类应用时的架构能力和交互设计水平。
从上面的架构流程图可以看出,整个应用的数据流是单向的:状态数据驱动UI渲染,用户交互修改状态数据,状态变化触发UI自动更新。这种清晰的数据流是声明式UI的核心优势,也是保证应用复杂度可控的关键。
数据模型定义:六个接口的结构化设计
商品接口 GoodT6
interface GoodT6 {
name: string
points: number
price: number
stock: number
tag: string
emoji: string
hot: number
}

这段代码定义了商品的数据结构,包含了商品名称、所需积分、参考价格、库存数量、标签分类、表情图标和热度值共七个字段。在ArkTS中,interface用于定义对象的形状(Shape),它描述了一组属性的类型约束。与TypeScript中的interface一样,ArkTS的interface在编译阶段进行类型检查,在运行时不会产生任何代码,因此不会影响应用的包体积。
技术要点:ArkTS中的interface是纯类型声明,不参与运行时逻辑。 这意味着interface本身不消耗内存,只在编译阶段帮助开发者捕获类型错误。这是ArkTS类型安全体系的基础设施。
任务接口 TaskT6
interface TaskT6 {
title: string
desc: string
pts: number
kind: string
done: boolean
}
任务接口定义了健康任务的完整描述,包括任务标题、描述文字、奖励积分、任务类型(每日/每周/长期)和完成状态。值得注意的是kind字段使用string类型而非枚举,这在快速原型开发中是一种常见的灵活处理方式。done字段是boolean类型,用于标记任务是否已完成,它直接影响UI上的按钮文案和颜色展示。
签到接口 SignT6
interface SignT6 {
day: string
pts: number
signed: boolean
}

签到接口简洁明了,只有三个字段:星期几、该日签到可获得的积分、是否已签到。这个接口虽然简单,但承载了签到日历的核心数据逻辑。在实际应用中,签到数据通常需要持久化到本地或服务端,这里为了演示目的使用了内存数据。
夺宝接口 LuckyT6
interface LuckyT6 {
name: string
total: number
joined: number
mine: number
emoji: string
}
夺宝接口定义了积分夺宝活动数据,包含活动名称、总人次、已参与人次、我参与的人次和活动图标。total和joined两个字段用于计算进度条百分比,mine字段用于展示用户的参与程度。这种数据结构设计使得进度条的计算变得非常直观:joined / total * 100即可得到进度百分比。
记录接口 RecT6 与 横幅接口 BannerT6
interface RecT6 {
title: string
pts: number
date: string
kind: string
}
interface BannerT6 {
title: string
sub: string
bg: string
emoji: string
}
积分记录接口RecT6定义了积分变动明细的每一条记录,pts字段为正数表示获得积分,为负数表示消耗积分。这种用正负号区分收支的设计在UI层只需要一个简单的三元运算符即可决定颜色:r.pts > 0 ? '#4ECDC4' : '#FF6B6B'。横幅接口BannerT6定义了头部轮播横幅的数据,其中bg字段直接存储颜色值字符串,使得每张横幅可以拥有不同的背景色,实现了数据驱动的视觉差异化。
静态数据源:七个常量数组的初始化
商品数据 GOODS6
const GOODS6: Array<GoodT6> = [
{ name: '全身体检 5 折券', points: 3000, price: 300, stock: 42, tag: '超值', emoji: '🎫', hot: 98 },
{ name: '超声波洁牙免单券', points: 2400, price: 260, stock: 18, tag: '爆款', emoji: '🦷', hot: 95 },
{ name: '专家号优先预约卡', points: 1800, price: 0, stock: 66, tag: '稀缺', emoji: '💳', hot: 92 },
{ name: '智能体脂秤 S1', points: 5200, price: 129, stock: 9, tag: '数码', emoji: '⚖️', hot: 88 },
{ name: '颈椎按摩仪 Mini', points: 8800, price: 199, stock: 5, tag: '热销', emoji: '💆', hot: 90 },
{ name: '医用外科口罩×100', points: 800, price: 25, stock: 120, tag: '常备', emoji: '😷', hot: 85 },
{ name: '叶黄素软糖 60 粒', points: 1600, price: 89, stock: 33, tag: '护眼', emoji: '🍬', hot: 82 },
{ name: '电子血压计臂式', points: 6400, price: 169, stock: 12, tag: '家庭', emoji: '🩺', hot: 87 },
{ name: '艾灸贴×10 贴', points: 600, price: 39, stock: 88, tag: '养生', emoji: '🌿', hot: 78 },
{ name: '体检加项抵用 ¥50', points: 1000, price: 50, stock: 200, tag: '通用', emoji: '💰', hot: 91 },
{ name: '睡眠助眠香薰灯', points: 3600, price: 99, stock: 21, tag: '好眠', emoji: '🕯️', hot: 76 },
{ name: '儿童视力筛查券', points: 1200, price: 60, stock: 45, tag: '亲子', emoji: '👓', hot: 80 },
{ name: '筋膜枪 Mini', points: 7600, price: 179, stock: 7, tag: '运动', emoji: '🔨', hot: 89 },
{ name: '体检早餐升级券', points: 300, price: 15, stock: 300, tag: '贴心', emoji: '🥣', hot: 70 }
]

这是一组完整的商品静态数据,包含14件健康相关商品。数据采用const关键字声明,表示这是一个不可变引用。Array<GoodT6>是ArkTS的泛型数组类型声明,明确指定数组中每个元素都必须符合GoodT6接口的结构约束。在实际开发中,这类数据通常来自服务端API,这里为了演示目的使用了硬编码的静态数据。
从数据中可以观察到几个设计细节:第一,积分跨度从300到8800,覆盖了从低门槛到高端的不同层次;第二,库存数量从5到300不等,低库存的商品在UI上会以红色进度条和"仅剩N件"的文字提示来营造紧迫感;第三,每件商品都配有一个emoji表情作为视觉标识,这是一种轻量级的图标方案,无需引入图片资源即可实现丰富的视觉表现。
设计思考:使用emoji作为商品图标是一种聪明的轻量方案。 在原型开发阶段,emoji可以快速填充视觉空白,避免引入大量图片资源带来的包体积问题。但在生产环境中,建议替换为专业的SVG图标或IconFont,以获得更一致的视觉风格和更好的渲染性能。
任务数据 TASKS6
const TASKS6: Array<TaskT6> = [
{ title: '每日步数 6000+', desc: '今天走够 6000 步', pts: 30, kind: '每日', done: true },
{ title: '记录一次血压', desc: '早晚任一次记录', pts: 20, kind: '每日', done: true },
{ title: '睡眠 7 小时以上', desc: '昨晚睡眠达标', pts: 30, kind: '每日', done: false },
{ title: '喝够 8 杯水', desc: '今日饮水打卡', pts: 15, kind: '每日', done: false },
{ title: '体重记录', desc: '晨起空腹称重', pts: 10, kind: '每日', done: true },
{ title: '健康文章阅读 3 分钟', desc: '学一点健康知识', pts: 10, kind: '每日', done: true },
{ title: '完成一次体检预约', desc: '为自己或家人', pts: 200, kind: '每周', done: false },
{ title: '每周运动 3 次', desc: '本周已 2/3 次', pts: 100, kind: '每周', done: false },
{ title: '连续签到 7 天', desc: '本周已签 5 天', pts: 150, kind: '每周', done: false },
{ title: '分享健康报告', desc: '转发给家人查看', pts: 50, kind: '每周', done: true },
{ title: '完成年度体检', desc: '年度大任务', pts: 1000, kind: '长期', done: false },
{ title: '邀请家人加入', desc: '邀请 1 位家人', pts: 300, kind: '长期', done: true }
]

任务数据包含12个健康任务,按kind字段分为"每日"、"每周"和"长期"三类。这种分类设计使得UI层可以通过this.tasks.filter((t: TaskT6) => t.kind === k)进行分组展示。任务积分从10到1000不等,每日任务积分较低(10-30分),每周任务中等(50-200分),长期任务最高(300-1000分),形成了一个合理的激励梯度。
签到、夺宝、记录、横幅与周积分数据
const SIGNS6: Array<SignT6> = [
{ day: '周一', pts: 10, signed: true },
{ day: '周二', pts: 15, signed: true },
{ day: '周三', pts: 20, signed: true },
{ day: '周四', pts: 25, signed: true },
{ day: '周五', pts: 30, signed: true },
{ day: '周六', pts: 40, signed: false },
{ day: '周日', pts: 60, signed: false }
]
签到数据展示了从周一到周日的签到状态,积分从10分递增到60分,周日最高。这种递增设计激励用户持续签到,越到后面奖励越丰厚。当前状态是周一至周五已签到,周六和周日待签,签到率约71%。
const LUCKY6: Array<LuckyT6> = [
{ name: '体检全家福套餐', total: 2000, joined: 1687, mine: 0, emoji: '👨👩👧👦' },
{ name: 'Apple Watch SE', total: 5000, joined: 4210, mine: 0, emoji: '⌚' },
{ name: '按摩椅体验月卡', total: 800, joined: 766, mine: 0, emoji: '🪑' },
{ name: '体检加项大礼包', total: 1200, joined: 905, mine: 0, emoji: '🎁' },
{ name: '智能手环 8', total: 3000, joined: 2511, mine: 0, emoji: '⌚' },
{ name: '年度 VIP 会员', total: 1000, joined: 882, mine: 0, emoji: '👑' },
{ name: '基因检测一次', total: 1500, joined: 1402, mine: 0, emoji: '🧬' },
{ name: '营养师季卡', total: 600, joined: 455, mine: 0, emoji: '🥗' }
]

夺宝数据包含8个活动,每个活动有不同的总人次和已参与人次。从数据可以看出,有些活动接近开奖(如按摩椅体验月卡已参与766/800),有些还远未达到开奖条件。这种数据设计为UI层的进度条展示提供了丰富的视觉差异。
const RECS6: Array<RecT6> = [
{ title: '兑换超声波洁牙券', pts: -2400, date: '08-27', kind: '兑换' },
{ title: '每日签到奖励', pts: 30, date: '08-27', kind: '签到' },
{ title: '完成步数任务', pts: 30, date: '08-27', kind: '任务' },
{ title: '完成血压记录', pts: 20, date: '08-27', kind: '任务' },
{ title: '邀请奶奶加入家庭', pts: 300, date: '08-26', kind: '长期' },
{ title: '兑换口罩 100 只', pts: -800, date: '08-26', kind: '兑换' },
{ title: '每日签到奖励', pts: 25, date: '08-26', kind: '签到' },
{ title: '完成年度体检', pts: 1000, date: '08-25', kind: '长期' },
{ title: '分享报告给家人', pts: 50, date: '08-25', kind: '每周' },
{ title: '每日签到奖励', pts: 20, date: '08-25', kind: '签到' },
{ title: '连续签到 7 天奖励', pts: 150, date: '08-24', kind: '每周' },
{ title: '注册奖励', pts: 500, date: '08-01', kind: '一次性' }
]

积分记录数据按时间倒序排列,最新的记录在最前面。每条记录的pts字段用正负号区分收入和支出,这种设计在UI层非常直观:正数显示绿色加号,负数显示红色。kind字段的值包括"兑换"、“签到”、“任务”、“长期”、“每周”、"一次性"等多种类型,为记录的分类筛选提供了基础。
const BANNERS6: Array<BannerT6> = [
{ title: '积分翻倍周', sub: '本周任务积分 ×2', bg: '#FF6B6B', emoji: '🎉' },
{ title: '新人体检礼包', sub: '1 元领 3 项检查', bg: '#6C5CE7', emoji: '🎁' },
{ title: '家庭组队瓜分', sub: '百万积分池等你来', bg: '#4ECDC4', emoji: '👨👩👧' }
]
const WEEKPTS6: Array<number> = [65, 85, 120, 75, 95, 60, 30]
横幅数据包含三张促销活动卡片,每张使用不同的背景色(红色、紫色、青色),形成视觉差异。周积分数据WEEKPTS6是一个纯数字数组,表示一周七天每天获得的积分,最大值120(周三),最小值30(周日),用于在首页绘制柱状图。
组件入口与状态管理
@Entry与@Component装饰器
@Entry
@Component
struct Index {
@Entry装饰器标记该组件为页面的入口组件,一个页面只能有一个@Entry组件。@Component装饰器声明这是一个自定义组件,只有被@Component装饰的struct才能使用@State、@Builder等装饰器。struct是ArkTS中定义组件的关键字,与TypeScript中的class不同,struct是值类型,但在ArkTS中经过编译器处理后,其行为更像引用类型。
核心概念:@Entry是页面的"大门"。 它告诉系统"这个组件是一个独立页面的根"。没有@Entry装饰的组件只能被其他组件引用,不能独立作为页面存在。@Component则是组件的"身份证",它赋予了struct使用ArkUI装饰器语法的能力。
状态变量定义
@State selTab: number = 0
@State goods: Array<GoodT6> = GOODS6.slice()
@State tasks: Array<TaskT6> = TASKS6.slice()
@State signs: Array<SignT6> = SIGNS6.slice()
@State luckys: Array<LuckyT6> = LUCKY6.slice()
@State recs: Array<RecT6> = RECS6.slice()
@State myPts: number = 4860
@State signDays: number = 18

@State是ArkTS中最核心的状态管理装饰器。被@State装饰的变量是组件的内部状态,当这些变量的值发生变化时,框架会自动重新执行build方法(或相关的@Builder),更新依赖这些状态的UI部分。
值得注意的是,这里使用了GOODS6.slice()而不是直接赋值GOODS6。slice()方法创建数组的浅拷贝,这样组件内部的状态数组与原始常量数组就独立了。这是一个重要的防御性编程实践:如果不使用slice(),组件内部对数组的修改(如兑换商品时减少库存)会直接修改原始常量数据,导致数据污染。
selTab变量控制当前选中的Tab索引,初始值为0(首页)。myPts变量存储用户当前可用积分,初始值4860。signDays变量记录连续签到天数,初始值18。这三个变量是应用的核心状态,几乎所有的交互逻辑都围绕它们展开。
技术要点:@State的响应式机制。 @State变量的变化检测是基于赋值操作的。对于基本类型(number、boolean、string),直接赋值即可触发更新。对于数组类型,必须赋值一个新数组才能触发更新,仅修改数组元素的属性(如
arr[0].name = 'xxx')不会触发。这就是为什么后续代码中大量使用this.goods = this.goods.map(...)而非直接修改数组元素的原因。
弹框状态变量
// 兑换
@State showEx: boolean = false
@State exGood: GoodT6 = GOODS6[0]
@State exCnt: number = 1
// 签到
@State showSign: boolean = false
@State makeUp: boolean = false
// 任务详情
@State showTask: boolean = false
@State selTask: TaskT6 = TASKS6[0]
// 夺宝
@State showLucky: boolean = false
@State selLucky: LuckyT6 = LUCKY6[0]
@State luckyCnt: number = 1
// 编辑目标
@State showGoal: boolean = false
@State goalPts: number = 8000
@State goalRemind: boolean = true
// 删除记录
@State showDel: boolean = false
@State delRec: RecT6 = RECS6[0]
@State delArmed: boolean = false

这段代码定义了6个弹框的状态变量,每个弹框对应一组状态。以兑换弹框为例:showEx控制弹框的显示/隐藏,exGood存储当前选中的商品,exCnt存储兑换数量。这种"一个boolean开关 + 若干上下文数据"的模式是ArkTS中实现弹框的常见做法。
删除记录弹框中的delArmed变量是一个"安全开关",用户需要先拨动Toggle确认后才能点击删除按钮,这是一种防止误操作的交互设计。编辑目标弹框中的goalPts和goalRemind分别存储积分目标值和是否开启提醒。
动画状态变量
// 特效
@State coinY: number = 0
@State flashOp: number = 0.5
coinY控制金币图标的垂直偏移量,用于实现上下浮动的动画效果。flashOp控制限时闪兑区域的透明度,用于实现闪烁效果。这两个变量的值由aboutToAppear中的animateTo动画自动驱动,开发者不需要手动修改它们。
生命周期与动画系统
aboutToAppear生命周期钩子
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 2000, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.coinY = -14
})
this.getUIContext().animateTo({ duration: 700, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.flashOp = 1
})
}
aboutToAppear是ArkTS组件的生命周期钩子,在组件创建后、build方法执行前调用。它通常用于初始化数据、启动动画和发起网络请求。这里在aboutToAppear中启动了两个无限循环动画。
this.getUIContext().animateTo()是ArkUI提供的动画API。第一个参数是动画配置对象:duration指定动画持续时间(毫秒),iterations: -1表示无限循环,playMode: PlayMode.Alternate表示交替播放(正向→反向→正向…),curve: Curve.EaseInOut指定缓动曲线为先慢后快再慢。第二个参数是回调函数,在回调中修改状态变量的值,框架会在动画期间自动插值过渡。
第一个动画持续2秒,将coinY从0过渡到-14,交替播放后金币会上下浮动,模拟金币飘动效果。第二个动画持续0.7秒,将flashOp从0.5过渡到1,交替播放后元素会闪烁,用于限时闪兑区域的视觉强调。
技术要点:PlayMode枚举。 PlayMode定义了动画的播放模式:
Normal(正向播放一次)、Reverse(反向播放一次)、Alternate(交替播放)、Iterations(按迭代次数播放)。在无限循环场景下,Alternate是最常用的模式,因为它能产生"呼吸感"的动画效果,比单向循环更自然。
业务逻辑方法层
任务完成统计 taskDone6
taskDone6(): number {
let n: number = 0
this.tasks.forEach((t: TaskT6) => {
if (t.done) {
n += 1
}
})
return n
}
这个方法遍历任务数组,统计已完成任务的数量。forEach是数组的遍历方法,对每个元素执行回调函数。这里使用一个局部变量n作为计数器,遍历结束后返回计数结果。这个方法的返回值在UI层用于显示"X/12 已完成"的进度信息,以及计算任务进度环的百分比。
签到率计算 signRate6
signRate6(): number {
let n: number = 0
this.signs.forEach((s: SignT6) => {
if (s.signed) {
n += 1
}
})
return Math.round(n * 100 / 7)
}
签到率计算方法与taskDone6类似,遍历签到数组统计已签天数,然后通过Math.round(n * 100 / 7)计算百分比并四舍五入。Math.round是JavaScript/TypeScript内置的数学函数,ArkTS完全支持。返回值直接用于Progress进度条的value属性。
兑换确认逻辑 confirmEx
confirmEx(): void {
const cost: number = this.exCnt * this.exGood.points
if (cost > this.myPts) {
return
}
this.myPts -= cost
this.goods = this.goods.map((x: GoodT6) => {
if (x === this.exGood) {
const ng: GoodT6 = { name: x.name, points: x.points, price: x.price, stock: x.stock - this.exCnt, tag: x.tag, emoji: x.emoji, hot: x.hot + 1 }
return ng
}
return x
})
const nr: RecT6 = { title: '兑换 ' + this.exGood.name + ' ×' + this.exCnt.toString(), pts: -cost, date: '08-28', kind: '兑换' }
this.recs = [nr].concat(this.recs)
this.showEx = false
}
这是兑换商品的核心业务逻辑,包含以下几个步骤:
第一步,计算消耗积分cost = exCnt * exGood.points,并检查积分是否充足。如果不足,直接return,不执行任何操作。
第二步,扣除积分this.myPts -= cost。由于myPts是@State变量,这个修改会触发UI更新,积分余额会实时变化。
第三步,更新商品库存。使用map方法遍历商品数组,找到选中商品后创建一个新的GoodT6对象(注意不是修改原对象,而是创建新对象),将库存减去兑换数量,热度加1。这里使用x === this.exGood进行引用比较,确保只修改选中的那个商品。
第四步,创建积分记录。构造一个新的RecT6对象,pts为负数(表示支出),然后通过[nr].concat(this.recs)将新记录插入到数组最前面,保证最新的记录显示在最上方。
第五步,关闭弹框this.showEx = false。
关键技术:不可变数据更新模式。 这段代码中使用了
this.goods = this.goods.map(...)而不是直接修改this.goods[i].stock -= exCnt。这是ArkTS中触发数组状态更新的正确方式——必须赋值一个新数组。map方法会创建并返回一个新数组,满足了这个要求。同时,对于数组中的对象元素,也是创建新对象(const ng = {...})而非修改原对象,这种"不可变更新"模式保证了状态的纯净性和可追踪性。
签到确认逻辑 confirmSign
confirmSign(): void {
const list: Array<SignT6> = this.signs.slice()
for (let i: number = 0; i < list.length; i++) {
if (!list[i].signed) {
const ns: SignT6 = { day: list[i].day, pts: list[i].pts, signed: true }
list[i] = ns
this.myPts += this.makeUp ? Math.floor(list[i].pts / 2) : list[i].pts
const nr: RecT6 = { title: this.makeUp ? '补签奖励' : '每日签到奖励', pts: this.makeUp ? Math.floor(list[i].pts / 2) : list[i].pts, date: '08-28', kind: '签到' }
this.recs = [nr].concat(this.recs)
break
}
}
this.signs = list
if (!this.makeUp) {
this.signDays += 1
}
this.showSign = false
}
签到逻辑比兑换更复杂一些。首先用slice()创建签到数组的副本,然后使用for循环找到第一个未签到的天数。找到后,创建一个新的SignT6对象将signed设为true,更新到副本数组中。
接下来根据makeUp(是否补签)决定积分发放:正常签到获得全额积分,补签只能获得半额积分(Math.floor(pts / 2)向下取整)。然后创建积分记录并插入到记录列表最前面。
最后,将修改后的副本数组赋值给this.signs(触发UI更新),如果是正常签到则递增连续签到天数,关闭弹框。这里使用break跳出循环,确保每次只签到一天。
任务完成逻辑 finishTask6
finishTask6(t: TaskT6): void {
if (t.done) {
return
}
this.tasks = this.tasks.map((x: TaskT6) => {
if (x === t) {
const nt: TaskT6 = { title: x.title, desc: x.desc, pts: x.pts, kind: x.kind, done: true }
return nt
}
return x
})
this.myPts += t.pts
const nr: RecT6 = { title: '完成任务:' + t.title, pts: t.pts, date: '08-28', kind: '任务' }
this.recs = [nr].concat(this.recs)
this.showTask = false
}
任务完成逻辑首先检查任务是否已完成(防止重复领取),然后使用map方法将目标任务的状态更新为done: true。接着增加用户积分、创建积分记录、关闭任务详情弹框。与confirmEx相同的不可变更新模式在这里同样适用。
夺宝参与逻辑 confirmLucky
confirmLucky(): void {
const cost: number = this.luckyCnt * 100
if (cost > this.myPts) {
return
}
this.myPts -= cost
this.luckys = this.luckys.map((x: LuckyT6) => {
if (x === this.selLucky) {
const nl: LuckyT6 = { name: x.name, total: x.total, joined: x.joined + this.luckyCnt, mine: x.mine + this.luckyCnt, emoji: x.emoji }
return nl
}
return x
})
const nr: RecT6 = { title: '参与夺宝:' + this.selLucky.name, pts: -cost, date: '08-28', kind: '夺宝' }
this.recs = [nr].concat(this.recs)
this.showLucky = false
}
夺宝参与的逻辑与兑换类似:计算消耗积分(每次100积分)、检查积分余额、扣除积分、更新夺宝数据(joined和mine都增加参与人次)、创建积分记录、关闭弹框。每次参与100积分的设计使得成本计算非常简单:luckyCnt * 100。
删除记录逻辑 doDel6
doDel6(): void {
this.recs = this.recs.filter((x: RecT6) => x !== this.delRec)
this.showDel = false
}
删除记录逻辑使用filter方法过滤掉要删除的记录,返回一个不包含该记录的新数组,赋值给this.recs。filter方法不会修改原数组,而是创建新数组,这符合ArkTS的状态更新要求。注意这里使用了引用比较x !== this.delRec,因为delRec存储的是对原始数组中某个对象的引用。
待领积分计算 pendPts6
pendPts6(): number {
let n: number = 0
this.tasks.forEach((t: TaskT6) => {
if (!t.done && t.kind === '每日') {
n += t.pts
}
})
return n
}
这个方法计算今日待领取的积分总额,只统计未完成且类型为"每日"的任务。这个值在任务页面头部展示为"今日待领 X 分",给用户一个明确的激励目标。
底部导航栏构建
Tab按钮构建器 tabBtn6
@Builder
tabBtn6(icon: string, label: string, idx: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(idx === this.selTab ? 20 : 18)
.opacity(idx === this.selTab ? 1 : 0.55)
Text(label)
.fontSize(10)
.fontColor(idx === this.selTab ? '#FF6B6B' : '#9AA5B1')
.fontWeight(idx === this.selTab ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.onClick(() => {
this.selTab = idx
})
}
@Builder装饰器定义了一个可复用的UI构建函数。与直接在build中写代码不同,@Builder可以被多次调用,实现UI的复用。这个tabBtn6构建器接收三个参数:图标(emoji)、标签文字和索引值。
Column({ space: 3 })创建一个垂直布局容器,子元素之间的间距为3vp。Column是ArkUI中最基础的容器组件之一,它将子元素按垂直方向从上到下排列。
容器组件详解:Column。 Column是ArkUI中的纵向布局容器,它的核心作用是将子组件按垂直方向排列。它接受一个可选的配置对象参数,其中
space属性定义子元素之间的间距。Column的主轴方向是垂直的,交叉轴方向是水平的。通过justifyContent可以控制子元素在主轴(垂直)方向的对齐方式,通过alignItems可以控制子元素在交叉轴(水平)方向的对齐方式。
三元运算符idx === this.selTab ? 20 : 18实现了选中态与未选中态的视觉区分:选中时图标更大(20vp)、不透明(1)、文字加粗且变红;未选中时图标较小(18vp)、半透明(0.55)、文字正常且灰色。这种基于状态的条件样式是声明式UI的典型用法。
layoutWeight(1)是ArkUI的弹性布局属性,它让当前组件在父容器中按权重分配剩余空间。这里所有Tab按钮都设置了layoutWeight(1),意味着它们等分父容器的宽度。onClick回调中修改selTab的值,触发Tab切换。
双排Tab栏 tabBar6
@Builder
tabBar6() {
Column() {
Divider()
.color('#E8EBF4')
Row() {
this.tabBtn6('🏠', '首页', 0)
this.tabBtn6('⭐', '积分', 1)
this.tabBtn6('🛍️', '兑换', 2)
this.tabBtn6('📋', '任务', 3)
}
.width('100%')
Row() {
this.tabBtn6('📅', '签到', 4)
this.tabBtn6('🎰', '夺宝', 5)
this.tabBtn6('🙋', '我的', 6)
}
.width('100%')
}
.width('100%')
.backgroundColor('#FFFFFF')
}
底部Tab栏采用4+3的双排布局设计。第一排4个Tab(首页、积分、兑换、任务),第二排3个Tab(签到、夺宝、我的),共7个页面。这种双排设计在Tab数量超过5个时是一种合理的解决方案,避免了单排Tab的图标过小问题。
容器组件详解:Row。 Row是ArkUI中的横向布局容器,与Column互补。Column按垂直方向排列子元素,Row按水平方向排列子元素。Row的主轴方向是水平的,交叉轴方向是垂直的。在这个Tab栏中,外层Column包含一个Divider和两个Row,Divider是分割线组件,用于在视觉上分隔Tab栏和上方内容区。
Divider是ArkUI的分割线组件,color方法设置分割线颜色。外层Column设置backgroundColor('#FFFFFF')确保Tab栏有白色背景。两个Row都设置了width('100%'),确保它们占满父容器的宽度。
头部区域构建 headerBar6
标题与积分显示
@Builder
headerBar6() {
Column({ space: 12 }) {
Row() {
Column({ space: 2 }) {
Text('健康积分商城')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('做任务 · 攒积分 · 换健康好物')
.fontSize(11)
.fontColor('#9AA5B1')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row({ space: 6 }) {
Text('🪙')
.fontSize(16)
.offset({ y: this.coinY })
Text(this.myPts.toString())
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#FFE9E9')
.borderRadius(14)
.onClick(() => {
this.selTab = 1
})
}
.width('100%')
头部区域使用Column容器嵌套多个Row和Column实现复杂的布局结构。最外层是Column({ space: 12 }),设置子元素间距12vp。第一行是标题和积分余额的横向排列。
左侧标题区使用Column({ space: 2 }),包含应用名称(18vp粗体)和副标题(11vp灰色)。alignItems(HorizontalAlign.Start)设置交叉轴左对齐,使文字靠左排列。layoutWeight(1)让标题区占据剩余空间。
右侧积分显示区使用Row({ space: 6 }),包含金币emoji和积分数值。offset({ y: this.coinY })给金币图标添加垂直偏移,coinY的值由aboutToAppear中的animateTo动画驱动,实现金币上下浮动的效果。整个积分区域有浅红色背景(#FFE9E9)和14vp圆角,点击后跳转到积分Tab(this.selTab = 1)。
搜索栏
Row({ space: 8 }) {
Text('🔎')
.fontSize(15)
Text('搜索商品 / 任务 / 夺宝')
.fontSize(13)
.fontColor('#B8C0CC')
.layoutWeight(1)
Text('搜')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#FF6B6B')
.borderRadius(10)
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(20)
搜索栏是一个Row容器,包含搜索图标、占位文字和搜索按钮。占位文字使用layoutWeight(1)占据中间空间,搜索按钮使用红色背景和白色文字。整个搜索栏有白色背景和20vp大圆角,视觉上是一个独立的卡片。
金刚区入口
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🎫')
.fontSize(22)
Text('体检券')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 2
})
Column({ space: 4 }) {
Text('😷')
.fontSize(22)
Text('健康好物')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 2
})
Column({ space: 4 }) {
Text('📋')
.fontSize(22)
Text('赚积分')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 3
})
Column({ space: 4 }) {
Text('📅')
.fontSize(22)
Text('每日签到')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.showSign = true
})
Column({ space: 4 }) {
Text('🎰')
.fontSize(22)
Text('积分夺宝')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 5
})
}
.width('100%')
金刚区是电商应用的经典功能区,这里包含5个快捷入口:体检券、健康好物、赚积分、每日签到和积分夺宝。每个入口都是一个Column容器,包含emoji图标和文字标签,使用layoutWeight(1)等分宽度,有白色背景和14vp圆角。
每个入口的点击事件不同:体检券和健康好物跳转到兑换Tab(selTab = 2),赚积分跳转到任务Tab(selTab = 3),每日签到直接打开签到弹框(showSign = true),积分夺宝跳转到夺宝Tab(selTab = 5)。这种金刚区直接弹框的设计减少了用户操作路径,提升了交互效率。
横幅轮播
Scroll() {
Row({ space: 10 }) {
ForEach(BANNERS6, (b: BannerT6) => {
Row({ space: 10 }) {
Text(b.emoji)
.fontSize(28)
Column({ space: 2 }) {
Text(b.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(b.sub)
.fontSize(10)
.fontColor('#FFFFFF')
.opacity(0.85)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去参与')
.fontSize(10)
.fontColor(b.bg)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FFFFFF')
.borderRadius(10)
}
.width(240)
.padding(13)
.backgroundColor(b.bg)
.borderRadius(16)
}, (b: BannerT6) => b.title)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
横幅轮播使用横向Scroll容器实现。Scroll是ArkUI的滚动容器组件,可以包含超出自身尺寸的内容,支持滚动查看。scrollable(ScrollDirection.Horizontal)设置滚动方向为水平,scrollBar(BarState.Off)隐藏滚动条,实现干净的横滑效果。
ForEach是ArkUI的列表渲染语法,接收三个参数:数据源数组、子项生成函数和键值生成函数。这里遍历BANNERS6数组,为每个Banner生成一张横幅卡片。每张卡片固定宽度240vp,使用Banner的bg属性作为背景色,包含emoji图标、标题、副标题和"去参与"按钮。
渲染控制:ForEach。 ForEach是ArkUI中最常用的列表渲染语法。它的第一个参数是数据源数组,第二个参数是子项生成回调函数(返回组件树),第三个参数是键值生成函数(用于Diff算法,决定哪些项需要更新)。键值函数的重要性往往被忽视——如果键值不唯一或不稳定,可能导致列表更新异常。这里使用
b.title作为键值,因为每个横幅的标题是唯一的。
容器组件详解:Scroll。 Scroll是ArkUI的滚动容器,它可以使内容超出自身边界时支持滚动浏览。通过
scrollable方法可以设置滚动方向(Horizontal水平、Vertical垂直),通过scrollBar方法可以控制滚动条的显示状态(On显示、Off隐藏、Auto自动)。Scroll常用于实现轮播图、横向列表、长页面等场景。
区块标题构建器 secTitle6
@Builder
secTitle6(icon: string, title: string, extra: string) {
Row() {
Text(icon)
.fontSize(15)
.margin({ right: 6 })
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
.layoutWeight(1)
Text(extra)
.fontSize(12)
.fontColor('#FF6B6B')
}
.width('100%')
.margin({ top: 18, bottom: 10 })
}
这是一个通用的区块标题构建器,在多个Tab页面中复用。它接收三个参数:图标emoji、标题文字和附加文字(通常是操作引导或计数)。使用Row横向排列,标题占据主要空间(layoutWeight(1)),附加文字靠右显示为红色。上下边距(margin: { top: 18, bottom: 10 })保证了区块之间的视觉间距。
首页Tab homeTab
限时闪兑区域
@Builder
homeTab() {
Column({ space: 0 }) {
this.secTitle6('⚡', '限时闪兑', '手慢无')
Scroll() {
Row({ space: 12 }) {
ForEach(this.goods.slice(0, 4), (g: GoodT6) => {
Column({ space: 6 }) {
Text(g.emoji)
.fontSize(30)
.opacity(this.flashOp)
Text(g.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 130 })
Text('🪙 ' + g.points.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
Text('仅剩 ' + g.stock.toString() + ' 件')
.fontSize(10)
.fontColor('#F0932B')
Text('抢兑')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 5, bottom: 5 })
.backgroundColor('#FF6B6B')
.borderRadius(12)
.onClick(() => {
this.exGood = g
this.exCnt = 1
this.showEx = true
})
}
.width(150)
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}, (g: GoodT6) => g.name + g.stock.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
首页的第一个区块是"限时闪兑",展示前4件商品。使用横向Scroll容器实现横滑卡片列表。每张卡片是一个Column容器,宽度固定150vp。
opacity(this.flashOp)给商品emoji添加了闪烁动画效果,flashOp的值在0.5到1之间循环变化,由aboutToAppear中的animateTo驱动。这种全局动画状态变量的复用是ArkTS动画系统的一种高效实践——一个动画变量可以同时驱动多个UI元素的动画效果。
maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis })组合使用实现单行文本溢出省略号效果。constraintSize({ maxWidth: 130 })限制文本最大宽度为130vp,防止长名称撑破卡片布局。这两个属性配合使用是ArkUI中处理长文本的标准做法。
文本处理:maxLines与textOverflow。
maxLines限制文本的最大行数,textOverflow定义溢出时的处理方式。TextOverflow.Ellipsis表示用省略号(…)代替溢出部分。如果不设置textOverflow,即使设置了maxLines,超出的文字也会被直接截断而非显示省略号,用户体验较差。
每张卡片的"抢兑"按钮点击后会设置exGood为当前商品、exCnt为1、showEx为true,从而打开兑换确认弹框。这种"先设置上下文数据,再打开弹框"的模式在ArkTS弹框开发中非常常见。
本周积分柱状图
this.secTitle6('📊', '本周积分获取', '每日走势')
Column({ space: 8 }) {
Row() {
ForEach(WEEKPTS6, (p: number, i: number) => {
Column({ space: 4 }) {
Text(p.toString())
.fontSize(9)
.fontColor('#9AA5B1')
Column() {
Row()
.width('100%')
.height((p * 100 / 130).toFixed(0) + '%')
.backgroundColor(p >= 100 ? '#4ECDC4' : '#FF6B6B')
.borderRadius({ topLeft: 4, topRight: 4 })
}
.width(20)
.height(64)
.backgroundColor('#F0F2F8')
.borderRadius(4)
.justifyContent(FlexAlign.End)
Text('周' + '一二三四五六日'.charAt(i))
.fontSize(10)
.fontColor('#9AA5B1')
}
.layoutWeight(1)
}, (p: number, i: number) => i.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
Text('累计本周获取 530 分,超过 78% 的用户')
.fontSize(11)
.fontColor('#9AA5B1')
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(18)
这是一个自定义柱状图实现,没有使用任何图表库,纯靠ArkUI的基础组件组合而成。ForEach遍历WEEKPTS6数组,为每个数据点生成一根柱子。
每根柱子的核心实现是:外层Column固定高度64vp作为柱子的"轨道",背景色为浅灰色(#F0F2F8)。内层Row的宽度100%,高度通过(p * 100 / 130).toFixed(0) + '%'动态计算——以130作为基准值,将积分值转换为百分比高度。justifyContent(FlexAlign.End)将内层Row推到外层Column的底部,实现从底部向上生长的柱状图效果。
柱子的颜色根据积分值动态选择:p >= 100 ? '#4ECDC4' : '#FF6B6B',达到100分以上用青色,否则用红色。borderRadius({ topLeft: 4, topRight: 4 })只设置上方两个圆角,模拟圆角柱状图的视觉效果。
布局技巧:FlexAlign.End实现柱状图。
justifyContent(FlexAlign.End)是让子元素在主轴方向(Column的主轴是垂直方向)靠末端对齐。对于Column容器来说,End就是底部对齐。这样柱子就会从容器底部开始向上延伸,形成标准的柱状图效果。这是一个利用布局属性实现数据可视化的经典案例。
'一二三四五六日'.charAt(i)是一种巧妙的中文星期显示方法,直接从字符串中按索引取字符。alignItems(VerticalAlign.Bottom)让外层Row的子元素底部对齐,确保所有柱子在同一基线上。
兑换榜单
this.secTitle6('🏆', '兑换榜单', '本周最热')
Column({ space: 10 }) {
ForEach(this.goods.slice(0, 5), (g: GoodT6, i: number) => {
Row({ space: 10 }) {
Text(i === 0 ? '🥇' : (i === 1 ? '🥈' : (i === 2 ? '🥉' : (i + 1).toString())))
.fontSize(15)
.width(26)
Text(g.emoji)
.fontSize(20)
Text(g.name)
.fontSize(12)
.fontColor('#2D3436')
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('热度 ' + g.hot.toString())
.fontSize(10)
.fontColor('#9AA5B1')
.width(52)
Text('去兑换')
.fontSize(10)
.fontColor('#4ECDC4')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#E6F9F7')
.borderRadius(8)
.onClick(() => {
this.exGood = g
this.exCnt = 1
this.showEx = true
})
}
.width('100%')
.padding(11)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}, (g: GoodT6, i: number) => g.name + i.toString())
}
.width('100%')
兑换榜单展示前5件热门商品,使用排名徽章(金、银、铜牌emoji)和热度值进行展示。排名显示使用嵌套三元运算符:第一名显示🥇,第二名显示🥈,第三名显示🥉,第四名及以后显示数字。每行包含排名、商品图标、商品名称、热度和"去兑换"按钮。
积分Tab ptsTab
积分总览卡片
@Builder
ptsTab() {
Column({ space: 0 }) {
Column({ space: 12 }) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('🪙')
.fontSize(32)
.offset({ y: this.coinY })
Text('连续签到 ' + this.signDays.toString() + ' 天')
.fontSize(10)
.fontColor('#FFFFFF')
.opacity(0.85)
}
Column({ space: 4 }) {
Text(this.myPts.toString())
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('当前可用积分')
.fontSize(11)
.fontColor('#FFFFFF')
.opacity(0.85)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('明细')
.fontSize(11)
.fontColor('#FF6B6B')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
.width('100%')
积分总览卡片使用红色背景(#FF6B6B)和20vp圆角,是整个积分页面的视觉焦点。卡片分为两个信息区:左侧是金币图标和连续签到天数,右侧是积分余额和标签文字。金币图标同样使用offset({ y: this.coinY })实现浮动动画。
视觉设计:品牌色卡片。 使用品牌色(这里是#FF6B6B珊瑚红)作为核心信息卡片的背景,是电商应用中常见的视觉策略。大面积的品牌色能快速抓住用户注意力,白色文字在彩色背景上具有最高对比度,确保关键信息(积分余额)的辨识度。
统计数据行
Row() {
Column({ space: 2 }) {
Text('530')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周获取')
.fontSize(10)
.fontColor('#FFFFFF')
.opacity(0.85)
}
.layoutWeight(1)
Column({ space: 2 }) {
Text('3200')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本月获取')
.fontSize(10)
.fontColor('#FFFFFF')
.opacity(0.85)
}
.layoutWeight(1)
Column({ space: 2 }) {
Text('12800')
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text('累计获取')
.fontSize(10)
.fontColor('#FFFFFF')
.opacity(0.85)
}
.layoutWeight(1)
}
.width('100%')
统计数据行使用三个等宽的Column展示本周、本月和累计获取的积分。每个Column都设置了layoutWeight(1),确保三等分。白色文字在红色背景上有很好的可读性,opacity(0.85)让副标题文字稍微透明,形成视觉层级。
积分目标进度条
Progress({ value: this.myPts * 100 / this.goalPts, total: 100, type: ProgressType.Linear })
.width('100%')
.style({ strokeWidth: 10 })
Row() {
Text('积分目标 ' + this.goalPts.toString() + ' 分')
.fontSize(10)
.fontColor('#FFFFFF')
.opacity(0.85)
.layoutWeight(1)
Text('编辑目标')
.fontSize(10)
.fontColor('#FFD93D')
.onClick(() => {
this.showGoal = true
})
}
.width('100%')
Progress是ArkUI的进度条组件,这里使用ProgressType.Linear线性模式。value属性通过this.myPts * 100 / this.goalPts动态计算当前积分占目标的百分比。style({ strokeWidth: 10 })设置进度条粗细为10vp。点击"编辑目标"文字打开编辑目标弹框。
基础组件详解:Progress。 Progress是ArkUI的进度展示组件,支持三种类型:
ProgressType.Linear(线性进度条)、ProgressType.Ring(环形进度条)和ProgressType.Eclipse(月食进度条)。通过构造参数{ value, total, type }设置当前值、总值和类型。style方法可以进一步调整视觉样式,如strokeWidth(线条粗细)。
积分明细列表
this.secTitle6('🧾', '积分明细', '共 ' + this.recs.length.toString() + ' 条')
Column({ space: 10 }) {
ForEach(this.recs, (r: RecT6) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(r.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(r.kind + ' · ' + r.date)
.fontSize(10)
.fontColor('#9AA5B1')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text((r.pts > 0 ? '+' : '') + r.pts.toString())
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(r.pts > 0 ? '#4ECDC4' : '#FF6B6B')
.width(70)
Text('删')
.fontSize(10)
.fontColor('#FF6B6B')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#FFE9E9')
.borderRadius(8)
.onClick(() => {
this.delRec = r
this.delArmed = false
this.showDel = true
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}, (r: RecT6) => r.title + r.date)
}
.width('100%')
积分明细列表使用ForEach遍历this.recs数组,为每条记录生成一个卡片。每条记录包含标题、类型和日期、积分变动值和删除按钮。
积分变动值的显示使用了一个巧妙的三元运算符:(r.pts > 0 ? '+' : '') + r.pts.toString()。当积分为正时,前面加上"+“号;为负时,数字本身就有”-"号,无需额外添加。颜色同样根据正负值动态选择:正数用青色(#4ECDC4),负数用红色(#FF6B6B),一眼即可区分收入和支出。
删除按钮点击后设置delRec为当前记录、delArmed为false(重置安全开关)、showDel为true,打开删除确认弹框。
兑换Tab exTab
双列瀑布流布局
@Builder
exTab() {
Column({ space: 0 }) {
this.secTitle6('🛍️', '积分兑换', '双列瀑布')
Row({ space: 12 }) {
Column({ space: 10 }) {
ForEach(this.goods.filter((g: GoodT6, i: number) => i % 2 === 0), (g: GoodT6) => {
this.goodCell6(g)
}, (g: GoodT6) => g.name + g.stock.toString())
}
.layoutWeight(1)
Column({ space: 10 }) {
ForEach(this.goods.filter((g: GoodT6, i: number) => i % 2 === 1), (g: GoodT6) => {
this.goodCell6(g)
}, (g: GoodT6) => g.name + g.stock.toString())
}
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
双列瀑布流是电商应用中常见的商品展示布局。实现原理是将商品数组按奇偶索引分成两组,分别放入左右两个Column容器中。filter((g, i) => i % 2 === 0)取偶数索引(第0、2、4…个),filter((g, i) => i % 2 === 1)取奇数索引(第1、3、5…个)。
两个Column都设置了layoutWeight(1),等分父容器的宽度。外层Row设置alignItems(VerticalAlign.Top)确保两列从顶部开始对齐。由于左右两列的卡片数量和高度可能不同,这种布局会产生参差不齐的"瀑布"效果。
布局技巧:双列瀑布流的实现。 瀑布流的核心是将数据分成多列分别渲染。这里使用奇偶索引分组是最简单的方式。在更复杂的场景中,可能需要根据内容的实际高度动态分配到最短的一列,但这在前端纯CSS/声明式UI中难以实现。ArkUI目前没有原生的瀑布流组件,这种Column分组方式是最实用的替代方案。
商品卡片 goodCell6
@Builder
goodCell6(g: GoodT6) {
Column({ space: 6 }) {
Text(g.emoji)
.fontSize(32)
.alignSelf(ItemAlign.Center)
.padding({ top: 6, bottom: 6 })
Text(g.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(g.tag + ' · 库存 ' + g.stock.toString())
.fontSize(10)
.fontColor('#F0932B')
Row() {
Row()
.width(g.stock.toString() + '%')
.height(4)
.backgroundColor(g.stock < 20 ? '#FF6B6B' : '#4ECDC4')
.borderRadius(2)
}
.layoutWeight(1)
.height(4)
.backgroundColor('#F0F2F8')
.borderRadius(2)
.width('100%')
Row({ space: 6 }) {
Text('🪙' + g.points.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
.layoutWeight(1)
Text('兑换')
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#FF6B6B')
.borderRadius(10)
.onClick(() => {
this.exGood = g
this.exCnt = 1
this.showEx = true
})
}
.width('100%')
}
.padding(10)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
商品卡片是一个独立的@Builder构建器,在双列瀑布流中被调用。alignSelf(ItemAlign.Center)让emoji图标在Column中水平居中——这是一个交叉轴对齐的覆盖属性,它覆盖了父容器Column的alignItems(HorizontalAlign.Start)设置,只对当前元素生效。
库存进度条是另一个自定义实现:外层Row作为轨道(灰色背景),内层Row的宽度通过g.stock.toString() + '%'动态设置,颜色根据库存量动态选择——库存低于20时用红色(紧急),高于20时用青色(充足)。
任务Tab taskTab
任务进度概览
@Builder
taskTab() {
Column({ space: 0 }) {
this.secTitle6('📋', '赚积分任务', this.taskDone6().toString() + '/' + this.tasks.length.toString() + ' 已完成')
Column({ space: 12 }) {
Row({ space: 14 }) {
Stack() {
Progress({ value: this.taskDone6() * 100 / this.tasks.length, total: 100 })
.width(80)
.height(80)
.style({ strokeWidth: 9 })
Column({ space: 1 }) {
Text('🎯')
.fontSize(13)
Text(this.taskDone6().toString() + '/' + this.tasks.length.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
}
}
Column({ space: 5 }) {
Text('今日待领 ' + this.pendPts6().toString() + ' 分')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('完成任务即得积分,可兑换体检券与健康好物')
.fontSize(11)
.fontColor('#9AA5B1')
Text('本周积分翻倍进行中 ×2')
.fontSize(11)
.fontColor('#FF6B6B')
.opacity(this.flashOp)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(18)
任务页面顶部是一个进度概览卡片,左侧是环形进度条,右侧是文字描述。Stack是ArkUI的层叠布局容器,它将子元素叠放在一起,后声明的子元素在上层。这里使用Stack将环形Progress和中心文字叠放,实现"进度环+中心文字"的效果。
容器组件详解:Stack。 Stack是ArkUI的层叠布局容器,它将所有子元素按照声明顺序叠放在一起,后声明的在上层。Stack常用于实现"图层"效果,如进度环+中心文字、图片+浮层文字、按钮+角标等场景。Stack的对齐方式通过
alignContent属性设置,默认为中心对齐(Alignment.Center)。
Progress这里使用了默认的环形模式(不指定type时默认为Ring),宽高都设为80vp,strokeWidth: 9设置环的粗细。中心的Column显示🎯emoji和完成进度文字。
右侧文字区有三行信息:今日待领积分、任务说明和积分翻倍提示。opacity(this.flashOp)让"本周积分翻倍"文字闪烁,吸引用户注意。
分组任务列表
ForEach(['每日', '每周', '长期'], (k: string) => {
Column({ space: 10 }) {
Row() {
Text(k + '任务')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
.layoutWeight(1)
Text(k === '每日' ? '每天 0 点刷新' : (k === '每周' ? '每周一刷新' : '一次性任务'))
.fontSize(10)
.fontColor('#9AA5B1')
}
.width('100%')
.margin({ top: 14, bottom: 0 })
ForEach(this.tasks.filter((t: TaskT6) => t.kind === k), (t: TaskT6) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(t.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(t.done ? '#B8C0CC' : '#2D3436')
Text(t.desc)
.fontSize(10)
.fontColor('#9AA5B1')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.onClick(() => {
this.selTask = t
this.showTask = true
})
Text('+' + t.pts.toString())
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(t.done ? '#B8C0CC' : '#4ECDC4')
.width(48)
Text(t.done ? '已完成' : '去完成')
.fontSize(10)
.fontColor(t.done ? '#9AA5B1' : '#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(t.done ? '#F0F2F8' : '#4ECDC4')
.borderRadius(10)
.onClick(() => {
this.selTask = t
this.showTask = true
})
}
.width('100%')
.padding(11)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}, (t: TaskT6) => t.title + t.done.toString())
}
.width('100%')
}, (k: string) => k)
这里使用了嵌套的ForEach:外层遍历任务类型数组['每日', '每周', '长期'],内层通过this.tasks.filter((t: TaskT6) => t.kind === k)过滤出对应类型的任务。这种"先分组再渲染"的模式在列表展示中非常实用。
每个任务卡片的样式根据done状态动态变化:已完成时标题变灰、积分变灰、按钮变灰且文案改为"已完成";未完成时标题深色、积分青色、按钮青色且文案为"去完成"。点击任务标题或按钮都会打开任务详情弹框。
签到Tab signTab
签到日历
@Builder
signTab() {
Column({ space: 0 }) {
this.secTitle6('📅', '本周签到', '连续 ' + this.signDays.toString() + ' 天')
Column({ space: 14 }) {
Row({ space: 8 }) {
ForEach(this.signs, (s: SignT6) => {
Column({ space: 5 }) {
Text(s.signed ? '✅' : '○')
.fontSize(17)
Text(s.day)
.fontSize(10)
.fontColor(s.signed ? '#4ECDC4' : '#9AA5B1')
Text('+' + s.pts.toString())
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.signed ? '#4ECDC4' : '#B8C0CC')
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(s.signed ? '#E6F9F7' : '#F0F2F8')
.borderRadius(12)
}, (s: SignT6) => s.day + s.signed.toString())
}
.width('100%')
Progress({ value: this.signRate6(), total: 100, type: ProgressType.Linear })
.width('100%')
.style({ strokeWidth: 10 })
Text('本周已签 ' + this.signRate6().toString() + '%,周日签到可得 60 分')
.fontSize(11)
.fontColor('#9AA5B1')
.width('100%')
Text('立即签到 / 补签')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('#FF6B6B')
.borderRadius(16)
.onClick(() => {
this.makeUp = false
this.showSign = true
})
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(18)
签到日历使用ForEach渲染7天的签到状态,每天的卡片包含签到图标(已签✅/未签○)、星期和积分。卡片背景色根据签到状态变化:已签为浅青色(#E6F9F7),未签为浅灰色(#F0F2F8)。所有卡片通过layoutWeight(1)等分宽度。
签到进度条和提示文字显示当前签到率,点击"立即签到 / 补签"按钮打开签到弹框(设置makeUp = false表示正常签到)。
签到里程碑
this.secTitle6('🎁', '连续签到里程碑', '大奖等你拿')
Column({ space: 10 }) {
ForEach(['连续 7 天:体检加项抵用券 ¥30', '连续 21 天:洁牙 8 折券一张', '连续 60 天:体检 5 折券一张', '连续 180 天:神秘大礼包 + 专属徽章'], (s: string, i: number) => {
Row({ space: 10 }) {
Text(i === 0 ? '🥉' : (i === 1 ? '🥈' : (i === 2 ? '🥇' : '👑')))
.fontSize(18)
Text(s)
.fontSize(12)
.fontColor('#2D3436')
.layoutWeight(1)
Text(this.signDays >= (i + 1) * 7 * (i === 0 ? 1 : (i === 1 ? 3 : (i === 2 ? 8.5 : 25))) ? '已达成' : '未达成')
.fontSize(10)
.fontColor(this.signDays >= (i + 1) * 7 * (i === 0 ? 1 : (i === 1 ? 3 : (i === 2 ? 8.5 : 25))) ? '#4ECDC4' : '#B8C0CC')
.width(44)
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}, (s: string) => s)
}
.width('100%')
签到里程碑展示四个连续签到目标(7天、21天、60天、180天)及其奖励。达成状态的判断使用了一个复杂的嵌套三元运算符来计算每个里程碑需要的天数,然后与当前连续签到天数signDays比较。虽然表达式较为复杂,但逻辑清晰:根据里程碑的索引计算目标天数,判断是否已达成。
夺宝Tab luckyTab
@Builder
luckyTab() {
Column({ space: 0 }) {
this.secTitle6('🎰', '积分夺宝', '100 分/人次')
Column({ space: 8 }) {
Text('🎲 公平开奖 · 人满即开 · 未中积分退还 30%')
.fontSize(11)
.fontColor('#9AA5B1')
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
Column({ space: 12 }) {
ForEach(this.luckys, (l: LuckyT6) => {
Column({ space: 9 }) {
Row({ space: 12 }) {
Text(l.emoji)
.fontSize(30)
Column({ space: 3 }) {
Text(l.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('总 ' + l.total.toString() + ' 人次 · 我已参与 ' + l.mine.toString() + ' 人次')
.fontSize(10)
.fontColor('#9AA5B1')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('参与')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#6C5CE7')
.borderRadius(12)
.onClick(() => {
this.selLucky = l
this.luckyCnt = 1
this.showLucky = true
})
}
.width('100%')
Column({ space: 3 }) {
Row() {
Row()
.width((l.joined * 100 / l.total).toFixed(0) + '%')
.height(8)
.backgroundColor('#6C5CE7')
.borderRadius(4)
Row()
.layoutWeight(1)
.height(8)
.backgroundColor('#F0F2F8')
.borderRadius(4)
}
.width('100%')
Text('已参与 ' + l.joined.toString() + '/' + l.total.toString() + '(' + (l.joined * 100 / l.total).toFixed(0) + '%)')
.fontSize(9)
.fontColor('#9AA5B1')
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}, (l: LuckyT6) => l.name + l.joined.toString())
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
夺宝页面展示所有夺宝活动列表,每个活动卡片包含活动图标、名称、参与信息、参与按钮和进度条。进度条的实现方式与商品卡片的库存进度条类似:外层Row作为灰色轨道,内层Row的宽度通过(l.joined * 100 / l.total).toFixed(0) + '%'动态计算。
夺宝页面使用紫色(#6C5CE7)作为主题色,与首页的红色和积分页的红色形成区分,帮助用户通过颜色快速识别当前所在的功能区域。点击"参与"按钮打开夺宝参与弹框。
视觉设计:功能区域色彩区分。 在复杂应用中,为不同功能区域使用不同的主题色是一种有效的导航辅助策略。首页用红色(#FF6B6B)、夺宝用紫色(#6C5CE7)、任务用青色(#4ECDC4),用户即使不看文字也能通过颜色感知当前所在页面。这种色彩编码策略在大型电商应用中非常常见。
我的Tab mineTab
个人信息卡片
@Builder
mineTab() {
Column({ space: 0 }) {
Column({ space: 8 }) {
Text('🧑')
.fontSize(42)
.padding(14)
.backgroundColor('#FFE9E9')
.borderRadius(30)
Text('李小天')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('积分达人 Lv.6 · 已连续签到 ' + this.signDays.toString() + ' 天')
.fontSize(12)
.fontColor('#FF6B6B')
Row({ space: 8 }) {
Text('🪙 ' + this.myPts.toString())
.fontSize(11)
.fontColor('#FF6B6B')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FFE9E9')
.borderRadius(10)
Text('兑换 12 单')
.fontSize(11)
.fontColor('#4ECDC4')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#E6F9F7')
.borderRadius(10)
Text('夺宝 8 次')
.fontSize(11)
.fontColor('#6C5CE7')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#EEEBFB')
.borderRadius(10)
}
}
.width('100%')
.padding({ top: 22, bottom: 18 })
.backgroundColor('#FFFFFF')
.borderRadius(20)
个人中心页面顶部是用户信息卡片,包含头像(emoji)、用户名、等级标签和三个数据徽章。三个徽章分别使用红色、青色和紫色背景,对应积分、兑换和夺宝三个功能区域的主题色,形成一致的视觉语言。
兑换订单列表
this.secTitle6('📦', '我的兑换订单', '近 30 天')
Column({ space: 10 }) {
ForEach(['超声波洁牙免单券 · 待使用', '医用外科口罩×100 · 已发货', '体检加项抵用 ¥50 · 已使用', '叶黄素软糖 60 粒 · 已签收'], (s: string) => {
Row({ space: 10 }) {
Text('📦')
.fontSize(18)
Text(s)
.fontSize(12)
.fontColor('#2D3436')
.layoutWeight(1)
Text('查看')
.fontSize(10)
.fontColor('#FF6B6B')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FFE9E9')
.borderRadius(8)
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}, (s: string) => s)
}
.width('100%')
兑换订单列表使用ForEach遍历订单状态字符串数组。这里的数据是硬编码的字符串,实际应用中应该使用结构化的订单数据。每个订单卡片显示包裹图标、订单描述和"查看"按钮。
设置列表
this.secTitle6('⚙️', '设置', '')
Column({ space: 0 }) {
ForEach(['🔔 签到与任务提醒', '🪙 积分规则说明', '📦 收货地址管理', '👨👩👧 家庭积分共享', '🎰 夺宝记录', '📞 联系客服'], (s: string) => {
Row({ space: 8 }) {
Text(s)
.fontSize(13)
.fontColor('#2D3436')
.layoutWeight(1)
Text('›')
.fontSize(16)
.fontColor('#B8C0CC')
}
.width('100%')
.padding({ top: 13, bottom: 13 })
.backgroundColor('#FFFFFF')
}, (s: string) => s)
}
.width('100%')
.borderRadius(14)
.clip(true)
Text('POINTS MALL · 健康积分商城 v6.3.0')
.fontSize(10)
.fontColor('#B8C0CC')
.margin({ top: 18, bottom: 6 })
设置列表使用ForEach渲染6个设置项,每项包含emoji+文字和右箭头›。外层Column设置了borderRadius(14)和clip(true),clip(true)的作用是裁剪子元素超出圆角范围的部分,确保列表的第一项上方和最后一项下方也遵循圆角效果。
布局属性详解:clip。
clip(true)是ArkUI的裁剪属性,它将组件的内容裁剪到组件的边界范围内。当设置了borderRadius圆角时,子元素(如有背景色的Row)在圆角处会超出圆角边界,clip(true)可以确保所有子元素都被裁剪在圆角范围内。这在列表分组中非常常用,可以让整个列表组拥有统一的圆角外观。
弹框系统:六种弹框的架构设计
弹框1:兑换确认(底部抽屉)
@Builder
exchangeModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#662D3436')
.onClick(() => {
this.showEx = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 14 }) {
Row()
.width(44)
.height(5)
.backgroundColor('#DFE3EC')
.borderRadius(3)
.margin({ top: 10 })
Text('🛍️ 确认兑换')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
兑换弹框采用"底部抽屉"设计模式。整个弹框由两层组成:第一层是蒙层(半透明遮罩),第二层是内容卡片。
蒙层是一个填满全屏的Column,背景色#662D3436(40%透明度的深灰),点击蒙层关闭弹框。蒙层使用position({ x: 0, y: 0 })绝对定位到屏幕左上角,zIndex(999)设置层级为999,确保蒙层在普通内容之上。
内容卡片使用position({ x: 0, y: '46%' })定位到屏幕下方46%位置,borderRadius({ topLeft: 24, topRight: 24 })只设置上方圆角,zIndex(1000)比蒙层高一级,确保内容在蒙层之上。
层级控制:zIndex。
zIndex是ArkUI的层级控制属性,值越大层级越高,在上层显示。在弹框系统中,通常将蒙层设为999,内容设为1000,确保内容浮在蒙层之上。当多个弹框同时显示时,可以通过不同的zIndex值控制它们的叠放顺序。
弹框内容包括拖拽指示条(44x5的灰色圆角条)、标题、商品信息、数量选择器、积分计算区和按钮组。数量选择器使用"-"和"+"按钮实现,每次点击修改exCnt的值,减号最小到1,加号最大到Math.min(5, this.exGood.stock)(最多5件或库存量)。
积分计算区实时显示消耗积分、当前积分和兑换后余额。兑换后余额的颜色根据是否充足动态变化:this.myPts - this.exCnt * this.exGood.points >= 0 ? '#4ECDC4' : '#FF6B6B',充足为青色,不足为红色。
弹框2:每日签到(居中卡片)
@Builder
signModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#662D3436')
.onClick(() => {
this.showSign = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 14 }) {
Column({ space: 6 }) {
Text('🪙')
.fontSize(40)
.offset({ y: this.coinY })
Text('每日签到')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('已连续签到 ' + this.signDays.toString() + ' 天')
.fontSize(12)
.fontColor('#FF6B6B')
}
Row({ space: 6 }) {
ForEach(this.signs, (s: SignT6) => {
Column({ space: 3 }) {
Text(s.signed ? '✅' : s.day)
.fontSize(s.signed ? 12 : 10)
.fontWeight(FontWeight.Bold)
.fontColor(s.signed ? '#4ECDC4' : '#9AA5B1')
Text('+' + s.pts.toString())
.fontSize(9)
.fontColor(s.signed ? '#4ECDC4' : '#B8C0CC')
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.backgroundColor(s.signed ? '#E6F9F7' : '#F5F6FB')
.borderRadius(10)
}, (s: SignT6) => s.day + s.signed.toString())
}
.width('100%')
签到弹框采用"居中卡片"设计模式,使用position({ x: '8%', y: '16%' })定位到屏幕中央偏上。弹框内容包含金币图标(带浮动动画)、标题、连续签到天数、七天签到日历和补签开关。
Toggle({ type: ToggleType.Switch, isOn: this.makeUp })是ArkUI的开关组件,ToggleType.Switch指定为滑动开关样式。onChange回调在开关状态变化时触发,更新makeUp状态变量。
基础组件详解:Toggle。 Toggle是ArkUI的开关组件,支持两种类型:
ToggleType.Switch(滑动开关)和ToggleType.Checkbox(复选框)。通过isOn属性控制开关状态,通过onChange回调监听状态变化。Toggle在设置页、弹框中的选项控制等场景中非常常用。
弹框3:任务详情(居中卡片)
@Builder
taskModalOverlay() {
// ... 蒙层代码省略 ...
Column({ space: 13 }) {
Text('📋 ' + this.selTask.kind + '任务')
.fontSize(12)
.fontColor('#9AA5B1')
Text(this.selTask.title)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text(this.selTask.desc)
.fontSize(12)
.fontColor('#9AA5B1')
.width('100%')
.textAlign(TextAlign.Center)
Text('+' + this.selTask.pts.toString() + ' 积分')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#4ECDC4')
Column({ space: 7 }) {
Text('完成步骤')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
.width('100%')
Text('① 打开对应健康功能完成记录\n② 回到本页领取任务积分\n③ 积分实时到账,可立即兑换')
.fontSize(11)
.fontColor('#9AA5B1')
.lineHeight(18)
}
.width('100%')
.padding(12)
.backgroundColor('#F5F6FB')
.borderRadius(14)
任务详情弹框展示选中任务的详细信息,包括类型标签、标题、描述、奖励积分和完成步骤。完成步骤使用\n换行符实现多行文本,lineHeight(18)设置行高为18vp,保证多行文本的可读性。textAlign(TextAlign.Center)让文本居中对齐。
按钮文案根据任务状态动态变化:已完成显示"已完成"(灰色),未完成显示"立即完成"(青色)。点击"立即完成"调用finishTask6(this.selTask)方法完成任务。
弹框4:夺宝参与(底部抽屉)
@Builder
luckyModalOverlay() {
// ... 蒙层代码省略 ...
Column({ space: 14 }) {
Row()
.width(44)
.height(5)
.backgroundColor('#DFE3EC')
.borderRadius(3)
.margin({ top: 10 })
Row({ space: 12 }) {
Text(this.selLucky.emoji)
.fontSize(38)
Column({ space: 3 }) {
Text('🎰 参与夺宝')
.fontSize(12)
.fontColor('#9AA5B1')
Text(this.selLucky.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('100 积分 / 人次,未中退还 30%')
.fontSize(10)
.fontColor('#6C5CE7')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
夺宝参与弹框与兑换弹框类似,采用底部抽屉设计。内容包含活动信息、进度条、参与人次选择器和确认按钮。参与人次选择器的加号上限为10(if (this.luckyCnt < 10)),每次100积分。确认按钮的颜色根据积分是否充足动态变化:充足为紫色(#6C5CE7),不足为灰色(#B8C0CC)。
弹框5:编辑积分目标(居中卡片)
@Builder
goalModalOverlay() {
// ... 蒙层代码省略 ...
Column({ space: 14 }) {
Text('🎯 编辑积分目标')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('达成目标可解锁专属徽章')
.fontSize(11)
.fontColor('#9AA5B1')
Row({ space: 8 }) {
ForEach([5000, 8000, 12000], (p: number) => {
Text(p.toString())
.fontSize(12)
.fontColor(this.goalPts === p ? '#FFFFFF' : '#9AA5B1')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(this.goalPts === p ? '#FF6B6B' : '#F5F6FB')
.borderRadius(12)
.onClick(() => {
this.goalPts = p
})
}, (p: number) => p.toString())
}
Row({ space: 14 }) {
Text('-500')
.fontSize(12)
.fontColor('#4ECDC4')
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.backgroundColor('#E6F9F7')
.borderRadius(10)
.onClick(() => {
if (this.goalPts > 1000) {
this.goalPts -= 500
}
})
Text(this.goalPts.toString() + ' 分')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
Text('+500')
.fontSize(12)
.fontColor('#FF6B6B')
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.backgroundColor('#FFE9E9')
.borderRadius(10)
.onClick(() => {
if (this.goalPts < 50000) {
this.goalPts += 500
}
})
}
.justifyContent(FlexAlign.Center)
编辑目标弹框提供了三种目标设置方式:预设快捷值(5000、8000、12000)、每次减500和每次加500。快捷值使用ForEach渲染,当前选中的值显示为红色背景白色文字,未选中为灰色背景。加减按钮有边界检查:最小1000,最大50000。justifyContent(FlexAlign.Center)让加减按钮组在Row中居中排列。
弹框6:删除记录(危险确认卡)
@Builder
delModalOverlay() {
// ... 蒙层代码省略 ...
Column({ space: 12 }) {
Text('⚠️')
.fontSize(28)
Text('删除积分记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
Text('「' + this.delRec.title + '」删除后仅影响本地明细展示,积分余额不变。')
.fontSize(12)
.fontColor('#9AA5B1')
.textAlign(TextAlign.Center)
.lineHeight(18)
Row({ space: 8 }) {
Text('我已确认')
.fontSize(12)
.fontColor('#9AA5B1')
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.delArmed })
.onChange((v: boolean) => {
this.delArmed = v
})
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.backgroundColor('#FFF5F5')
.borderRadius(12)
Row({ space: 10 }) {
Text('再想想')
.fontSize(13)
.fontColor('#9AA5B1')
.padding({ left: 16, right: 16, top: 9, bottom: 9 })
.backgroundColor('#F0F2F8')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showDel = false
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 16, right: 16, top: 9, bottom: 9 })
.backgroundColor(this.delArmed ? '#FF6B6B' : '#F3B7B7')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
if (this.delArmed) {
this.doDel6()
}
})
}
.width('100%')
}
.width('74%')
.padding(18)
.backgroundColor('#FFFFFF')
.borderRadius(20)
.position({ x: '13%', y: '26%' })
.zIndex(1000)
}
删除确认弹框采用"窄危险卡"设计,宽度仅74%,定位在屏幕中央偏上。弹框内容包含警告图标、标题、说明文字、确认开关和按钮组。
交互设计:二次确认机制。 删除操作是不可逆的,因此需要二次确认。这里使用了Toggle开关作为"安全锁"——用户必须先拨动开关确认,删除按钮才会从浅红色(
#F3B7B7)变为深红色(#FF6B6B)并变为可点击状态。这种设计比简单的"确认/取消"对话框更加安全,有效防止了误操作。if (this.delArmed) { this.doDel6() }确保即使点击了删除按钮,如果安全锁未打开也不会执行删除。
主布局build方法
build() {
Stack() {
Column() {
this.headerBar6()
Scroll() {
Column() {
if (this.selTab === 0) {
this.homeTab()
} else if (this.selTab === 1) {
this.ptsTab()
} else if (this.selTab === 2) {
this.exTab()
} else if (this.selTab === 3) {
this.taskTab()
} else if (this.selTab === 4) {
this.signTab()
} else if (this.selTab === 5) {
this.luckyTab()
} else {
this.mineTab()
}
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.width('100%')
.backgroundColor('#F5F6FB')
this.tabBar6()
}
.width('100%')
.height('100%')
if (this.showEx) {
this.exchangeModalOverlay()
}
if (this.showSign) {
this.signModalOverlay()
}
if (this.showTask) {
this.taskModalOverlay()
}
if (this.showLucky) {
this.luckyModalOverlay()
}
if (this.showGoal) {
this.goalModalOverlay()
}
if (this.showDel) {
this.delModalOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F6FB')
}
build方法是每个@Component组件的核心方法,它描述了组件的UI结构。整个应用的build方法使用Stack作为根容器,包含两层内容:
第一层是主内容区,使用Column从上到下排列头部(headerBar6)、可滚动内容区(Scroll + 条件渲染的Tab页面)和底部导航栏(tabBar6)。Scroll容器设置了layoutWeight(1),占据头部和底部导航栏之间的所有剩余空间。scrollBar(BarState.Off)隐藏滚动条,保持界面整洁。
Tab页面的切换通过if/else条件渲染实现:根据selTab的值渲染对应的Tab页面。当selTab变化时,框架会自动卸载旧页面并渲染新页面。
第二层是弹框层,6个弹框通过if条件渲染控制显示。每个弹框都有一个对应的boolean状态变量,当变量为true时渲染弹框,为false时不渲染。这种"条件渲染弹框"的方式比"始终渲染但通过visibility控制"更加高效,因为不渲染的弹框不会占用任何计算资源。
架构设计:Stack根布局的分层设计。 使用Stack作为根容器实现"主内容+弹框"的分层架构是ArkTS应用中的最佳实践。主内容在底层,弹框在上层。这种设计的优势在于:弹框可以浮在所有内容之上(包括底部导航栏),且弹框的显示/隐藏完全由状态变量驱动,不需要手动管理DOM层级。当多个弹框同时显示时,通过zIndex控制叠放顺序即可。
渲染控制:if/else条件渲染。 ArkUI的if/else条件渲染与ForEach一样是渲染控制语法。当条件为true时渲染对应分支的组件,为false时不渲染。与visibility属性不同,if/else会完全创建/销毁组件,不保留组件状态。这对于Tab页面切换是合适的——切换Tab时旧页面的状态不需要保留。但如果需要保留页面状态(如表单输入),应该使用visibility而非if/else。
综合技术对比表格
| 技术要素 | 类别 | 名称 | 用途/作用 | 使用位置 |
|---|---|---|---|---|
| GoodT6 | 接口 | 商品数据结构 | 定义商品的字段约束 | 商品数据源 |
| TaskT6 | 接口 | 任务数据结构 | 定义任务的字段约束 | 任务数据源 |
| SignT6 | 接口 | 签到数据结构 | 定义签到的字段约束 | 签到数据源 |
| LuckyT6 | 接口 | 夺宝数据结构 | 定义夺宝活动的字段约束 | 夺宝数据源 |
| RecT6 | 接口 | 积分记录结构 | 定义积分变动记录的字段约束 | 积分明细列表 |
| BannerT6 | 接口 | 横幅数据结构 | 定义轮播横幅的字段约束 | 头部横幅轮播 |
| @Entry | 装饰器 | 页面入口 | 标记组件为页面根入口 | 组件声明 |
| @Component | 装饰器 | 自定义组件 | 声明struct为可复用组件 | 组件声明 |
| @State | 装饰器 | 响应式状态 | 状态变化触发UI更新 | 全部状态变量 |
| @Builder | 装饰器 | UI构建函数 | 定义可复用的UI片段 | 所有UI构建器 |
| Column | 容器组件 | 纵向布局 | 子元素垂直排列 | 几乎所有布局 |
| Row | 容器组件 | 横向布局 | 子元素水平排列 | 几乎所有布局 |
| Stack | 容器组件 | 层叠布局 | 子元素叠放 | 根布局、进度环 |
| Scroll | 容器组件 | 滚动容器 | 内容超出可滚动 | 内容区、横幅轮播 |
| Progress | 基础组件 | 进度展示 | 显示进度(线性/环形) | 目标进度、签到率、任务环 |
| Toggle | 基础组件 | 开关组件 | 布尔值切换 | 补签开关、提醒开关、删除确认 |
| Text | 基础组件 | 文本展示 | 显示文字内容 | 全文无处不在 |
| Divider | 基础组件 | 分割线 | 视觉分隔 | 底部导航栏顶部 |
| ForEach | 渲染控制 | 列表渲染 | 遍历数组生成组件 | 商品列表、任务列表、签到日历等 |
| if/else | 渲染控制 | 条件渲染 | 根据条件渲染组件 | Tab切换、弹框显示 |
| layoutWeight | 布局属性 | 权重分配 | 按比例分配剩余空间 | Tab按钮等分、统计列等分 |
| position | 布局属性 | 绝对定位 | 指定元素位置 | 弹框蒙层和内容 |
| zIndex | 层级属性 | 层级控制 | 控制叠放顺序 | 弹框蒙层(999)和内容(1000) |
| offset | 变换属性 | 相对偏移 | 微调元素位置 | 金币浮动动画 |
| clip | 视觉属性 | 裁剪 | 裁剪超出边界的内容 | 设置列表圆角裁剪 |
| borderRadius | 视觉属性 | 圆角 | 设置圆角半径 | 所有卡片和按钮 |
| backgroundColor | 视觉属性 | 背景色 | 设置背景颜色 | 所有容器和组件 |
| opacity | 视觉属性 | 透明度 | 设置元素透明度 | 闪兑动画、未选中态 |
| animateTo | 动画API | 显式动画 | 驱动状态变量动画 | 金币浮动、闪烁效果 |
| aboutToAppear | 生命周期 | 组件初始化 | 组件创建后执行 | 启动动画 |
| PlayMode.Alternate | 枚举 | 动画播放模式 | 交替正反播放 | 循环动画 |
| Curve.EaseInOut | 枚举 | 缓动曲线 | 先慢后快再慢 | 动画曲线 |
| ProgressType.Linear | 枚举 | 进度条类型 | 线性进度条 | 目标进度、签到率 |
| ToggleType.Switch | 枚举 | 开关类型 | 滑动开关 | 补签开关、删除确认 |
| ScrollDirection.Horizontal | 枚举 | 滚动方向 | 水平滚动 | 横幅轮播、闪兑卡片 |
| BarState.Off | 枚举 | 滚动条状态 | 隐藏滚动条 | 所有Scroll容器 |
| TextOverflow.Ellipsis | 枚举 | 文本溢出 | 省略号 | 商品名称等长文本 |
| FontWeight.Bold | 枚举 | 字重 | 粗体 | 标题、数值 |
| FlexAlign.End | 枚举 | 主轴对齐 | 靠末端对齐 | 柱状图底部对齐 |
| FlexAlign.Center | 枚举 | 主轴对齐 | 居中对齐 | 目标编辑加减按钮 |
| HorizontalAlign.Start | 枚举 | 交叉轴对齐 | 靠左对齐 | 左对齐文字 |
| VerticalAlign.Top | 枚举 | 交叉轴对齐 | 顶部对齐 | 瀑布流两列 |
| VerticalAlign.Bottom | 枚举 | 交叉轴对齐 | 底部对齐 | 柱状图基线 |
| ItemAlign.Center | 枚举 | 自身对齐 | 居中对齐 | 商品卡片emoji |
| TextAlign.Center | 枚举 | 文字对齐 | 居中对齐 | 弹框按钮文字 |
| maxLines | 文本属性 | 最大行数 | 限制文本行数 | 长文本截断 |
| constraintSize | 布局属性 | 约束尺寸 | 限制最大宽高 | 商品名称宽度限制 |
| margin | 间距属性 | 外边距 | 元素外部间距 | 区块间距 |
| padding | 间距属性 | 内边距 | 元素内部间距 | 所有卡片内边距 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 电商多巴胺浅色 · 7 tab 双排底部导航(4+3)· 6 弹框 · 金刚区头部 / 双列商品瀑布 / 签到日历 / 积分收支横条 / 夺宝进度
// 交互全部真实生效:tab 切换 / 兑换扣积分 / 每日签到 / 完成任务 / 夺宝参与 / 编辑目标 / 删除记录
interface GoodT6 {
name: string
points: number
price: number
stock: number
tag: string
emoji: string
hot: number
}
interface TaskT6 {
title: string
desc: string
pts: number
kind: string
done: boolean
}
interface SignT6 {
day: string
pts: number
signed: boolean
}
interface LuckyT6 {
name: string
total: number
joined: number
mine: number
emoji: string
}
interface RecT6 {
title: string
pts: number
date: string
kind: string
}
interface BannerT6 {
title: string
sub: string
bg: string
emoji: string
}
const GOODS6: Array<GoodT6> = [
{ name: '全身体检 5 折券', points: 3000, price: 300, stock: 42, tag: '超值', emoji: '🎫', hot: 98 },
{ name: '超声波洁牙免单券', points: 2400, price: 260, stock: 18, tag: '爆款', emoji: '🦷', hot: 95 },
{ name: '专家号优先预约卡', points: 1800, price: 0, stock: 66, tag: '稀缺', emoji: '💳', hot: 92 },
{ name: '智能体脂秤 S1', points: 5200, price: 129, stock: 9, tag: '数码', emoji: '⚖️', hot: 88 },
{ name: '颈椎按摩仪 Mini', points: 8800, price: 199, stock: 5, tag: '热销', emoji: '💆', hot: 90 },
{ name: '医用外科口罩×100', points: 800, price: 25, stock: 120, tag: '常备', emoji: '😷', hot: 85 },
{ name: '叶黄素软糖 60 粒', points: 1600, price: 89, stock: 33, tag: '护眼', emoji: '🍬', hot: 82 },
{ name: '电子血压计臂式', points: 6400, price: 169, stock: 12, tag: '家庭', emoji: '🩺', hot: 87 },
{ name: '艾灸贴×10 贴', points: 600, price: 39, stock: 88, tag: '养生', emoji: '🌿', hot: 78 },
{ name: '体检加项抵用 ¥50', points: 1000, price: 50, stock: 200, tag: '通用', emoji: '💰', hot: 91 },
{ name: '睡眠助眠香薰灯', points: 3600, price: 99, stock: 21, tag: '好眠', emoji: '🕯️', hot: 76 },
{ name: '儿童视力筛查券', points: 1200, price: 60, stock: 45, tag: '亲子', emoji: '👓', hot: 80 },
{ name: '筋膜枪 Mini', points: 7600, price: 179, stock: 7, tag: '运动', emoji: '🔨', hot: 89 },
{ name: '体检早餐升级券', points: 300, price: 15, stock: 300, tag: '贴心', emoji: '🥣', hot: 70 }
]
const TASKS6: Array<TaskT6> = [
{ title: '每日步数 6000+', desc: '今天走够 6000 步', pts: 30, kind: '每日', done: true },
{ title: '记录一次血压', desc: '早晚任一次记录', pts: 20, kind: '每日', done: true },
{ title: '睡眠 7 小时以上', desc: '昨晚睡眠达标', pts: 30, kind: '每日', done: false },
{ title: '喝够 8 杯水', desc: '今日饮水打卡', pts: 15, kind: '每日', done: false },
{ title: '体重记录', desc: '晨起空腹称重', pts: 10, kind: '每日', done: true },
{ title: '健康文章阅读 3 分钟', desc: '学一点健康知识', pts: 10, kind: '每日', done: true },
{ title: '完成一次体检预约', desc: '为自己或家人', pts: 200, kind: '每周', done: false },
{ title: '每周运动 3 次', desc: '本周已 2/3 次', pts: 100, kind: '每周', done: false },
{ title: '连续签到 7 天', desc: '本周已签 5 天', pts: 150, kind: '每周', done: false },
{ title: '分享健康报告', desc: '转发给家人查看', pts: 50, kind: '每周', done: true },
{ title: '完成年度体检', desc: '年度大任务', pts: 1000, kind: '长期', done: false },
{ title: '邀请家人加入', desc: '邀请 1 位家人', pts: 300, kind: '长期', done: true }
]
const SIGNS6: Array<SignT6> = [
{ day: '周一', pts: 10, signed: true },
{ day: '周二', pts: 15, signed: true },
{ day: '周三', pts: 20, signed: true },
{ day: '周四', pts: 25, signed: true },
{ day: '周五', pts: 30, signed: true },
{ day: '周六', pts: 40, signed: false },
{ day: '周日', pts: 60, signed: false }
]
const LUCKY6: Array<LuckyT6> = [
{ name: '体检全家福套餐', total: 2000, joined: 1687, mine: 0, emoji: '👨👩👧👦' },
{ name: 'Apple Watch SE', total: 5000, joined: 4210, mine: 0, emoji: '⌚' },
{ name: '按摩椅体验月卡', total: 800, joined: 766, mine: 0, emoji: '🪑' },
{ name: '体检加项大礼包', total: 1200, joined: 905, mine: 0, emoji: '🎁' },
{ name: '智能手环 8', total: 3000, joined: 2511, mine: 0, emoji: '⌚' },
{ name: '年度 VIP 会员', total: 1000, joined: 882, mine: 0, emoji: '👑' },
{ name: '基因检测一次', total: 1500, joined: 1402, mine: 0, emoji: '🧬' },
{ name: '营养师季卡', total: 600, joined: 455, mine: 0, emoji: '🥗' }
]
const RECS6: Array<RecT6> = [
{ title: '兑换超声波洁牙券', pts: -2400, date: '08-27', kind: '兑换' },
{ title: '每日签到奖励', pts: 30, date: '08-27', kind: '签到' },
{ title: '完成步数任务', pts: 30, date: '08-27', kind: '任务' },
{ title: '完成血压记录', pts: 20, date: '08-27', kind: '任务' },
{ title: '邀请奶奶加入家庭', pts: 300, date: '08-26', kind: '长期' },
{ title: '兑换口罩 100 只', pts: -800, date: '08-26', kind: '兑换' },
{ title: '每日签到奖励', pts: 25, date: '08-26', kind: '签到' },
{ title: '完成年度体检', pts: 1000, date: '08-25', kind: '长期' },
{ title: '分享报告给家人', pts: 50, date: '08-25', kind: '每周' },
{ title: '每日签到奖励', pts: 20, date: '08-25', kind: '签到' },
{ title: '连续签到 7 天奖励', pts: 150, date: '08-24', kind: '每周' },
{ title: '注册奖励', pts: 500, date: '08-01', kind: '一次性' }
]
const BANNERS6: Array<BannerT6> = [
{ title: '积分翻倍周', sub: '本周任务积分 ×2', bg: '#FF6B6B', emoji: '🎉' },
{ title: '新人体检礼包', sub: '1 元领 3 项检查', bg: '#6C5CE7', emoji: '🎁' },
{ title: '家庭组队瓜分', sub: '百万积分池等你来', bg: '#4ECDC4', emoji: '👨👩👧' }
]
const WEEKPTS6: Array<number> = [65, 85, 120, 75, 95, 60, 30]
@Entry
@Component
struct Index {
@State selTab: number = 0
@State goods: Array<GoodT6> = GOODS6.slice()
@State tasks: Array<TaskT6> = TASKS6.slice()
@State signs: Array<SignT6> = SIGNS6.slice()
@State luckys: Array<LuckyT6> = LUCKY6.slice()
@State recs: Array<RecT6> = RECS6.slice()
@State myPts: number = 4860
@State signDays: number = 18
// 兑换
@State showEx: boolean = false
@State exGood: GoodT6 = GOODS6[0]
@State exCnt: number = 1
// 签到
@State showSign: boolean = false
@State makeUp: boolean = false
// 任务详情
@State showTask: boolean = false
@State selTask: TaskT6 = TASKS6[0]
// 夺宝
@State showLucky: boolean = false
@State selLucky: LuckyT6 = LUCKY6[0]
@State luckyCnt: number = 1
// 编辑目标
@State showGoal: boolean = false
@State goalPts: number = 8000
@State goalRemind: boolean = true
// 删除记录
@State showDel: boolean = false
@State delRec: RecT6 = RECS6[0]
@State delArmed: boolean = false
// 特效
@State coinY: number = 0
@State flashOp: number = 0.5
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 2000, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.coinY = -14
})
this.getUIContext().animateTo({ duration: 700, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.flashOp = 1
})
}
taskDone6(): number {
let n: number = 0
this.tasks.forEach((t: TaskT6) => {
if (t.done) {
n += 1
}
})
return n
}
signRate6(): number {
let n: number = 0
this.signs.forEach((s: SignT6) => {
if (s.signed) {
n += 1
}
})
return Math.round(n * 100 / 7)
}
confirmEx(): void {
const cost: number = this.exCnt * this.exGood.points
if (cost > this.myPts) {
return
}
this.myPts -= cost
this.goods = this.goods.map((x: GoodT6) => {
if (x === this.exGood) {
const ng: GoodT6 = { name: x.name, points: x.points, price: x.price, stock: x.stock - this.exCnt, tag: x.tag, emoji: x.emoji, hot: x.hot + 1 }
return ng
}
return x
})
const nr: RecT6 = { title: '兑换 ' + this.exGood.name + ' ×' + this.exCnt.toString(), pts: -cost, date: '08-28', kind: '兑换' }
this.recs = [nr].concat(this.recs)
this.showEx = false
}
confirmSign(): void {
const list: Array<SignT6> = this.signs.slice()
for (let i: number = 0; i < list.length; i++) {
if (!list[i].signed) {
const ns: SignT6 = { day: list[i].day, pts: list[i].pts, signed: true }
list[i] = ns
this.myPts += this.makeUp ? Math.floor(list[i].pts / 2) : list[i].pts
const nr: RecT6 = { title: this.makeUp ? '补签奖励' : '每日签到奖励', pts: this.makeUp ? Math.floor(list[i].pts / 2) : list[i].pts, date: '08-28', kind: '签到' }
this.recs = [nr].concat(this.recs)
break
}
}
this.signs = list
if (!this.makeUp) {
this.signDays += 1
}
this.showSign = false
}
finishTask6(t: TaskT6): void {
if (t.done) {
return
}
this.tasks = this.tasks.map((x: TaskT6) => {
if (x === t) {
const nt: TaskT6 = { title: x.title, desc: x.desc, pts: x.pts, kind: x.kind, done: true }
return nt
}
return x
})
this.myPts += t.pts
const nr: RecT6 = { title: '完成任务:' + t.title, pts: t.pts, date: '08-28', kind: '任务' }
this.recs = [nr].concat(this.recs)
this.showTask = false
}
confirmLucky(): void {
const cost: number = this.luckyCnt * 100
if (cost > this.myPts) {
return
}
this.myPts -= cost
this.luckys = this.luckys.map((x: LuckyT6) => {
if (x === this.selLucky) {
const nl: LuckyT6 = { name: x.name, total: x.total, joined: x.joined + this.luckyCnt, mine: x.mine + this.luckyCnt, emoji: x.emoji }
return nl
}
return x
})
const nr: RecT6 = { title: '参与夺宝:' + this.selLucky.name, pts: -cost, date: '08-28', kind: '夺宝' }
this.recs = [nr].concat(this.recs)
this.showLucky = false
}
doDel6(): void {
this.recs = this.recs.filter((x: RecT6) => x !== this.delRec)
this.showDel = false
}
@Builder
tabBtn6(icon: string, label: string, idx: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(idx === this.selTab ? 20 : 18)
.opacity(idx === this.selTab ? 1 : 0.55)
Text(label)
.fontSize(10)
.fontColor(idx === this.selTab ? '#FF6B6B' : '#9AA5B1')
.fontWeight(idx === this.selTab ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 6, bottom: 6 })
.onClick(() => {
this.selTab = idx
})
}
@Builder
tabBar6() {
Column() {
Divider()
.color('#E8EBF4')
Row() {
this.tabBtn6('🏠', '首页', 0)
this.tabBtn6('⭐', '积分', 1)
this.tabBtn6('🛍️', '兑换', 2)
this.tabBtn6('📋', '任务', 3)
}
.width('100%')
Row() {
this.tabBtn6('📅', '签到', 4)
this.tabBtn6('🎰', '夺宝', 5)
this.tabBtn6('🙋', '我的', 6)
}
.width('100%')
}
.width('100%')
.backgroundColor('#FFFFFF')
}
@Builder
headerBar6() {
Column({ space: 12 }) {
Row() {
Column({ space: 2 }) {
Text('健康积分商城')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#2D3436')
Text('做任务 · 攒积分 · 换健康好物')
.fontSize(11)
.fontColor('#9AA5B1')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row({ space: 6 }) {
Text('🪙')
.fontSize(16)
.offset({ y: this.coinY })
Text(this.myPts.toString())
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B6B')
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#FFE9E9')
.borderRadius(14)
.onClick(() => {
this.selTab = 1
})
}
.width('100%')
Row({ space: 8 }) {
Text('🔎')
.fontSize(15)
Text('搜索商品 / 任务 / 夺宝')
.fontSize(13)
.fontColor('#B8C0CC')
.layoutWeight(1)
Text('搜')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#FF6B6B')
.borderRadius(10)
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(20)
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🎫')
.fontSize(22)
Text('体检券')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 2
})
Column({ space: 4 }) {
Text('😷')
.fontSize(22)
Text('健康好物')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 2
})
Column({ space: 4 }) {
Text('📋')
.fontSize(22)
Text('赚积分')
.fontSize(10)
.fontColor('#2D3436')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.onClick(() => {
this.selTab = 3
})
Column({ space: 4 }) {
Text('📅')
blog.csdnimg.cn/direct/a51c89001fcf4b179363c4a710dce26d.png#pic_center)
this.goalModalOverlay()
}
if (this.showDel) {
this.delModalOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F6FB')
}
}

总结
一、架构设计的分层思想
本项目展现了清晰的分层架构设计:数据层(6个接口 + 7个静态数组)、状态层(近30个@State变量)、逻辑层(7个业务方法)、UI层(7个Tab页面 + 6个弹框 + 4个公共构建器)和动画层(2个animateTo动画)。各层之间通过状态变量进行数据传递,数据流单向且清晰。这种分层设计使得代码的可读性和可维护性都得到了保障,即使在面对7个Tab和6个弹框的复杂交互场景下,代码结构依然清晰可控。
二、状态管理的不可变更新模式
项目中所有数组状态的更新都遵循"不可变更新"模式:使用map、filter、concat、slice等方法创建新数组,而非直接修改原数组。这种模式是ArkTS状态管理的核心要求——只有赋值新数组才能触发@State的响应式更新。同时,数组中的对象元素也是创建新对象而非修改原对象,保证了状态的纯净性。虽然这种写法相比直接修改稍微冗长,但它带来了更好的可追踪性和更少的副作用。
三、弹框系统的统一架构
6个弹框采用统一的架构模式:"蒙层 + 内容卡片"的双层结构。蒙层使用半透明背景 + 全屏position + zIndex(999),内容卡片使用不同的定位方式(底部抽屉用y: '46%',居中卡片用x: '8%', y: '16%')和zIndex(1000)。所有弹框的显示/隐藏完全由boolean状态变量驱动,通过if条件渲染控制。这种统一的架构模式使得弹框的维护和扩展都非常方便——新增一个弹框只需要定义状态变量、编写@Builder、在build中添加if条件即可。
四、自定义数据可视化的实践
项目中的柱状图、进度条、进度环等数据可视化效果全部使用ArkUI基础组件组合实现,没有依赖任何图表库。柱状图通过Column嵌套Row + justifyContent(FlexAlign.End)实现,进度条通过Row嵌套Row + 动态宽度实现,进度环通过Stack叠加Progress和Text实现。这种"纯组件"方式的好处是包体积小、性能好、可定制性强,缺点是开发量较大。在数据可视化需求不太复杂的场景下,这种方案是非常实用的选择。
五、动画系统的全局复用
项目中的两个动画变量coinY和flashOp在多个位置被复用:金币浮动动画在头部积分区和签到弹框中使用,闪烁动画在闪兑商品和任务翻倍提示中使用。这种全局复用的设计减少了动画变量的数量和aboutToAppear中的动画启动代码,是一种高效的动画管理策略。animateTo的PlayMode.Alternate模式产生了自然的"呼吸感"效果,适合无限循环的装饰性动画。
更多推荐




所有评论(0)