基于HarmonyOS API 24健身运动追踪应用深度解析,selectedWorkout变量的类型是 WorkoutItem | null,是一个联合类型,可以是一个训练项目对象,也可以是null
一、引言:为什么我们需要一个运动追踪应用
在当今快节奏的现代生活中,健康问题已经成为了越来越多人关注的焦点。久坐不动的工作方式、高强度的工作压力、不规律的饮食习惯,这些因素都在悄无声息地侵蚀着我们的身体素质。健身,作为一种主动管理健康的方式,正被越来越多的人所接受和实践。然而,健身并非一朝一夕之功,它需要长期的坚持、科学的规划以及持续的数据追踪。

很多人在健身的初期都充满热情,但随着时间的推移,由于缺乏系统的记录和反馈机制,训练逐渐变得盲目,动力也慢慢消退。今天做了哪些动作?消耗了多少卡路里?上周的训练频率如何?这个月的进步在哪里?如果没有一个清晰的记录和可视化展示,这些问题很难得到准确回答。这正是运动追踪应用存在的意义所在——它不仅是记录工具,更是陪伴用户坚持训练的"数字教练"。
一个优秀的运动追踪应用应当具备几个核心能力。首先,它需要提供清晰的训练计划管理功能,让用户能够方便地查看、新增、编辑和删除训练项目。其次,它应当具备完整的训练记录功能,将每一次训练的细节都留存下来,形成可追溯的历史档案。第三,数据统计与可视化是不可或缺的,通过图表直观地展示训练趋势、类型分布和能量消耗,帮助用户理解自己的训练模式。第四,挑战机制能够为训练增添趣味性和目标感,激励用户持续突破自我。最后,个人档案页面整合用户的基本信息、身体数据和成就徽章,给予用户全面的自我认知。
本文将深入剖析一个完整的健身运动追踪应用的源代码实现。该应用采用声明式 UI 开发范式构建,以活力红为主色调,营造出充满运动激情的视觉氛围。应用包含训练计划、运动记录、数据统计、挑战任务和个人档案五大功能模块,涵盖了从数据建模、状态管理、组件化设计到交互逻辑的完整技术链路。通过对每一部分代码的逐行解读,读者将能够深入理解声明式 UI 的开发思路,掌握复杂应用的状态管理技巧,学习如何构建可复用的组件体系,以及如何设计优雅的用户交互体验。
二、应用整体架构概览
在深入具体代码之前,我们先从宏观层面理解这个应用的整体架构设计。整个应用采用了清晰的分层架构,自上而下可以分为四个层次。
最上层是入口组件层,即应用的主入口结构体,它负责管理全局状态、协调各子组件之间的数据流转,以及统一调度弹窗的显示与隐藏。这一层是整个应用的"大脑",所有的状态变更最终都会汇聚到这里。
第二层是导航框架层,由顶部统计栏和底部标签栏组成。顶部统计栏始终展示用户最关心的核心数据概览,而底部标签栏则提供了五个主要功能模块之间的快速切换能力。这种"头部信息 + 底部导航"的结构是移动端应用的经典布局模式。
第三层是内容页面层,包含训练内容、运动记录、数据统计、挑战任务和个人档案五个独立的内容组件。每个组件都封装了自己的 UI 布局和数据展示逻辑,通过属性绑定的方式与主组件进行数据通信。这种设计使得各页面之间保持高度解耦,便于独立开发和维护。
最底层是数据模型层,由一系列接口定义和静态数据组成。接口定义了数据的结构契约,静态数据则提供了应用的初始数据源。颜色映射表等辅助数据也位于这一层,为 UI 渲染提供统一的样式参考。
下面我们逐层深入,从数据模型开始,一步步解析整个应用的实现细节。
三、数据模型设计:接口定义的艺术
3.1 训练项目接口
在任何应用中,数据模型的设计都是最基础也最关键的一步。好的数据模型应当既能准确描述业务实体,又保持足够的简洁性。这个应用定义了多个接口来描述不同类型的数据实体。
首先是训练项目的接口定义,它描述了一个健身动作的完整信息:
interface WorkoutItem {
id: number
name: string
type: string
duration: number
calories: number
sets: number
reps: number
difficulty: string
muscleGroup: string
equipment: string
isCompleted: boolean
restTime: number
image: string
}

让我们逐行分析这个接口的每一个字段。
id 字段是每个训练项目的唯一标识符,类型为数字。在列表渲染、编辑和删除操作中,这个字段用于精确定位特定的训练项目。在声明式 UI 的列表渲染中,唯一标识符还扮演着优化渲染性能的角色——框架可以通过对比 id 来判断哪些列表项发生了变化,从而只更新变化的部分。
name 字段存储训练动作的名称,例如"杠铃深蹲""卧推训练"等。这是一个字符串类型的字段,直接展示在列表卡片和详情页面中,是用户识别训练项目的主要依据。
type 字段表示运动类型,取值包括"力量"“有氧”“HIIT”“核心”"柔韧"五种。这个字段不仅用于分类展示,还与颜色映射表关联,不同类型对应不同的主题色,使列表在视觉上形成清晰的分类区分。
duration 字段记录预计训练时长,单位为分钟。calories 字段记录预计消耗的卡路里,单位为千卡。这两个数值字段是用户评估训练强度的重要参考指标,在卡片列表和详情页面中都有突出展示。
sets 和 reps 字段分别代表组数和每组次数,这两个字段对于力量训练尤为重要。例如"4组x12次"的杠铃深蹲,清晰地定义了训练的容量。对于有氧运动,这两个字段通常设为1。
difficulty 字段表示难度等级,取值为"初级"“中级”"高级"三档。这个字段同样与颜色映射表关联,初级用绿色表示亲和力,中级用橙色表示进阶,高级用红色表示挑战。
muscleGroup 字段记录目标肌群,如"腿部"“胸部”“背部"等。equipment 字段记录所需器械,如"杠铃”“哑铃”"无"等。这两个字段帮助用户快速了解训练的针对性和场地要求。
isCompleted 字段是一个布尔值,标记该训练项目是否已完成。已完成的项目会在列表中显示绿色对勾标记,给用户即时的成就感反馈。
restTime 字段记录组间休息时间,单位为秒。这个字段在详情页面的组数详情中有展示,帮助用户科学控制训练节奏。
image 字段存储一个 Emoji 表情符号,作为训练项目的视觉图标。使用 Emoji 作为图标是一种轻量级的方案,无需引入图片资源即可实现丰富的视觉表现。
3.2 训练记录接口
训练记录接口用于存储用户每次实际训练的历史数据,它与训练项目接口有相似之处,但更侧重于记录实际发生的情况:
interface LogItem {
id: number
date: string
exercise: string
duration: number
calories: number
sets: number
weight: number
notes: string
mood: string
}

逐行来看,id 字段同样是唯一标识符。date 字段以字符串形式记录训练日期,格式为"YYYY-MM-DD",这种格式既便于人类阅读,也便于程序解析和排序。exercise 字段记录本次训练对应的动作名称,与训练项目中的 name 字段相对应。
duration、calories、sets 三个字段记录了本次训练的实际时长、实际消耗和实际组数。与训练项目中的预设值不同,这里的数值反映的是用户真实完成的训练量。
weight 字段是一个很有特色的设计,它记录了本次训练使用的重量(公斤)。对于力量训练来说,重量的追踪至关重要,它是衡量力量进步的核心指标。对于自重训练或有氧运动,这个字段设为0。
notes 字段允许用户为每次训练添加文字备注,例如"状态很好,重量增加""配速7km/h"等。这些备注为枯燥的数据增添了人情味,也是日后回顾训练历程时的宝贵记忆。
mood 字段记录训练后的心情,使用 Emoji 表情符号表示。这个设计非常巧妙,它让训练记录不再只是冷冰冰的数字,而是充满了情感色彩。心情还与颜色映射表关联,不同的心情对应不同的背景色,使记录列表在视觉上更加生动。
3.3 挑战任务接口
挑战任务接口描述了应用中的各种训练挑战,为用户设定阶段性目标:
interface ChallengeItem {
id: number
title: string
description: string
progress: number
target: number
reward: string
deadline: string
isCompleted: boolean
}

id 字段为唯一标识符。title 字段是挑战的简短标题,如"30天连续训练"“本月跑步50公里”。description 字段提供更详细的挑战说明,帮助用户理解挑战的具体要求。
progress 和 target 是一对关键字段,分别表示当前进度和目标值。这两个字段的比值决定了进度条的填充比例,是挑战页面最核心的数据展示。例如进度为18、目标为30,表示已完成60%。
reward 字段描述完成挑战后可以获得的奖励,通常是一个徽章和称号。这种游戏化的设计能够有效激发用户的完成欲望,将枯燥的训练目标转化为有趣的收集体验。
deadline 字段记录挑战的截止日期,为用户创造紧迫感。isCompleted 字段标记挑战是否已完成,已完成的挑战在进度条颜色上会从红色变为绿色,给予明确的视觉反馈。
3.4 统计数据接口
数据统计页面需要两种特殊的接口来支撑图表展示:
interface StatsBarItem {
day: string
value: number
}
interface TypePercentItem {
type: string
percent: number
color: string
}

StatsBarItem 接口用于柱状图数据,day 字段是星期几的缩写(如"周一"“周二”),value 字段是对应的训练时长(分钟)。这个接口结构非常简洁,专门服务于一周训练时长的柱状图展示。
TypePercentItem 接口用于饼图图例数据,type 字段是运动类型名称,percent 字段是占比百分比,color 字段是该类型对应的展示颜色。由于饼图的颜色需要与图例保持一致,所以颜色信息直接内嵌在数据中,避免了额外的查找操作。
3.5 导航项接口
最后一个接口用于定义底部导航栏的标签项:
interface TabItem {
title: string
icon: string
builder: () => void
}

title 字段是标签的文字标题,icon 字段是 Emoji 图标。builder 字段是一个返回 void 的函数类型,这种设计允许将页面构建器作为数据的一部分传递,实现数据驱动的页面切换。不过在当前实现中,这个接口实际上并未直接使用,页面切换是通过条件判断实现的,这个接口更像是一种预留的设计思路。
四、静态数据与颜色映射:应用的初始数据源
4.1 训练项目数据
应用预置了20条训练项目数据,覆盖了力量、有氧、HIIT、核心、柔韧五种运动类型:
const workoutData: WorkoutItem[] = [
{ id: 1, name: '杠铃深蹲', type: '力量', duration: 45, calories: 320, sets: 4, reps: 12, difficulty: '高级', muscleGroup: '腿部', equipment: '杠铃', isCompleted: true, restTime: 90, image: '🏋️' },
{ id: 2, name: '卧推训练', type: '力量', duration: 40, calories: 280, sets: 5, reps: 8, difficulty: '高级', muscleGroup: '胸部', equipment: '杠铃', isCompleted: false, restTime: 120, image: '💪' },
{ id: 3, name: '跑步机慢跑', type: '有氧', duration: 30, calories: 250, sets: 1, reps: 1, difficulty: '初级', muscleGroup: '全身', equipment: '跑步机', isCompleted: true, restTime: 0, image: '🏃' },
{ id: 4, name: 'HIIT燃脂训练', type: 'HIIT', duration: 25, calories: 350, sets: 6, reps: 15, difficulty: '高级', muscleGroup: '全身', equipment: '无', isCompleted: false, restTime: 30, image: '🔥' },
{ id: 5, name: '瑜伽拉伸', type: '柔韧', duration: 60, calories: 150, sets: 1, reps: 1, difficulty: '初级', muscleGroup: '全身', equipment: '瑜伽垫', isCompleted: true, restTime: 0, image: '🧘' }
]

这里展示了前5条数据作为示例。我们可以观察到数据设计的几个特点。
第一,训练项目的名称都是中文,贴近国内用户的习惯。第二,每种运动类型都有多个项目,确保用户有丰富的选择。第三,难度分布合理,既有适合新手的初级项目,也有挑战性的高级项目。第四,isCompleted 字段的值有真有假,这样列表展示时会呈现出已完成和待完成混合的状态,更接近真实使用场景。
值得注意的是,有氧运动和柔韧训练的 sets 和 reps 都设为1,restTime 设为0,这是因为这些运动通常是持续性的,不需要分组练习。这种细节处理体现了数据建模时对运动特性的深入理解。
4.2 训练记录数据
训练记录同样预置了20条数据,按日期倒序排列,覆盖了大约10天的训练历史:
const logData: LogItem[] = [
{ id: 1, date: '2026-08-06', exercise: '杠铃深蹲', duration: 45, calories: 320, sets: 4, weight: 80, notes: '状态很好,重量增加', mood: '😊' },
{ id: 2, date: '2026-08-06', exercise: '跑步机慢跑', duration: 30, calories: 250, sets: 1, weight: 0, notes: '配速7km/h', mood: '😊' },
{ id: 3, date: '2026-08-05', exercise: '卧推训练', duration: 40, calories: 280, sets: 5, weight: 60, notes: '推到力竭', mood: '😎' },
{ id: 7, date: '2026-08-03', exercise: '硬拉训练', duration: 50, calories: 400, sets: 5, weight: 100, notes: 'PR突破', mood: '😎' }
]

这里选取了几条典型数据进行分析。我们可以看到,同一天可能有多条训练记录,例如8月6日同时记录了杠铃深蹲和跑步机慢跑。weight 字段对于力量训练记录了具体重量(如深蹲80kg、硬拉100kg),而对于有氧运动则为0。notes 字段的内容丰富多样,有记录状态的、有记录数据的、有记录感受的,使每条记录都具有独特的个性。mood 字段使用了不同的 Emoji,反映了训练后的不同情绪状态。
4.3 挑战任务数据
挑战任务预置了10条数据,涵盖了时长、距离、次数、卡路里等多种挑战类型:
const challengeData: ChallengeItem[] = [
{ id: 1, title: '30天连续训练', description: '连续30天完成至少一次训练', progress: 18, target: 30, reward: '🏅 训练达人徽章', deadline: '2026-08-30', isCompleted: false },
{ id: 4, title: '燃烧10000卡路里', description: '本月累计消耗10000卡路里', progress: 4200, target: 10000, reward: '🔥 燃脂战士', deadline: '2026-08-31', isCompleted: false },
{ id: 8, title: '一周5练', description: '本周完成5次训练', progress: 5, target: 5, reward: '✅ 本周全勤', deadline: '2026-08-07', isCompleted: true }
]

