一、引言:数字时代的身心修行

在快节奏的现代生活中,越来越多的人开始关注身心健康。瑜伽和冥想作为古老的修行方式,正在通过移动应用技术焕发新的生命力。本文将深入剖析一款基于声明式UI框架开发的瑜伽冥想应用,该应用采用薰衣草紫禅意视觉风格,为用户提供从课程管理、冥想引导到身心数据追踪的一站式体验。

在这里插入图片描述

1.1 应用背景与市场意义

随着健康意识的全面提升,数字健康应用市场呈现出爆发式增长。瑜伽与冥想类应用在这一趋势中占据了重要位置。这类应用不仅需要具备功能完整性,更需要在视觉设计和交互体验上传达出宁静、专注的禅意美学,让用户在使用过程中就能感受到身心放松的效果。

本应用正是基于这一理念构建的。它涵盖了今日课程管理、冥想引导播放、练习记录追踪、身心数据可视化以及个人成就系统等五大核心模块。通过精心设计的薰衣草紫色系视觉语言,配合圆角卡片、柔和阴影和渐变效果,营造出一种既现代又充满禅意的用户体验。

1.2 技术选型与设计理念

应用采用了声明式UI开发范式,这是一种近年来在前端和移动开发领域广泛流行的编程模式。声明式UI的核心思想是:开发者只需描述界面的当前状态,框架会自动处理状态变化时的UI更新。这种模式极大地简化了复杂界面的开发流程,使代码更加清晰可维护。

在状态管理方面,应用使用了基于观察者模式的响应式状态管理机制。通过装饰器标记可观察的数据类和组件状态变量,当数据发生变化时,框架会自动触发依赖该状态的UI组件重新渲染。这种数据驱动的方式让开发者能够专注于业务逻辑,而不需要手动操作DOM节点。

1.3 视觉设计语言

