文章目录
- 打卡页的反馈链路
- 训练数据如何驱动统计
- 关键代码:环形进度抽成 `Builder`
- 关键代码:打卡时同步更新总数据
- 完整代码
- 可以继续加的一个功能
健身打卡页最重要的是反馈快。用户点一下完成训练,卡路里、分钟数、完成率都应该马上变;否则这个页面就不像“打卡”,更像静态展示。
这个案例用本周日历、环形进度和训练列表做了一个完整的打卡页面,适合练@Builder复用和数组状态更新。
我写这类页面时会先做“点一下有没有反馈”。视觉可以后面慢慢调,反馈链路一定要先通。
打卡页的反馈链路
这个页面的交互链路很短,但很典型:
点击训练项 -> 修改 completed -> 更新总卡路里 / 总分钟 -> 环形进度刷新页面里的信息可以这样分层:
| 区域 | 展示内容 | 关键字段 |
|---|---|---|
| 本周打卡 | 一周记录、今天标记 | weekDays |
| 今日数据 | 卡路里、分钟、完成率 | totalCalories、totalMinutes、workouts |
| 训练计划 | 训练名称、时长、月度进度、打卡按钮 | workouts |
| 激励文案 | 底部提醒 | 静态文本 |
训练数据如何驱动统计
workouts是这页的核心数据。它既负责列表展示,也参与计算总消耗和完成率。
interfaceWorkoutRecord{id:numbername:stringicon:stringduration:numbercalories:numbercompleted:booleantargetDays:numberdoneDays:numbercolor:ResourceColor}其中targetDays和doneDays用来做月度进度,completed用来控制今日是否完成。一个数据结构同时服务两个维度,页面就不会散。
关键代码:环形进度抽成Builder
三个环形进度长得一样,只是颜色、数值和标题不同,所以这里用@Builder抽出ringProgress。
@BuilderringProgress(progress:number,color:ResourceColor,size:number,label:string,value:string){Stack({alignContent:Alignment.Center}){Progress({value:0,total:100,type:ProgressType.Ring}).width(size).height(size).color('#1A1A2E').style({strokeWidth:size*0.1})Progress({value:progress,total:100,type:ProgressType.Ring}).width(size).height(size).color(color).style({strokeWidth:size*0.1})Column({space:2}){Text(value).fontSize(size*0.2).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')Text(label).fontSize(size*0.12).fontColor('#94A3B8')}}}这个Builder的好处很明显:页面里调用三次就能得到三种进度,不用复制三段Stack + Progress。
关键代码:打卡时同步更新总数据
toggleWorkout负责切换单个训练项,同时增减总卡路里和总分钟数。
toggleWorkout(id:number){this.workouts=this.workouts.map(w=>{if(w.id===id){constnewCompleted=!w.completedthis.totalCalories+=newCompleted?w.calories:-w.caloriesthis.totalMinutes+=newCompleted?w.duration:-w.duration w.completed=newCompletedreturnw}returnw})}这里有一个可以继续优化的点:当前代码在map里修改了w.completed。学习示例能看懂,但真实项目里我更倾向返回新对象,状态变化会更干净。
完整代码
interfaceWorkoutRecord{id:numbername:stringicon:stringduration:numbercalories:numbercompleted:booleantargetDays:numberdoneDays:numbercolor:ResourceColor}interfaceWeekDay{day:stringlabel:stringhasRecord:booleanisToday:boolean}@Entry@Componentstruct FitnessCheckinPage{@StatetotalCalories:number=0@StatetotalMinutes:number=0@StatestreakDays:number=7@Stateworkouts:WorkoutRecord[]=[{id:1,name:'晨跑',icon:'🏃',duration:30,calories:280,completed:true,targetDays:20,doneDays:14,color:'#FF6B35'},{id:2,name:'核心训练',icon:'💪',duration:20,calories:150,completed:false,targetDays:15,doneDays:8,color:'#4ECDC4'},{id:3,name:'瑜伽',icon:'🧘',duration:45,calories:120,completed:true,targetDays:30,doneDays:22,color:'#A78BFA'},{id:4,name:'游泳',icon:'🏊',duration:60,calories:400,completed:false,targetDays:10,doneDays:5,color:'#3B82F6'}]@StateweekDays:WeekDay[]=[{day:'一',label:'Mon',hasRecord:true,isToday:false},{day:'二',label:'Tue',hasRecord:true,isToday:false},{day:'三',label:'Wed',hasRecord:true,isToday:false},{day:'四',label:'Thu',hasRecord:false,isToday:false},{day:'五',label:'Fri',hasRecord:true,isToday:false},{day:'六',label:'Sat',hasRecord:true,isToday:false},{day:'日',label:'Sun',hasRecord:false,isToday:true}]aboutToAppear(){this.workouts.forEach(w=>{if(w.completed){this.totalCalories+=w.caloriesthis.totalMinutes+=w.duration}})}toggleWorkout(id:number){this.workouts=this.workouts.map(w=>{if(w.id===id){constnewCompleted=!w.completedthis.totalCalories+=newCompleted?w.calories:-w.caloriesthis.totalMinutes+=newCompleted?w.duration:-w.duration w.completed=newCompletedreturnw}returnw})}@BuilderringProgress(progress:number,color:ResourceColor,size:number,label:string,value:string){Stack({alignContent:Alignment.Center}){Progress({value:0,total:100,type:ProgressType.Ring}).width(size).height(size).color('#1A1A2E').style({strokeWidth:size*0.1})Progress({value:progress,total:100,type:ProgressType.Ring}).width(size).height(size).color(color).style({strokeWidth:size*0.1})Column({space:2}){Text(value).fontSize(size*0.2).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')Text(label).fontSize(size*0.12).fontColor('#94A3B8')}}}build(){Column(){// 顶部标题栏Row(){Column({space:2}){Text('健身打卡').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')Text('今天是你坚持的第 '+this.streakDays+' 天 🔥').fontSize(13).fontColor('#94A3B8')}.alignItems(HorizontalAlign.Start)Blank()Text('🏆').fontSize(32)}.width('100%').padding({left:20,right:20,top:50,bottom:16})Scroll(){Column({space:16}){// 本周打卡日历Column({space:12}){Text('本周打卡').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#94A3B8').alignSelf(ItemAlign.Start)Row({space:8}){ForEach(this.weekDays,(day:WeekDay)=>{Column({space:6}){Text(day.day).fontSize(12).fontColor(day.isToday?'#FFFFFF':'#64748B')Stack(){Circle({width:36,height:36}).fill(day.isToday?'#FF6B35':day.hasRecord?'#22C55E':'#1E293B')if(day.hasRecord&&!day.isToday){Text('✓').fontSize(14).fontColor('#FFFFFF')}elseif(day.isToday){Text('今').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)}}}})}.justifyContent(FlexAlign.SpaceBetween).width('100%')}.width('100%').padding(16).backgroundColor('#0F172A').borderRadius(16)// 今日数据环形图Row({space:20}){this.ringProgress(this.totalCalories/6,'#FF6B35',90,'卡路里',this.totalCalories+'')this.ringProgress(this.totalMinutes/0.9,'#4ECDC4',90,'分钟',this.totalMinutes+'')this.ringProgress(this.workouts.filter(w=>w.completed).length/this.workouts.length*100,'#A78BFA',90,'完成率',this.workouts.filter(w=>w.completed).length+'/'+this.workouts.length)}.justifyContent(FlexAlign.SpaceAround).width('100%').padding(20).backgroundColor('#0F172A').borderRadius(16)// 训练项目列表Column({space:12}){Text('今日训练计划').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#94A3B8').alignSelf(ItemAlign.Start)ForEach(this.workouts,(workout:WorkoutRecord)=>{Row({space:14}){Text(workout.icon).fontSize(28).width(52).height(52).textAlign(TextAlign.Center).backgroundColor('#1E293B').borderRadius(14)Column({space:6}){Row(){Text(workout.name).fontSize(16).fontWeight(FontWeight.Medium).fontColor('#FFFFFF')Blank()Text(workout.duration+' 分钟').fontSize(12).fontColor('#64748B')}.width('100%')// 月度进度条Row({space:8}){Progress({value:workout.doneDays,total:workout.targetDays,type:ProgressType.Linear}).width(120).height(6).color(workout.color).backgroundColor('#1E293B')Text(workout.doneDays+'/'+workout.targetDays+'天').fontSize(11).fontColor('#94A3B8')}Text('消耗 '+workout.calories+' 千卡').fontSize(12).fontColor('#64748B')}.layoutWeight(1).alignItems(HorizontalAlign.Start)// 打卡按钮Stack(){Circle({width:40,height:40}).fill(workout.completed?'#22C55E':'#1E293B').stroke(workout.completed?'#22C55E':'#334155').strokeWidth(1.5)Text(workout.completed?'✓':'○').fontSize(16).fontColor(workout.completed?'#FFFFFF':'#475569')}.onClick(()=>this.toggleWorkout(workout.id))}.width('100%').padding(14).backgroundColor('#0F172A').borderRadius(14)})}.width('100%')// 底部激励文字Row(){Text('💬').fontSize(20)Text(' 坚持就是胜利!每一次训练都让你更强大 💪').fontSize(13).fontColor('#94A3B8').layoutWeight(1)}.width('100%').padding(16).backgroundColor('#0F172A').borderRadius(12).margin({bottom:30})}.padding({left:16,right:16})}.layoutWeight(1)}.width('100%').height('100%').backgroundColor('#020617')}}可以继续加的一个功能
这个案例很适合继续加“今日目标”。比如设置目标卡路里和目标分钟数,再让环形进度基于目标计算,会比现在的演示数据更接近真实打卡应用。