这里选取了三条具有代表性的挑战数据。第一条"30天连续训练"是一个长期挑战,进度18/30,尚未完成。第二条"燃烧10000卡路里"展示了大数值挑战的设计,进度4200/10000。第三条"一周5练"是一个已完成的挑战,进度5/5,isCompleted 为 true。这种多样化的数据设计确保了挑战页面能够展示各种状态:进行中的、接近完成的、已经完成的。
4.4 统计图表数据
柱状图数据定义了一周七天的训练时长:
const statsBarData: StatsBarItem[] = [
{ day: '周一', value: 45 },
{ day: '周二', value: 60 },
{ day: '周三', value: 30 },
{ day: '周四', value: 75 },
{ day: '周五', value: 50 },
{ day: '周六', value: 90 },
{ day: '周日', value: 40 }
]

这组数据模拟了一周的训练情况,周末(周六)训练时长最长达到90分钟,周三最短只有30分钟。这种波动分布很符合真实用户的训练习惯——周末时间充裕训练量大,工作日则相对紧凑。
类型占比数据定义了五种运动类型的分布:
const typePercentData: TypePercentItem[] = [
{ type: '力量', percent: 40, color: '#C62828' },
{ type: '有氧', percent: 25, color: '#EF5350' },
{ type: 'HIIT', percent: 20, color: '#FF6F00' },
{ type: '核心', percent: 10, color: '#2E7D32' },
{ type: '柔韧', percent: 5, color: '#7B1FA2' }
]

力量训练占比最高达40%,这与预置的20条训练项目中力量类项目数量最多是一致的。每种类型都有独立的颜色,这些颜色在整个应用中保持统一使用。
4.5 颜色映射表
应用定义了三组颜色映射表,分别用于运动类型、难度等级和心情表情:
const typeColorMap: Record<string, string> = {
'力量': '#C62828',
'有氧': '#EF5350',
'HIIT': '#FF6F00',
'核心': '#2E7D32',
'柔韧': '#7B1FA2'
}
const difficultyColorMap: Record<string, string> = {
'初级': '#2E7D32',
'中级': '#FF6F00',
'高级': '#C62828'
}
const moodColorMap: Record<string, string> = {
'😊': '#2E7D32',
'😎': '#C62828',
'🔥': '#FF6F00',
'😅': '#EF5350',
'😌': '#7B1FA2',
'💪': '#C62828'
}

这三组映射表的设计思路值得深入探讨。
typeColorMap 将每种运动类型映射到一个固定的十六进制颜色值。力量训练使用主色活力红 #C62828,有氧使用辅色浅红 #EF5350,HIIT 使用橙色 #FF6F00,核心使用绿色 #2E7D32,柔韧使用紫色 #7B1FA2。这种映射使得在列表卡片中,左侧色条和类型标签的颜色能够一致地反映运动类型,用户通过颜色就能快速识别类别。
difficultyColorMap 采用了一种语义化的颜色方案。初级用绿色表示"安全、友好",中级用橙色表示"注意、进阶",高级用红色表示"挑战、危险"。这种颜色语义与人们对难度的直觉感知高度一致。
moodColorMap 是一个很有趣的设计,它将 Emoji 表情符号映射到颜色值。开心😊对应绿色,酷😎和加油💪对应红色,火热🔥对应橙色,尴尬😅对应浅红,放松😌对应紫色。这些颜色在记录列表中用作心情图标的背景色(加上透明度后),为每条记录增添了情绪色彩。
使用 Record<string, string> 类型来定义映射表是一种简洁高效的方式。它允许通过键名直接访问颜色值,例如 typeColorMap['力量'] 即可获取到 '#C62828',无需编写复杂的查找逻辑。
五、主组件状态管理:应用的核心枢纽
5.1 装饰器与组件声明
主组件是整个应用的入口点,它通过装饰器声明为入口组件和自定义组件:
@Entry
@Component
struct FitnessTracker {
@State currentTab: number = 0
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedWorkout: WorkoutItem | null = null
@State workoutList: WorkoutItem[] = workoutData
@State logList: LogItem[] = logData
@State challengeList: ChallengeItem[] = challengeData
// ...
}

这段代码涉及几个关键概念。
@Entry 装饰器标记这是一个入口组件,即应用的根组件。每个页面只有一个入口组件,它是整个组件树的根节点。框架会以此为起点构建 UI 树。
@Component 装饰器声明这是一个自定义组件。自定义组件可以复用,可以包含状态和构建方法。被 @Component 修饰的结构体必须包含一个 build() 方法,用于描述组件的 UI 结构。
@State 装饰器用于声明组件内部的状态变量。当状态变量的值发生变化时,框架会自动重新调用 build() 方法,更新对应的 UI 部分。这是声明式 UI 的核心机制——开发者只需关注状态的变化,UI 的更新由框架自动完成。
5.2 导航与弹窗状态
主组件管理着两大类状态:导航状态和弹窗状态。
导航状态只有一个变量 currentTab,初始值为0,表示默认显示第一个标签页(训练计划)。当用户点击底部标签栏时,这个变量的值会改变为对应的索引(0到4),从而触发页面内容的切换。
弹窗状态包含四个布尔变量,分别控制四种弹窗的显示与隐藏:
showAddModal:新增训练计划弹窗showEditModal:编辑训练计划弹窗showDeleteConfirm:删除确认弹窗showDetailModal:训练详情弹窗
selectedWorkout 变量的类型是 WorkoutItem | null,这是一个联合类型,表示它可以是一个训练项目对象,也可以是 null。当用户点击某个训练项目的查看、编辑或删除按钮时,该变量会被赋值为对应的训练项目,弹窗内容据此渲染。当弹窗关闭后,虽然代码没有显式将其重置为 null,但弹窗的显示条件已经为 false,所以不会影响界面。
5.3 列表数据状态
三个列表数据状态分别持有训练项目、训练记录和挑战任务的数据:
@State workoutList: WorkoutItem[] = workoutData
@State logList: LogItem[] = logData
@State challengeList: ChallengeItem[] = challengeData

