文章目录打卡页的反馈链路训练数据如何驱动统计关键代码环形进度抽成 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.workoutsthis.workouts.map(w{if(w.idid){constnewCompleted!w.completedthis.totalCaloriesnewCompleted?w.calories:-w.caloriesthis.totalMinutesnewCompleted?w.duration:-w.duration w.completednewCompletedreturnw}returnw})}这里有一个可以继续优化的点当前代码在map里修改了w.completed。学习示例能看懂但真实项目里我更倾向返回新对象状态变化会更干净。完整代码interfaceWorkoutRecord{id:numbername:stringicon:stringduration:numbercalories:numbercompleted:booleantargetDays:numberdoneDays:numbercolor:ResourceColor}interfaceWeekDay{day:stringlabel:stringhasRecord:booleanisToday:boolean}EntryComponentstruct FitnessCheckinPage{StatetotalCalories:number0StatetotalMinutes:number0StatestreakDays:number7Stateworkouts: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.totalCaloriesw.caloriesthis.totalMinutesw.duration}})}toggleWorkout(id:number){this.workoutsthis.workouts.map(w{if(w.idid){constnewCompleted!w.completedthis.totalCaloriesnewCompleted?w.calories:-w.caloriesthis.totalMinutesnewCompleted?w.duration:-w.duration w.completednewCompletedreturnw}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(ww.completed).length/this.workouts.length*100,#A78BFA,90,完成率,this.workouts.filter(ww.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)}}可以继续加的一个功能这个案例很适合继续加“今日目标”。比如设置目标卡路里和目标分钟数再让环形进度基于目标计算会比现在的演示数据更接近真实打卡应用。