应用的视觉设计以薰衣草紫色系为核心,主色调为深紫色(#6A1B9A),搭配浅紫色背景(#F3E5F5)和白色卡片,形成层次分明的视觉层次。整体设计遵循以下原则:

  • 禅意美学:通过柔和的渐变、圆角设计和适度的留白,传达宁静感
  • 信息层次:利用字号、颜色和间距的差异化,构建清晰的信息层级
  • 视觉一致性:全应用统一使用相同的色彩体系和圆角规范
  • 情感化设计:通过表情符号和柔和的色彩,增强应用的情感亲和力

二、整体架构概述

本应用采用了模块化的组件架构,整体可以分为以下几个层次:

2.1 架构分层

第一层是类型定义层,定义了应用中使用的所有数据结构接口,包括难度元数据、课程类型、冥想类型、强度等级、心情标签、状态标识和柔韧度等级等。这些接口为整个应用提供了类型安全的约束基础。

第二层是数据模型层,使用装饰器标记的可观察类来表示业务实体,包括课程项、冥想引导、练习记录、柔韧度评估、心情记录和成就徽章等六大核心模型。每个模型都包含了完整的属性定义和构造函数。

第三层是设计令牌层,将所有视觉配置抽象为统一的配置对象,包括颜色映射、图标映射和背景色映射等。这种集中式的配置管理使得主题切换和视觉调整变得极为便捷。

第四层是数据层,包含了所有模拟数据和统计汇总函数,为应用提供完整的运行时数据支撑。

第五层是UI组件层,由一个入口组件和五个功能页面组件组成,每个页面组件内部又包含多个构建器方法和弹窗组件。

2.2 导航结构

应用采用底部Tab导航模式,共包含五个主要页面:今日课程、冥想引导、练习记录、身心数据和我的。入口组件负责管理当前激活的Tab状态,并根据状态值渲染对应的页面内容。这种设计简洁直观,符合移动端应用的操作习惯。


三、类型定义体系深度解析

类型定义是构建类型安全应用的基础。本应用通过一系列接口(interface)定义了各类元数据的结构规范,确保配置数据在使用时具有明确的类型约束。

3.1 难度元数据接口

interface DifficultyMeta {
  label: string
  color: string
  icon: string
}

在这里插入图片描述

DifficultyMeta接口定义了课程难度级别的元数据结构。它包含三个字段:

  • label:用于显示的文本标签,例如"初级"、“中级”、“高级”
  • color:该难度等级对应的主题颜色,采用十六进制颜色值表示
  • icon:该难度等级对应的图标,这里使用表情符号作为视觉标识

这个接口的设计思路是将难度级别的所有视觉表现属性集中管理,避免在UI代码中硬编码颜色和图标。当需要调整某个难度的显示效果时,只需修改一处配置即可全局生效。

3.2 课程类型元数据接口

interface CourseTypeMeta {
  label: string
  icon: string
  color: string
  bg: string
}

在这里插入图片描述

CourseTypeMeta接口在难度元数据的基础上增加了bg字段,用于指定背景色。课程类型涵盖了哈他、流瑜伽、阴瑜伽等十种不同的瑜伽流派,每种类型都有独立的图标、文字颜色和背景色配置。

  • label:课程类型的显示名称
  • icon:课程类型对应的表情符号图标
  • color:文字和强调元素的主色调
  • bg:标签背景色,通常为对应主色调的浅色版本

增加bg字段的设计考量是:课程类型在UI中通常以标签(Tag)的形式呈现,需要同时设置文字色和背景色来形成视觉对比。将这两个颜色绑定在同一个配置对象中,可以确保它们始终协调一致。

3.3 冥想类型与强度元数据

interface MeditationTypeMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface IntensityMeta {
  label: string
  color: string
  level: number
}

在这里插入图片描述

MeditationTypeMeta的结构与课程类型元数据完全一致,因为冥想同样以标签形式展示,需要文字色和背景色的配合。冥想类型包括呼吸、正念、睡眠和放松四种。

IntensityMeta则引入了level字段,这是一个数值型的等级标识。强度等级分为轻松(level=1)、中等(level=2)和高强(level=3)。level字段的设计目的是为后续可能的数值比较和排序操作提供基础,例如根据强度筛选课程或绘制强度分布图。

3.4 心情、状态与柔韧度元数据

interface MoodMeta {
  label: string
  icon: string
  color: string
}

interface StatusMeta {
  label: string
  color: string
  bg: string
}

interface FlexLevelMeta {
  label: string
  color: string
}

在这里插入图片描述

这三个接口各有侧重:

MoodMeta用于心情记录,包含十二种心情状态,从平静、愉悦到焦虑、释然,覆盖了瑜伽练习后可能出现的各种情绪反应。心情记录不需要背景色,因为心情在UI中通常以独立卡片或图标形式展示。

StatusMeta用于课程状态管理,包含已预约、进行中、未开始和已完成四种状态。状态标签需要文字色和背景色的配合,以形成醒目的状态指示器。

FlexLevelMeta用于柔韧度评估等级,包含优秀、良好、一般和需提升四个等级。它的结构最为简单,仅包含标签和颜色,因为柔韧度等级在UI中主要以进度条颜色来呈现,不需要独立的背景色。


四、数据模型层详解

数据模型层是应用的核心业务实体定义。所有模型都使用了@Observed装饰器进行标记,这使它们成为可观察的对象。当对象的属性发生变化时,绑定了该对象的UI组件会自动收到通知并触发重新渲染。

4.1 课程项模型

@Observed
export class CourseItem {
  id: number = 0
  name: string = ''
  duration: number = 0
  difficulty: string = ''
  coach: string = ''
  type: string = ''
  status: string = ''
  description: string = ''
  calories: number = 0
  constructor(id: number, name: string, duration: number, difficulty: string,
              coach: string, type: string, status: string,
              description: string, calories: number) {
    this.id = id; this.name = name; this.duration = duration
    this.difficulty = difficulty; this.coach = coach; this.type = type
    this.status = status; this.description = description; this.calories = calories
  }
}

在这里插入图片描述

CourseItem是应用中最核心的数据模型之一,代表一节瑜伽课程。我们逐行分析:

  • @Observed装饰器将类标记为可观察对象,这是响应式数据流的基础
  • export class CourseItem表示该类可以被其他模块导入使用
  • id:课程唯一标识符,数值类型,初始化为0
  • name:课程名称,字符串类型,如"晨光唤醒瑜伽"
  • duration:课程时长,以分钟为单位
  • difficulty:难度等级,取值为"初级"、“中级"或"高级”,与DIFFICULTY_CONFIG中的键对应
  • coach:授课教练姓名
  • type:课程类型,取值为"哈他"、"流瑜伽"等,与COURSE_TYPE_CONFIG中的键对应
  • status:课程状态,取值为"已预约"、"进行中"等,与STATUS_CONFIG中的键对应
  • description:课程描述文本
  • calories:预计消耗的卡路里

构造函数接收所有参数并依次赋值。值得注意的是,所有属性都有默认初始值(空字符串或0),这是一个良好的防御性编程实践,可以避免在对象创建后访问未初始化属性导致的错误。

difficulty、type和status字段使用字符串类型而非枚举,这些字符串值将作为键去对应的配置对象中查找元数据。这种设计在灵活性和类型安全之间取得了平衡。

4.2 冥想引导模型

@Observed
export class MeditationGuide {
  id: number = 0
  title: string = ''
  duration: number = 0
  type: string = ''
  description: string = ''
  playCount: number = 0
  isFavorite: boolean = false
  constructor(id: number, title: string, duration: number, type: string,
              description: string, playCount: number, isFavorite: boolean) {
    this.id = id; this.title = title; this.duration = duration
    this.type = type; this.description = description
    this.playCount = playCount; this.isFavorite = isFavorite
  }
}

在这里插入图片描述

MeditationGuide代表一条冥想引导音频。与课程模型相比,它有以下特点:

  • title取代了name,因为冥想引导更适合用"标题"来描述
  • 没有难度、教练和卡路里字段,因为冥想的核心指标是时长和类型
  • playCount:播放次数,用于展示冥想引导的受欢迎程度
  • isFavorite:布尔值,标记是否已被用户收藏

isFavorite字段使用布尔类型,这是响应式状态的一个典型应用场景。当用户点击收藏按钮时,该字段的值发生变化,收藏图标会自动更新。不过在本应用的实现中,收藏操作是通过弹窗确认后执行的,体现了对用户操作的审慎态度。

4.3 练习记录模型

@Observed
export class PracticeRecord {
  id: number = 0
  date: string = ''
  courseName: string = ''
  duration: number = 0
  type: string = ''
  intensity: string = ''
  feeling: string = ''
  calories: number = 0
  constructor(id: number, date: string, courseName: string, duration: number,
              type: string, intensity: string, feeling: string, calories: number) {
    this.id = id; this.date = date; this.courseName = courseName
    this.duration = duration; this.type = type; this.intensity = intensity
    this.feeling = feeling; this.calories = calories
  }
}

在这里插入图片描述

PracticeRecord记录了用户每次练习的详细信息。这个模型的设计亮点在于:

  • date使用字符串格式(如"08-10"),简化了日期显示逻辑
  • courseName直接存储课程名称字符串,而非外键引用,这是为了在记录列表中避免额外的关联查询
  • intensity:练习强度,取值为"轻松"、“中等”、“高强”
  • feeling:练习后的主观感受,这是一个自由文本字段,如"愉悦"、"疲惫但满足"等

4.4 柔韧度评估模型

@Observed
export class FlexibilityAssessment {
  id: number = 0
  part: string = ''
  level: string = ''
  score: number = 0
  date: string = ''
  constructor(id: number, part: string, level: string, score: number, date: string) {
    this.id = id; this.part = part; this.level = level
    this.score = score; this.date = date
  }
}

在这里插入图片描述

FlexibilityAssessment用于记录用户身体各部位的柔韧度评估结果。part字段表示身体部位(如前屈、后弯、侧弯等),level表示等级(优秀、良好、一般、需提升),score是具体的分数值。score字段同时被用于进度条的宽度计算,实现了数据与视觉的直接映射。

4.5 心情记录与成就徽章模型

@Observed
export class MoodRecord {
  id: number = 0
  date: string = ''
  mood: string = ''
  note: string = ''
  constructor(id: number, date: string, mood: string, note: string) {
    this.id = id; this.date = date; this.mood = mood; this.note = note
  }
}

@Observed
export class AchievementBadge {
  id: number = 0
  name: string = ''
  icon: string = ''
  description: string = ''
  isUnlocked: boolean = false
  date: string = ''
  constructor(id: number, name: string, icon: string, description: string,
              isUnlocked: boolean, date: string) {
    this.id = id; this.name = name; this.icon = icon
    this.description = description; this.isUnlocked = isUnlocked; this.date = date
  }
}

在这里插入图片描述

MoodRecord记录用户每天的心情状态和备注,mood字段与MOOD_CONFIG中的键对应。note字段允许用户添加自由文本备注,记录心情变化的细节。

AchievementBadge代表成就徽章,isUnlocked布尔值标记徽章是否已解锁。未解锁的徽章在UI中以灰色半透明状态显示,激励用户继续练习以解锁更多成就。date字段记录解锁日期,对于未解锁的徽章该值为空字符串。


五、设计令牌系统

设计令牌(Design Token)是现代UI设计中的核心概念,它将所有视觉属性抽象为可配置的令牌,实现设计与代码的统一管理。本应用通过一系列Record类型的常量对象实现了设计令牌系统。

5.1 难度配置

const DIFFICULTY_CONFIG: Record<string, DifficultyMeta> = {
  '初级': { label: '初级', color: '#66BB6A', icon: '🟢' },
  '中级': { label: '中级', color: '#FFA726', icon: '🟡' },
  '高级': { label: '高级', color: '#EF5350', icon: '🔴' }
}

DIFFICULTY_CONFIG使用Record<string, DifficultyMeta>类型,将难度名称作为键,元数据对象作为值。三个难度等级分别对应绿色、橙色和红色,这种色彩编码符合用户对难度递增的直觉认知:

  • 初级使用绿色(#66BB6A),传达轻松、入门的信号
  • 中级使用橙色(#FFA726),提示需要一定基础
  • 高级使用红色(#EF5350),警示需要较高水平

使用Record类型而非普通对象的好处是TypeScript会进行更严格的类型检查,确保所有访问的键都返回正确类型的值。

5.2 课程类型配置

const COURSE_TYPE_CONFIG: Record<string, CourseTypeMeta> = {
  '哈他': { label: '哈他', icon: '🧘', color: '#6A1B9A', bg: '#F3E5F5' },
  '流瑜伽': { label: '流瑜伽', icon: '🌊', color: '#1565C0', bg: '#E3F2FD' },
  '阴瑜伽': { label: '阴瑜伽', icon: '🌙', color: '#00695C', bg: '#E0F2F1' },
  '力量瑜伽': { label: '力量', icon: '💪', color: '#C62828', bg: '#FFEBEE' },
  '恢复瑜伽': { label: '恢复', icon: '🌿', color: '#2E7D32', bg: '#E8F5E9' },
  '呼吸法': { label: '呼吸', icon: '🌬️', color: '#7B1FA2', bg: '#F3E5F5' },
  '理疗瑜伽': { label: '理疗', icon: '⚕️', color: '#00838F', bg: '#E0F7FA' },
  '阿斯汤加': { label: '阿斯汤加', icon: '🔥', color: '#E65100', bg: '#FFF3E0' },
  '空中瑜伽': { label: '空中', icon: '🪢', color: '#4527A0', bg: '#EDE7F6' },
  '双人瑜伽': { label: '双人', icon: '👫', color: '#AD1457', bg: '#FCE4EC' }
}

COURSE_TYPE_CONFIG是应用中最大的配置对象,涵盖了十种瑜伽流派。每种类型都有精心选择的图标和色彩搭配:

  • 哈他瑜伽使用冥想图标和紫色系,呼应应用整体色调
  • 流瑜伽使用波浪图标和蓝色系,传达流动感
  • 阴瑜伽使用月亮图标和青色系,营造夜间舒缓氛围
  • 力量瑜伽使用肌肉图标和红色系,强调力量感
  • 恢复瑜伽使用草本图标和深绿色系,象征恢复与生长

每个配置项的color和bg都采用同色系的深浅搭配,确保文字在背景上具有足够的对比度。这种系统化的色彩管理方式,使得十种课程类型在视觉上既有区分度又保持整体协调。

5.3 冥想类型与强度配置

const MEDITATION_TYPE_CONFIG: Record<string, MeditationTypeMeta> = {
  '呼吸': { label: '呼吸', icon: '🌬️', color: '#6A1B9A', bg: '#F3E5F5' },
  '正念': { label: '正念', icon: '🧠', color: '#1565C0', bg: '#E3F2FD' },
  '睡眠': { label: '睡眠', icon: '😴', color: '#283593', bg: '#E8EAF6' },
  '放松': { label: '放松', icon: '🌸', color: '#00695C', bg: '#E0F2F1' }
}

const INTENSITY_CONFIG: Record<string, IntensityMeta> = {
  '轻松': { label: '轻松', color: '#66BB6A', level: 1 },
  '中等': { label: '中等', color: '#FFA726', level: 2 },
  '高强': { label: '高强', color: '#EF5350', level: 3 }
}

冥想类型配置与课程类型配置结构相似,但只包含四种类型。睡眠冥想使用了较深的靛蓝色(#283593),区别于其他类型的明亮色调,暗示其适用于夜间场景。

强度配置复用了难度配置的颜色方案(绿、橙、红),但增加了数值型的level字段。这种颜色复用是有意为之的,因为强度和难度在用户认知中都代表着由低到高的递进关系,使用相同的色彩语义可以降低用户的认知成本。

5.4 心情配置

const MOOD_CONFIG: Record<string, MoodMeta> = {
  '平静': { label: '平静', icon: '😌', color: '#6A1B9A' },
  '愉悦': { label: '愉悦', icon: '😊', color: '#FFA726' },
  '放松': { label: '放松', icon: '🧘', color: '#00695C' },
  '专注': { label: '专注', icon: '🧠', color: '#1565C0' },
  '疲惫': { label: '疲惫', icon: '😴', color: '#78909C' },
  '充实': { label: '充实', icon: '✨', color: '#66BB6A' },
  '兴奋': { label: '兴奋', icon: '🤩', color: '#EF5350' },
  '焦虑': { label: '焦虑', icon: '😰', color: '#E91E63' },
  '感恩': { label: '感恩', icon: '🙏', color: '#7B1FA2' },
  '惬意': { label: '惬意', icon: '🍵', color: '#00838F' },
  '觉知': { label: '觉知', icon: '🪷', color: '#4527A0' },
  '释然': { label: '释然', icon: '🕊️', color: '#5C6BC0' }
}

MOOD_CONFIG包含了十二种心情状态,这是所有配置中情感维度最丰富的一个。每种心情都有对应的表情符号和主题色:

  • 正面情绪如平静、愉悦、充实使用暖色调或紫色系
  • 中性情绪如专注、觉知使用蓝色系
  • 负面情绪如焦虑使用粉红色(#E91E63),疲惫使用灰色(#78909C)
  • 特殊情绪如感恩使用深紫色,释然使用浅靛蓝

十二种心情的设置体现了应用对用户情感状态的细腻关注,让用户能够精确表达每次练习后的心理感受。

5.5 状态与柔韧度等级配置

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '已预约': { label: '已预约', color: '#6A1B9A', bg: '#F3E5F5' },
  '进行中': { label: '进行中', color: '#FFA726', bg: '#FFF3E0' },
  '未开始': { label: '未开始', color: '#78909C', bg: '#ECEFF1' },
  '已完成': { label: '已完成', color: '#66BB6A', bg: '#E8F5E9' }
}

const FLEX_LEVEL_CONFIG: Record<string, FlexLevelMeta> = {
  '优秀': { label: '优秀', color: '#66BB6A' },
  '良好': { label: '良好', color: '#6A1B9A' },
  '一般': { label: '一般', color: '#FFA726' },
  '需提升': { label: '需提升', color: '#EF5350' }
}

状态配置使用四种颜色区分课程的不同阶段:紫色表示已预约(与主题色一致),橙色表示进行中(吸引注意),灰色表示未开始(低调待命),绿色表示已完成(成功反馈)。

柔韧度等级配置中有一个有趣的设计:良好等级使用了主题紫色(#6A1B9A)而非传统的蓝色。这使柔韧度评估的视觉表现与应用整体色调保持一致,避免了色彩过于杂乱。

5.6 筛选器配置

const COURSE_FILTERS: string[] = ['全部', '哈他', '流瑜伽', '阴瑜伽', '力量瑜伽',
  '恢复瑜伽', '呼吸法', '理疗瑜伽', '阿斯汤加', '空中瑜伽', '双人瑜伽']
const MED_FILTERS: string[] = ['全部', '呼吸', '正念', '睡眠', '放松']

两个筛选器数组定义了课程和冥想的筛选选项。COURSE_FILTERS包含十一种筛选条件(全部加上十种课程类型),MED_FILTERS包含五种筛选条件。这些数组将用于筛选栏的渲染和数据过滤逻辑。


六、模拟数据与统计函数

6.1 课程数据

const mockCourses: CourseItem[] = [
  new CourseItem(1, '晨光唤醒瑜伽', 30, '初级', '林心怡', '哈他', '已预约',
    '唤醒身体能量,以柔和的哈他体式开启活力一天', 120),
  new CourseItem(2, '能量流瑜伽', 45, '中级', '王梓涵', '流瑜伽', '进行中',
    '流畅体式串联,配合呼吸节奏激活全身能量', 180),
  new CourseItem(3, '深度拉伸放松', 60, '初级', '陈静雯', '阴瑜伽', '未开始',
    '缓慢深入的阴瑜伽体式,释放筋膜深层紧张', 90),
  new CourseItem(4, '核心力量瑜伽', 40, '高级', 'Lisa Chen', '力量瑜伽', '已完成',
    '强化核心肌群,提升身体稳定与力量', 220),
  new CourseItem(5, '晚间舒缓瑜伽', 35, '初级', '张雨晴', '恢复瑜伽', '未开始',
    '睡前恢复练习,放松身心助眠', 80),
  // ... 更多课程
]

mockCourses数组包含了十条课程数据,覆盖了所有十种课程类型和所有难度等级。每条数据都经过精心设计:

  • 课程名称富有诗意,如"晨光唤醒瑜伽"、“能量流瑜伽”
  • 教练姓名中英文混合,体现了应用的国际化视野
  • 描述文案简洁而富有感染力,能够激发用户的练习欲望
  • 卡路里数据根据课程类型和时长合理设置,力量瑜伽消耗最高(220kcal),呼吸法最低(40kcal)

6.2 冥想引导数据

const mockMeditations: MeditationGuide[] = [
  new MeditationGuide(1, '4-7-8呼吸法', 10, '呼吸',
    '缓解焦虑的经典呼吸技巧,吸气4秒、屏息7秒、呼气8秒', 1280, true),
  new MeditationGuide(2, '身体扫描冥想', 20, '正念',
    '从头到脚感受每个身体部位,释放深层紧张', 2150, false),
  new MeditationGuide(3, '深度睡眠引导', 30, '睡眠',
    '伴随自然白噪音,引导进入深度睡眠状态', 3580, true),
  // ... 更多冥想引导
]

冥想引导数据包含十三条记录,涵盖了呼吸、正念、睡眠和放松四种类型。播放次数数据反映了不同冥想的受欢迎程度:"海浪声入眠"以4200次播放位居榜首,"五感觉察练习"以650次播放排名最末。这种数据分布符合真实场景中睡眠类冥想最受欢迎的规律。

6.3 练习记录与心情数据

练习记录数据包含二十二条记录,时间跨度从07-26到08-10,覆盖了约两周的练习历史。每条记录都包含完整的课程信息、强度、感受和卡路里数据。

心情记录数据包含十六条记录,与练习记录的时间线基本对应。心情数据的设计亮点在于包含负面情绪(如"焦虑"),真实反映了用户在练习过程中的情感波动。

6.4 柔韧度与成就数据

柔韧度评估数据覆盖了前屈、后弯、侧弯、扭转、髋部开放和肩部灵活六个身体部位。分数从60到90不等,反映了不同部位柔韧度的差异。

成就徽章数据包含十枚徽章,其中八枚已解锁、两枚未解锁。未解锁的徽章(冥想大师、瑜伽达人)设置了较高的门槛,为用户提供了长期目标。

6.5 统计汇总函数

function getTodayCourseCount(): number { return 10 }
function getCompletedCourseCount(): number { return 2 }
function getPendingCourseCount(): number { return 6 }
function getInProgressCourseCount(): number { return 1 }
function getMeditationCount(): number { return 13 }
function getRecordCount(): number { return 22 }
function getTotalDuration(): number { return 1680 }
function getTotalCalories(): number { return 2380 }
function getStreakDays(): number { return 42 }
function getCompletionRate(): number { return 78 }

这十个统计函数为应用的各个页面提供汇总数据。在实际应用中,这些函数应该从数据库或API获取数据并实时计算,但在此演示版本中直接返回固定值。

函数命名遵循get + 数据名 + Count/Total/Rate的模式,语义清晰。返回值涵盖了数量(课程数、记录数)、总量(总时长、总卡路里)和比率(完成率)三种统计维度。


七、入口页面与导航架构

入口页面是整个应用的骨架,负责管理页面切换和底部导航栏的渲染。

7.1 Tab枚举定义

enum YogaTab {
  TODAY = 0,
  MEDITATION = 1,
  RECORDS = 2,
  BODY = 3,
  PROFILE = 4
}

YogaTab枚举定义了五个页面的索引值。使用枚举而非魔法数字(如直接用0、1、2)的好处是显而易见的:代码可读性大幅提升,且在重构时不容易出错。枚举值从0开始递增,与数组索引的惯例保持一致。

7.2 入口组件结构

@Entry
@Component
struct YogaMeditationApp {
  @State activeTab: YogaTab = YogaTab.TODAY
  // ...
}

@Entry装饰器标记该组件为应用的入口页面,框架会以此为起点构建组件树。@Component装饰器声明这是一个自定义组件。@State activeTab是组件的内部状态变量,初始值为YogaTab.TODAY,表示应用启动时默认显示今日课程页面。

@State装饰器的关键作用是:当activeTab的值发生变化时,所有依赖该状态的UI部分都会自动重新渲染。这是声明式UI响应式更新的核心机制。

7.3 内容区域构建器

@Builder contentArea() {
  Column() {
    if (this.activeTab === YogaTab.TODAY) {
      TodayCourseContent()
    } else if (this.activeTab === YogaTab.MEDITATION) {
      MeditationGuideContent()
    } else if (this.activeTab === YogaTab.RECORDS) {
      PracticeRecordContent()
    } else if (this.activeTab === YogaTab.BODY) {
      BodyDataContent()
    } else {
      YogaProfileContent()
    }
  }
  .layoutWeight(1)
}

@Builder装饰器定义了一个可复用的UI构建方法。contentArea方法根据activeTab的当前值,条件渲染对应的页面组件。

  • Column()容器包裹所有页面内容,确保垂直排列
  • if-else条件链根据Tab状态选择渲染哪个页面组件
  • .layoutWeight(1)让内容区域占据除底部导航栏之外的所有剩余空间

这里使用if-else而非switch语句,是因为在声明式UI框架中,if-else条件渲染能够更清晰地表达"根据状态显示不同组件"的意图,且框架能够正确追踪条件分支中的组件生命周期。

7.4 底部Tab项构建器

@Builder bottomTabItem(icon: string, label: string, tab: YogaTab) {
  Column() {
    Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
    Text(label).fontSize(9)
      .fontColor(this.activeTab === tab ? '#6A1B9A' : '#999999')
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 1 })
    if (this.activeTab === tab) {
      Column().width(18).height(3)
        .backgroundColor('#6A1B9A').borderRadius(2).margin({ top: 2 })
    }
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 5, bottom: 5 })
  .onClick(() => { this.activeTab = tab })
}

bottomTabItem构建器是底部导航栏的核心。它接收三个参数:图标字符串、标签文字和对应的Tab枚举值。逐行分析:

  • Text(icon).fontSize(20).opacity(...):渲染表情符号图标,选中时完全不透明(1.0),未选中时半透明(0.45),通过透明度差异提示当前选中状态
  • Text(label).fontSize(9):渲染标签文字,字号很小(9),适合底部导航栏的紧凑布局
  • .fontColor(...):选中时使用主题紫色(#6A1B9A),未选中时使用灰色(#999999)
  • .fontWeight(...):选中时加粗显示,增强视觉强调
  • if (this.activeTab === tab)条件块:仅在选中状态下渲染一个小型指示条(宽18、高3的圆角矩形),作为激活状态的视觉标识
  • .layoutWeight(1):每个Tab项等分底部导航栏的宽度
  • .onClick(() => { this.activeTab = tab }):点击时更新activeTab状态,触发页面切换

这种通过参数化构建器来生成导航项的方式,避免了为每个Tab重复编写几乎相同的代码,体现了DRY(Don’t Repeat Yourself)原则。

7.5 主构建方法

build() {
  Column() {
    this.contentArea()
    Row() {
      this.bottomTabItem('🌅', '今日课程', YogaTab.TODAY)
      this.bottomTabItem('🧘', '冥想引导', YogaTab.MEDITATION)
      this.bottomTabItem('📝', '练习记录', YogaTab.RECORDS)
      this.bottomTabItem('🌿', '身心数据', YogaTab.BODY)
      this.bottomTabItem('🪷', '我的', YogaTab.PROFILE)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .padding({ top: 4, bottom: 6 })
    .shadow({ radius: 8, color: '#1A6A1B9A', offsetY: -2 })
  }
  .width('100%').height('100%')
  .backgroundColor('#F3E5F5')
}

build()方法是每个组件必须实现的核心方法,定义了组件的UI结构。入口页面的构建方法非常清晰:

  • 最外层Column占满整个屏幕,背景色为浅紫色(#F3E5F5),这是应用的基础背景
  • this.contentArea()调用内容区域构建器,渲染当前选中的页面
  • Row容器水平排列五个底部导航项
  • .shadow({ radius: 8, color: '#1A6A1B9A', offsetY: -2 })为底部导航栏添加向上投影,阴影颜色带有透明度(#1A前缀表示约10%透明度),营造悬浮效果

五个Tab的图标选择也很有讲究:日出代表今日课程,冥想人物代表冥想引导,笔记本代表练习记录,草本代表身心数据,莲花代表个人中心。每个图标都与对应页面的主题高度契合。


八、今日课程页面深度解析

今日课程页面是应用的默认首页,展示了用户当天的课程安排,并提供课程详情查看、编辑和开始练习等功能。

8.1 状态变量定义

@Component
struct TodayCourseContent {
  @State showStartModal: boolean = false
  @State showDetailModal: boolean = false
  @State showEditModal: boolean = false
  @State selectedCourse: CourseItem | null = null
  @State editingCourse: CourseItem | null = null
  @State formName: string = ''
  @State formDuration: string = ''
  @State formDifficulty: string = '初级'
  @State formCoach: string = ''
  @State formType: string = '哈他'
  @State formDesc: string = ''

页面定义了十个状态变量,可以分为三类:

弹窗控制变量:showStartModal、showDetailModal、showEditModal分别控制三个弹窗的显示与隐藏。使用独立的布尔变量而非单一的状态枚举,是因为这些弹窗之间可能存在嵌套关系(如从详情弹窗中打开编辑弹窗)。

选中数据变量:selectedCourse和editingCourse存储当前被选中的课程对象,类型为CourseItem | null,初始值为null表示未选中任何课程。

表单数据变量:formName、formDuration等用于编辑表单的数据绑定。formDifficulty和formType有默认值(‘初级’和’哈他’),因为它们通常对应下拉选择器,需要预设默认选项。

8.2 遮罩层构建器

@Builder modalOverlay(onClose: () => void) {
  Column()
    .width('100%').height('100%')
    .backgroundColor('rgba(74,20,140,0.5)')
    .onClick(onClose)
}

modalOverlay是一个通用的弹窗遮罩层构建器。它接收一个onClose回调函数作为参数。

  • Column()没有子内容,仅作为占位容器
  • .width('100%').height('100%')撑满整个屏幕
  • .backgroundColor('rgba(74,20,140,0.5)')设置半透明紫色遮罩,RGBA格式中最后的0.5表示50%透明度
  • .onClick(onClose)点击遮罩层时执行回调函数,关闭弹窗

这种参数化的遮罩层设计使得所有弹窗都能复用同一个遮罩组件,保证了视觉一致性。

8.3 开始练习弹窗

@Builder startCourseModal() {
  Column() {
    this.modalOverlay(() => { this.showStartModal = false })
    Column() {
      Text('🧘 开始练习').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        .margin({ top: 24 })
      Text(this.selectedCourse?.name ?? '').fontSize(16).fontColor('#6A1B9A')
        .fontWeight(FontWeight.Medium).margin({ top: 8 })
      Column() {
        Row() {
          Column() {
            Text('⏱️ 时长').fontSize(10).fontColor('#888888')
            Text((this.selectedCourse?.duration ?? 0) + '分钟').fontSize(14)
              .fontColor('#4A148C').fontWeight(FontWeight.Bold).margin({ top: 4 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('🔥 卡路里').fontSize(10).fontColor('#888888')
            Text((this.selectedCourse?.calories ?? 0) + 'kcal').fontSize(14)
              .fontColor('#EF5350').fontWeight(FontWeight.Bold).margin({ top: 4 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').margin({ top: 20 })
        // ... 难度和教练信息
      }
      .width('100%').padding(16).backgroundColor('#F3E5F5').borderRadius(16)
      .margin({ left: 20, right: 20, top: 20 })
      Text('准备好开始你的瑜伽之旅了吗?').fontSize(13).fontColor('#7B1FA2')
        .margin({ top: 20 })
      Row() {
        Text('取消').fontSize(14).fontColor('#7B1FA2')
          .backgroundColor('#E1BEE7').borderRadius(24)
          .padding({ left: 28, right: 28, top: 12, bottom: 12 })
          .onClick(() => { this.showStartModal = false })
        Text('开始练习').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#6A1B9A').borderRadius(24)
          .padding({ left: 28, right: 28, top: 12, bottom: 12 })
          .margin({ left: 12 })
          .onClick(() => { this.showStartModal = false })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%').margin({ top: 24, bottom: 24 })
    }
    .width('80%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '10%', y: '30%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

这个弹窗是用户开始课程练习前的确认对话框。结构分析:

  • 最外层Column占满全屏,zIndex(999)确保弹窗浮于所有内容之上
  • 首先调用this.modalOverlay(...)渲染半透明遮罩层
  • 内层Column是弹窗主体,宽度80%,白色背景,圆角24
  • .position({ x: '10%', y: '30%' })将弹窗定位在屏幕水平居中、垂直30%处
  • 标题"开始练习"使用深紫色加粗显示
  • 课程名称通过可选链this.selectedCourse?.name ?? ''安全访问,如果selectedCourse为null则返回空字符串
  • 四个信息项(时长、卡路里、难度、教练)以2x2网格排列,每项包含标签和数值
  • 信息区域使用浅紫色背景(#F3E5F5),与白色弹窗主体形成对比
  • 底部两个按钮:取消按钮使用浅紫色背景,开始练习按钮使用深紫色背景
  • 按钮采用胶囊形设计(borderRadius: 24,配合较大的padding)

?.可选链和??空值合并运算符的频繁使用,是处理可空对象的安全模式。即使selectedCourse为null,也不会导致运行时错误。

8.4 课程详情弹窗

@Builder detailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetailModal = false })
    Column() {
      Row() {
        Column() {
          Text(COURSE_TYPE_CONFIG[this.selectedCourse?.type ?? '哈他']?.icon ?? '🧘').fontSize(32)
        }.width(60).height(60).backgroundColor('#F3E5F5').borderRadius(30)
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        Column() {
          Text(this.selectedCourse?.name ?? '').fontSize(16)
            .fontWeight(FontWeight.Bold).fontColor('#4A148C')
          Row() {
            Text(DIFFICULTY_CONFIG[this.selectedCourse?.difficulty ?? '初级']?.icon ?? '🟢').fontSize(12)
            Text(DIFFICULTY_CONFIG[this.selectedCourse?.difficulty ?? '初级']?.label ?? '初级').fontSize(11)
              .fontColor(DIFFICULTY_CONFIG[this.selectedCourse?.difficulty ?? '初级']?.color ?? '#66BB6A')
              .margin({ left: 4 })
            Text(STATUS_CONFIG[this.selectedCourse?.status ?? '未开始']?.label ?? '未开始').fontSize(10)
              .fontColor(STATUS_CONFIG[this.selectedCourse?.status ?? '未开始']?.color ?? '#78909C')
              .backgroundColor(STATUS_CONFIG[this.selectedCourse?.status ?? '未开始']?.bg ?? '#ECEFF1')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).margin({ left: 8 })
          }
          .margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showDetailModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 20 })
      Divider().color('#F3E5F5').margin({ top: 16 })
      Scroll() {
        Column() {
          // 时长、卡路里、教练、类型、课程简介
        }
        .padding({ left: 20, right: 20, bottom: 16 })
      }
      .layoutWeight(1)
      Row() {
        Text('✏️ 编辑').fontSize(13).fontColor('#FFFFFF')
          .backgroundColor('#6A1B9A').borderRadius(20)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .onClick(() => { this.showEditModal = true })
        Text('▶️ 开始').fontSize(13).fontColor('#FFFFFF')
          .backgroundColor('#66BB6A').borderRadius(20)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .margin({ left: 8 })
          .onClick(() => { this.showStartModal = true })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 12, bottom: 16 })
    }
    .width('90%').height('70%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '5%', y: '15%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

详情弹窗比开始练习弹窗更为复杂,它占据屏幕90%宽度和70%高度,展示课程的完整信息。

头部区域包含三部分:左侧是课程类型图标(60x60圆形背景),中间是课程名称和状态标签,右侧是关闭按钮。状态标签通过多层配置查找实现:先从selectedCourse获取状态值,再用该值作为键去STATUS_CONFIG中查找对应的颜色和背景色。

Divider().color('#F3E5F5')是一条浅紫色分隔线,将头部与内容区分隔开来。

Scroll()容器使内容区域可滚动,这对于描述文本较长的情况非常重要。.layoutWeight(1)让滚动区域占据弹窗主体中除头部和底部按钮之外的所有空间。

底部操作栏提供两个按钮:编辑(紫色)和开始(绿色)。编辑按钮的点击事件设置showEditModal = true,触发编辑弹窗的显示。这种从详情弹窗中打开编辑弹窗的设计,体现了弹窗的嵌套调用关系。

8.5 编辑课程弹窗

@Builder editModal() {
  Column() {
    this.modalOverlay(() => { this.showEditModal = false })
    Column() {
      Row() {
        Text('✏️ 编辑课程').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showEditModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 20, bottom: 12 })
      Divider().color('#F3E5F5')
      Scroll() {
        Column() {
          Text('课程名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: this.editingCourse?.name ?? '' })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formName = v })
          Text('时长(分钟)').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: (this.editingCourse?.duration ?? 0).toString() })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formDuration = v })
          // ... 教练、课程简介等字段
        }
      }
      .layoutWeight(1)
      Row() {
        Text('取消').fontSize(14).fontColor('#7B1FA2')
          .backgroundColor('#E1BEE7').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .onClick(() => { this.showEditModal = false })
        Text('保存修改').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#6A1B9A').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => { this.showEditModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 16, bottom: 16 })
    }
    .width('90%').height('65%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '5%', y: '17%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

编辑弹窗包含一个表单,允许用户修改课程的各项信息。关键设计点:

  • TextInput的placeholder使用当前课程的数据作为占位文本,让用户看到原始值
  • .onChange((v: string) => { this.formName = v })在输入变化时实时更新对应的状态变量,实现双向数据绑定
  • 输入框使用浅紫色背景(#F3E5F5)和圆角(12),与应用整体风格一致
  • TextArea用于课程简介等多行文本输入,设置了固定的60高度
  • 表单字段按照"标签-输入框"的模式排列,标签使用小字号灰色文字

8.6 课程列表项构建器

@Builder courseItemBuilder(c: CourseItem) {
  Column() {
    Row() {
      Column() {
        Text(COURSE_TYPE_CONFIG[c.type]?.icon ?? '🧘').fontSize(24)
      }.width(48).height(48).backgroundColor(COURSE_TYPE_CONFIG[c.type]?.bg ?? '#F3E5F5')
      .borderRadius(24).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      Column() {
        Text(c.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        Row() {
          Text(DIFFICULTY_CONFIG[c.difficulty]?.icon ?? '🟢').fontSize(10)
          Text(c.difficulty).fontSize(10).fontColor(DIFFICULTY_CONFIG[c.difficulty]?.color ?? '#66BB6A')
            .margin({ left: 2 })
          Text('·').fontSize(10).fontColor('#CCCCCC').margin({ left: 4 })
          Text(c.coach).fontSize(10).fontColor('#7B1FA2').margin({ left: 4 })
          Text('·').fontSize(10).fontColor('#CCCCCC').margin({ left: 4 })
          Text(c.duration + 'min').fontSize(10).fontColor('#888888').margin({ left: 4 })
        }
        .margin({ top: 4 })
        Row() {
          Text(COURSE_TYPE_CONFIG[c.type]?.label ?? c.type).fontSize(9)
            .fontColor(COURSE_TYPE_CONFIG[c.type]?.color ?? '#6A1B9A')
            .backgroundColor(COURSE_TYPE_CONFIG[c.type]?.bg ?? '#F3E5F5')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          Text(STATUS_CONFIG[c.status]?.label ?? c.status).fontSize(9)
            .fontColor(STATUS_CONFIG[c.status]?.color ?? '#78909C')
            .backgroundColor(STATUS_CONFIG[c.status]?.bg ?? '#ECEFF1')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).margin({ left: 6 })
          Text('🔥' + c.calories + 'kcal').fontSize(9).fontColor('#EF5350').margin({ left: 6 })
        }
        .margin({ top: 4 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      Text('>').fontSize(14).fontColor('#E1BEE7')
    }
    .width('100%').padding(14)
  }
  .width('100%').backgroundColor('#FFFFFF')
  .borderRadius(20).margin({ left: 12, right: 12, top: 6 })
  .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
  .onClick(() => { this.selectedCourse = c; this.showDetailModal = true })
}

courseItemBuilder是课程列表中每一项的UI构建器。它接收一个CourseItem参数,渲染一张完整的课程卡片。

卡片布局分为三个水平部分:

  • 左侧图标区:48x48的圆形背景,背景色取自课程类型配置,中间放置对应的表情符号图标
  • 中间信息区:占据剩余空间(layoutWeight(1)),包含三行信息
    • 第一行:课程名称(14号字加粗深紫色)
    • 第二行:难度图标+难度文字+教练名+时长,用"·"分隔符连接
    • 第三行:课程类型标签+状态标签+卡路里信息
  • 右侧箭头:浅紫色的">"符号,提示可点击进入详情

每行信息中的字号递减(14->10->9),形成了清晰的信息层次。标签使用padding和borderRadius组合实现胶囊形外观。

卡片整体使用白色背景、20的圆角和柔和阴影,margin设置左右12的上边距6,使卡片之间有适当的间距。onClick事件将传入的课程对象赋值给selectedCourse状态变量,并打开详情弹窗。

8.7 页面主构建方法

build() {
  Stack() {
    Column() {
      // 呼吸引导圆环
      Row() {
        Column() {
          Stack() {
            Column()
              .width(90).height(90).borderRadius(45)
              .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
              .shadow({ radius: 12, color: '#336A1B9A', offsetY: 0 })
            Column() {
              Text('🧘').fontSize(22).fontColor('#FFFFFF')
              Text('呼吸引导').fontSize(10).fontColor('#E1BEE7').margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
          }
          .width(90).height(90)
        }
        .alignItems(HorizontalAlign.Center)
        Column() {
          Text('今日课程').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          Text('深呼吸,感受当下的力量').fontSize(11).fontColor('#7B1FA2').margin({ top: 4 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 16 })
        Column() {
          Text('+').fontSize(22).fontColor('#FFFFFF')
            .backgroundColor('#6A1B9A').width(36).height(36).borderRadius(18)
            .textAlign(TextAlign.Center)
        }
      }
      .width('100%').padding({ left: 16, right: 16, top: 16, bottom: 12 })

      // 统计条
      Row() {
        Column() {
          Text(getTodayCourseCount().toString()).fontSize(20)
            .fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
          Text('今日课程').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text(getInProgressCourseCount().toString()).fontSize(20)
            .fontWeight(FontWeight.Bold).fontColor('#FFA726')
          Text('进行中').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text(getCompletedCourseCount().toString()).fontSize(20)
            .fontWeight(FontWeight.Bold).fontColor('#66BB6A')
          Text('已完成').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text(getPendingCourseCount().toString()).fontSize(20)
            .fontWeight(FontWeight.Bold).fontColor('#78909C')
          Text('待开始').fontSize(10).fontColor('#888888')
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }
      .width('100%').padding({ top: 12, bottom: 12 })
      .backgroundColor('#FFFFFF')
      .margin({ left: 12, right: 12 })
      .borderRadius(16)
      .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

      // 课程列表
      Scroll() {
        Column() {
          this.courseItemBuilder(mockCourses[0])
          this.courseItemBuilder(mockCourses[1])
          // ... 共10个课程项
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')

    if (this.showStartModal) { this.startCourseModal() }
    if (this.showDetailModal) { this.detailModal() }
    if (this.showEditModal) { this.editModal() }
  }
}

页面主构建方法使用Stack作为根容器,Stack的特点是子元素可以层叠放置。这里将主内容Column和三个弹窗放在同一层级,弹窗通过zIndex浮于内容之上。

页面内容从上到下分为三个区域:

头部区域:包含一个呼吸引导圆环、标题文字和一个添加按钮。圆环使用linearGradient实现135度角的紫色渐变,配合shadow营造立体效果。圆环内部的文字居中对齐,"呼吸引导"四个字使用浅紫色(#E1BEE7),与白色图标形成对比。

统计条:四列等宽的统计数据,每列包含数值和标签。数值使用20号字加粗,标签使用10号字灰色。四种数据分别使用不同的颜色:紫色(今日课程总数)、橙色(进行中)、绿色(已完成)、灰色(待开始),与状态配置的颜色方案保持一致。

课程列表:使用Scroll容器包裹,scrollBar(BarState.Off)隐藏滚动条,保持视觉简洁。列表中依次渲染十个课程项构建器。

最后三个条件语句根据状态变量的值决定是否渲染对应的弹窗。这种"条件渲染弹窗"的模式是声明式UI中处理弹窗的典型做法。


九、冥想引导页面深度解析

冥想引导页面展示了各类冥想音频资源,支持按类型筛选、收藏和开始冥想。

9.1 状态变量

@Component
struct MeditationGuideContent {
  @State selectedType: string = '全部'
  @State showStartModal: boolean = false
  @State showFavoriteModal: boolean = false
  @State selectedMeditation: MeditationGuide | null = null

与课程页面类似,冥想页面定义了四个状态变量。selectedType初始值为’全部’,用于类型筛选。showStartModal和showFavoriteModal分别控制开始冥想弹窗和收藏确认弹窗。

9.2 开始冥想弹窗

@Builder startMeditationModal() {
  Column() {
    this.modalOverlay(() => { this.showStartModal = false })
    Column() {
      Stack() {
        Column()
          .width(100).height(100).borderRadius(50)
          .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#E1BEE7', 1]] })
          .shadow({ radius: 16, color: '#336A1B9A', offsetY: 0 })
        Column() {
          Text(MEDITATION_TYPE_CONFIG[this.selectedMeditation?.type ?? '呼吸']?.icon ?? '🌬️').fontSize(32)
        }
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      }
      .width(100).height(100).margin({ top: 24 })
      Text(this.selectedMeditation?.title ?? '').fontSize(18)
        .fontWeight(FontWeight.Bold).fontColor('#4A148C').margin({ top: 16 })
      Text((this.selectedMeditation?.duration ?? 0) + '分钟 · '
        + (MEDITATION_TYPE_CONFIG[this.selectedMeditation?.type ?? '呼吸']?.label ?? '呼吸'))
        .fontSize(13).fontColor('#7B1FA2').margin({ top: 6 })
      Text(this.selectedMeditation?.description ?? '').fontSize(12).fontColor('#555555')
        .margin({ left: 24, right: 24, top: 16 })
        .textAlign(TextAlign.Center)
      Text('播放 ' + (this.selectedMeditation?.playCount ?? 0) + ' 次').fontSize(11)
        .fontColor('#888888').margin({ top: 12 })
      Row() {
        Text('取消').fontSize(14).fontColor('#7B1FA2')
          .backgroundColor('#E1BEE7').borderRadius(24)
          .padding({ left: 28, right: 28, top: 12, bottom: 12 })
          .onClick(() => { this.showStartModal = false })
        Text('开始冥想').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#6A1B9A').borderRadius(24)
          .padding({ left: 28, right: 28, top: 12, bottom: 12 })
          .margin({ left: 12 })
          .onClick(() => { this.showStartModal = false })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%').margin({ top: 24, bottom: 24 })
    }
    .width('82%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '9%', y: '20%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

冥想开始弹窗的视觉设计比课程开始弹窗更加精致。顶部使用了一个100x100的渐变圆形作为视觉焦点,渐变从深紫色(#6A1B9A)过渡到浅紫色(#E1BEE7),配合16的阴影半径,营造出柔和的光晕效果。

圆形内部放置冥想类型的表情符号图标,通过多层配置查找获取:先从selectedMeditation获取类型值,再从MEDITATION_TYPE_CONFIG中查找对应的图标。

弹窗展示了冥想的完整信息:标题、时长与类型的组合文本、描述和播放次数。描述文本使用textAlign(TextAlign.Center)居中对齐,配合左右24的边距,确保文本在弹窗中视觉平衡。

9.3 收藏确认弹窗

@Builder favoriteModal() {
  Column() {
    this.modalOverlay(() => { this.showFavoriteModal = false })
    Column() {
      Text('🤍 收藏冥想').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C').margin({ top: 24 })
      Text(this.selectedMeditation?.title ?? '').fontSize(14).fontColor('#7B1FA2')
        .fontWeight(FontWeight.Medium).margin({ top: 8 })
      Text('收藏后可在「我的」页面快速找到').fontSize(12).fontColor('#888888').margin({ top: 6 })
      Row() {
        Text('取消').fontSize(14).fontColor('#7B1FA2')
          .backgroundColor('#E1BEE7').borderRadius(24)
          .padding({ left: 28, right: 28, top: 12, bottom: 12 })
          .onClick(() => { this.showFavoriteModal = false })
        Text('确认收藏').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#6A1B9A').borderRadius(24)
          .padding({ left: 28, right: 28, top: 12, bottom: 12 })
          .margin({ left: 12 })
          .onClick(() => { this.showFavoriteModal = false })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%').margin({ top: 24, bottom: 24 })
    }
    .width('80%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '10%', y: '35%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

收藏弹窗是一个简洁的确认对话框。它展示了被收藏的冥想标题,并提示用户"收藏后可在我的页面快速找到",提供了操作结果的预览信息。

9.4 冥想列表项构建器

@Builder meditationItemBuilder(m: MeditationGuide) {
  Column() {
    Row() {
      Column() {
        Text(MEDITATION_TYPE_CONFIG[m.type]?.icon ?? '🌬️').fontSize(22)
      }.width(44).height(44).backgroundColor(MEDITATION_TYPE_CONFIG[m.type]?.bg ?? '#F3E5F5')
      .borderRadius(22).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      Column() {
        Text(m.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        Text(m.description).fontSize(10).fontColor('#888888').margin({ top: 2 })
        Row() {
          Text(m.duration + 'min').fontSize(9).fontColor('#7B1FA2')
          Text('·').fontSize(9).fontColor('#CCCCCC').margin({ left: 4 })
          Text(MEDITATION_TYPE_CONFIG[m.type]?.label ?? m.type).fontSize(9)
            .fontColor(MEDITATION_TYPE_CONFIG[m.type]?.color ?? '#6A1B9A').margin({ left: 4 })
          Text('·').fontSize(9).fontColor('#CCCCCC').margin({ left: 4 })
          Text('▶ ' + m.playCount).fontSize(9).fontColor('#888888').margin({ left: 4 })
        }
        .margin({ top: 4 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      Column() {
        Text(m.isFavorite ? '🤍' : '🤍').fontSize(18)
          .onClick(() => { this.selectedMeditation = m; this.showFavoriteModal = true })
        Text('▶').fontSize(16).fontColor('#6A1B9A').margin({ top: 6 })
          .onClick(() => { this.selectedMeditation = m; this.showStartModal = true })
      }
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%').padding(14)
  }
  .width('100%').backgroundColor('#FFFFFF')
  .borderRadius(20).margin({ left: 12, right: 12, top: 6 })
  .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
}

冥想列表项与课程列表项结构相似,但有以下区别:

  • 图标区更小(44x44),因为冥想列表项需要展示更多文字信息
  • 右侧操作区改为两个垂直排列的按钮:收藏(心形图标)和播放(三角形图标)
  • 第三行信息显示时长、类型和播放次数,播放次数前加了"▶"符号作为视觉前缀

9.5 精选冥想卡片与类型筛选

build() {
  Stack() {
    Column() {
      // 精选冥想卡片
      Column() {
        Row() {
          Column() {
            Stack() {
              Column()
                .width(70).height(70).borderRadius(35)
                .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
              Column() {
                Text('🌬️').fontSize(20).fontColor('#FFFFFF')
                Text('呼吸').fontSize(9).fontColor('#E1BEE7').margin({ top: 1 })
              }
              .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
            }
            .width(70).height(70)
          }
          Column() {
            Text('今日推荐').fontSize(11).fontColor('#E1BEE7')
            Text('4-7-8呼吸法').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('10分钟 · 缓解焦虑').fontSize(11).fontColor('#E1BEE7').margin({ top: 2 })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
          Text('▶').fontSize(20).fontColor('#FFFFFF')
        }
        .width('100%').padding(16)
      }
      .width('100%')
      .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
      .borderRadius({ bottomLeft: 24, bottomRight: 24 })
      .shadow({ radius: 8, color: '#336A1B9A', offsetY: 4 })

      // 类型筛选
      Scroll() {
        Row() {
          ForEach(MED_FILTERS, (t: string) => {
            if (this.selectedType === t) {
              Text(t).fontSize(11).fontColor('#FFFFFF').backgroundColor('#6A1B9A')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(16)
                .margin({ left: 3, right: 3 })
            } else {
              Text(t).fontSize(11).fontColor('#6A1B9A').backgroundColor('#F3E5F5')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(16)
                .margin({ left: 3, right: 3 })
                .onClick(() => { this.selectedType = t })
            }
          })
        }
        .padding({ left: 8, right: 8 })
      }
      .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)

      // 冥想列表
      Scroll() {
        Column() {
          this.meditationItemBuilder(mockMeditations[0])
          // ... 共13个冥想项
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')

    if (this.showStartModal) { this.startMeditationModal() }
    if (this.showFavoriteModal) { this.favoriteModal() }
  }
}

冥想页面的构建方法包含三个主要区域:

精选冥想卡片:这是页面顶部的推荐区域,使用全宽的紫色渐变背景,底部两侧设置24的圆角(borderRadius({ bottomLeft: 24, bottomRight: 24 })),形成向下弯曲的视觉效果。卡片内的文字全部使用白色或浅紫色,确保在深色背景上的可读性。

类型筛选栏:使用水平滚动的Scroll容器,内部通过ForEach遍历MED_FILTERS数组渲染筛选标签。选中的标签使用紫色背景白色文字,未选中的使用浅紫色背景紫色文字。.scrollable(ScrollDirection.Horizontal)设置水平滚动方向,.height(40)固定高度。

ForEach是声明式UI中用于列表渲染的核心方法。它接收数据数组和渲染函数,自动为每个数据项生成对应的UI组件。配合条件渲染,可以灵活处理选中态和未选中态的视觉差异。

冥想列表:垂直滚动的列表区域,使用layoutWeight(1)占据剩余空间。


十、练习记录页面深度解析

练习记录页面展示了用户的历史练习数据,包含周练习时长柱状图、统计汇总和详细的记录列表。

10.1 图表数据与状态变量

@Component
struct PracticeRecordContent {
  weekData: number[] = [30, 55, 70, 48, 75, 43, 30]
  weekDays: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
  maxWeek: number = 75

  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedRecord: PracticeRecord | null = null
  @State formCourseName: string = ''
  @State formDuration: string = ''
  @State formIntensity: string = '轻松'
  @State formFeeling: string = ''
  @State formCalories: string = ''

页面定义了三个非状态变量用于柱状图渲染:

  • weekData:一周七天的练习时长数据(分钟)
  • weekDays:对应的星期标签
  • maxWeek:数据最大值,用于计算柱状图高度的基准

这些数据没有使用@State装饰,因为它们是静态的展示数据,不需要响应式更新。只有需要触发UI重渲染的数据才应该使用@State。

10.2 新增记录弹窗

@Builder addRecordModal() {
  Column() {
    this.modalOverlay(() => { this.showAddModal = false })
    Column() {
      Row() {
        Text('➕ 新增记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showAddModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 20, bottom: 12 })
      Divider().color('#F3E5F5')
      Scroll() {
        Column() {
          Text('课程名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: '输入课程名称...' })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formCourseName = v })
          Text('时长(分钟)').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: '输入时长...' })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formDuration = v })
          Text('卡路里').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: '输入卡路里...' })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formCalories = v })
          Text('感受').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextArea({ placeholder: '练习感受...' })
            .placeholderColor('#BBBBBB').fontSize(12).width('100%').height(60)
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formFeeling = v })
        }
      }
      .layoutWeight(1)
      Row() {
        Text('取消').fontSize(14).fontColor('#7B1FA2')
          .backgroundColor('#E1BEE7').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .onClick(() => { this.showAddModal = false })
        Text('保存记录').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#6A1B9A').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => { this.showAddModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 16, bottom: 16 })
    }
    .width('90%').height('70%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '5%', y: '15%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

新增记录弹窗包含四个表单字段:课程名称、时长、卡路里和感受。与编辑课程弹窗不同,新增弹窗的输入框placeholder使用的是提示文本(如"输入课程名称…")而非现有数据,因为这是创建新记录的场景。

10.3 删除确认弹窗

@Builder deleteRecordModal() {
  Column() {
    this.modalOverlay(() => { this.showDeleteModal = false })
    Column() {
      Text('⚠️').fontSize(48).margin({ top: 24 })
      Text('确认删除此记录?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
      Text('删除后不可恢复').fontSize(13).fontColor('#EF5350').margin({ top: 4 })
      Column() {
        Text(this.selectedRecord?.courseName ?? '').fontSize(14)
          .fontColor('#4A148C').fontWeight(FontWeight.Bold)
        Text((this.selectedRecord?.date ?? '') + ' · '
          + (this.selectedRecord?.duration ?? 0) + 'min')
          .fontSize(11).fontColor('#888888').margin({ top: 4 })
      }
      .backgroundColor('#F3E5F5').borderRadius(12)
      .padding({ left: 20, right: 20, top: 12, bottom: 12 }).margin({ top: 16 })
      Row() {
        Text('取消').fontSize(14).fontColor('#7B1FA2')
          .backgroundColor('#E1BEE7').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showDeleteModal = false })
        Text('确认删除').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#EF5350').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => { this.showDeleteModal = false })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%').margin({ top: 24, bottom: 24 })
    }
    .width('80%').backgroundColor('#FFFFFF').borderRadius(24)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '10%', y: '35%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

删除确认弹窗是所有弹窗中视觉警告最强的。它的设计特点:

  • 顶部使用48号字的警告表情符号(⚠️),形成强烈的视觉警示
  • "删除后不可恢复"使用红色(#EF5350)文字,明确提示操作的不可逆性
  • 中间的信息卡片展示即将删除的记录摘要,让用户确认操作对象
  • "确认删除"按钮使用红色背景(#EF5350),与取消按钮的紫色形成鲜明对比

这种谨慎的删除确认设计是良好的用户体验实践,可以有效防止误操作导致的数据丢失。

10.4 记录列表项构建器

@Builder recordItemBuilder(r: PracticeRecord) {
  Column() {
    Row() {
      Column() {
        Text(r.date).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
        Text('2026').fontSize(8).fontColor('#BA68C8')
      }.width(50).alignItems(HorizontalAlign.Center)
      Column() {
        Text(r.courseName).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        Row() {
          Text(COURSE_TYPE_CONFIG[r.type]?.label ?? r.type).fontSize(9)
            .fontColor(COURSE_TYPE_CONFIG[r.type]?.color ?? '#6A1B9A')
            .backgroundColor(COURSE_TYPE_CONFIG[r.type]?.bg ?? '#F3E5F5')
            .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6)
          Text(INTENSITY_CONFIG[r.intensity]?.label ?? r.intensity).fontSize(9)
            .fontColor(INTENSITY_CONFIG[r.intensity]?.color ?? '#66BB6A').margin({ left: 6 })
          Text('·').fontSize(9).fontColor('#CCCCCC').margin({ left: 4 })
          Text(r.feeling).fontSize(9).fontColor('#888888').margin({ left: 4 })
        }
        .margin({ top: 4 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      Column() {
        Text(r.duration + 'min').fontSize(13).fontColor('#6A1B9A').fontWeight(FontWeight.Bold)
        Text('🔥' + r.calories).fontSize(10).fontColor('#EF5350').margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%').padding(14)
    Row() {
      Text('✏️').fontSize(12)
        .onClick(() => { this.selectedRecord = r; this.showEditModal = true })
      Text('🗑️').fontSize(12).margin({ left: 16 })
        .onClick(() => { this.selectedRecord = r; this.showDeleteModal = true })
      Column().layoutWeight(1)
      Text('查看详情 >').fontSize(10).fontColor('#BA68C8')
    }
    .width('100%').padding({ left: 14, right: 14, bottom: 10 })
  }
  .width('100%').backgroundColor('#FFFFFF')
  .borderRadius(16).margin({ left: 12, right: 12, top: 6 })
  .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
}

记录列表项的布局分为上下两部分:

上半部分(主信息区):三列水平排列

  • 左列:日期信息,16号字的日期(如"08-10")和8号字的年份"2026"
  • 中列:课程名称和标签行,标签行包含类型标签、强度文字和感受描述
  • 右列:时长和卡路里数据,右对齐

下半部分(操作栏):编辑图标、删除图标和"查看详情"链接

  • 编辑和删除图标使用表情符号,通过onClick绑定操作
  • Column().layoutWeight(1)作为弹性占位符,将"查看详情"推到右侧

10.5 周练习时长柱状图

// 周练习时长柱状图
Column() {
  Text('📊 本周练习时长').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
    .width('100%').padding({ left: 16, top: 12, bottom: 8 })
  Row() {
    ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
      Column() {
        Text(this.weekData[d] + 'min').fontSize(8).fontColor('#6A1B9A').margin({ bottom: 3 })
        Column()
          .width(24)
          .height((this.weekData[d] / this.maxWeek * 80).toFixed(0) + 'vp')
          .backgroundColor(d === 4 ? '#6A1B9A' : '#E1BEE7')
          .borderRadius({ topLeft: 4, topRight: 4 })
        Text(this.weekDays[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
    })
  }
  .padding({ left: 12, right: 12, bottom: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(16)
.margin({ left: 12, right: 12, top: 4 })
.shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

这是应用中第一个数据可视化组件——柱状图。它的实现原理值得深入分析:

  • ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {...})遍历0到6的索引值
  • 每个柱子由三部分组成:顶部的数值标签、中间的柱体、底部的星期标签
  • 柱体高度通过计算得出:(this.weekData[d] / this.maxWeek * 80).toFixed(0) + 'vp'
    • this.weekData[d]获取当前天的数据值
    • 除以maxWeek(最大值75)得到比例
    • 乘以80得到以vp为单位的高度值
    • .toFixed(0)将结果四舍五入为整数
    • + 'vp'拼接单位后缀,vp是框架中的虚拟像素单位
  • 柱体宽度固定为24,背景色根据索引判断:索引4(周五)使用深紫色高亮,其余使用浅紫色
  • borderRadius({ topLeft: 4, topRight: 4 })仅设置顶部圆角,模拟真实柱状图的视觉效果

这是一个纯CSS/布局实现的柱状图,没有使用任何图表库。这种轻量级实现方式适合数据量较小的场景,避免了引入第三方库的开销。


十一、身心数据页面深度解析

身心数据页面是一个数据密集型页面,展示了用户的身体指标、柔韧度评估、月度练习趋势和心情记录。

11.1 身体数据卡片构建器

@Builder bodyDataCard(icon: string, label: string, value: string, unit: string,
                      color: string, bg: string, percent: number) {
  Column() {
    Row() {
      Text(icon).fontSize(20)
      Text(label).fontSize(11).fontColor('#888888').margin({ left: 6 })
      Column().layoutWeight(1)
      Text(percent + '%').fontSize(10).fontColor(color)
    }
    .width('100%')
    Row() {
      Text(value).fontSize(28).fontWeight(FontWeight.Bold).fontColor(color)
      Text(unit).fontSize(11).fontColor('#888888').margin({ left: 4, bottom: 4 })
    }
    .alignItems(VerticalAlign.Bottom).margin({ top: 8 })
    Row() {
      Column()
        .width(percent + '%')
        .height(4).backgroundColor(color).borderRadius(2)
      Column().layoutWeight(1)
    }
    .width('100%').height(4).backgroundColor(bg).borderRadius(2).margin({ top: 8 })
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF')
  .borderRadius(16).shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
}

bodyDataCard是一个高度参数化的构建器,接收七个参数来生成不同类型的身体数据卡片。这种设计使得心率、压力指数、睡眠质量和冥想时长等不同指标可以复用同一个卡片模板。

卡片结构分为三层:

  • 头部行:图标+标签+百分比,百分比右对齐
  • 数值行:大号数值(28号字)+单位(11号字),底部对齐
  • 进度条:宽度为percent + '%'的彩色条,外层是背景色的轨道

进度条的实现技巧值得注意:外层Row的高度为4、背景色为浅色(bg),内部的Column宽度为百分比、背景色为深色(color)。这种"轨道+填充"的模式是进度条的通用实现方式。

11.2 柔韧度评估项构建器

@Builder flexItemBuilder(f: FlexibilityAssessment) {
  Column() {
    Row() {
      Text(f.part).fontSize(13).fontColor('#4A148C').fontWeight(FontWeight.Medium).layoutWeight(1)
      Text(FLEX_LEVEL_CONFIG[f.level]?.label ?? f.level).fontSize(11)
        .fontColor(FLEX_LEVEL_CONFIG[f.level]?.color ?? '#6A1B9A')
        .backgroundColor('#F3E5F5')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
      Text(f.score + '分').fontSize(13).fontColor('#6A1B9A').fontWeight(FontWeight.Bold).margin({ left: 10 })
    }
    .width('100%')
    Row() {
      Column()
        .width(f.score + '%')
        .height(5).backgroundColor(FLEX_LEVEL_CONFIG[f.level]?.color ?? '#6A1B9A').borderRadius(3)
      Column().layoutWeight(1)
    }
    .width('100%').height(5).backgroundColor('#F3E5F5').borderRadius(3).margin({ top: 6 })
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF')
  .borderRadius(12).margin({ left: 12, right: 12, top: 4 })
  .shadow({ radius: 2, color: '#0D6A1B9A', offsetY: 1 })
}

柔韧度评估项展示了身体各部位的柔韧度状态。每个评估项包含:

  • 部位名称(左侧,弹性布局占据剩余空间)
  • 等级标签(中间,胶囊形标签,颜色根据等级动态变化)
  • 分数(右侧,紫色加粗显示)
  • 进度条(底部,宽度等于分数百分比,颜色根据等级动态变化)

等级标签和进度条的颜色都通过FLEX_LEVEL_CONFIG[f.level]?.color动态获取,确保视觉表现与数据语义一致。例如,"优秀"等级使用绿色进度条,"需提升"使用红色进度条。

11.3 心情记录项构建器

@Builder moodItemBuilder(m: MoodRecord) {
  Column() {
    Text(MOOD_CONFIG[m.mood]?.icon ?? '😊').fontSize(22)
    Text(MOOD_CONFIG[m.mood]?.label ?? m.mood).fontSize(9)
      .fontColor(MOOD_CONFIG[m.mood]?.color ?? '#6A1B9A')
    Text(m.date).fontSize(8).fontColor('#888888').margin({ top: 2 })
  }
  .width(60).padding({ top: 10, bottom: 10 }).backgroundColor('#FFFFFF')
  .borderRadius(16).margin({ left: 4, right: 4 })
  .shadow({ radius: 2, color: '#0D6A1B9A', offsetY: 1 })
  .alignItems(HorizontalAlign.Center)
}

心情记录项是一个紧凑的卡片,固定宽度60,包含图标、心情标签和日期三行信息。图标使用22号字,是卡片中最大的元素,作为视觉焦点。心情标签的颜色从MOOD_CONFIG动态获取,使每种心情都有独特的色彩标识。

11.4 页面整体布局

build() {
  Column() {
    Text('身心数据').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
      .width('100%').padding({ left: 16, top: 14, bottom: 8 })

    Scroll() {
      Column() {
        // 身体数据卡片 2x2
        Row() {
          Column() {
            this.bodyDataCard('💓', '心率', '68', 'bpm', '#EF5350', '#FFEBEE', 68)
          }.layoutWeight(1).margin({ left: 12, right: 6 })
          Column() {
            this.bodyDataCard('🧠', '压力指数', '32', '分', '#FFA726', '#FFF3E0', 32)
          }.layoutWeight(1).margin({ left: 6, right: 12 })
        }
        .width('100%').margin({ top: 4 })
        Row() {
          Column() {
            this.bodyDataCard('😴', '睡眠质量', '85', '%', '#6A1B9A', '#F3E5F5', 85)
          }.layoutWeight(1).margin({ left: 12, right: 6, top: 6 })
          Column() {
            this.bodyDataCard('🧘', '冥想时长', '320', 'min', '#00695C', '#E0F2F1', 64)
          }.layoutWeight(1).margin({ left: 6, right: 12, top: 6 })
        }
        .width('100%')

        // 柔韧度评估
        Text('🤸 柔韧度评估').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          .width('100%').padding({ left: 16, top: 16, bottom: 6 })
        this.flexItemBuilder(mockFlexibility[0])
        // ... 共6个柔韧度评估项

        // 月度冥想时长柱状图
        Column() {
          Text('📊 月度冥想时长').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
            .width('100%').padding({ left: 16, top: 12, bottom: 8 })
          Row() {
            ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
              Column() {
                Text(this.monthData[d * 5] + 'min').fontSize(7).fontColor('#6A1B9A').margin({ bottom: 3 })
                Column()
                  .width(20)
                  .height((this.monthData[d * 5] / this.maxMonth * 70).toFixed(0) + 'vp')
                  .backgroundColor(d === 3 ? '#6A1B9A' : '#E1BEE7')
                  .borderRadius({ topLeft: 3, topRight: 3 })
                Text(this.monthDays[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
            })
          }
          .padding({ left: 12, right: 12, bottom: 12 })
        }
        .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
        .margin({ left: 12, right: 12, top: 8 })
        .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

        // 心情记录
        Text('🌈 心情记录').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          .width('100%').padding({ left: 16, top: 16, bottom: 6 })
        Scroll() {
          Row() {
            this.moodItemBuilder(mockMoods[0])
            // ... 共16个心情记录项
          }
          .padding({ left: 8, right: 8 })
        }
        .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(90)
        .margin({ left: 12, right: 12 })
      }
      .padding({ bottom: 20 })
    }
    .layoutWeight(1).scrollBar(BarState.Off)
  }
  .width('100%').height('100%')
}

身心数据页面的布局结构非常丰富,从上到下包含四个主要区域:

2x2身体数据卡片网格:通过两个Row各包含两个Column实现2x2网格布局。每个卡片通过layoutWeight(1)等分宽度,margin的左右值交替设置(12和6),形成均匀的间距。四张卡片分别展示心率(红色)、压力指数(橙色)、睡眠质量(紫色)和冥想时长(青色),每张卡片的颜色都与其数据类型语义对应。

柔韧度评估列表:六项柔韧度评估垂直排列,每项包含部位名称、等级标签、分数和进度条。

月度冥想时长柱状图:与周练习时长柱状图实现方式相同,但数据源不同。这里使用了this.monthData[d * 5]的索引方式,从30天的月度数据中每隔5天取一个样本,共取7个数据点用于展示。柱体宽度为20(比周图表的24略窄),高亮索引为3(对应第15天)。

心情记录水平滚动列表:使用水平Scroll容器,高度固定为90,内部水平排列16个心情卡片。.scrollable(ScrollDirection.Horizontal)设置水平滚动。

整个页面使用外层Scroll包裹所有内容,因为内容总量超出一屏,需要垂直滚动。.scrollBar(BarState.Off)隐藏滚动条,保持视觉简洁。


十二、我的页面深度解析

我的页面是用户的个人中心,展示了用户信息、练习统计、成就徽章和月度练习趋势。

12.1 成就徽章构建器

@Builder badgeItemBuilder(b: AchievementBadge) {
  Column() {
    Stack() {
      Column()
        .width(56).height(56).borderRadius(28)
        .backgroundColor(b.isUnlocked ? '#F3E5F5' : '#ECEFF1')
      Text(b.icon).fontSize(26)
        .opacity(b.isUnlocked ? 1.0 : 0.4)
    }
    .width(56).height(56)
    Text(b.name).fontSize(9).fontColor(b.isUnlocked ? '#4A148C' : '#999999')
      .margin({ top: 4 })
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
}

成就徽章构建器使用Stack实现图标与背景圆的重叠效果。Stack的子元素按顺序层叠,后声明的元素在上层。

  • 背景Column:56x56的圆形,已解锁使用浅紫色(#F3E5F5),未解锁使用灰色(#ECEFF1)
  • 图标Text:26号字的表情符号,已解锁完全不透明(1.0),未解锁半透明(0.4)
  • 名称Text:9号字,已解锁深紫色,未解锁灰色

通过opacity和backgroundColor的双重变化,清晰地传达了徽章的解锁状态。未解锁的徽章看起来暗淡无光,激发用户的解锁欲望。

12.2 用户信息卡片

// 用户信息卡片
Column() {
  Row() {
    Stack() {
      Column()
        .width(70).height(70).borderRadius(35)
        .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
        .shadow({ radius: 8, color: '#336A1B9A', offsetY: 0 })
      Text('🧘‍♀️').fontSize(30)
    }
    .width(70).height(70)
    Column() {
      Text('冥想修行者').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('瑜伽爱好者 · 上海').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
      Text('连续修行 ' + getStreakDays() + ' 天').fontSize(10).fontColor('#E1BEE7').margin({ top: 2 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
  }
  .width('100%').padding(16)
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
.borderRadius(24).margin({ left: 12, right: 12, top: 10 })
.shadow({ radius: 8, color: '#336A1B9A', offsetY: 4 })

用户信息卡片是整个应用中视觉最突出的组件之一。它使用了与精选冥想卡片相同的紫色渐变背景,配合8的阴影半径和4的Y轴偏移,营造出悬浮于页面之上的立体效果。

卡片内容包含:

  • 70x70的渐变圆形头像,内含冥想表情符号
  • 用户名"冥想修行者"(白色18号字加粗)
  • 副标题"瑜伽爱好者 · 上海"(浅紫色11号字)
  • 连续修行天数(调用getStreakDays()函数获取值,动态拼接到文本中)

12.3 练习统计栏

// 练习统计
Row() {
  Column() {
    Text(getTotalDuration() + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
    Text('总时长(min)').fontSize(9).fontColor('#888888')
  }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
  Column() {
    Text(getRecordCount() + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#66BB6A')
    Text('总课程').fontSize(9).fontColor('#888888')
  }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
  Column() {
    Text(getStreakDays() + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFA726')
    Text('连续天数').fontSize(9).fontColor('#888888')
  }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
  Column() {
    Text(getCompletionRate() + '%').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#EF5350')
    Text('完成率').fontSize(9).fontColor('#888888')
  }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(16)
.margin({ left: 12, right: 12, top: 8 })
.shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

练习统计栏展示了四个核心指标,每个指标使用不同的颜色:紫色(总时长)、绿色(总课程)、橙色(连续天数)、红色(完成率)。这与今日课程页面的统计条设计风格一致,但数据维度不同。

getTotalDuration() + ''这种将数字转换为字符串的方式是TypeScript中的常见模式,也可以使用toString()方法,但使用空字符串拼接更为简洁。

12.4 成就徽章网格

// 成就徽章
Column() {
  Text('🏆 成就徽章').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
    .width('100%').padding({ left: 16, top: 12, bottom: 8 })
  Row() {
    this.badgeItemBuilder(mockBadges[0])
    this.badgeItemBuilder(mockBadges[1])
    this.badgeItemBuilder(mockBadges[2])
    this.badgeItemBuilder(mockBadges[3])
  }
  .width('100%')
  Row() {
    this.badgeItemBuilder(mockBadges[4])
    this.badgeItemBuilder(mockBadges[5])
    this.badgeItemBuilder(mockBadges[6])
    this.badgeItemBuilder(mockBadges[7])
  }
  .width('100%')
  Row() {
    this.badgeItemBuilder(mockBadges[8])
    this.badgeItemBuilder(mockBadges[9])
    Column().layoutWeight(1)
    Column().layoutWeight(1)
  }
  .width('100%')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(16)
.margin({ left: 12, right: 12, top: 8 })
.shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

成就徽章区域使用三个Row实现4列布局。前两行各4个徽章,第三行2个徽章加2个空占位符(Column().layoutWeight(1)),确保最后一行的两个徽章与前两行对齐。

十枚徽章中,前八枚已解锁(如"初心修行者"、"七日坚持"等),后两枚未解锁(“冥想大师”、“瑜伽达人”)。这种渐进式的成就系统设计,为用户提供了清晰的短期和长期目标。

12.5 月度练习柱状图与版本信息

// 月度练习柱状图
Column() {
  Text('📊 月度练习时长').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
    .width('100%').padding({ left: 16, top: 12, bottom: 8 })
  Row() {
    ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
      Column() {
        Text(this.monthData[d * 5] + 'min').fontSize(7).fontColor('#6A1B9A').margin({ bottom: 3 })
        Column()
          .width(20)
          .height((this.monthData[d * 5] / this.maxMonth * 70).toFixed(0) + 'vp')
          .backgroundColor(d === 5 ? '#6A1B9A' : '#E1BEE7')
          .borderRadius({ topLeft: 3, topRight: 3 })
        Text(this.monthDays[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
    })
  }
  .padding({ left: 12, right: 12, bottom: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(16)
.margin({ left: 12, right: 12, top: 8 })
.shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

Text('🪷 瑜伽冥想 · v1.0 · 2026').fontSize(10).fontColor('#BA68C8')
  .alignSelf(ItemAlign.Center).margin({ top: 16, bottom: 16 })

月度练习柱状图与前两个柱状图实现方式相同,但数据源和配置略有不同:

  • monthData包含30天的数据(练习记录页的月度数据是冥想时长,这里的是练习时长)
  • maxMonth为80(练习时长的最大值)
  • 高亮索引为5(对应第25天)
  • 柱体宽度为20,与身心数据页的月度图表一致

页面底部是版本信息文本,使用alignSelf(ItemAlign.Center)实现居中对齐,字号10,颜色为浅紫色(#BA68C8),作为页面的结尾标识。


十三、关键特性对比总结表

以下表格对应用中五个主要页面的关键特性进行了系统对比:

特性维度今日课程页冥想引导页练习记录页身心数据页我的页面
核心功能课程展示与管理冥想资源浏览与播放练习历史记录与统计身体指标与柔韧度追踪个人信息与成就展示
数据模型CourseItemMeditationGuidePracticeRecordFlexibilityAssessment + MoodRecordAchievementBadge
弹窗数量3个(开始/详情/编辑)2个(开始/收藏)3个(新增/编辑/删除)0个0个
数据可视化统计条精选推荐卡片周柱状图 + 统计条2x2数据卡片 + 月柱状图 + 进度条月柱状图 + 统计栏
列表渲染10个课程项13个冥想项22个记录项6个柔韧度项 + 16个心情项10个徽章项
筛选功能无类型筛选(5种)无无无
CRUD操作查看详情、编辑开始、收藏新增、编辑、删除仅展示仅展示
滚动方向垂直垂直 + 水平筛选垂直垂直 + 水平心情垂直
视觉重点呼吸引导圆环渐变推荐卡片周柱状图2x2数据卡片用户信息渐变卡片
主色调紫色系紫色系紫色系多色(根据数据类型)紫色系
交互复杂度高(弹窗嵌套)中(筛选+弹窗)高(CRUD+图表)低(纯展示)低(纯展示)

十四、总结

14.1 技术架构总结

本文详细解析了一款瑜伽冥想应用的完整技术实现。该应用基于声明式UI框架构建,采用了分层架构设计,从类型定义、数据模型、设计令牌、模拟数据到UI组件,各层职责清晰、耦合度低。

应用的核心技术特点包括:

响应式状态管理:通过@State和@Observed装饰器实现了完整的数据驱动UI更新机制。所有界面变化都由状态变量驱动,开发者无需手动操作DOM,极大地简化了状态管理复杂度。

参数化构建器模式:大量使用@Builder装饰器定义可复用的UI构建方法,如modalOverlay、courseItemBuilder、bodyDataCard等。这些构建器通过参数传递实现高度定制化,既保证了代码复用率,又保持了灵活性。

设计令牌系统:通过七个Record类型的配置对象,将所有视觉属性集中管理。这种设计使得主题切换、色彩调整等需求可以在一处修改、全局生效,是现代UI工程的 Best Practice。

纯布局数据可视化:应用中的柱状图、进度条等数据可视化组件完全使用布局组件(Column、Row)实现,没有引入任何第三方图表库。这种轻量级实现方式在数据量较小的场景下具有明显的性能优势。

14.2 交互设计总结

应用的交互设计遵循了以下原则:

渐进式信息披露:列表项展示概要信息,点击后通过弹窗展示完整详情。这种设计避免了信息过载,同时保证了信息的可获取性。

操作确认机制:所有破坏性操作(如删除)和重要操作(如开始练习、收藏)都通过弹窗进行二次确认,防止误操作。

视觉反馈一致性:选中态使用主题紫色加粗显示,未选中态使用灰色普通字重,这种模式在Tab导航、筛选标签等处保持一致。

嵌套弹窗支持:课程详情弹窗中可以打开编辑弹窗,弹窗之间通过独立的状态变量控制,互不干扰。

14.3 视觉设计总结

应用的视觉设计以薰衣草紫色系为核心,通过以下手段营造禅意美学:

  • 渐变运用:呼吸引导圆环、精选推荐卡片、用户信息卡片等关键视觉元素都使用了135度角的紫色渐变
  • 圆角规范:卡片使用16-24的圆角,按钮使用20-24的胶囊形圆角,标签使用6-10的小圆角,形成了多层次的圆角体系
  • 阴影系统:所有卡片都使用了带有紫色色调的阴影(如#0D6A1B9A),而非纯黑色阴影,使阴影与整体色调和谐统一
  • 表情符号:大量使用表情符号作为图标,既降低了视觉严肃感,又避免了引入图标库的开销

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============ 瑜伽冥想 App — 薰衣草紫禅意风 ============

// ============ 类型定义 ============
interface DifficultyMeta {
  label: string
  color: string
  icon: string
}

interface CourseTypeMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface MeditationTypeMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface IntensityMeta {
  label: string
  color: string
  level: number
}

interface MoodMeta {
  label: string
  icon: string
  color: string
}

interface StatusMeta {
  label: string
  color: string
  bg: string
}

interface FlexLevelMeta {
  label: string
  color: string
}

// ============ 课程数据模型 ============
@Observed
export class CourseItem {
  id: number = 0
  name: string = ''
  duration: number = 0
  difficulty: string = ''
  coach: string = ''
  type: string = ''
  status: string = ''
  description: string = ''
  calories: number = 0
  constructor(id: number, name: string, duration: number, difficulty: string, coach: string, type: string, status: string, description: string, calories: number) {
    this.id = id; this.name = name; this.duration = duration
    this.difficulty = difficulty; this.coach = coach; this.type = type
    this.status = status; this.description = description; this.calories = calories
  }
}

// ============ 冥想引导数据模型 ============
@Observed
export class MeditationGuide {
  id: number = 0
  title: string = ''
  duration: number = 0
  type: string = ''
  description: string = ''
  playCount: number = 0
  isFavorite: boolean = false
  constructor(id: number, title: string, duration: number, type: string, description: string, playCount: number, isFavorite: boolean) {
    this.id = id; this.title = title; this.duration = duration
    this.type = type; this.description = description
    this.playCount = playCount; this.isFavorite = isFavorite
  }
}

// ============ 练习记录数据模型 ============
@Observed
export class PracticeRecord {
  id: number = 0
  date: string = ''
  courseName: string = ''
  duration: number = 0
  type: string = ''
  intensity: string = ''
  feeling: string = ''
  calories: number = 0
  constructor(id: number, date: string, courseName: string, duration: number, type: string, intensity: string, feeling: string, calories: number) {
    this.id = id; this.date = date; this.courseName = courseName
    this.duration = duration; this.type = type; this.intensity = intensity
    this.feeling = feeling; this.calories = calories
  }
}

// ============ 柔韧度评估数据模型 ============
@Observed
export class FlexibilityAssessment {
  id: number = 0
  part: string = ''
  level: string = ''
  score: number = 0
  date: string = ''
  constructor(id: number, part: string, level: string, score: number, date: string) {
    this.id = id; this.part = part; this.level = level
    this.score = score; this.date = date
  }
}

// ============ 心情记录数据模型 ============
@Observed
export class MoodRecord {
  id: number = 0
  date: string = ''
  mood: string = ''
  note: string = ''
  constructor(id: number, date: string, mood: string, note: string) {
    this.id = id; this.date = date; this.mood = mood; this.note = note
  }
}

// ============ 成就徽章数据模型 ============
@Observed
export class AchievementBadge {
  id: number = 0
  name: string = ''
  icon: string = ''
  description: string = ''
  isUnlocked: boolean = false
  date: string = ''
  constructor(id: number, name: string, icon: string, description: string, isUnlocked: boolean, date: string) {
    this.id = id; this.name = name; this.icon = icon
    this.description = description; this.isUnlocked = isUnlocked; this.date = date
  }
}

// ============ 设计令牌 ============
const DIFFICULTY_CONFIG: Record<string, DifficultyMeta> = {
  '初级': { label: '初级', color: '#66BB6A', icon: '🟢' },
  '中级': { label: '中级', color: '#FFA726', icon: '🟡' },
  '高级': { label: '高级', color: '#EF5350', icon: '🔴' }
}

const COURSE_TYPE_CONFIG: Record<string, CourseTypeMeta> = {
  '哈他': { label: '哈他', icon: '🧘', color: '#6A1B9A', bg: '#F3E5F5' },
  '流瑜伽': { label: '流瑜伽', icon: '🌊', color: '#1565C0', bg: '#E3F2FD' },
  '阴瑜伽': { label: '阴瑜伽', icon: '🌙', color: '#00695C', bg: '#E0F2F1' },
  '力量瑜伽': { label: '力量', icon: '💪', color: '#C62828', bg: '#FFEBEE' },
  '恢复瑜伽': { label: '恢复', icon: '🌿', color: '#2E7D32', bg: '#E8F5E9' },
  '呼吸法': { label: '呼吸', icon: '🌬️', color: '#7B1FA2', bg: '#F3E5F5' },
  '理疗瑜伽': { label: '理疗', icon: '⚕️', color: '#00838F', bg: '#E0F7FA' },
  '阿斯汤加': { label: '阿斯汤加', icon: '🔥', color: '#E65100', bg: '#FFF3E0' },
  '空中瑜伽': { label: '空中', icon: '🪢', color: '#4527A0', bg: '#EDE7F6' },
  '双人瑜伽': { label: '双人', icon: '👫', color: '#AD1457', bg: '#FCE4EC' }
}

const MEDITATION_TYPE_CONFIG: Record<string, MeditationTypeMeta> = {
  '呼吸': { label: '呼吸', icon: '🌬️', color: '#6A1B9A', bg: '#F3E5F5' },
  '正念': { label: '正念', icon: '🧠', color: '#1565C0', bg: '#E3F2FD' },
  '睡眠': { label: '睡眠', icon: '😴', color: '#283593', bg: '#E8EAF6' },
  '放松': { label: '放松', icon: '🌸', color: '#00695C', bg: '#E0F2F1' }
}

const INTENSITY_CONFIG: Record<string, IntensityMeta> = {
  '轻松': { label: '轻松', color: '#66BB6A', level: 1 },
  '中等': { label: '中等', color: '#FFA726', level: 2 },
  '高强': { label: '高强', color: '#EF5350', level: 3 }
}

const MOOD_CONFIG: Record<string, MoodMeta> = {
  '平静': { label: '平静', icon: '😌', color: '#6A1B9A' },
  '愉悦': { label: '愉悦', icon: '😊', color: '#FFA726' },
  '放松': { label: '放松', icon: '🧘', color: '#00695C' },
  '专注': { label: '专注', icon: '🧠', color: '#1565C0' },
  '疲惫': { label: '疲惫', icon: '😴', color: '#78909C' },
  '充实': { label: '充实', icon: '✨', color: '#66BB6A' },
  '兴奋': { label: '兴奋', icon: '🤩', color: '#EF5350' },
  '焦虑': { label: '焦虑', icon: '😰', color: '#E91E63' },
  '感恩': { label: '感恩', icon: '🙏', color: '#7B1FA2' },
  '惬意': { label: '惬意', icon: '🍵', color: '#00838F' },
  '觉知': { label: '觉知', icon: '🪷', color: '#4527A0' },
  '释然': { label: '释然', icon: '🕊️', color: '#5C6BC0' }
}

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '已预约': { label: '已预约', color: '#6A1B9A', bg: '#F3E5F5' },
  '进行中': { label: '进行中', color: '#FFA726', bg: '#FFF3E0' },
  '未开始': { label: '未开始', color: '#78909C', bg: '#ECEFF1' },
  '已完成': { label: '已完成', color: '#66BB6A', bg: '#E8F5E9' }
}

const FLEX_LEVEL_CONFIG: Record<string, FlexLevelMeta> = {
  '优秀': { label: '优秀', color: '#66BB6A' },
  '良好': { label: '良好', color: '#6A1B9A' },
  '一般': { label: '一般', color: '#FFA726' },
  '需提升': { label: '需提升', color: '#EF5350' }
}

const COURSE_FILTERS: string[] = ['全部', '哈他', '流瑜伽', '阴瑜伽', '力量瑜伽', '恢复瑜伽', '呼吸法', '理疗瑜伽', '阿斯汤加', '空中瑜伽', '双人瑜伽']
const MED_FILTERS: string[] = ['全部', '呼吸', '正念', '睡眠', '放松']


  build() {
    Stack() {
      Column() {
        // 呼吸引导圆环
        Row() {
          Column() {
            Stack() {
              Column()
                .width(90).height(90).borderRadius(45)
                .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
                .shadow({ radius: 12, color: '#336A1B9A', offsetY: 0 })
              Column() {
                Text('🧘').fontSize(22).fontColor('#FFFFFF')
                Text('呼吸引导').fontSize(10).fontColor('#E1BEE7').margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
            }
            .width(90).height(90)
          }
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('今日课程').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
            Text('深呼吸,感受当下的力量').fontSize(11).fontColor('#7B1FA2').margin({ top: 4 })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 16 })
          Column() {
            Text('+').fontSize(22).fontColor('#FFFFFF')
              .backgroundColor('#6A1B9A').width(36).height(36).borderRadius(18)
              .textAlign(TextAlign.Center)
          }
        }
        .width('100%').padding({ left: 16, right: 16, top: 16, bottom: 12 })

        // 统计条
        Row() {
          Column() {
            Text(getTodayCourseCount().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
            Text('今日课程').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text(getInProgressCourseCount().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFA726')
            Text('进行中').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text(getCompletedCourseCount().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#66BB6A')
            Text('已完成').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text(getPendingCourseCount().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#78909C')
            Text('待开始').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .margin({ left: 12, right: 12 })
        .borderRadius(16)
        .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

        // 课程列表
        Scroll() {
          Column() {
            this.courseItemBuilder(mockCourses[0])
            this.courseItemBuilder(mockCourses[1])
            this.courseItemBuilder(mockCourses[2])
            this.courseItemBuilder(mockCourses[3])
            this.courseItemBuilder(mockCourses[4])
            this.courseItemBuilder(mockCourses[5])
            this.courseItemBuilder(mockCourses[6])
            this.courseItemBuilder(mockCourses[7])
            this.courseItemBuilder(mockCourses[8])
            this.courseItemBuilder(mockCourses[9])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showStartModal) { this.startCourseModal() }
      if (this.showDetailModal) { this.detailModal() }
      if (this.showEditModal) { this.editModal() }
    }
  }
}

// ============ 冥想引导页 ============
@Component
struct MeditationGuideContent {
  @State selectedType: string = '全部'
  @State showStartModal: boolean = false
  @State showFavoriteModal: boolean = false
  @State selectedMeditation: MeditationGuide | null = null

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(74,20,140,0.5)')
      .onClick(onClose)
  }

  @Builder startMeditationModal() {
    Column() {
      this.modalOverlay(() => { this.showStartModal = false })
      Column() {
        Stack() {
          Column()
            .width(100).height(100).borderRadius(50)
            .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#E1BEE7', 1]] })
            .shadow({ radius: 16, color: '#336A1B9A', offsetY: 0 })
          Column() {
            Text(MEDITATION_TYPE_CONFIG[this.selectedMeditation?.type ?? '呼吸']?.icon ?? '🌬️').fontSize(32)
          }
          .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        }
        .width(100).height(100).margin({ top: 24 })
        Text(this.selectedMeditation?.title ?? '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          .margin({ top: 16 })
        Text((this.selectedMeditation?.duration ?? 0) + '分钟 · ' + (MEDITATION_TYPE_CONFIG[this.selectedMeditation?.type ?? '呼吸']?.label ?? '呼吸'))
          .fontSize(13).fontColor('#7B1FA2').margin({ top: 6 })
        Text(this.selectedMeditation?.description ?? '').fontSize(12).fontColor('#555555')
          .margin({ left: 24, right: 24, top: 16 })
          .textAlign(TextAlign.Center)
        Text('播放 ' + (this.selectedMeditation?.playCount ?? 0) + ' 次').fontSize(11).fontColor('#888888')
          .margin({ top: 12 })
        Row() {
          Text('取消').fontSize(14).fontColor('#7B1FA2')
            .backgroundColor('#E1BEE7').borderRadius(24)
            .padding({ left: 28, right: 28, top: 12, bottom: 12 })
            .onClick(() => { this.showStartModal = false })
          Text('开始冥想').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#6A1B9A').borderRadius(24)
            .padding({ left: 28, right: 28, top: 12, bottom: 12 })
            .margin({ left: 12 })
            .onClick(() => { this.showStartModal = false })
        }
        .justifyContent(FlexAlign.Center)
        .width('100%').margin({ top: 24, bottom: 24 })
      }
      .width('82%').backgroundColor('#FFFFFF').borderRadius(24)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '9%', y: '20%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder favoriteModal() {
    Column() {
      this.modalOverlay(() => { this.showFavoriteModal = false })
      Column() {
        Text('🤍 收藏冥想').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C').margin({ top: 24 })
        Text(this.selectedMeditation?.title ?? '').fontSize(14).fontColor('#7B1FA2')
          .fontWeight(FontWeight.Medium).margin({ top: 8 })
        Text('收藏后可在「我的」页面快速找到').fontSize(12).fontColor('#888888').margin({ top: 6 })
        Row() {
          Text('取消').fontSize(14).fontColor('#7B1FA2')
            .backgroundColor('#E1BEE7').borderRadius(24)
            .padding({ left: 28, right: 28, top: 12, bottom: 12 })
            .onClick(() => { this.showFavoriteModal = false })
          Text('确认收藏').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#6A1B9A').borderRadius(24)
            .padding({ left: 28, right: 28, top: 12, bottom: 12 })
            .margin({ left: 12 })
            .onClick(() => { this.showFavoriteModal = false })
        }
        .justifyContent(FlexAlign.Center)
        .width('100%').margin({ top: 24, bottom: 24 })
      }
      .width('80%').backgroundColor('#FFFFFF').borderRadius(24)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '10%', y: '35%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder meditationItemBuilder(m: MeditationGuide) {
    Column() {
      Row() {
        Column() {
          Text(MEDITATION_TYPE_CONFIG[m.type]?.icon ?? '🌬️').fontSize(22)
        }.width(44).height(44).backgroundColor(MEDITATION_TYPE_CONFIG[m.type]?.bg ?? '#F3E5F5')
        .borderRadius(22).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        Column() {
          Text(m.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          Text(m.description).fontSize(10).fontColor('#888888').margin({ top: 2 })
          Row() {
            Text(m.duration + 'min').fontSize(9).fontColor('#7B1FA2')
            Text('·').fontSize(9).fontColor('#CCCCCC').margin({ left: 4 })
            Text(MEDITATION_TYPE_CONFIG[m.type]?.label ?? m.type).fontSize(9)
              .fontColor(MEDITATION_TYPE_CONFIG[m.type]?.color ?? '#6A1B9A').margin({ left: 4 })
            Text('·').fontSize(9).fontColor('#CCCCCC').margin({ left: 4 })
            Text('▶ ' + m.playCount).fontSize(9).fontColor('#888888').margin({ left: 4 })
          }
          .margin({ top: 4 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
        Column() {
          Text(m.isFavorite ? '🤍' : '🤍').fontSize(18)
            .onClick(() => { this.selectedMeditation = m; this.showFavoriteModal = true })
          Text('▶').fontSize(16).fontColor('#6A1B9A').margin({ top: 6 })
            .onClick(() => { this.selectedMeditation = m; this.showStartModal = true })
        }
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%').padding(14)
    }
    .width('100%').backgroundColor('#FFFFFF')
    .borderRadius(20).margin({ left: 12, right: 12, top: 6 })
    .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
  }

  build() {
    Stack() {
      Column() {
        // 精选冥想卡片
        Column() {
          Row() {
            Column() {
              Stack() {
                Column()
                  .width(70).height(70).borderRadius(35)
                  .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
                Column() {
                  Text('🌬️').fontSize(20).fontColor('#FFFFFF')
                  Text('呼吸').fontSize(9).fontColor('#E1BEE7').margin({ top: 1 })
                }
                .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
              }
              .width(70).height(70)
            }
            Column() {
              Text('今日推荐').fontSize(11).fontColor('#E1BEE7')
              Text('4-7-8呼吸法').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('10分钟 · 缓解焦虑').fontSize(11).fontColor('#E1BEE7').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
            Text('▶').fontSize(20).fontColor('#FFFFFF')
          }
          .width('100%').padding(16)
        }
        .width('100%')
        .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
        .borderRadius({ bottomLeft: 24, bottomRight: 24 })
        .shadow({ radius: 8, color: '#336A1B9A', offsetY: 4 })

        // 类型筛选
        Scroll() {
          Row() {
            ForEach(MED_FILTERS, (t: string) => {
              if (this.selectedType === t) {
                Text(t).fontSize(11).fontColor('#FFFFFF').backgroundColor('#6A1B9A')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(16)
                  .margin({ left: 3, right: 3 })
              } else {
                Text(t).fontSize(11).fontColor('#6A1B9A').backgroundColor('#F3E5F5')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(16)
                  .margin({ left: 3, right: 3 })
                  .onClick(() => { this.selectedType = t })
              }
            })
          }
          .padding({ left: 8, right: 8 })
        }
        .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)

        // 冥想列表
        Scroll() {
          Column() {
            this.meditationItemBuilder(mockMeditations[0])
            this.meditationItemBuilder(mockMeditations[1])
            this.meditationItemBuilder(mockMeditations[2])
            this.meditationItemBuilder(mockMeditations[3])
            this.meditationItemBuilder(mockMeditations[4])
            this.meditationItemBuilder(mockMeditations[5])
            this.meditationItemBuilder(mockMeditations[6])
            this.meditationItemBuilder(mockMeditations[7])
            this.meditationItemBuilder(mockMeditations[8])
            this.meditationItemBuilder(mockMeditations[9])
            this.meditationItemBuilder(mockMeditations[10])
            this.meditationItemBuilder(mockMeditations[11])
            this.meditationItemBuilder(mockMeditations[12])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showStartModal) { this.startMeditationModal() }
      if (this.showFavoriteModal) { this.favoriteModal() }
    }
  }
}

// ============ 练习记录页 ============
@Component
struct PracticeRecordContent {
  weekData: number[] = [30, 55, 70, 48, 75, 43, 30]
  weekDays: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
  maxWeek: number = 75

  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedRecord: PracticeRecord | null = null
  @State formCourseName: string = ''
  @State formDuration: string = ''
  @State formIntensity: string = '轻松'
  @State formFeeling: string = ''
  @State formCalories: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(74,20,140,0.5)')
      .onClick(onClose)
  }

  @Builder addRecordModal() {
    Column() {
      this.modalOverlay(() => { this.showAddModal = false })
      Column() {
        Row() {
          Text('➕ 新增记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showAddModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 20, bottom: 12 })
        Divider().color('#F3E5F5')
        Scroll() {
          Column() {
            Text('课程名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextInput({ placeholder: '输入课程名称...' })
              .placeholderColor('#BBBBBB').fontSize(14).width('100%')
              .backgroundColor('#F3E5F5').borderRadius(12)
              .margin({ left: 20, right: 20, top: 4 })
              .onChange((v: string) => { this.formCourseName = v })
            Text('时长(分钟)').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextInput({ placeholder: '输入时长...' })
              .placeholderColor('#BBBBBB').fontSize(14).width('100%')
              .backgroundColor('#F3E5F5').borderRadius(12)
              .margin({ left: 20, right: 20, top: 4 })
              .onChange((v: string) => { this.formDuration = v })
            Text('卡路里').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextInput({ placeholder: '输入卡路里...' })
              .placeholderColor('#BBBBBB').fontSize(14).width('100%')
              .backgroundColor('#F3E5F5').borderRadius(12)
              .margin({ left: 20, right: 20, top: 4 })
              .onChange((v: string) => { this.formCalories = v })
            Text('感受').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
            TextArea({ placeholder: '练习感受...' })
              .placeholderColor('#BBBBBB').fontSize(12).width('100%').height(60)
              .backgroundColor('#F3E5F5').borderRadius(12)
              .margin({ left: 20, right: 20, top: 4 })
              .onChange((v: string) => { this.formFeeling = v })
          }
        }
        .layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#7B1FA2')
            .backgroundColor('#E1BEE7').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showAddModal = false })
          Text('保存记录').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#6A1B9A').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showAddModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').height('70%').backgroundColor('#FFFFFF').borderRadius(24)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '5%', y: '15%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder editRecordModal() {
    Column() {
      this.modalOverlay(() => { this.showEditModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 20, bottom: 12 })
        Divider().color('#F3E5F5')
        Column() {
          Text('课程名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: this.selectedRecord?.courseName ?? '' })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
          Text('时长(分钟)').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: (this.selectedRecord?.duration ?? 0).toString() })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
          Text('感受').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
          TextInput({ placeholder: this.selectedRecord?.feeling ?? '' })
            .placeholderColor('#BBBBBB').fontSize(14).width('100%')
            .backgroundColor('#F3E5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
        }
        .layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#7B1FA2')
            .backgroundColor('#E1BEE7').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showEditModal = false })
          Text('保存修改').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#6A1B9A').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').height('60%').backgroundColor('#FFFFFF').borderRadius(24)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '5%', y: '20%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder deleteRecordModal() {
    Column() {
      this.modalOverlay(() => { this.showDeleteModal = false })
      Column() {
        Text('⚠️').fontSize(48).margin({ top: 24 })
        Text('确认删除此记录?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        Text('删除后不可恢复').fontSize(13).fontColor('#EF5350').margin({ top: 4 })
        Column() {
          Text(this.selectedRecord?.courseName ?? '').fontSize(14).fontColor('#4A148C').fontWeight(FontWeight.Bold)
          Text((this.selectedRecord?.date ?? '') + ' · ' + (this.selectedRecord?.duration ?? 0) + 'min')
            .fontSize(11).fontColor('#888888').margin({ top: 4 })
        }
        .backgroundColor('#F3E5F5').borderRadius(12)
        .padding({ left: 20, right: 20, top: 12, bottom: 12 }).margin({ top: 16 })
        Row() {
          Text('取消').fontSize(14).fontColor('#7B1FA2')
            .backgroundColor('#E1BEE7').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .onClick(() => { this.showDeleteModal = false })
          Text('确认删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#EF5350').borderRadius(20)
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showDeleteModal = false })
        }
        .justifyContent(FlexAlign.Center)
        .width('100%').margin({ top: 24, bottom: 24 })
      }
      .width('80%').backgroundColor('#FFFFFF').borderRadius(24)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '10%', y: '35%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder recordItemBuilder(r: PracticeRecord) {
    Column() {
      Row() {
        Column() {
          Text(r.date).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
          Text('2026').fontSize(8).fontColor('#BA68C8')
        }.width(50).alignItems(HorizontalAlign.Center)
        Column() {
          Text(r.courseName).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          Row() {
            Text(COURSE_TYPE_CONFIG[r.type]?.label ?? r.type).fontSize(9)
              .fontColor(COURSE_TYPE_CONFIG[r.type]?.color ?? '#6A1B9A')
              .backgroundColor(COURSE_TYPE_CONFIG[r.type]?.bg ?? '#F3E5F5')
              .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6)
            Text(INTENSITY_CONFIG[r.intensity]?.label ?? r.intensity).fontSize(9)
              .fontColor(INTENSITY_CONFIG[r.intensity]?.color ?? '#66BB6A').margin({ left: 6 })
            Text('·').fontSize(9).fontColor('#CCCCCC').margin({ left: 4 })
            Text(r.feeling).fontSize(9).fontColor('#888888').margin({ left: 4 })
          }
          .margin({ top: 4 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
        Column() {
          Text(r.duration + 'min').fontSize(13).fontColor('#6A1B9A').fontWeight(FontWeight.Bold)
          Text('🔥' + r.calories).fontSize(10).fontColor('#EF5350').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%').padding(14)
      Row() {
        Text('✏️').fontSize(12)
          .onClick(() => { this.selectedRecord = r; this.showEditModal = true })
        Text('🗑️').fontSize(12).margin({ left: 16 })
          .onClick(() => { this.selectedRecord = r; this.showDeleteModal = true })
        Column().layoutWeight(1)
        Text('查看详情 >').fontSize(10).fontColor('#BA68C8')
      }
      .width('100%').padding({ left: 14, right: 14, bottom: 10 })
    }
    .width('100%').backgroundColor('#FFFFFF')
    .borderRadius(16).margin({ left: 12, right: 12, top: 6 })
    .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
  }

  build() {
    Stack() {
      Column() {
        Text('练习记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
          .width('100%').padding({ left: 16, top: 14, bottom: 8 })

        // 周练习时长柱状图
        Column() {
          Text('📊 本周练习时长').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
            .width('100%').padding({ left: 16, top: 12, bottom: 8 })
          Row() {
            ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
              Column() {
                Text(this.weekData[d] + 'min').fontSize(8).fontColor('#6A1B9A').margin({ bottom: 3 })
                Column()
                  .width(24)
                  .height((this.weekData[d] / this.maxWeek * 80).toFixed(0) + 'vp')
                  .backgroundColor(d === 4 ? '#6A1B9A' : '#E1BEE7')
                  .borderRadius({ topLeft: 4, topRight: 4 })
                Text(this.weekDays[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
            })
          }
          .padding({ left: 12, right: 12, bottom: 12 })
        }
        .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
        .margin({ left: 12, right: 12, top: 4 })
        .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

        // 统计条
        Row() {
          Column() {
            Text(getTotalDuration() + 'min').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
            Text('总时长').fontSize(9).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text(getTotalCalories() + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#EF5350')
            Text('总卡路里').fontSize(9).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text(getRecordCount() + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#66BB6A')
            Text('总记录').fontSize(9).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('+').fontSize(20).fontColor('#FFFFFF')
              .backgroundColor('#6A1B9A').width(32).height(32).borderRadius(16)
              .textAlign(TextAlign.Center)
              .onClick(() => { this.showAddModal = true })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .margin({ left: 12, right: 12, top: 6 })
        .borderRadius(16)

        // 记录列表
        Scroll() {
          Column() {
            this.recordItemBuilder(mockRecords[0])
            this.recordItemBuilder(mockRecords[1])
            this.recordItemBuilder(mockRecords[2])
            this.recordItemBuilder(mockRecords[3])
            this.recordItemBuilder(mockRecords[4])
            this.recordItemBuilder(mockRecords[5])
            this.recordItemBuilder(mockRecords[6])
            this.recordItemBuilder(mockRecords[7])
            this.recordItemBuilder(mockRecords[8])
            this.recordItemBuilder(mockRecords[9])
            this.recordItemBuilder(mockRecords[10])
            this.recordItemBuilder(mockRecords[11])
            this.recordItemBuilder(mockRecords[12])
            this.recordItemBuilder(mockRecords[13])
            this.recordItemBuilder(mockRecords[14])
            this.recordItemBuilder(mockRecords[15])
            this.recordItemBuilder(mockRecords[16])
            this.recordItemBuilder(mockRecords[17])
            this.recordItemBuilder(mockRecords[18])
            this.recordItemBuilder(mockRecords[19])
            this.recordItemBuilder(mockRecords[20])
            this.recordItemBuilder(mockRecords[21])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showAddModal) { this.addRecordModal() }
      if (this.showEditModal) { this.editRecordModal() }
      if (this.showDeleteModal) { this.deleteRecordModal() }
    }
  }
}

// ============ 身心数据页 ============
@Component
struct BodyDataContent {
  monthData: number[] = [120, 180, 90, 220, 80, 110, 250, 200, 150, 60, 30, 50, 90, 30, 180, 40, 80, 30, 120, 20, 250, 150, 60, 40, 30, 20, 90, 45, 110, 30]
  monthDays: string[] = ['1', '5', '10', '15', '20', '25', '30']
  maxMonth: number = 250

  @Builder bodyDataCard(icon: string, label: string, value: string, unit: string, color: string, bg: string, percent: number) {
    Column() {
      Row() {
        Text(icon).fontSize(20)
        Text(label).fontSize(11).fontColor('#888888').margin({ left: 6 })
        Column().layoutWeight(1)
        Text(percent + '%').fontSize(10).fontColor(color)
      }
      .width('100%')
      Row() {
        Text(value).fontSize(28).fontWeight(FontWeight.Bold).fontColor(color)
        Text(unit).fontSize(11).fontColor('#888888').margin({ left: 4, bottom: 4 })
      }
      .alignItems(VerticalAlign.Bottom).margin({ top: 8 })
      Row() {
        Column()
          .width(percent + '%')
          .height(4).backgroundColor(color).borderRadius(2)
        Column().layoutWeight(1)
      }
      .width('100%').height(4).backgroundColor(bg).borderRadius(2).margin({ top: 8 })
    }
    .width('100%').padding(14).backgroundColor('#FFFFFF')
    .borderRadius(16).shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })
  }

  @Builder flexItemBuilder(f: FlexibilityAssessment) {
    Column() {
      Row() {
        Text(f.part).fontSize(13).fontColor('#4A148C').fontWeight(FontWeight.Medium).layoutWeight(1)
        Text(FLEX_LEVEL_CONFIG[f.level]?.label ?? f.level).fontSize(11)
          .fontColor(FLEX_LEVEL_CONFIG[f.level]?.color ?? '#6A1B9A')
          .backgroundColor('#F3E5F5')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
        Text(f.score + '分').fontSize(13).fontColor('#6A1B9A').fontWeight(FontWeight.Bold).margin({ left: 10 })
      }
      .width('100%')
      Row() {
        Column()
          .width(f.score + '%')
          .height(5).backgroundColor(FLEX_LEVEL_CONFIG[f.level]?.color ?? '#6A1B9A').borderRadius(3)
        Column().layoutWeight(1)
      }
      .width('100%').height(5).backgroundColor('#F3E5F5').borderRadius(3).margin({ top: 6 })
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF')
    .borderRadius(12).margin({ left: 12, right: 12, top: 4 })
    .shadow({ radius: 2, color: '#0D6A1B9A', offsetY: 1 })
  }

  @Builder moodItemBuilder(m: MoodRecord) {
    Column() {
      Text(MOOD_CONFIG[m.mood]?.icon ?? '😊').fontSize(22)
      Text(MOOD_CONFIG[m.mood]?.label ?? m.mood).fontSize(9)
        .fontColor(MOOD_CONFIG[m.mood]?.color ?? '#6A1B9A')
      Text(m.date).fontSize(8).fontColor('#888888').margin({ top: 2 })
    }
    .width(60).padding({ top: 10, bottom: 10 }).backgroundColor('#FFFFFF')
    .borderRadius(16).margin({ left: 4, right: 4 })
    .shadow({ radius: 2, color: '#0D6A1B9A', offsetY: 1 })
    .alignItems(HorizontalAlign.Center)
  }

  build() {
    Column() {
      Text('身心数据').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4A148C')
        .width('100%').padding({ left: 16, top: 14, bottom: 8 })

      Scroll() {
        Column() {
          // 身体数据卡片 2x2
          Row() {
            Column() {
              this.bodyDataCard('💓', '心率', '68', 'bpm', '#EF5350', '#FFEBEE', 68)
            }.layoutWeight(1).margin({ left: 12, right: 6 })
            Column() {
              this.bodyDataCard('🧠', '压力指数', '32', '分', '#FFA726', '#FFF3E0', 32)
            }.layoutWeight(1).margin({ left: 6, right: 12 })
          }
          .width('100%').margin({ top: 4 })
          Row() {
            Column() {
              this.bodyDataCard('😴', '睡眠质量', '85', '%', '#6A1B9A', '#F3E5F5', 85)
            }.layoutWeight(1).margin({ left: 12, right: 6, top: 6 })
            Column() {
              this.bodyDataCard('🧘', '冥想时长', '320', 'min', '#00695C', '#E0F2F1', 64)
            }.layoutWeight(1).margin({ left: 6, right: 12, top: 6 })
          }
          .width('100%')

          // 柔韧度评估
          Text('🤸 柔韧度评估').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
            .width('100%').padding({ left: 16, top: 16, bottom: 6 })
          this.flexItemBuilder(mockFlexibility[0])
          this.flexItemBuilder(mockFlexibility[1])
          this.flexItemBuilder(mockFlexibility[2])
          this.flexItemBuilder(mockFlexibility[3])
          this.flexItemBuilder(mockFlexibility[4])
          this.flexItemBuilder(mockFlexibility[5])

          // 月度冥想时长柱状图
          Column() {
            Text('📊 月度冥想时长').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
              .width('100%').padding({ left: 16, top: 12, bottom: 8 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
                Column() {
                  Text(this.monthData[d * 5] + 'min').fontSize(7).fontColor('#6A1B9A').margin({ bottom: 3 })
                  Column()
                    .width(20)
                    .height((this.monthData[d * 5] / this.maxMonth * 70).toFixed(0) + 'vp')
                    .backgroundColor(d === 3 ? '#6A1B9A' : '#E1BEE7')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                  Text(this.monthDays[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 12, right: 12, bottom: 12 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

          // 心情记录
          Text('🌈 心情记录').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
            .width('100%').padding({ left: 16, top: 16, bottom: 6 })
          Scroll() {
            Row() {
              this.moodItemBuilder(mockMoods[0])
              this.moodItemBuilder(mockMoods[1])
              this.moodItemBuilder(mockMoods[2])
              this.moodItemBuilder(mockMoods[3])
              this.moodItemBuilder(mockMoods[4])
              this.moodItemBuilder(mockMoods[5])
              this.moodItemBuilder(mockMoods[6])
              this.moodItemBuilder(mockMoods[7])
              this.moodItemBuilder(mockMoods[8])
              this.moodItemBuilder(mockMoods[9])
              this.moodItemBuilder(mockMoods[10])
              this.moodItemBuilder(mockMoods[11])
              this.moodItemBuilder(mockMoods[12])
              this.moodItemBuilder(mockMoods[13])
              this.moodItemBuilder(mockMoods[14])
              this.moodItemBuilder(mockMoods[15])
            }
            .padding({ left: 8, right: 8 })
          }
          .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(90)
          .margin({ left: 12, right: 12 })
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ============ 我的页面 ============
@Component
struct YogaProfileContent {
  monthData: number[] = [40, 55, 30, 65, 45, 80, 35, 60, 70, 50, 30, 55, 45, 40, 60, 35, 75, 50, 45, 60, 55, 40, 30, 65, 45, 50, 35, 60, 45, 55]
  monthDays: string[] = ['1', '5', '10', '15', '20', '25', '30']
  maxMonth: number = 80

  @Builder badgeItemBuilder(b: AchievementBadge) {
    Column() {
      Stack() {
        Column()
          .width(56).height(56).borderRadius(28)
          .backgroundColor(b.isUnlocked ? '#F3E5F5' : '#ECEFF1')
        Text(b.icon).fontSize(26)
          .opacity(b.isUnlocked ? 1.0 : 0.4)
      }
      .width(56).height(56)
      Text(b.name).fontSize(9).fontColor(b.isUnlocked ? '#4A148C' : '#999999')
        .margin({ top: 4 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 用户信息卡片
          Column() {
            Row() {
              Stack() {
                Column()
                  .width(70).height(70).borderRadius(35)
                  .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
                  .shadow({ radius: 8, color: '#336A1B9A', offsetY: 0 })
                Text('🧘‍♀️').fontSize(30)
              }
              .width(70).height(70)
              Column() {
                Text('冥想修行者').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('瑜伽爱好者 · 上海').fontSize(11).fontColor('#E1BEE7').margin({ top: 3 })
                Text('连续修行 ' + getStreakDays() + ' 天').fontSize(10).fontColor('#E1BEE7').margin({ top: 2 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
            }
            .width('100%').padding(16)
          }
          .width('100%')
          .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#4A148C', 1]] })
          .borderRadius(24).margin({ left: 12, right: 12, top: 10 })
          .shadow({ radius: 8, color: '#336A1B9A', offsetY: 4 })

          // 练习统计
          Row() {
            Column() {
              Text(getTotalDuration() + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
              Text('总时长(min)').fontSize(9).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            Column() {
              Text(getRecordCount() + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#66BB6A')
              Text('总课程').fontSize(9).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            Column() {
              Text(getStreakDays() + '').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFA726')
              Text('连续天数').fontSize(9).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            Column() {
              Text(getCompletionRate() + '%').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#EF5350')
              Text('完成率').fontSize(9).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

          // 成就徽章
          Column() {
            Text('🏆 成就徽章').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
              .width('100%').padding({ left: 16, top: 12, bottom: 8 })
            Row() {
              this.badgeItemBuilder(mockBadges[0])
              this.badgeItemBuilder(mockBadges[1])
              this.badgeItemBuilder(mockBadges[2])
              this.badgeItemBuilder(mockBadges[3])
            }
            .width('100%')
            Row() {
              this.badgeItemBuilder(mockBadges[4])
              this.badgeItemBuilder(mockBadges[5])
              this.badgeItemBuilder(mockBadges[6])
              this.badgeItemBuilder(mockBadges[7])
            }
            .width('100%')
            Row() {
              this.badgeItemBuilder(mockBadges[8])
              this.badgeItemBuilder(mockBadges[9])
              Column().layoutWeight(1)
              Column().layoutWeight(1)
            }
            .width('100%')
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

          // 月度练习柱状图
          Column() {
            Text('📊 月度练习时长').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4A148C')
              .width('100%').padding({ left: 16, top: 12, bottom: 8 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
                Column() {
                  Text(this.monthData[d * 5] + 'min').fontSize(7).fontColor('#6A1B9A').margin({ bottom: 3 })
                  Column()
                    .width(20)
                    .height((this.monthData[d * 5] / this.maxMonth * 70).toFixed(0) + 'vp')
                    .backgroundColor(d === 5 ? '#6A1B9A' : '#E1BEE7')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                  Text(this.monthDays[d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 12, right: 12, bottom: 12 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#0D6A1B9A', offsetY: 2 })

          Text('🪷 瑜伽冥想 · v1.0 · 2026').fontSize(10).fontColor('#BA68C8')
            .alignSelf(ItemAlign.Center).margin({ top: 16, bottom: 16 })
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}


14.4 改进方向

尽管应用在功能完整性和视觉设计上已经达到了较高水准,但仍有一些可以改进的方向:

在这里插入图片描述

数据动态化:当前所有数据都是硬编码的模拟数据,实际应用中需要接入后端API或本地数据库,实现数据的持久化存储和动态加载。

筛选逻辑实现:冥想页面的类型筛选目前仅更新了selectedType状态,但没有实现实际的数据过滤逻辑。需要根据筛选值对mockMeditations数组进行过滤。

表单验证:编辑和新增弹窗中的表单目前没有输入验证逻辑,如时长应为正数、课程名称不应为空等。

动画效果:当前应用缺少过渡动画,如弹窗的淡入淡出、Tab切换的滑动效果等。添加适当的动画可以进一步提升用户体验。

无障碍支持:表情符号作为图标对于屏幕阅读器等辅助技术不够友好,可以考虑添加语义化的无障碍标签。

总的来说,这款瑜伽冥想应用在技术架构、交互设计和视觉呈现上都展现了较高的水准,其薰衣草紫禅意风格的设计语言为健康类应用提供了一个优秀的视觉范本。通过声明式UI框架的响应式特性和参数化构建器模式,应用以相对简洁的代码实现了丰富的功能和精致的界面,值得开发者深入学习和借鉴。

更多推荐