这里将之前定义的静态数据赋值给状态变量,作为初始数据。之所以不直接使用静态常量,是因为这些列表需要支持增删改操作——新增训练计划时需要向 workoutList 添加数据,删除时需要从中移除数据,编辑时需要替换对应项。将这些数据声明为 @State 变量,确保任何修改都能触发 UI 的自动更新。
5.4 表单状态
新增训练计划弹窗需要一个表单来收集用户输入的信息,这些表单字段也被声明为状态变量:
@State newName: string = ''
@State newType: string = '力量'
@State newDuration: number = 30
@State newCalories: number = 200
@State newSets: number = 3
@State newReps: number = 12
@State newDifficulty: string = '中级'
@State newMuscleGroup: string = '全身'
@State newEquipment: string = '无'
@State newRestTime: number = 60
每个表单字段都有合理的默认值。newName 默认为空字符串,等待用户输入。newType 默认为"力量",因为力量训练是这个应用中最常见的类型。newDuration 默认30分钟,newCalories 默认200千卡,这些都是中等强度训练的典型数值。newSets 默认3组,newReps 默认12次,这是力量训练的常见配置。newDifficulty 默认"中级",newMuscleGroup 默认"全身",newEquipment 默认"无",newRestTime 默认60秒。
这些默认值的设计考虑了用户体验——当用户打开新增弹窗时,大部分字段已经有合理的预填值,用户只需修改名称即可快速创建一条训练计划,大大降低了操作成本。
5.5 主布局结构
主组件的 build() 方法定义了应用的整体布局:
build() {
Column() {
// 顶部统计栏
TopStatsBar()
// Tab内容区
Column() {
if (this.currentTab === 0) {
WorkoutContent({
workoutList: this.workoutList,
onItemClick: (item: WorkoutItem) => {
this.selectedWorkout = item
this.showDetailModal = true
},
onEditClick: (item: WorkoutItem) => {
this.selectedWorkout = item
this.showEditModal = true
},
onDeleteClick: (item: WorkoutItem) => {
this.selectedWorkout = item
this.showDeleteConfirm = true
},
onAddClick: () => {
this.showAddModal = true
}
})
} else if (this.currentTab === 1) {
LogContent({ logList: this.logList })
} else if (this.currentTab === 2) {
StatsContent()
} else if (this.currentTab === 3) {
ChallengeContent({ challengeList: this.challengeList })
} else if (this.currentTab === 4) {
ProfileContent()
}
}
.layoutWeight(1)
.width('100%')
// 底部Tab栏
BottomTabBar({
currentTab: this.currentTab,
onTabChange: (index: number) => {
this.currentTab = index
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFEBEE')
.bindContentCover(this.showAddModal, this.AddModalBuilder(), ModalTransition.DEFAULT)
.bindContentCover(this.showEditModal, this.EditModalBuilder(), ModalTransition.DEFAULT)
.bindContentCover(this.showDeleteConfirm, this.DeleteConfirmBuilder(), ModalTransition.DEFAULT)
.bindContentCover(this.showDetailModal, this.DetailModalBuilder(), ModalTransition.DEFAULT)
}
这段代码是整个应用的骨架,我们来逐段分析。
最外层是一个 Column 容器,占据了整个屏幕的宽度和高度,背景色设为浅红色 #FFEBEE,营造出温暖的运动氛围。这个 Column 从上到下依次排列三个部分:顶部统计栏、内容区和底部标签栏。
TopStatsBar() 是顶部统计栏组件,它不接受任何参数,内部维护自己的展示数据。它始终显示在屏幕顶部,无论用户切换到哪个标签页都保持可见。
中间的内容区是一个 Column,通过 .layoutWeight(1) 占据剩余的所有空间。这个 Column 内部使用 if-else 条件判断来根据 currentTab 的值渲染不同的页面组件。这种条件渲染的方式简单直接——当 currentTab 为0时显示训练内容页,为1时显示运动记录页,以此类推。
在渲染 WorkoutContent 时,传递了多个参数。workoutList 使用 @Link 绑定,实现父子组件间的双向数据同步。其余四个参数都是回调函数,分别处理查看详情、编辑、删除和新增四种用户操作。每个回调函数的实现都很简洁:将对应的训练项目赋值给 selectedWorkout,然后将对应的弹窗状态设为 true。
BottomTabBar 组件接收两个参数:currentTab 用于高亮当前选中的标签,onTabChange 是标签切换的回调函数,它将用户点击的索引值赋给 currentTab,触发页面切换。
最关键的部分是四个 bindContentCover 调用。bindContentCover 是一个模态弹窗绑定方法,它接受三个参数:一个布尔值控制弹窗的显示与隐藏,一个 @Builder 方法构建弹窗内容,以及一个过渡动画类型。四个弹窗分别绑定到四个布尔状态变量上,当任何一个变量变为 true 时,对应的弹窗就会以默认过渡动画显示出来。这种设计将弹窗的管理集中在了主组件层面,使得各子组件只需触发回调即可,无需关心弹窗的具体实现。
六、弹窗系统:四大模态对话框的实现
6.1 新增训练计划弹窗
新增弹窗是一个功能完整的表单对话框,允许用户创建新的训练项目:
@Builder
AddModalBuilder() {
Column() {
// 弹框头部
Row() {
Text('新增训练计划')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Column() {
Text('✕')
.fontSize(22)
.fontColor('#999999')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#F5F5F5')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showAddModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(20)
Divider().color('#EEEEEE')
@Builder 装饰器用于声明一个构建方法,它不能独立存在,必须被其他组件或方法引用。AddModalBuilder 方法构建了新增弹窗的完整 UI。
弹窗头部是一个 Row,左侧是标题文字"新增训练计划",字号20,加粗,深灰色。右侧是一个关闭按钮,由一个32x32的圆形容器包裹一个"✕"符号组成,背景色为浅灰。点击关闭按钮会将 showAddModal 设为 false,关闭弹窗。Row 使用 SpaceBetween 对齐方式,使标题和关闭按钮分别靠左和靠右。
Divider 组件创建一条分隔线,颜色为极浅的灰色,将弹窗头部与表单内容在视觉上分隔开。
接下来是表单内容区域,包含多个输入字段:
Scroll() {
Column() {
Text('运动名称')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
TextInput({ text: this.newName, placeholder: '请输入运动名称' })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.margin({ left: 20, right: 20 })
.onChange((value: string) => {
this.newName = value
})
表单被包裹在 Scroll 组件中,因为表单内容可能超过弹窗的最大高度,需要支持滚动浏览。每个字段由一个标签 Text 和一个输入框 TextInput 组成。
运动名称字段的标签字号14,灰色,带有上、下、左外边距。输入框绑定到 this.newName 状态变量,设置了占位提示文字"请输入运动名称"。输入框高度48,圆角12,背景色浅灰,左右外边距20。onChange 回调在用户输入时将值同步到 newName 状态变量。
运动类型的选择采用了标签按钮的形式,而非下拉菜单:
Text('运动类型')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
Row() {
ForEach(['力量', '有氧', 'HIIT', '核心', '柔韧'], (t: string) => {
Text(t)
.fontSize(14)
.fontColor(this.newType === t ? '#FFFFFF' : '#666666')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(20)
.backgroundColor(this.newType === t ? '#C62828' : '#F5F5F5')
.margin({ right: 8 })
.onClick(() => {
this.newType = t
})
})
}
.width('100%')
.padding({ left: 20, right: 20 })
ForEach 遍历五种运动类型,为每种类型生成一个圆角标签按钮。选中的标签文字为白色,背景为主色红色;未选中的标签文字为灰色,背景为浅灰。点击标签会将 newType 更新为对应的类型值,触发 UI 重新渲染,选中的标签即时变为高亮状态。这种设计比下拉菜单更直观,用户可以一目了然地看到所有选项并快速选择。
难度选择采用了相同的标签按钮模式,但有一个细微差别——选中状态的颜色使用的是 difficultyColorMap[d],即不同难度对应不同颜色。初级选中时变绿,中级变橙,高级变红,这种颜色反馈与难度语义一致。
数值输入字段(时长、卡路里、组数、次数)采用两列布局:
Row() {
Column() {
Text('时长(分钟)')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newDuration.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
this.newDuration = parseInt(value) || 0
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('卡路里')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newCalories.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
this.newCalories = parseInt(value) || 0
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 16 })
两个 Column 各占一半宽度(layoutWeight(1)),中间通过12的左外边距分隔。数值输入框设置了 type(InputType.Number),这会调出数字键盘,方便用户输入数值。onChange 回调中使用 parseInt(value) || 0 将字符串转换为数字,如果转换失败(如用户清空了输入框)则默认为0,这是一种防御性编程的做法。
弹窗底部的操作按钮区域:
Row() {
Button('取消')
.fontSize(16)
.fontColor('#666666')
.backgroundColor('#F5F5F5')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.onClick(() => {
this.showAddModal = false
})
Button('确认新增')
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#C62828')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
const newItem: WorkoutItem = {
id: this.workoutList.length + 1,
name: this.newName,
type: this.newType,
duration: this.newDuration,
calories: this.newCalories,
sets: this.newSets,
reps: this.newReps,
difficulty: this.newDifficulty,
muscleGroup: this.newMuscleGroup,
equipment: this.newEquipment,
isCompleted: false,
restTime: this.newRestTime,
image: '🏋️'
}
this.workoutList = [newItem, ...this.workoutList]
this.showAddModal = false
this.newName = ''
this.newDuration = 30
this.newCalories = 200
this.newSets = 3
this.newReps = 12
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 24, bottom: 40 })
取消按钮使用浅灰背景和灰色文字,确认新增按钮使用主色红色背景和白色文字,两者各占一半宽度。确认新增的点击事件首先根据表单数据构建一个新的 WorkoutItem 对象,id 取当前列表长度加1,isCompleted 默认为 false,image 默认设为举重 Emoji。然后使用扩展运算符 [newItem, ...this.workoutList] 将新项目添加到列表头部,这样新创建的项目会显示在列表最上方。最后关闭弹窗并重置表单字段为默认值,为下次新增做好准备。
弹窗容器的样式设置:
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.borderRadius(24)
.constraintSize({ maxHeight: '85%' })
弹窗宽度占满屏幕,但通过 constraintSize 设置最大高度为屏幕的85%,确保弹窗不会完全覆盖屏幕,底部留出一部分空间让用户看到背景内容。圆角24使弹窗边缘更加柔和。
6.2 编辑训练计划弹窗
编辑弹窗的结构与新增弹窗基本一致,区别在于表单字段的初始值来自 selectedWorkout 而非新增表单状态变量:
@Builder
EditModalBuilder() {
Column() {
Row() {
Text('编辑训练计划')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Column() {
Text('✕')
.fontSize(22)
.fontColor('#999999')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#F5F5F5')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showEditModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(20)
Divider().color('#EEEEEE')
if (this.selectedWorkout !== null) {
Scroll() {
Column() {
Text('运动名称')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
TextInput({ text: this.selectedWorkout.name })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.margin({ left: 20, right: 20 })
.onChange((value: string) => {
if (this.selectedWorkout !== null) {
}
})
编辑弹窗的标题为"编辑训练计划",关闭按钮将 showEditModal 设为 false。
表单内容被包裹在 if (this.selectedWorkout !== null) 条件判断中,这是因为 selectedWorkout 是一个可空类型,在渲染表单前必须确保它不为 null,否则访问其属性会导致运行时错误。这种空值检查是类型安全的必要措施。
每个输入框的 text 参数绑定到 this.selectedWorkout 的对应属性,如 this.selectedWorkout.name、this.selectedWorkout.duration.toString() 等。需要注意的是,编辑弹窗中的 onChange 回调目前是空的,这意味着当前实现中编辑表单的修改不会实际保存到 selectedWorkout 对象中。这是一个待完善的功能点。
编辑弹窗的保存逻辑:
Button('保存修改')
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#C62828')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.selectedWorkout !== null) {
this.workoutList = this.workoutList.map((item: WorkoutItem) => {
if (item.id === this.selectedWorkout!.id) {
return this.selectedWorkout!
}
return item
})
}
this.showEditModal = false
})
保存修改的点击事件使用 map 方法遍历整个 workoutList,找到 id 与 selectedWorkout 相同的项目,将其替换为 selectedWorkout 对象。这里的 ! 是非空断言操作符,告诉编译器在此时 selectedWorkout 一定不为 null(因为外层已经有 null 检查)。替换完成后关闭弹窗。map 方法返回一个全新的数组,将其赋值给 workoutList 触发 UI 更新。
6.3 删除确认弹窗
删除确认弹窗采用了居中对话框的样式,比全屏弹窗更加轻量:
@Builder
DeleteConfirmBuilder() {
Column() {
Column() {
Text('⚠️')
.fontSize(48)
.margin({ top: 32, bottom: 16 })
Text('确认删除')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 8 })
if (this.selectedWorkout !== null) {
Text('确定要删除「' + this.selectedWorkout.name + '」吗?此操作不可撤销。')
.fontSize(14)
.fontColor('#999999')
.textAlign(TextAlign.Center)
.margin({ left: 32, right: 32, bottom: 24 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
Row() {
Button('取消')
.fontSize(16)
.fontColor('#666666')
.backgroundColor('#F5F5F5')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.onClick(() => {
this.showDeleteConfirm = false
})
Button('确认删除')
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#C62828')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.selectedWorkout !== null) {
this.workoutList = this.workoutList.filter((item: WorkoutItem) => {
return item.id !== this.selectedWorkout!.id
})
}
this.showDeleteConfirm = false
})
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 32 })
}
.width('80%')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.justifyContent(FlexAlign.Center)
}
这个弹窗的设计注重警示效果。顶部是一个大号的警告符号"⚠️",字号48,立即吸引用户注意。下面是"确认删除"标题,再下面是描述文字,通过字符串拼接动态显示即将删除的训练项目名称,并强调"此操作不可撤销"。
删除操作使用 filter 方法,过滤掉 id 与 selectedWorkout 相同的项目,返回一个不包含该项目的全新数组。与 map 类似,filter 也不修改原数组,而是返回新数组,赋值给 workoutList 后触发 UI 更新。
弹窗宽度设为80%,居中显示,圆角20。这种部分宽度的居中对话框是确认类操作的经典样式,既醒目又不会过于打断用户的使用流程。
6.4 训练详情弹窗
详情弹窗是信息最丰富的弹窗,全面展示一个训练项目的所有信息:
@Builder
DetailModalBuilder() {
Column() {
Row() {
Text('训练详情')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Column() {
Text('✕')
.fontSize(22)
.fontColor('#999999')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#F5F5F5')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showDetailModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(20)
Divider().color('#EEEEEE')
头部结构与新增和编辑弹窗一致,标题为"训练详情"。
在条件判断确保 selectedWorkout 不为 null 后,详情内容分为几个板块。首先是动作头部区域:
if (this.selectedWorkout !== null) {
Scroll() {
Column() {
// 动作头部
Row() {
Text(this.selectedWorkout.image)
.fontSize(48)
Column() {
Text(this.selectedWorkout.name)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Row() {
Text(this.selectedWorkout.type)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(typeColorMap[this.selectedWorkout.type])
Text(this.selectedWorkout.difficulty)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(difficultyColorMap[this.selectedWorkout.difficulty])
.margin({ left: 8 })
}
.margin({ top: 8 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 16 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 20 })
动作头部左侧是一个大号 Emoji 图标,字号48。右侧是动作名称(字号22,加粗)和两个标签——运动类型和难度等级。类型标签的背景色通过 typeColorMap 查找,难度标签的背景色通过 difficultyColorMap 查找。两个标签并排排列,中间有8的间距。
核心数据展示区域采用三列布局:
// 核心数据
Row() {
Column() {
Text(this.selectedWorkout.duration.toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('分钟')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(this.selectedWorkout.calories.toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('卡路里')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(this.selectedWorkout.sets.toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('组数')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ left: 20, right: 20, top: 24 })
三列分别展示训练时长(红色)、消耗卡路里(橙色)和组数(绿色),每个数值字号24加粗,下方是小号的单位说明。三列等宽分布,居中对齐,形成清晰的数据概览。
组数详情区域通过 ForEach 动态生成每组的信息:
ForEach([1, 2, 3, 4, 5].slice(0, this.selectedWorkout.sets), (setNum: number) => {
Row() {
Text('第' + setNum + '组')
.fontSize(14)
.fontColor('#666666')
Row() {
Text('次')
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
Text('休息')
.fontSize(12)
.fontColor('#999999')
.margin({ left: 12 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
}
.width('100%')
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('#FAFAFA')
.margin({ left: 20, right: 20, bottom: 8 })
})
这里使用了一个巧妙的技巧:[1, 2, 3, 4, 5].slice(0, this.selectedWorkout.sets) 先创建一个1到5的数组,然后根据实际组数截取前N个元素。例如组数为4时,截取 [1, 2, 3, 4],生成4行组数详情。每行左侧显示"第N组",右侧显示次数和休息时间,背景色为极浅灰,行与行之间有8的间距。
动作说明区域调用了一个辅助方法获取描述文字:
Text('动作说明')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 20, top: 24, bottom: 12 })
Text(this.getExerciseDescription(this.selectedWorkout.type))
.fontSize(14)
.lineHeight(24)
.padding({ left: 20, right: 20 })
getExerciseDescription 方法根据运动类型返回相应的训练指导文字。训练信息区域以键值对的形式展示目标肌群、所需器械、休息时间和完成状态:
Column() {
Row() {
Text('目标肌群')
.fontSize(14)
.fontColor('#999999')
Text(this.selectedWorkout.muscleGroup)
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
Divider().color('#F0F0F0')
Row() {
Text('完成状态')
.fontSize(14)
.fontColor('#999999')
Text(this.selectedWorkout.isCompleted ? '✅ 已完成' : '⏳ 待完成')
.fontSize(14)
.fontColor(this.selectedWorkout.isCompleted ? '#2E7D32' : '#FF6F00')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
}
.padding({ left: 20, right: 20 })
.margin({ left: 20, right: 20, bottom: 40 })
每行使用 SpaceBetween 对齐,左侧是灰色标签,右侧是深色值。行与行之间用浅色分隔线隔开。完成状态行使用了条件表达式——已完成显示绿色"✅ 已完成",待完成显示橙色"⏳ 待完成",颜色也相应变化。
6.5 运动描述辅助方法
getExerciseDescription 方法是一个简单的条件分支函数,根据不同的运动类型返回对应的训练指导文字:
getExerciseDescription(type: string): string {
if (type === '力量') {
return '力量训练是通过负重练习来增强肌肉力量和体积的训练方式。建议先进行5-10分钟热身,选择合适的重量,控制动作速度,注意呼吸节奏。每组间休息60-120秒,确保肌肉充分恢复。训练后注意拉伸放松,补充蛋白质。'
} else if (type === '有氧') {
return '有氧运动是以增强心肺功能为主的训练。建议保持中等强度,心率控制在最大心率的60%-80%。运动前热身5分钟,运动中注意补充水分,运动后进行拉伸。每周3-5次,每次30-60分钟效果最佳。'
} else if (type === 'HIIT') {
return '高强度间歇训练(HIIT)是短时间高强度运动与休息交替进行的训练方式。每个动作全力以赴20-40秒,休息10-30秒。适合有一定运动基础的人群,燃脂效率极高。训练前后需充分热身和拉伸。'
} else if (type === '核心') {
return '核心训练针对腹部、腰部、骨盆等核心肌群。核心力量是所有运动的基础,有助于改善体态、预防损伤。建议每次训练15-30分钟,注意呼吸配合,动作要标准,避免代偿。'
} else {
return '柔韧性训练通过拉伸来增加关节活动度和肌肉弹性。建议在运动后或单独进行,每个动作保持15-30秒,不要弹震。深呼吸配合拉伸,循序渐进,避免过度拉伸导致损伤。'
}
}
这个方法使用 if-else 链来判断类型,每种类型返回一段详细的训练指导。这些指导文字涵盖了热身建议、强度控制、呼吸节奏、休息时间和训练后恢复等方面,为用户提供了专业的训练参考。虽然使用 switch 语句或对象映射也能实现相同功能,但 if-else 链在这种场景下可读性同样良好。
七、顶部统计栏:数据概览的第一窗口
7.1 组件声明与状态
顶部统计栏是用户打开应用后看到的第一块内容区域,它以渐变红色背景呈现核心训练数据:
@Component
struct TopStatsBar {
@State todayCalories: number = 320
@State todayDuration: number = 45
@State weekCalories: number = 2150
@State goalCalories: number = 500
build() {
Column() {
// ...
}
.width('100%')
.linearGradient({
angle: 135,
colors: [['#C62828', 0.0], ['#EF5350', 1.0]]
})
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
}
}
这个组件维护了四个内部状态变量:今日消耗卡路里(320千卡)、今日训练时长(45分钟)、本周消耗卡路里(2150千卡)和今日目标(500分钟)。这些状态没有从父组件传入,而是由组件自身管理,表明在当前实现中它们是静态展示数据。
整个组件最外层是一个 Column,应用了线性渐变背景。渐变角度135度,从主色红 #C62828 过渡到浅红 #EF5350,形成从深到浅的视觉效果。底部左右圆角为24,使统计栏与下方内容区之间有一个柔和的过渡。
7.2 标题栏与通知按钮
Row() {
Text('💪 Fitness Tracker')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column() {
Text('🔔')
.fontSize(20)
}
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor('rgba(255,255,255,0.2)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
标题栏左侧是应用名称"Fitness Tracker",前缀一个肌肉 Emoji,字号18,加粗,白色文字。右侧是一个通知按钮,由36x36的圆形容器组成,背景色为半透明白色 rgba(255,255,255,0.2),内部居中放置一个铃铛 Emoji。半透明白色在红色渐变背景上呈现出一种磨砂玻璃的效果,既不抢眼又保持了可识别性。
7.3 环形进度与数据网格
统计栏的核心区域分为左右两部分。左侧是一个环形进度展示,右侧是一个2x2的数据网格:
Row() {
// 环形进度
Column() {
Stack() {
Column() {
Column() {
Text(this.todayCalories.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('kcal')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 2 })
}
.width(80)
.height(80)
.borderRadius(40)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.border({ width: 6, color: 'rgba(255,255,255,0.3)' })
}
.width(100)
.height(100)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width(100)
.height(100)
Text('今日卡路里')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
环形进度的实现方式值得关注。由于声明式 UI 不直接提供环形进度条组件,这里通过嵌套容器和边框来模拟。最内层是一个80x80的圆形容器,显示今日卡路里数值和"kcal"单位。这个容器设置了6像素宽的半透明白色边框,模拟环形进度的轨道效果。外层再包裹一个100x100的容器进行居中定位。下方的"今日卡路里"标签使用半透明白色文字,字号12。
右侧的数据网格展示四个关键指标:
Column({ space: 12 }) {
Row() {
Column() {
Text(this.todayDuration.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('今日时长(分)')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text(this.weekCalories.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周卡路里')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Row() {
Column() {
Text(this.goalCalories.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('今日目标(分)')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('5')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周训练天数')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.layoutWeight(1)
.margin({ left: 20 })
右侧区域使用 Column({ space: 12 }) 创建一个带12像素间距的垂直布局,内部分为两行,每行包含两个数据项。四个数据项分别是今日训练时长、本周消耗卡路里、今日目标和本周训练天数。每个数据项由一个大号白色数值和一个半透明白色标签组成,使用 layoutWeight(1) 等宽分布。整个右侧区域通过 layoutWeight(1) 占据剩余空间,与左侧环形进度之间有20的间距。
八、底部导航栏:五标签切换机制
底部导航栏是应用的主要导航入口,提供了五个功能模块的快速切换:
@Component
struct BottomTabBar {
@Prop currentTab: number
onTabChange: (index: number) => void = () => {}
build() {
Row() {
ForEach([
{ title: '训练', icon: '💪' },
{ title: '记录', icon: '📝' },
{ title: '数据', icon: '📊' },
{ title: '挑战', icon: '🏆' },
{ title: '我的', icon: '👤' }
], (tab: Record<string, string>, index: number) => {
Column() {
Text(tab.icon)
.fontSize(22)
Text(tab.title)
.fontSize(11)
.fontColor(this.currentTab === index ? '#C62828' : '#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.onTabChange(index)
})
})
}
.width('100%')
.height(60)
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: '#EEEEEE' })
}
}
这个组件使用了 @Prop 装饰器而非 @State。@Prop 用于接收父组件传递的数据,它是一种单向数据流——父组件的值变化会同步到子组件,但子组件不能直接修改 @Prop 变量。currentTab 通过 @Prop 接收当前选中的标签索引,用于控制标签的高亮状态。
onTabChange 是一个回调函数类型的属性,默认值为一个空函数。当用户点击某个标签时,调用这个回调并将标签索引传递给父组件,由父组件更新 currentTab 状态,再通过 @Prop 回传给本组件,完成高亮状态的切换。这种"状态上提、事件下传"的模式是声明式 UI 中父子组件通信的标准做法。
ForEach 遍历五个标签配置,每个标签由一个 Emoji 图标(字号22)和一个文字标题(字号11)组成。当前选中的标签文字颜色为主色红 #C62828,未选中的为灰色 #999999。每个标签通过 layoutWeight(1) 等宽分布,整个导航栏高度60,背景白色,顶部有一条1像素的浅灰分隔线。
九、训练内容页:列表与悬浮按钮的完美配合
9.1 组件接口与数据绑定
训练内容页是应用的第一个标签页,也是信息最丰富的页面:
@Component
struct WorkoutContent {
@Link workoutList: WorkoutItem[]
onItemClick: (item: WorkoutItem) => void = () => {}
onEditClick: (item: WorkoutItem) => void = () => {}
onDeleteClick: (item: WorkoutItem) => void = () => {}
onAddClick: () => void = void = () => {}
@Link 装饰器建立了与父组件的双向数据绑定。当父组件的 workoutList 发生变化时(如新增、删除操作),变化会同步到子组件;反之,子组件对 workoutList 的修改也会同步回父组件。这种双向同步确保了数据的一致性。
四个回调函数分别处理查看详情、编辑、删除和新增四种操作。它们的默认值都是空函数,这是一种防御性设计——即使父组件没有传递某个回调,调用时也不会报错。
9.2 列表标题与滚动容器
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
Scroll() {
Column({ space: 12 }) {
Row() {
Text('训练计划')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('共' + this.workoutList.length + '项')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 16 })
最外层使用了 Stack 容器,对齐方式为 BottomEnd(右下角)。Stack 允许子元素叠加,这里将滚动列表和悬浮新增按钮叠加在一起,按钮通过 BottomEnd 对齐固定在右下角。
Scroll 组件包裹整个列表内容,支持上下滚动。内部的 Column 设置了12像素的间距,使各卡片之间保持均匀的间隔。标题行左侧是"训练计划"大标题,右侧是"共N项"的计数文字,使用 SpaceBetween 对齐方式分列两端。计数文字通过 this.workoutList.length 动态获取列表长度,当列表增删时自动更新。
9.3 训练卡片设计
每个训练项目以卡片形式展示,卡片设计层次丰富:
ForEach(this.workoutList, (item: WorkoutItem) => {
Row() {
// 左侧彩色色条
Column() {
Column() {
Text(item.image)
.fontSize(28)
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width(6)
.height(80)
.backgroundColor(typeColorMap[item.type])
.borderRadius(3)
Column({ space: 6 }) {
Row() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(item.type)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(typeColorMap[item.type])
.margin({ left: 8 })
if (item.isCompleted) {
Text('✅')
.fontSize(14)
.margin({ left: 6 })
}
}
Row() {
Text('⏱ ' + item.duration + '分钟')
.fontSize(12)
.fontColor('#999999')
Text('🔥 ' + item.calories + 'kcal')
.fontSize(12)
.fontColor('#FF6F00')
.margin({ left: 12 })
Text('💪 ' + item.sets + 'x' + item.reps)
.fontSize(12)
.fontColor('#999999')
.margin({ left: 12 })
}
Row() {
Text(item.muscleGroup)
.fontSize(11)
.fontColor('#666666')
Text('·')
.fontSize(11)
.fontColor('#CCCCCC')
.margin({ left: 4, right: 4 })
Text(item.equipment)
.fontSize(11)
.fontColor('#666666')
Text('·')
.fontSize(11)
.fontColor('#CCCCCC')
.margin({ left: 4, right: 4 })
Text(item.difficulty)
.fontSize(11)
.fontColor(difficultyColorMap[item.difficulty])
.fontWeight(FontWeight.Medium)
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
卡片的左侧是一个6像素宽的彩色色条,高度80,颜色由 typeColorMap[item.type] 决定,圆角3。这个色条是卡片的视觉标识,用户通过颜色就能快速判断运动类型。色条内部嵌套了一个48x48的容器显示 Emoji 图标,但由于色条宽度只有6,这个图标容器实际上被裁剪了,真正显示的图标在右侧的内容区域。
卡片主体是一个 Column,包含三行信息。第一行是动作名称(字号16,加粗)和类型标签(白字红底,圆角小标签),如果训练已完成还会显示一个绿色对勾。第二行是三个关键数据——时长、卡路里和组数次数,使用 Emoji 前缀增强可读性,卡路里数值用橙色突出显示。第三行是附加信息——目标肌群、器械和难度,用中点分隔符连接,难度文字使用对应的难度颜色。
卡片右侧的操作按钮区域:
Column() {
Row({ space: 8 }) {
Text('📋')
.fontSize(18)
.onClick(() => {
this.onItemClick(item)
})
Text('✏️')
.fontSize(18)
.onClick(() => {
this.onEditClick(item)
})
Text('🗑')
.fontSize(18)
.onClick(() => {
this.onDeleteClick(item)
})
}
}
}
.width('90%')
.height(96)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.padding({ left: 12, right: 16, top: 8, bottom: 8 })
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
})
三个操作按钮分别是查看详情(📋)、编辑(✏️)和删除(🗑),使用 Emoji 作为图标,字号18,间距8。每个按钮绑定了对应的回调函数,点击时将当前训练项目传递给父组件处理。
卡片容器的样式设计精致:宽度90%(居中显示),高度96,白色背景,圆角16,内边距适中。最重要的是阴影效果——shadow 属性设置了半径8、颜色为半透明主色红 rgba(198,40,40,0.08)、垂直偏移2的阴影。这种淡淡的红色阴影与整体主题色呼应,比普通的灰色阴影更有设计感。
9.4 悬浮新增按钮
// 悬浮新增按钮
Column() {
Text('➕')
.fontSize(28)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('#C62828')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.shadow({ radius: 12, color: 'rgba(198,40,40,0.4)', offsetY: 4 })
.margin({ right: 20, bottom: 20 })
.onClick(() => {
this.onAddClick()
})
}
.width('100%')
.height('100%')
}
}
悬浮新增按钮是一个56x56的圆形按钮,背景为主色红,内部居中放置一个加号 Emoji。它通过 Stack 的 BottomEnd 对齐固定在右下角,距右边缘和底部各20像素。阴影更加明显——半径12,颜色为半透明红色(透明度0.4),垂直偏移4,营造出按钮悬浮于卡片之上的立体感。点击按钮触发 onAddClick 回调,打开新增弹窗。
十、运动记录页:时间线式的历史档案
10.1 页面结构
运动记录页以时间线的方式展示用户的训练历史:
@Component
struct LogContent {
@Link logList: LogItem[]
build() {
Scroll() {
Column({ space: 10 }) {
Row() {
Text('运动记录')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('共' + this.logList.length + '条')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 16 })
页面结构与训练内容页类似,标题行显示"运动记录"和记录总数。logList 通过 @Link 双向绑定,确保数据同步。
10.2 月度统计卡片
在记录列表上方有一个月度统计卡片,展示三项汇总数据:
// 本月统计
Row() {
Column() {
Text('20')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('本月训练次数')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('4,860')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('本月消耗卡路里')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('680')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('本月训练时长(分)')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('90%')
.padding({ top: 16, bottom: 16 })
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
月度统计卡片使用三列等宽布局,分别展示本月训练次数(红色)、本月消耗卡路里(橙色)和本月训练时长(绿色)。三个数值使用不同颜色,与整个应用的色彩体系保持一致。卡片宽度90%,白色背景,圆角16,带有红色调阴影。
10.3 记录卡片设计
每条训练记录以卡片形式展示,设计注重情感表达:
ForEach(this.logList, (item: LogItem) => {
Row() {
Column() {
Text(item.mood)
.fontSize(28)
}
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor(moodColorMap[item.mood] + '20')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Row() {
Text(item.exercise)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(item.date)
.fontSize(11)
.fontColor('#999999')
.margin({ left: 8 })
}
Row() {
Text('⏱ ' + item.duration + '分')
.fontSize(11)
.fontColor('#999999')
Text('🔥 ' + item.calories + 'kcal')
.fontSize(11)
.fontColor('#FF6F00')
.margin({ left: 8 })
if (item.sets > 1) {
Text('📊 ' + item.sets + '组')
.fontSize(11)
.fontColor('#999999')
.margin({ left: 8 })
}
if (item.weight > 0) {
Text('🏋 ' + item.weight + 'kg')
.fontSize(11)
.fontColor('#C62828')
.margin({ left: 8 })
}
}
if (item.notes.length > 0) {
Text('📝 ' + item.notes)
.fontSize(11)
.fontColor('#AAAAAA')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('90%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 6, color: 'rgba(198,40,40,0.06)', offsetX: 0, offsetY: 2 })
})
记录卡片的左侧是一个48x48的圆形心情图标,背景色通过 moodColorMap[item.mood] + '20' 获取。这里 '20' 是十六进制的透明度值(约12.5%),将心情对应的颜色以极淡的背景色呈现,既呼应了心情色彩,又不会过于抢眼。
右侧内容区域包含三行信息。第一行是训练动作名称和日期。第二行是训练数据,包括时长、卡路里,以及条件显示的组数和重量。组数只在大于1时显示(有氧运动的组数为1不需要展示),重量只在大于0时显示(自重训练的重量为0不需要展示)。这种条件渲染使卡片信息更加精炼,避免了无意义的"0组""0kg"显示。
第三行是备注信息,仅在备注非空时显示。备注使用浅灰色文字,设置了 maxLines(1) 限制为一行,并使用 textOverflow({ overflow: TextOverflow.Ellipsis }) 在文字过长时以省略号截断。这确保了卡片高度的一致性,不会被过长的备注撑开。
十一、数据统计页:多维度的可视化呈现
11.1 柱状图:本周运动时长
数据统计页通过多种图表展示训练数据,首先是柱状图:
@Component
struct StatsContent {
build() {
Scroll() {
Column({ space: 16 }) {
Text('数据统计')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 20, top: 16 })
// 本周训练时长柱状图
Column() {
Text('📊 本周运动时长')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 8 })
Row() {
ForEach(statsBarData, (item: StatsBarItem) => {
Column() {
Column() {
Text(item.value.toString())
.fontSize(10)
.fontColor('#C62828')
.fontWeight(FontWeight.Bold)
}
.width(24)
.height(item.value)
.borderRadius({ topLeft: 4, topRight: 4 })
.linearGradient({
angle: 180,
colors: [['#EF5350', 0.0], ['#C62828', 1.0]]
})
.justifyContent(FlexAlign.Start)
.alignItems(HorizontalAlign.Center)
Text(item.day)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
.height(140)
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
柱状图的实现完全基于容器组件,没有使用任何图表库。每根柱子是一个 Column,宽度24像素,高度直接绑定到 item.value(训练时长数值)。柱子顶部圆角4,应用了从浅红到深红的180度垂直渐变。柱子顶部显示对应的数值,字号10,红色加粗。柱子下方是对应的星期标签。
整个图表区域高度140,七根柱子通过 ForEach 遍历 statsBarData 生成,每根柱子使用 layoutWeight(1) 等宽分布。这种纯容器实现的柱状图虽然功能简单,但视觉效果并不逊色,而且完全可控、可定制。
11.2 饼图模拟:训练类型占比
// 训练类型占比
Column() {
Text('🎯 训练类型占比')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
// 模拟饼图
Stack() {
Column() {
Column() {
Text('100%')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
}
.width(80)
.height(80)
.borderRadius(40)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF')
}
.width(100)
.height(100)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.border({ width: 20, color: '#C62828' })
.borderRadius(50)
}
.width(120)
.height(120)
Column({ space: 8 }) {
ForEach(typePercentData, (item: TypePercentItem) => {
Row() {
Column() {
Text('')
}
.width(12)
.height(12)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.type)
.fontSize(13)
.fontColor('#666666')
.margin({ left: 6 })
Text(item.percent + '%')
.fontSize(13)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
.margin({ left: 4 })
}
})
}
.layoutWeight(1)
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
饼图的模拟实现非常巧妙。通过一个100x100的圆形容器,设置20像素宽的红色边框,模拟出一个环形饼图的效果。内部嵌套一个80x80的白色圆形,中心显示"100%"文字。虽然这个"饼图"无法展示真实的比例分割,但它通过右侧的图例列表弥补了这一不足。
图例列表通过 ForEach 遍历 typePercentData,每行包含一个12x12的色块(颜色对应类型颜色)、类型名称和百分比数值。色块圆角2,与类型颜色一致,使用户能够将图例与饼图颜色对应起来。
11.3 数据汇总与月度趋势
// 本周数据汇总
Column() {
Text('📈 本周数据汇总')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
Column() {
Text('5')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('训练天数')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('390')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('总时长(分)')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('2,150')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('总卡路里')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
本周数据汇总卡片使用三列布局展示训练天数、总时长和总卡路里,配色方案与前述统计卡片一致。
月度趋势图是另一种柱状图,展示12个月的数据:
// 月度趋势
Column() {
Text('📅 月度趋势')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
ForEach([30, 45, 60, 35, 50, 75, 55, 40, 65, 80, 70, 90], (val: number, idx: number) => {
Column() {
Column() {
Text('')
}
.width(12)
.height(val)
.borderRadius({ topLeft: 3, topRight: 3 })
.backgroundColor('#EF5350')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
})
}
.width('100%')
.height(100)
.padding({ left: 16, right: 16 })
Row() {
Text('1月')
.fontSize(9)
.fontColor('#CCCCCC')
Text('12月')
.fontSize(9)
.fontColor('#CCCCCC')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 4, bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
月度趋势图直接在 ForEach 中内联了12个月的数值数组 [30, 45, 60, 35, 50, 75, 55, 40, 65, 80, 70, 90],每根柱子宽度12,高度绑定到对应数值,颜色统一为浅红 #EF5350。柱子通过 justifyContent(FlexAlign.End) 在容器底部对齐,形成从底部向上生长的效果。底部标注了"1月"和"12月"作为时间轴参考。
十二、挑战任务页:游戏化的目标驱动
12.1 挑战横幅
挑战任务页以一个醒目的横幅开始,展示当前挑战的整体进度:
@Component
struct ChallengeContent {
@Link challengeList: ChallengeItem[]
build() {
Scroll() {
Column({ space: 12 }) {
Row() {
Text('🏆 挑战任务')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(this.challengeList.filter((c: ChallengeItem) => c.isCompleted).length + '/' + this.challengeList.length + '完成')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 16 })
// 本周挑战横幅
Column() {
Row() {
Column() {
Text('🔥')
.fontSize(32)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('rgba(255,255,255,0.2)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text('本周挑战进行中')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('完成更多挑战赢取奖励')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 4 })
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
Column() {
Text(this.challengeList.filter((c: ChallengeItem) => c.isCompleted).length.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('已完成')
.fontSize(10)
.fontColor('rgba(255,255,255,0.8)')
}
}
.width('100%')
.padding(16)
}
.width('90%')
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [['#C62828', 0.0], ['#FF6F00', 1.0]]
})
.shadow({ radius: 12, color: 'rgba(198,40,40,0.2)', offsetX: 0, offsetY: 4 })
标题行右侧的完成计数使用了 filter 方法动态计算已完成的挑战数量:this.challengeList.filter((c: ChallengeItem) => c.isCompleted).length。这种实时计算确保了当挑战状态变化时,计数自动更新。
挑战横幅采用了从红色到橙色的135度渐变背景,比顶部统计栏的红色渐变更加热烈。横幅左侧是一个半透明白色圆形容器包裹的火焰 Emoji,中间是"本周挑战进行中"标题和副标题,右侧是已完成数量。整个横幅带有更深的红色阴影(透明度0.2,偏移4),营造出凸起的视觉效果。
12.2 挑战卡片
每个挑战以卡片形式展示,包含标题、描述、进度条和底部信息:
ForEach(this.challengeList, (item: ChallengeItem) => {
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text(item.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(item.description)
.fontSize(12)
.fontColor('#999999')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (item.isCompleted) {
Column() {
Text('✅')
.fontSize(20)
}
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor('#E8F5E9')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
// 进度条
Column({ space: 4 }) {
Row() {
Column() {
Text('')
}
.width((item.progress / item.target) * 100 + '%')
.height(8)
.borderRadius(4)
.backgroundColor(item.isCompleted ? '#2E7D32' : '#C62828')
}
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor('#F5F5F5')
Row() {
Text(item.progress + ' / ' + item.target)
.fontSize(11)
.fontColor('#999999')
Text(Math.round(item.progress / item.target * 100) + '%')
.fontSize(11)
.fontColor(item.isCompleted ? '#2E7D32' : '#C62828')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
// 底部信息
Row() {
Row() {
Text('🎁')
.fontSize(12)
Text(item.reward)
.fontSize(11)
.fontColor('#FF6F00')
.margin({ left: 4 })
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#FFF3E0')
Row() {
Text('⏰')
.fontSize(12)
Text(item.deadline)
.fontSize(11)
.fontColor('#999999')
.margin({ left: 4 })
}
.margin({ left: 8 })
}
.width('100%')
}
.width('90%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 6, color: 'rgba(198,40,40,0.06)', offsetX: 0, offsetY: 2 })
})
挑战卡片的信息层次非常清晰。顶部是标题和描述,描述限制了最多2行并以省略号截断。如果挑战已完成,右侧会显示一个绿色背景的圆形对勾标记。
进度条的实现值得仔细分析。外层是一个8像素高的浅灰圆角容器作为轨道,内层是一个宽度按比例计算的彩色圆角容器作为填充。填充宽度通过 (item.progress / item.target) * 100 + '%' 动态计算,例如进度18、目标30时,宽度为60%。填充颜色根据完成状态变化——未完成为红色,已完成为绿色。进度条下方左侧显示"进度/目标"的数值,右侧显示百分比,百分比经过 Math.round 四舍五入。
底部信息行包含奖励标签和截止日期。奖励标签使用浅橙色背景 #FFF3E0,橙色文字,形成一个药丸形状的标签。截止日期使用灰色文字,前面带一个闹钟 Emoji。
十三、个人档案页:全面的用户画像
13.1 用户信息卡
个人档案页整合了用户的个人信息、身体数据和成就:
@Component
struct ProfileContent {
build() {
Scroll() {
Column({ space: 16 }) {
Text('个人档案')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 20, top: 16 })
// 用户信息卡
Column() {
Row() {
Column() {
Text('🏃')
.fontSize(40)
}
.width(72)
.height(72)
.borderRadius(36)
.linearGradient({
angle: 135,
colors: [['#C62828', 0.0], ['#EF5350', 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 6 }) {
Text('健身达人')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Row() {
Text('⭐ LV.12')
.fontSize(12)
.fontColor('#FF6F00')
.fontWeight(FontWeight.Medium)
Text('·')
.fontSize(12)
.fontColor('#CCCCCC')
.margin({ left: 6, right: 6 })
Text('健身365天')
.fontSize(12)
.fontColor('#999999')
}
Row() {
Text('🔥 连续训练18天')
.fontSize(12)
.fontColor('#C62828')
.fontWeight(FontWeight.Medium)
}
}
.layoutWeight(1)
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
用户信息卡左侧是一个72x72的圆形头像,使用红到浅红的渐变背景,内部居中放置一个跑步 Emoji。右侧是用户昵称"健身达人",下方是等级标签"LV.12"(橙色)、健身天数"365天"和连续训练天数"18天"(红色)。等级和天数用中点分隔符连接,信息密度适中。
13.2 身体数据与成就徽章
// 身体数据
Column() {
Text('📊 身体数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
Column() {
Text('70')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('体重(kg)')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('175')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('身高(cm)')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('22.9')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('BMI')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('18%')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('体脂率')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
身体数据卡片使用四列等宽布局,展示体重、身高、BMI和体脂率四项指标。BMI值22.9用绿色标注,表示处于正常范围内。体重和体脂率用红色标注,提示用户关注这两项需要控制的指标。
成就徽章区域展示四个已获得的徽章:
// 成就徽章
Column() {
Text('🏅 成就徽章')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
ForEach([
{ icon: '🏆', name: '初学者', color: '#C62828' },
{ icon: '🔥', name: '燃脂王', color: '#FF6F00' },
{ icon: '💪', name: '力量王', color: '#2E7D32' },
{ icon: '⚡', name: 'HIIT王', color: '#7B1FA2' }
], (badge: Record<string, string>) => {
Column() {
Column() {
Text(badge.icon)
.fontSize(24)
}
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor(badge.color + '20')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Text(badge.name)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.padding({ bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
四个徽章分别是初学者(红色)、燃脂王(橙色)、力量王(绿色)和HIIT王(紫色),每个徽章由一个48x48的圆形图标容器和下方的名称组成。图标容器背景色使用 badge.color + '20' 实现极淡的色彩填充,与徽章主题色呼应。
13.3 累计数据与设置选项
累计数据卡片以列表形式展示用户的长期训练数据:
// 累计数据
Column() {
Text('📈 累计数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Column({ space: 0 }) {
Row() {
Text('累计训练天数')
.fontSize(14)
.fontColor('#666666')
Text('365天')
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
Divider().color('#F0F0F0').margin({ left: 16, right: 16 })
Row() {
Text('累计消耗卡路里')
.fontSize(14)
.fontColor('#666666')
Text('128,500 kcal')
.fontSize(14)
.fontColor('#FF6F00')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
Divider().color('#F0F0F0').margin({ left: 16, right: 16 })
Row() {
Text('最大深蹲重量')
.fontSize(14)
.fontColor('#666666')
Text('120 kg')
.fontSize(14)
.fontColor('#C62828')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
}
.width('100%')
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
累计数据包括训练天数(365天)、训练次数(520次)、消耗卡路里(128,500千卡,橙色标注)、训练时长(18,200分钟,绿色标注)和最大深蹲重量(120公斤,红色标注)。每行使用 SpaceBetween 对齐,行间用浅色分隔线隔开。不同类别的数值使用不同的颜色,使关键数据一目了然。
设置选项列表是页面的最后一个卡片:
// 设置选项
Column() {
ForEach([
{ icon: '🎯', name: '训练目标设置' },
{ icon: '🔔', name: '提醒设置' },
{ icon: '📦', name: '数据导出' },
{ icon: '⚙️', name: '应用设置' },
{ icon: 'ℹ️', name: '关于我们' }
], (setting: Record<string, string>) => {
Row() {
Text(setting.icon)
.fontSize(20)
Text(setting.name)
.fontSize(14)
.fontColor('#333333')
.margin({ left: 12 })
Column() {
Text('›')
.fontSize(20)
.fontColor('#CCCCCC')
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
})
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
五个设置项分别是训练目标设置、提醒设置、数据导出、应用设置和关于我们。每行由一个 Emoji 图标、选项名称和一个右箭头">"组成。右箭头使用浅灰色,暗示这些选项可以点击进入下一级页面。虽然当前实现中没有绑定点击事件,但这种视觉提示为未来的功能扩展预留了交互空间。
十四、关键技术特性对比总结
通过对整个应用源代码的全面解析,我们可以从多个维度对各个功能模块进行对比总结。下面的表格从核心功能、状态管理方式、数据通信模式、UI 组件特点和交互特性五个维度,对应用的五大功能页面进行了横向对比。
14.1 五大功能页面特性对比
| 特性维度 | 训练计划页 | 运动记录页 | 数据统计页 | 挑战任务页 | 个人档案页 |
|---|---|---|---|---|---|
| 核心功能 | 训练项目 CRUD 管理 | 历史训练记录展示 | 多维度数据可视化 | 挑战目标进度追踪 | 用户画像与成就展示 |
| 状态管理 | @Link 双向绑定 | @Link 双向绑定 | 无状态(纯展示) | @Link 双向绑定 | 无状态(纯展示) |
| 数据通信 | 四个回调函数向上传递 | 仅接收数据,无回调 | 不接收任何参数 | 仅接收数据,无回调 | 不接收任何参数 |
| 列表渲染 | ForEach 遍历 workoutList | ForEach 遍历 logList | ForEach 遍历静态数据 | ForEach 遍历 challengeList | ForEach 遍历静态配置 |
| 滚动支持 | Scroll + scrollBar 隐藏 | Scroll + scrollBar 隐藏 | Scroll + scrollBar 隐藏 | Scroll + scrollBar 隐藏 | Scroll + scrollBar 隐藏 |
| 特殊组件 | Stack 叠加悬浮按钮 | 条件渲染组数/重量 | 纯容器实现柱状图/饼图 | 动态宽度进度条 | 渐变头像 + 列表分隔 |
| 交互能力 | 查看/编辑/删除/新增 | 仅展示(无交互) | 仅展示(无交互) | 仅展示(无交互) | 仅展示(预留入口) |
| 卡片阴影 | 透明度 0.08 | 透明度 0.06 | 透明度 0.08 | 透明度 0.06 | 透明度 0.08 |
| 信息密度 | 高(每卡 3 行数据) | 中(每卡 3 行数据) | 高(4 个图表卡片) | 中(每卡 3 个区域) | 高(5 个功能卡片) |
14.2 四大弹窗特性对比
| 特性维度 | 新增弹窗 | 编辑弹窗 | 删除确认弹窗 | 详情弹窗 |
|---|---|---|---|---|
| 触发方式 | 悬浮按钮点击 | 卡片编辑按钮 | 卡片删除按钮 | 卡片详情按钮 |
| 弹窗类型 | 全屏底部弹窗 | 全屏底部弹窗 | 居中对话框 | 全屏底部弹窗 |
| 宽度 | 100% | 100% | 80% | 100% |
| 最大高度 | 85% | 85% | 自适应 | 85% |
| 圆角 | 24 | 24 | 20 | 24 |
| 表单字段 | 10 个 | 7 个 | 无 | 无(纯展示) |
| 数据来源 | new 系列状态变量 | selectedWorkout | selectedWorkout | selectedWorkout |
| 空值保护 | 不需要 | if 判断 + ! 断言 | if 判断 + ! 断言 | if 判断 |
| 操作按钮 | 取消 + 确认新增 | 取消 + 保存修改 | 取消 + 确认删除 | 仅关闭按钮 |
| 数据操作 | 数组头部插入 | map 替换 | filter 过滤 | 无操作 |
14.3 颜色体系对比
| 颜色用途 | 色值 | 语义含义 | 使用场景 |
|---|---|---|---|
| #C62828 | 活力红(主色) | 力量、挑战、高难度 | 主色背景、力量类型、高级难度、主按钮 |
| #EF5350 | 浅红(辅色) | 有氧、辅助元素 | 渐变终点、有氧类型、月度趋势柱 |
| #FF6F00 | 橙色(强调色) | 能量、卡路里、中级 | 卡路里数值、中级难度、奖励标签 |
| #2E7D32 | 绿色(完成色) | 完成、安全、初级 | 已完成状态、初级难度、BMI 正常 |
| #7B1FA2 | 紫色(辅助色) | 柔韧、放松 | 柔韧类型、放松心情、HIIT 徽章 |
| #FFEBEE | 浅红(背景色) | 温暖、运动氛围 | 全局背景 |
| #FFFFFF | 白色 | 卡片、内容 | 所有卡片背景 |
| #F5F5F5 | 浅灰 | 输入框、未选中 | 输入框背景、未选中标签 |
| #999999 | 灰色 | 次要文字 | 标签说明、辅助信息 |
| #333333 | 深灰 | 主要文字 | 标题、数值 |
14.4 状态装饰器使用对比
| 装饰器 | 使用位置 | 数据流向 | 触发更新 | 典型场景 |
|---|---|---|---|---|
| @State | 主组件所有状态 | 组件内部 | 值变化时重建 UI | currentTab、showAddModal、workoutList |
| @State | TopStatsBar | 组件内部 | 值变化时重建 UI | todayCalories 等展示数据 |
| @Link | WorkoutContent、LogContent、ChallengeContent | 父子双向同步 | 任一方变化均更新 | workoutList、logList、challengeList |
| @Prop | BottomTabBar | 父到子单向 | 父组件变化时更新 | currentTab |
| @Builder | 主组件四个弹窗 | 方法引用 | 被引用时构建 | AddModalBuilder 等 |
| @Entry | FitnessTracker | 应用入口 | - | 标记根组件 |
| @Component | 所有 struct | 自定义组件声明 | - | 声明可复用组件 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// 健身运动追踪 Fitness Tracker
// 主色:#C62828 活力红 | 辅色:#EF5350 | 背景:#FFEBEE | 卡片:#FFFFFF
// 强调色:#FF6F00 橙色(卡路里)| #2E7D32 绿色(完成)
// ==================== 接口定义 ====================
interface WorkoutItem {
id: number
name: string
type: string
duration: number
calories: number
sets: number
reps: number
difficulty: string
muscleGroup: string
equipment: string
isCompleted: boolean
restTime: number
image: string
}
interface LogItem {
id: number
date: string
exercise: string
duration: number
calories: number
sets: number
weight: number
notes: string
mood: string
}
interface ChallengeItem {
id: number
title: string
description: string
progress: number
target: number
reward: string
deadline: string
isCompleted: boolean
}
interface StatsBarItem {
day: string
value: number
}
interface TypePercentItem {
type: string
percent: number
color: string
}
interface TabItem {
title: string
icon: string
builder: () => void
}
// ==================== 静态数据 ====================
const workoutData: WorkoutItem[] = [
{ id: 1, name: '杠铃深蹲', type: '力量', duration: 45, calories: 320, sets: 4, reps: 12, difficulty: '高级', muscleGroup: '腿部', equipment: '杠铃', isCompleted: true, restTime: 90, image: '🏋️' },
{ id: 2, name: '卧推训练', type: '力量', duration: 40, calories: 280, sets: 5, reps: 8, difficulty: '高级', muscleGroup: '胸部', equipment: '杠铃', isCompleted: false, restTime: 120, image: '💪' },
{ id: 3, name: '跑步机慢跑', type: '有氧', duration: 30, calories: 250, sets: 1, reps: 1, difficulty: '初级', muscleGroup: '全身', equipment: '跑步机', isCompleted: true, restTime: 0, image: '🏃' },
{ id: 4, name: 'HIIT燃脂训练', type: 'HIIT', duration: 25, calories: 350, sets: 6, reps: 15, difficulty: '高级', muscleGroup: '全身', equipment: '无', isCompleted: false, restTime: 30, image: '🔥' },
{ id: 5, name: '瑜伽拉伸', type: '柔韧', duration: 60, calories: 150, sets: 1, reps: 1, difficulty: '初级', muscleGroup: '全身', equipment: '瑜伽垫', isCompleted: true, restTime: 0, image: '🧘' },
{ id: 6, name: '平板支撑', type: '核心', duration: 15, calories: 80, sets: 3, reps: 1, difficulty: '中级', muscleGroup: '核心', equipment: '无', isCompleted: true, restTime: 45, image: '🧎' },
{ id: 7, name: '硬拉训练', type: '力量', duration: 50, calories: 400, sets: 5, reps: 6, difficulty: '高级', muscleGroup: '背部', equipment: '杠铃', isCompleted: false, restTime: 150, image: '🏋️' },
{ id: 8, name: '动感单车', type: '有氧', duration: 40, calories: 300, sets: 1, reps: 1, difficulty: '中级', muscleGroup: '腿部', equipment: '单车', isCompleted: true, restTime: 0, image: '🚴' },
{ id: 9, name: '引体向上', type: '力量', duration: 20, calories: 180, sets: 4, reps: 10, difficulty: '高级', muscleGroup: '背部', equipment: '单杠', isCompleted: false, restTime: 90, image: '🤸' },
{ id: 10, name: '波比跳训练', type: 'HIIT', duration: 20, calories: 280, sets: 5, reps: 20, difficulty: '高级', muscleGroup: '全身', equipment: '无', isCompleted: true, restTime: 40, image: '🔥' },
{ id: 11, name: '哑铃弯举', type: '力量', duration: 25, calories: 150, sets: 4, reps: 12, difficulty: '中级', muscleGroup: '手臂', equipment: '哑铃', isCompleted: false, restTime: 60, image: '💪' },
{ id: 12, name: '登山者', type: 'HIIT', duration: 15, calories: 200, sets: 4, reps: 30, difficulty: '中级', muscleGroup: '核心', equipment: '无', isCompleted: true, restTime: 30, image: '🏔️' },
{ id: 13, name: '肩部推举', type: '力量', duration: 35, calories: 220, sets: 4, reps: 10, difficulty: '中级', muscleGroup: '肩部', equipment: '哑铃', isCompleted: false, restTime: 75, image: '🏋️' },
{ id: 14, name: '跳绳训练', type: '有氧', duration: 20, calories: 220, sets: 5, reps: 1, difficulty: '初级', muscleGroup: '全身', equipment: '跳绳', isCompleted: true, restTime: 30, image: '🪢' },
{ id: 15, name: '俄罗斯转体', type: '核心', duration: 15, calories: 100, sets: 3, reps: 20, difficulty: '初级', muscleGroup: '核心', equipment: '哑铃', isCompleted: true, restTime: 45, image: '🔄' },
{ id: 16, name: '腿举训练', type: '力量', duration: 40, calories: 300, sets: 4, reps: 15, difficulty: '中级', muscleGroup: '腿部', equipment: '腿举机', isCompleted: false, restTime: 90, image: '🦵' },
{ id: 17, name: '划船机', type: '有氧', duration: 30, calories: 260, sets: 1, reps: 1, difficulty: '中级', muscleGroup: '背部', equipment: '划船机', isCompleted: true, restTime: 0, image: '🚣' },
{ id: 18, name: '战绳训练', type: 'HIIT', duration: 15, calories: 250, sets: 5, reps: 30, difficulty: '高级', muscleGroup: '全身', equipment: '战绳', isCompleted: false, restTime: 45, image: '💥' },
{ id: 19, name: '臀桥训练', type: '力量', duration: 20, calories: 120, sets: 4, reps: 15, difficulty: '初级', muscleGroup: '臀部', equipment: '无', isCompleted: true, restTime: 60, image: '🍑' },
{ id: 20, name: '壶铃摆动', type: '力量', duration: 25, calories: 200, sets: 5, reps: 20, difficulty: '中级', muscleGroup: '全身', equipment: '壶铃', isCompleted: false, restTime: 60, image: '🫖' }
]
const logData: LogItem[] = [
{ id: 1, date: '2026-08-06', exercise: '杠铃深蹲', duration: 45, calories: 320, sets: 4, weight: 80, notes: '状态很好,重量增加', mood: '😊' },
{ id: 2, date: '2026-08-06', exercise: '跑步机慢跑', duration: 30, calories: 250, sets: 1, weight: 0, notes: '配速7km/h', mood: '😊' },
{ id: 3, date: '2026-08-05', exercise: '卧推训练', duration: 40, calories: 280, sets: 5, weight: 60, notes: '推到力竭', mood: '😎' },
{ id: 4, date: '2026-08-05', exercise: '平板支撑', duration: 15, calories: 80, sets: 3, weight: 0, notes: '坚持2分钟', mood: '😊' },
{ id: 5, date: '2026-08-04', exercise: 'HIIT燃脂训练', duration: 25, calories: 350, sets: 6, weight: 0, notes: '大汗淋漓', mood: '🔥' },
{ id: 6, date: '2026-08-04', exercise: '瑜伽拉伸', duration: 60, calories: 150, sets: 1, weight: 0, notes: '放松身心', mood: '😌' },
{ id: 7, date: '2026-08-03', exercise: '硬拉训练', duration: 50, calories: 400, sets: 5, weight: 100, notes: 'PR突破', mood: '😎' },
{ id: 8, date: '2026-08-03', exercise: '动感单车', duration: 40, calories: 300, sets: 1, weight: 0, notes: '心率偏高', mood: '😊' },
{ id: 9, date: '2026-08-02', exercise: '引体向上', duration: 20, calories: 180, sets: 4, weight: 0, notes: '做了40个', mood: '💪' },
{ id: 10, date: '2026-08-02', exercise: '波比跳训练', duration: 20, calories: 280, sets: 5, weight: 0, notes: '太累了', mood: '😅' },
{ id: 11, date: '2026-08-01', exercise: '哑铃弯举', duration: 25, calories: 150, sets: 4, weight: 15, notes: '泵感强烈', mood: '😎' },
{ id: 12, date: '2026-08-01', exercise: '登山者', duration: 15, calories: 200, sets: 4, weight: 0, notes: '速度很快', mood: '🔥' },
{ id: 13, date: '2026-07-31', exercise: '肩部推举', duration: 35, calories: 220, sets: 4, weight: 20, notes: '肩部酸胀', mood: '😊' },
{ id: 14, date: '2026-07-31', exercise: '跳绳训练', duration: 20, calories: 220, sets: 5, weight: 0, notes: '2000个', mood: '😊' },
{ id: 15, date: '2026-07-30', exercise: '俄罗斯转体', duration: 15, calories: 100, sets: 3, weight: 5, notes: '腹部有感觉', mood: '😌' },
{ id: 16, date: '2026-07-30', exercise: '腿举训练', duration: 40, calories: 300, sets: 4, weight: 120, notes: '腿软了', mood: '😅' },
{ id: 17, date: '2026-07-29', exercise: '划船机', duration: 30, calories: 260, sets: 1, weight: 0, notes: '5000米', mood: '😊' },
{ id: 18, date: '2026-07-29', exercise: '战绳训练', duration: 15, calories: 250, sets: 5, weight: 0, notes: '手臂炸了', mood: '🔥' },
{ id: 19, date: '2026-07-28', exercise: '臀桥训练', duration: 20, calories: 120, sets: 4, weight: 30, notes: '臀部发力好', mood: '😊' },
{ id: 20, date: '2026-07-28', exercise: '壶铃摆动', duration: 25, calories: 200, sets: 5, weight: 16, notes: '节奏稳定', mood: '😎' }
]
const challengeData: ChallengeItem[] = [
{ id: 1, title: '30天连续训练', description: '连续30天完成至少一次训练', progress: 18, target: 30, reward: '🏅 训练达人徽章', deadline: '2026-08-30', isCompleted: false },
{ id: 2, title: '本月跑步50公里', description: '一个月内累计跑步50公里', progress: 32, target: 50, reward: '🏃 跑步健将', deadline: '2026-08-31', isCompleted: false },
{ id: 3, title: '深蹲1000次', description: '累计完成1000次深蹲', progress: 650, target: 1000, reward: '🦵 腿部力量王', deadline: '2026-09-15', isCompleted: false },
{ id: 4, title: '燃烧10000卡路里', description: '本月累计消耗10000卡路里', progress: 4200, target: 10000, reward: '🔥 燃脂战士', deadline: '2026-08-31', isCompleted: false },
{ id: 5, title: '平板支撑10分钟', description: '单次累计平板支撑10分钟', progress: 6, target: 10, reward: '🧎 核心精英', deadline: '2026-08-20', isCompleted: false },
{ id: 6, title: '完成50次训练', description: '累计完成50次训练记录', progress: 38, target: 50, reward: '🏆 健身达人', deadline: '2026-09-01', isCompleted: false },
{ id: 7, title: 'HIIT训练挑战', description: '完成20次HIIT训练', progress: 12, target: 20, reward: '⚡ HIIT之王', deadline: '2026-09-10', isCompleted: false },
{ id: 8, title: '一周5练', description: '本周完成5次训练', progress: 5, target: 5, reward: '✅ 本周全勤', deadline: '2026-08-07', isCompleted: true },
{ id: 9, title: '举铁5000公斤', description: '累计举铁重量5000公斤', progress: 3200, target: 5000, reward: '💪 力量举重', deadline: '2026-09-20', isCompleted: false },
{ id: 10, title: '柔韧性挑战', description: '完成15次柔韧训练', progress: 9, target: 15, reward: '🧘 柔韧大师', deadline: '2026-08-25', isCompleted: false }
]
const statsBarData: StatsBarItem[] = [
{ day: '周一', value: 45 },
{ day: '周二', value: 60 },
{ day: '周三', value: 30 },
{ day: '周四', value: 75 },
{ day: '周五', value: 50 },
{ day: '周六', value: 90 },
{ day: '周日', value: 40 }
]
const typePercentData: TypePercentItem[] = [
{ type: '力量', percent: 40, color: '#C62828' },
{ type: '有氧', percent: 25, color: '#EF5350' },
{ type: 'HIIT', percent: 20, color: '#FF6F00' },
{ type: '核心', percent: 10, color: '#2E7D32' },
{ type: '柔韧', percent: 5, color: '#7B1FA2' }
]
const typeColorMap: Record<string, string> = {
'力量': '#C62828',
'有氧': '#EF5350',
'HIIT': '#FF6F00',
'核心': '#2E7D32',
'柔韧': '#7B1FA2'
}
const difficultyColorMap: Record<string, string> = {
'初级': '#2E7D32',
'中级': '#FF6F00',
'高级': '#C62828'
}
const moodColorMap: Record<string, string> = {
'😊': '#2E7D32',
'😎': '#C62828',
'🔥': '#FF6F00',
'😅': '#EF5350',
'😌': '#7B1FA2',
'💪': '#C62828'
}
// ==================== 全局状态 ====================
@Entry
@Component
struct FitnessTracker {
@State currentTab: number = 0
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedWorkout: WorkoutItem | null = null
@State workoutList: WorkoutItem[] = workoutData
@State logList: LogItem[] = logData
@State challengeList: ChallengeItem[] = challengeData
// 新增训练表单
@State newName: string = ''
@State newType: string = '力量'
@State newDuration: number = 30
@State newCalories: number = 200
@State newSets: number = 3
@State newReps: number = 12
@State newDifficulty: string = '中级'
@State newMuscleGroup: string = '全身'
@State newEquipment: string = '无'
@State newRestTime: number = 60
build() {
Column() {
// 顶部统计栏
TopStatsBar()
// Tab内容区
Column() {
if (this.currentTab === 0) {
WorkoutContent({
workoutList: this.workoutList,
onItemClick: (item: WorkoutItem) => {
this.selectedWorkout = item
this.showDetailModal = true
},
onEditClick: (item: WorkoutItem) => {
this.selectedWorkout = item
this.showEditModal = true
},
onDeleteClick: (item: WorkoutItem) => {
this.selectedWorkout = item
this.showDeleteConfirm = true
},
onAddClick: () => {
this.showAddModal = true
}
})
} else if (this.currentTab === 1) {
LogContent({ logList: this.logList })
} else if (this.currentTab === 2) {
StatsContent()
} else if (this.currentTab === 3) {
ChallengeContent({ challengeList: this.challengeList })
} else if (this.currentTab === 4) {
ProfileContent()
}
}
.layoutWeight(1)
.width('100%')
// 底部Tab栏
BottomTabBar({
currentTab: this.currentTab,
onTabChange: (index: number) => {
this.currentTab = index
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFEBEE')
.bindContentCover(this.showAddModal, this.AddModalBuilder(), ModalTransition.DEFAULT)
.bindContentCover(this.showEditModal, this.EditModalBuilder(), ModalTransition.DEFAULT)
.bindContentCover(this.showDeleteConfirm, this.DeleteConfirmBuilder(), ModalTransition.DEFAULT)
.bindContentCover(this.showDetailModal, this.DetailModalBuilder(), ModalTransition.DEFAULT)
}
@Builder
AddModalBuilder() {
Column() {
// 弹框头部
Row() {
Text('新增训练计划')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Column() {
Text('✕')
.fontSize(22)
.fontColor('#999999')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#F5F5F5')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showAddModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(20)
Divider().color('#EEEEEE')
Scroll() {
Column() {
Text('运动名称')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
TextInput({ text: this.newName, placeholder: '请输入运动名称' })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.margin({ left: 20, right: 20 })
.onChange((value: string) => {
this.newName = value
})
Text('运动类型')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
Row() {
ForEach(['力量', '有氧', 'HIIT', '核心', '柔韧'], (t: string) => {
Text(t)
.fontSize(14)
.fontColor(this.newType === t ? '#FFFFFF' : '#666666')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(20)
.backgroundColor(this.newType === t ? '#C62828' : '#F5F5F5')
.margin({ right: 8 })
.onClick(() => {
this.newType = t
})
})
}
.width('100%')
.padding({ left: 20, right: 20 })
Text('难度')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
Row() {
ForEach(['初级', '中级', '高级'], (d: string) => {
Text(d)
.fontSize(14)
.fontColor(this.newDifficulty === d ? '#FFFFFF' : '#666666')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(20)
.backgroundColor(this.newDifficulty === d ? difficultyColorMap[d] : '#F5F5F5')
.margin({ right: 8 })
.onClick(() => {
this.newDifficulty = d
})
})
}
.width('100%')
.padding({ left: 20, right: 20 })
Row() {
Column() {
Text('时长(分钟)')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newDuration.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
this.newDuration = parseInt(value) || 0
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('卡路里')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newCalories.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
this.newCalories = parseInt(value) || 0
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 16 })
Row() {
Column() {
Text('组数')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newSets.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
this.newSets = parseInt(value) || 0
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('次数')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newReps.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
this.newReps = parseInt(value) || 0
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 16 })
Row() {
Column() {
Text('肌肉群')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newMuscleGroup, placeholder: '如: 胸部' })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.onChange((value: string) => {
this.newMuscleGroup = value
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('器械')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.newEquipment, placeholder: '如: 哑铃' })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.onChange((value: string) => {
this.newEquipment = value
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 16 })
Text('休息时间(秒)')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
TextInput({ text: this.newRestTime.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.margin({ left: 20, right: 20 })
.onChange((value: string) => {
this.newRestTime = parseInt(value) || 0
})
Row() {
Button('取消')
.fontSize(16)
.fontColor('#666666')
.backgroundColor('#F5F5F5')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.onClick(() => {
this.showAddModal = false
})
Button('确认新增')
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#C62828')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
const newItem: WorkoutItem = {
id: this.workoutList.length + 1,
name: this.newName,
type: this.newType,
duration: this.newDuration,
calories: this.newCalories,
sets: this.newSets,
reps: this.newReps,
difficulty: this.newDifficulty,
muscleGroup: this.newMuscleGroup,
equipment: this.newEquipment,
isCompleted: false,
restTime: this.newRestTime,
image: '🏋️'
}
this.workoutList = [newItem, ...this.workoutList]
this.showAddModal = false
this.newName = ''
this.newDuration = 30
this.newCalories = 200
this.newSets = 3
this.newReps = 12
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 24, bottom: 40 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.align(Alignment.Top)
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.borderRadius(24)
.constraintSize({ maxHeight: '85%' })
}
@Builder
EditModalBuilder() {
Column() {
Row() {
Text('编辑训练计划')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Column() {
Text('✕')
.fontSize(22)
.fontColor('#999999')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#F5F5F5')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showEditModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(20)
Divider().color('#EEEEEE')
if (this.selectedWorkout !== null) {
Scroll() {
Column() {
Text('运动名称')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
TextInput({ text: this.selectedWorkout.name })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.margin({ left: 20, right: 20 })
.onChange((value: string) => {
if (this.selectedWorkout !== null) {
}
})
Text('运动类型')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
Row() {
ForEach(['力量', '有氧', 'HIIT', '核心', '柔韧'], (t: string) => {
Text(t)
.fontSize(14)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(20)
.margin({ right: 8 })
.onClick(() => {
if (this.selectedWorkout !== null) {
}
})
})
}
.width('100%')
.padding({ left: 20, right: 20 })
Text('难度')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 16, bottom: 8, left: 20 })
Row() {
ForEach(['初级', '中级', '高级'], (d: string) => {
Text(d)
.fontSize(14)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(20)
.margin({ right: 8 })
.onClick(() => {
if (this.selectedWorkout !== null) {
}
})
})
}
.width('100%')
.padding({ left: 20, right: 20 })
Row() {
Column() {
Text('时长(分钟)')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.selectedWorkout.duration.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
if (this.selectedWorkout !== null) {
}
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('卡路里')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.selectedWorkout.calories.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
if (this.selectedWorkout !== null) {
}
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 16 })
Row() {
Column() {
Text('组数')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.selectedWorkout.sets.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
if (this.selectedWorkout !== null) {
}
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('次数')
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 8 })
TextInput({ text: this.selectedWorkout.reps.toString() })
.fontSize(16)
.height(48)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.type(InputType.Number)
.onChange((value: string) => {
if (this.selectedWorkout !== null) {
}
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 16 })
Row() {
Button('取消')
.fontSize(16)
.fontColor('#666666')
.backgroundColor('#F5F5F5')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.onClick(() => {
this.showEditModal = false
})
Button('保存修改')
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#C62828')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.selectedWorkout !== null) {
this.workoutList = this.workoutList.map((item: WorkoutItem) => {
if (item.id === this.selectedWorkout!.id) {
return this.selectedWorkout!
}
return item
})
}
this.showEditModal = false
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 24, bottom: 40 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.align(Alignment.Top)
}
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.borderRadius(24)
.constraintSize({ maxHeight: '85%' })
}
@Builder
DeleteConfirmBuilder() {
Column() {
Column() {
Text('⚠️')
.fontSize(48)
.margin({ top: 32, bottom: 16 })
Text('确认删除')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 8 })
if (this.selectedWorkout !== null) {
Text('确定要删除「' + this.selectedWorkout.name + '」吗?此操作不可撤销。')
.fontSize(14)
.fontColor('#999999')
.textAlign(TextAlign.Center)
.margin({ left: 32, right: 32, bottom: 24 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
Row() {
Button('取消')
.fontSize(16)
.fontColor('#666666')
.backgroundColor('#F5F5F5')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.onClick(() => {
this.showDeleteConfirm = false
})
Button('确认删除')
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#C62828')
.borderRadius(12)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
if (this.selectedWorkout !== null) {
this.workoutList = this.workoutList.filter((item: WorkoutItem) => {
return item.id !== this.selectedWorkout!.id
})
}
this.showDeleteConfirm = false
})
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 32 })
}
.width('80%')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.justifyContent(FlexAlign.Center)
}
@Builder
DetailModalBuilder() {
Column() {
Row() {
Text('训练详情')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Column() {
Text('✕')
.fontSize(22)
.fontColor('#999999')
}
.width(32)
.height(32)
.borderRadius(16)
.backgroundColor('#F5F5F5')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showDetailModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(20)
Divider().color('#EEEEEE')
if (this.selectedWorkout !== null) {
Scroll() {
Column() {
// 动作头部
Row() {
Text(this.selectedWorkout.image)
.fontSize(48)
Column() {
Text(this.selectedWorkout.name)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Row() {
Text(this.selectedWorkout.type)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(typeColorMap[this.selectedWorkout.type])
Text(this.selectedWorkout.difficulty)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(difficultyColorMap[this.selectedWorkout.difficulty])
.margin({ left: 8 })
}
.margin({ top: 8 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 16 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 20 })
// 核心数据
Row() {
Column() {
Text(this.selectedWorkout.duration.toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('分钟')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(this.selectedWorkout.calories.toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('卡路里')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(this.selectedWorkout.sets.toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('组数')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ left: 20, right: 20, top: 24 })
// 组数详情
Text('组数详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 20, top: 24, bottom: 12 })
ForEach([1, 2, 3, 4, 5].slice(0, this.selectedWorkout.sets), (setNum: number) => {
Row() {
Text('第' + setNum + '组')
.fontSize(14)
.fontColor('#666666')
Row() {
Text('次')
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
Text('休息')
.fontSize(12)
.fontColor('#999999')
.margin({ left: 12 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
}
.width('100%')
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('#FAFAFA')
.margin({ left: 20, right: 20, bottom: 8 })
})
// 动作说明
Text('动作说明')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 20, top: 24, bottom: 12 })
Text(this.getExerciseDescription(this.selectedWorkout.type))
.fontSize(14)
.lineHeight(24)
.padding({ left: 20, right: 20 })
// 训练信息
Text('训练信息')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 20, top: 24, bottom: 12 })
Column() {
Row() {
Text('目标肌群')
.fontSize(14)
.fontColor('#999999')
Text(this.selectedWorkout.muscleGroup)
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
Divider().color('#F0F0F0')
Row() {
Text('所需器械')
.fontSize(14)
.fontColor('#999999')
Text(this.selectedWorkout.equipment)
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
Divider().color('#F0F0F0')
Row() {
Text('休息时间')
.fontSize(14)
.fontColor('#999999')
Text(this.selectedWorkout.restTime + '秒/组')
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
Divider().color('#F0F0F0')
Row() {
Text('完成状态')
.fontSize(14)
.fontColor('#999999')
Text(this.selectedWorkout.isCompleted ? '✅ 已完成' : '⏳ 待完成')
.fontSize(14)
.fontColor(this.selectedWorkout.isCompleted ? '#2E7D32' : '#FF6F00')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
}
.padding({ left: 20, right: 20 })
.margin({ left: 20, right: 20, bottom: 40 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.align(Alignment.Top)
}
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.borderRadius(24)
.constraintSize({ maxHeight: '85%' })
}
getExerciseDescription(type: string): string {
if (type === '力量') {
return '力量训练是通过负重练习来增强肌肉力量和体积的训练方式。建议先进行5-10分钟热身,选择合适的重量,控制动作速度,注意呼吸节奏。每组间休息60-120秒,确保肌肉充分恢复。训练后注意拉伸放松,补充蛋白质。'
} else if (type === '有氧') {
return '有氧运动是以增强心肺功能为主的训练。建议保持中等强度,心率控制在最大心率的60%-80%。运动前热身5分钟,运动中注意补充水分,运动后进行拉伸。每周3-5次,每次30-60分钟效果最佳。'
} else if (type === 'HIIT') {
return '高强度间歇训练(HIIT)是短时间高强度运动与休息交替进行的训练方式。每个动作全力以赴20-40秒,休息10-30秒。适合有一定运动基础的人群,燃脂效率极高。训练前后需充分热身和拉伸。'
} else if (type === '核心') {
return '核心训练针对腹部、腰部、骨盆等核心肌群。核心力量是所有运动的基础,有助于改善体态、预防损伤。建议每次训练15-30分钟,注意呼吸配合,动作要标准,避免代偿。'
} else {
return '柔韧性训练通过拉伸来增加关节活动度和肌肉弹性。建议在运动后或单独进行,每个动作保持15-30秒,不要弹震。深呼吸配合拉伸,循序渐进,避免过度拉伸导致损伤。'
}
}
}
// ==================== 顶部统计栏 ====================
@Component
struct TopStatsBar {
@State todayCalories: number = 320
@State todayDuration: number = 45
@State weekCalories: number = 2150
@State goalCalories: number = 500
build() {
Column() {
Row() {
Text('💪 Fitness Tracker')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column() {
Text('🔔')
.fontSize(20)
}
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor('rgba(255,255,255,0.2)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
Row() {
// 环形进度
Column() {
Stack() {
Column() {
Column() {
Text(this.todayCalories.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('kcal')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 2 })
}
.width(80)
.height(80)
.borderRadius(40)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.border({ width: 6, color: 'rgba(255,255,255,0.3)' })
}
.width(100)
.height(100)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width(100)
.height(100)
Text('今日卡路里')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
Column({ space: 12 }) {
Row() {
Column() {
Text(this.todayDuration.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('今日时长(分)')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text(this.weekCalories.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周卡路里')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Row() {
Column() {
Text(this.goalCalories.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('今日目标(分)')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('5')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周训练天数')
.fontSize(11)
.fontColor('rgba(255,255,255,0.8)')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.layoutWeight(1)
.margin({ left: 20 })
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 20 })
}
.width('100%')
.linearGradient({
angle: 135,
colors: [['#C62828', 0.0], ['#EF5350', 1.0]]
})
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
}
}
// ==================== 底部Tab栏 ====================
@Component
struct BottomTabBar {
@Prop currentTab: number
onTabChange: (index: number) => void = () => {}
build() {
Row() {
ForEach([
{ title: '训练', icon: '💪' },
{ title: '记录', icon: '📝' },
{ title: '数据', icon: '📊' },
{ title: '挑战', icon: '🏆' },
{ title: '我的', icon: '👤' }
], (tab: Record<string, string>, index: number) => {
Column() {
Text(tab.icon)
.fontSize(22)
Text(tab.title)
.fontSize(11)
.fontColor(this.currentTab === index ? '#C62828' : '#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.onTabChange(index)
})
})
}
.width('100%')
.height(60)
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: '#EEEEEE' })
}
}
// ==================== Tab1: 训练内容 ====================
@Component
struct WorkoutContent {
@Link workoutList: WorkoutItem[]
onItemClick: (item: WorkoutItem) => void = () => {}
onEditClick: (item: WorkoutItem) => void = () => {}
onDeleteClick: (item: WorkoutItem) => void = () => {}
onAddClick: () => void = () => {}
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
Scroll() {
Column({ space: 12 }) {
Row() {
Text('训练计划')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('共' + this.workoutList.length + '项')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 16 })
ForEach(this.workoutList, (item: WorkoutItem) => {
Row() {
// 左侧彩色色条
Column() {
Column() {
Text(item.image)
.fontSize(28)
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width(6)
.height(80)
.backgroundColor(typeColorMap[item.type])
.borderRadius(3)
Column({ space: 6 }) {
Row() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(item.type)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor(typeColorMap[item.type])
.margin({ left: 8 })
if (item.isCompleted) {
Text('✅')
.fontSize(14)
.margin({ left: 6 })
}
}
Row() {
Text('⏱ ' + item.duration + '分钟')
.fontSize(12)
.fontColor('#999999')
Text('🔥 ' + item.calories + 'kcal')
.fontSize(12)
.fontColor('#FF6F00')
.margin({ left: 12 })
Text('💪 ' + item.sets + 'x' + item.reps)
.fontSize(12)
.fontColor('#999999')
.margin({ left: 12 })
}
Row() {
Text(item.muscleGroup)
.fontSize(11)
.fontColor('#666666')
Text('·')
.fontSize(11)
.fontColor('#CCCCCC')
Scroll() {
Column({ space: 16 }) {
Text('数据统计')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 20, top: 16 })
// 本周训练时长柱状图
Column() {
Text('📊 本周运动时长')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 8 })
Row() {
ForEach(statsBarData, (item: StatsBarItem) => {
Column() {
Column() {
Text(item.value.toString())
.fontSize(10)
.fontColor('#C62828')
.fontWeight(FontWeight.Bold)
}
.width(24)
.height(item.value)
.borderRadius({ topLeft: 4, topRight: 4 })
.linearGradient({
angle: 180,
colors: [['#EF5350', 0.0], ['#C62828', 1.0]]
})
.justifyContent(FlexAlign.Start)
.alignItems(HorizontalAlign.Center)
Text(item.day)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
.height(140)
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 训练类型占比
Column() {
Text('🎯 训练类型占比')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
// 模拟饼图
Stack() {
Column() {
Column() {
Text('100%')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
}
.width(80)
.height(80)
.borderRadius(40)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF')
}
.width(100)
.height(100)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.border({ width: 20, color: '#C62828' })
.borderRadius(50)
}
.width(120)
.height(120)
Column({ space: 8 }) {
ForEach(typePercentData, (item: TypePercentItem) => {
Row() {
Column() {
Text('')
}
.width(12)
.height(12)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.type)
.fontSize(13)
.fontColor('#666666')
.margin({ left: 6 })
Text(item.percent + '%')
.fontSize(13)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
.margin({ left: 4 })
}
})
}
.layoutWeight(1)
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 本周数据汇总
Column() {
Text('📈 本周数据汇总')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
Column() {
Text('5')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('训练天数')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('390')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('总时长(分)')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('2,150')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('总卡路里')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 月度趋势
Column() {
Text('📅 月度趋势')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
ForEach([30, 45, 60, 35, 50, 75, 55, 40, 65, 80, 70, 90], (val: number, idx: number) => {
Column() {
Column() {
Text('')
}
.width(12)
.height(val)
.borderRadius({ topLeft: 3, topRight: 3 })
.backgroundColor('#EF5350')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
})
}
.width('100%')
.height(100)
.padding({ left: 16, right: 16 })
Row() {
Text('1月')
.fontSize(9)
.fontColor('#CCCCCC')
Text('12月')
.fontSize(9)
.fontColor('#CCCCCC')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 4, bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.align(Alignment.Top)
.scrollBar(BarState.Off)
}
}
// ==================== Tab4: 挑战任务 ====================
@Component
struct ChallengeContent {
@Link challengeList: ChallengeItem[]
build() {
Scroll() {
Column({ space: 12 }) {
Row() {
Text('🏆 挑战任务')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(this.challengeList.filter((c: ChallengeItem) => c.isCompleted).length + '/' + this.challengeList.length + '完成')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20, top: 16 })
// 本周挑战横幅
Column() {
Row() {
Column() {
Text('🔥')
.fontSize(32)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('rgba(255,255,255,0.2)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column() {
Text('本周挑战进行中')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('完成更多挑战赢取奖励')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 4 })
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
Column() {
Text(this.challengeList.filter((c: ChallengeItem) => c.isCompleted).length.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('已完成')
.fontSize(10)
.fontColor('rgba(255,255,255,0.8)')
}
}
.width('100%')
.padding(16)
}
.width('90%')
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [['#C62828', 0.0], ['#FF6F00', 1.0]]
})
.shadow({ radius: 12, color: 'rgba(198,40,40,0.2)', offsetX: 0, offsetY: 4 })
ForEach(this.challengeList, (item: ChallengeItem) => {
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text(item.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(item.description)
.fontSize(12)
.fontColor('#999999')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (item.isCompleted) {
Column() {
Text('✅')
.fontSize(20)
}
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor('#E8F5E9')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
// 进度条
Column({ space: 4 }) {
Row() {
Column() {
Text('')
}
.width((item.progress / item.target) * 100 + '%')
.height(8)
.borderRadius(4)
.backgroundColor(item.isCompleted ? '#2E7D32' : '#C62828')
}
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor('#F5F5F5')
Row() {
Text(item.progress + ' / ' + item.target)
.fontSize(11)
.fontColor('#999999')
Text(Math.round(item.progress / item.target * 100) + '%')
.fontSize(11)
.fontColor(item.isCompleted ? '#2E7D32' : '#C62828')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
// 底部信息
Row() {
// 奖励标签
Row() {
Text('🎁')
.fontSize(12)
Text(item.reward)
.fontSize(11)
.fontColor('#FF6F00')
.margin({ left: 4 })
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#FFF3E0')
Row() {
Text('⏰')
.fontSize(12)
Text(item.deadline)
.fontSize(11)
.fontColor('#999999')
.margin({ left: 4 })
}
.margin({ left: 8 })
}
.width('100%')
}
.width('90%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 6, color: 'rgba(198,40,40,0.06)', offsetX: 0, offsetY: 2 })
})
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.align(Alignment.Top)
.scrollBar(BarState.Off)
}
}
// ==================== Tab5: 个人档案 ====================
@Component
struct ProfileContent {
build() {
Scroll() {
Column({ space: 16 }) {
Text('个人档案')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 20, top: 16 })
// 用户信息卡
Column() {
Row() {
Column() {
Text('🏃')
.fontSize(40)
}
.width(72)
.height(72)
.borderRadius(36)
.linearGradient({
angle: 135,
colors: [['#C62828', 0.0], ['#EF5350', 1.0]]
})
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 6 }) {
Text('健身达人')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Row() {
Text('⭐ LV.12')
.fontSize(12)
.fontColor('#FF6F00')
.fontWeight(FontWeight.Medium)
Text('·')
.fontSize(12)
.fontColor('#CCCCCC')
.margin({ left: 6, right: 6 })
Text('健身365天')
.fontSize(12)
.fontColor('#999999')
}
Row() {
Text('🔥 连续训练18天')
.fontSize(12)
.fontColor('#C62828')
.fontWeight(FontWeight.Medium)
}
}
.layoutWeight(1)
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 身体数据
Column() {
Text('📊 身体数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
Column() {
Text('70')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('体重(kg)')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('175')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6F00')
Text('身高(cm)')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('22.9')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#2E7D32')
Text('BMI')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('18%')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#C62828')
Text('体脂率')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 成就徽章
Column() {
Text('🏅 成就徽章')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Row() {
ForEach([
{ icon: '🏆', name: '初学者', color: '#C62828' },
{ icon: '🔥', name: '燃脂王', color: '#FF6F00' },
{ icon: '💪', name: '力量王', color: '#2E7D32' },
{ icon: '⚡', name: 'HIIT王', color: '#7B1FA2' }
], (badge: Record<string, string>) => {
Column() {
Column() {
Text(badge.icon)
.fontSize(24)
}
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor(badge.color + '20')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Text(badge.name)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.padding({ bottom: 16 })
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 累计数据
Column() {
Text('📈 累计数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
Column({ space: 0 }) {
Row() {
Text('累计训练天数')
.fontSize(14)
.fontColor('#666666')
Text('365天')
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
Divider().color('#F0F0F0').margin({ left: 16, right: 16 })
Row() {
Text('累计训练次数')
.fontSize(14)
.fontColor('#666666')
Text('520次')
.fontSize(14)
.fontColor('#333333')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
Divider().color('#F0F0F0').margin({ left: 16, right: 16 })
Row() {
Text('累计消耗卡路里')
.fontSize(14)
.fontColor('#666666')
Text('128,500 kcal')
.fontSize(14)
.fontColor('#FF6F00')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
Divider().color('#F0F0F0').margin({ left: 16, right: 16 })
Row() {
Text('累计训练时长')
.fontSize(14)
.fontColor('#666666')
Text('18,200分钟')
.fontSize(14)
.fontColor('#2E7D32')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
Divider().color('#F0F0F0').margin({ left: 16, right: 16 })
Row() {
Text('最大深蹲重量')
.fontSize(14)
.fontColor('#666666')
Text('120 kg')
.fontSize(14)
.fontColor('#C62828')
.fontWeight(FontWeight.Medium)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
.padding({ left: 16, right: 16 })
}
.width('100%')
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
// 设置选项
Column() {
ForEach([
{ icon: '🎯', name: '训练目标设置' },
{ icon: '🔔', name: '提醒设置' },
{ icon: '📦', name: '数据导出' },
{ icon: '⚙️', name: '应用设置' },
{ icon: 'ℹ️', name: '关于我们' }
], (setting: Record<string, string>) => {
Row() {
Text(setting.icon)
.fontSize(20)
Text(setting.name)
.fontSize(14)
.fontColor('#333333')
.margin({ left: 12 })
Column() {
Text('›')
.fontSize(20)
.fontColor('#CCCCCC')
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
})
}
.width('90%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(198,40,40,0.08)', offsetX: 0, offsetY: 2 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.align(Alignment.Top)
.scrollBar(BarState.Off)
}
}
十五、总结:从代码中汲取的设计智慧
通过对这个健身运动追踪应用的逐行深度解析,我们不仅理解了每一行代码的功能,更从中提炼出了许多值得借鉴的设计思想和工程实践。

第一,数据建模的全面性。 应用定义了六个接口来描述不同类型的数据实体,每个接口的字段设计都紧贴业务需求。训练项目接口涵盖了从基础信息(名称、类型)到训练参数(组数、次数、休息时间)再到视觉标识(Emoji 图标)的完整信息。训练记录接口特别加入了心情和备注字段,让数据不再冰冷。这种以业务为中心的数据建模思路,确保了 UI 展示时所需的信息都能从数据模型中直接获取,无需额外的计算或转换。
第二,状态管理的分层设计。 应用将状态分为三类:全局状态(导航和弹窗控制)、列表数据状态(训练项目、记录、挑战)和表单状态(新增表单字段)。这种分类使得状态的职责边界清晰——全局状态由主组件集中管理,列表数据通过 @Link 与子组件共享,表单状态仅在需要时使用。@Prop 和 @Link 的合理选用也体现了对数据流向的精确控制:底部导航栏只需接收当前标签索引(@Prop),而列表数据需要双向同步(@Link)。
第三,组件化的高度解耦。 每个功能页面都是独立的自定义组件,通过参数接收数据和回调函数。训练内容页通过四个回调函数将查看、编辑、删除、新增的操作权交给父组件,自身只负责展示和触发。这种设计使得各页面可以独立开发、测试和维护,新增或修改功能不会影响其他页面。弹窗系统也遵循同样的理念——子组件触发回调,父组件统一调度弹窗的显示与隐藏。
第四,视觉设计的一致性。 整个应用建立了一套完整的色彩体系,以活力红为主色调,辅以橙色(卡路里)、绿色(完成)、紫色(柔韧)等语义色。颜色映射表(typeColorMap、difficultyColorMap、moodColorMap)将业务概念与颜色一一对应,确保相同的类型在任何地方都呈现相同的颜色。所有卡片都使用统一的圆角(16)、阴影(半透明红色)和宽度(90%),形成了视觉上的一致性。Emoji 图标的使用既轻量又生动,避免了引入大量图片资源的开销。
第五,纯容器实现复杂图表。 数据统计页的柱状图、饼图和月度趋势图完全基于 Column、Row、Stack 等基础容器组件实现,没有依赖任何第三方图表库。柱状图通过设置容器高度绑定数据值来实现,饼图通过圆形边框模拟环形效果,进度条通过内外两层容器的宽度差来实现。这种做法虽然功能相对简单,但极大地减少了依赖,降低了包体积,同时也提供了最大的定制自由度。
第六,交互细节的人性化。 新增表单的合理默认值降低了用户操作成本;记录卡片中组数和重量的条件渲染避免了无意义的零值显示;备注的省略号截断保证了卡片高度的一致性;删除确认弹窗的警告符号和不可撤销提示保护了用户免于误操作。这些细节虽然微小,但累积起来构成了良好的用户体验。
第七,防御性编程的实践。 可空类型 WorkoutItem | null 配合 if 判断和非空断言 !,确保了对 selectedWorkout 的访问安全。parseInt(value) || 0 的写法防止了用户清空输入框导致的 NaN 问题。回调函数的默认空函数值防止了未传递回调时的运行时错误。这些防御性措施提高了代码的健壮性。
当然,这个应用也有可以进一步完善的地方。编辑弹窗的 onChange 回调目前为空,用户的编辑修改无法实际保存;统计数据目前是静态的,未来可以接入真实的数据计算逻辑;设置选项尚未绑定点击事件,需要对接实际的功能页面。这些不足恰恰指明了下一步的开发方向。
更多推荐

所有评论(0)