一、背景与意义

随着全民健身浪潮的持续升温,私人教练这一职业已经从传统的健身房陪练角色,逐步演变为一项需要精细化管理、数据化运营的综合性服务岗位。一名成熟的私人教练,日常需要同时管理数十名学员的训练计划、跟踪每位学员的体测进度、安排每天多达十几节的私教课程,还需要对自身的月度收入、课时分布等经营数据进行复盘分析。在这个过程中,如果没有一套高效的数字化工具辅助,教练很容易陷入信息混乱、排课冲突、学员进度遗忘等困境,最终直接影响教学质量和客户满意度。

在这里插入图片描述

传统的健身管理方式往往依赖纸质排课表、微信沟通记录以及教练个人的记忆。这种方式在学员数量较少时尚可勉强应对,但当学员规模超过二十人、每日排课超过八节之后,就会出现大量管理瓶颈:教练无法快速查看某位学员的剩余课时,难以直观掌握本周的训练强度分布,也无法对月度收入进行科学统计。更关键的是,学员的体态矫正、增肌减脂等训练目标往往需要长期跟踪,缺乏系统化的数据沉淀会导致训练方案碎片化,影响训练效果的可量化评估。

正是在这样的行业痛点之下,一套专为私人教练量身打造的排课管理系统显得尤为重要。本文所解析的应用,正是一个面向移动端的健身教练排课管理平台。它以"教练"为核心用户角色,围绕"排课、学员、课程、数据、个人"五大业务模块,构建了一个功能闭环完整、交互体验流畅、视觉风格鲜明的移动端管理工具。

从技术层面来看,该应用采用了 HarmonyOS(鸿蒙操作系统)生态中的 ArkTS 声明式 UI 开发范式。ArkTS 是在 TypeScript 基础上扩展而来的应用开发语言,它保留了 TypeScript 的类型系统优势,同时引入了 @Entry@Component@State@Builder@Observed 等装饰器,使得开发者能够以声明式的方式描述界面结构,并通过状态驱动的机制实现 UI 的自动更新。这种开发范式相比传统的命令式 UI 编程,具有代码可读性高、状态管理清晰、渲染性能优异等显著优势。

该应用的整体设计采用了"深色霓虹风格"的视觉语言。主色调以深蓝近黑为底(#1A1A2E),辅以荧光绿(#00FF87)作为强调色,搭配金黄、橙红、天蓝等点缀色,营造出一种科技感十足、专业且充满活力的界面氛围。这种配色方案不仅贴合健身运动的活力主题,而且在深色背景下具有出色的对比度和信息辨识度,能够有效降低教练在长时间使用过程中的视觉疲劳。

从架构设计角度,该应用遵循了"配置驱动、组件分离、状态管理"三大设计原则。全局静态配置与 Mock 数据集中定义在文件顶部,便于统一维护和替换;五个核心业务模块各自封装为独立的 @Component 组件,职责清晰、互不耦合;主入口组件通过 @State 管理当前激活的 Tab 页签,配合条件渲染实现页面切换。这种架构使得后续扩展新功能、对接真实后端 API 时,改动范围可控、风险可量。

下面,我们将从代码的第一行开始,逐段、逐行地深入解析这套系统的实现细节。

二、接口定义:类型契约的基石

在任何健壮的应用中,数据类型的定义都是最基础也是最关键的一环。ArkTS 作为 TypeScript 的超集,天然支持 interface 关键字来定义数据结构契约。本应用在文件最顶部定义了五个核心接口,分别对应系统中的五类关键数据结构。

2.1 排课配置接口

interface ScheduleConfig {
  courseTypes: string[]
  statusOptions: string[]
  intensityLevels: string[]
  pageSize: number
}

在这里插入图片描述

这个接口定义了排课系统的全局配置结构。下面对每个字段进行详细说明:

  • courseTypes: string[]:课程类型数组,用于在排课表单中提供课程类型的可选项,如力量训练、有氧运动、HIIT 等。使用字符串数组而非枚举,是为了在表单组件中以动态列表的方式渲染下拉选项,便于后续扩展新的课程类型而无需修改类型定义。
  • statusOptions: string[]:排课状态选项数组,定义了一节课程可能处于的所有状态,包括"待开始"“进行中”“已完成”“已取消”。这四种状态覆盖了课程生命周期的完整闭环。
  • intensityLevels: string[]:训练强度等级数组,从"低强度"到"极限"共四个级别,用于课程库中对每门课程的强度进行分级标记。
  • pageSize: number:分页大小,用于长列表的分页加载策略。虽然当前 Mock 数据量较小,但该字段的预留体现了开发者对未来对接真实后端时数据量增长的预判。

2.2 教练档案接口

interface CoachProfile {
  name: string
  avatar: string
  title: string
  gym: string
  experience: number
  totalStudents: number
  totalCourses: number
  monthlyIncome: number
}

在这里插入图片描述

这个接口描述了教练的个人档案信息,是"我的"页面的核心数据结构:

  • name:教练姓名,在顶部标题栏和个人主页中展示。
  • avatar:头像,这里使用 Emoji 表情符号作为头像占位,体现了轻量化的设计思路,避免了图片资源的网络依赖。
  • title:职业头衔,如"金牌私教 / 营养师",用于在个人主页展示教练的专业资质。
  • gym:所属健身房名称,帮助教练快速识别自己的工作场所。
  • experience:从业年限(年),是衡量教练资历的核心指标。
  • totalStudents:当前在训学员总数,反映教练的业务规模。
  • totalCourses:累计授课总节数,体现教练的教学经验积累。
  • monthlyIncome:月度收入(元),用于个人主页的统计卡片展示。

2.3 图表数据接口

interface WeekChartItem {
  day: string
  hours: number
}

在这里插入图片描述

这个接口定义了"每周课时分布"柱状图中的单个数据项。day 字段存储星期几的中文标签(如"周一"),hours 字段存储该天的授课时长(小时)。这个简洁的结构足以支撑柱状图的渲染需求。

interface ProgressItem {
  name: string
  progress: number
  change: string
}

在这里插入图片描述

这个接口定义了"学员进步排行"中的单条记录。name 是学员姓名,progress 是当前进度百分比,change 是相比上期的变化幅度(如"+8%"),用于在进度条旁边以高亮颜色展示进步趋势。

interface IncomeStat {
  week: string
  income: number
}

在这里插入图片描述

这个接口定义了"月度收入统计"中的单周数据。week 是周次标签(如"第1周"),income 是该周的收入金额。通过四周的数据,教练可以直观地看到月度收入的波动趋势。

三、数据模型:@Observed 可观察对象

在 ArkTS 的状态管理体系中,@Observed 装饰器用于标记一个类为"可观察对象"。被 @Observed 修饰的类,其实例的属性变化可以被框架自动追踪。当这些属性被 @State@Prop@Link 等状态变量引用时,属性的变化会触发关联的 UI 组件自动刷新。本应用定义了三个 @Observed 数据模型,分别对应排课、学员和课程三大核心业务实体。

3.1 排课项模型

@Observed
class ScheduleItem {
  id: number = 0
  time: string = ''
  studentName: string = ''
  courseType: string = ''
  status: string = ''
  duration: number = 60
  note: string = ''
}

在这里插入图片描述

ScheduleItem 是排课表中单节课程的完整数据模型:

  • id:唯一标识符,用于列表渲染时的 ForEach 键值生成以及删除操作时的精确匹配。
  • time:时间段字符串,格式为"HH:MM-HH:MM"(如"09:00-10:00"),在卡片中通过 split('-') 方法拆分为开始时间和结束时间分别展示。
  • studentName:学员姓名,用于标识本节课对应的学员。
  • courseType:课程类型,如"力量训练""瑜伽"等,与课程库中的课程名称对应。
  • status:课程状态,取值范围为 CONFIG.statusOptions 中定义的四种状态。
  • duration:课程时长(分钟),默认值为 60,对应标准的一小时私教课。
  • note:训练备注,教练记录本节课的具体训练内容,如"深蹲日-加重5kg"。

每个字段都赋予了默认值,这是 ArkTS 中 @Observed 类的推荐实践,确保在通过 new ScheduleItem() 创建实例时,所有属性都有合法的初始值,避免 undefined 导致的渲染异常。

3.2 学员信息模型

@Observed
class StudentInfo {
  id: number = 0
  name: string = ''
  avatar: string = '💪'
  goal: string = ''
  progress: number = 0
  remainSessions: number = 0
  totalSessions: number = 0
  phone: string = ''
  level: string = ''
}

在这里插入图片描述

StudentInfo 是学员管理模块的核心数据模型,字段含义如下:

  • avatar 默认值为 '💪',这里使用 Emoji 作为学员头像,每个学员在 Mock 数据中被分配了不同的 Emoji,如举重、冥想、跑步等,形象地反映了学员的训练偏好。
  • goal:训练目标,如"增肌10kg"“减脂15kg”"体态矫正"等,是教练为学员制定训练计划的核心依据。
  • progress:目标完成进度(百分比),在学员卡片中以环形进度条和线性进度条两种形式可视化展示。
  • remainSessionstotalSessions:分别表示剩余课时和总课时,教练通过这两个字段判断是否需要提醒学员续费。
  • phone:联系电话,在卡片中以脱敏格式展示(如"138****1111"),保护学员隐私。
  • level:训练等级,分为"初级"“中级”"高级"三档,不同等级用不同颜色的标签区分。

3.3 课程类型模型

@Observed
class CourseType {
  id: number = 0
  name: string = ''
  duration: number = 60
  intensity: string = ''
  calories: number = 0
  target: string = ''
  icon: string = '🏋️'
}

在这里插入图片描述

CourseType 描述了课程库中每种课程的属性:

  • duration:默认时长 60 分钟。
  • intensity:强度等级,取值来自 CONFIG.intensityLevels
  • calories:预估消耗热量(千卡),帮助学员了解课程的能量消耗水平。
  • target:适用人群描述,如"增肌塑形人群""久坐办公人群"等,是教练为学员推荐课程的重要参考。
  • icon:课程图标,默认值为举重 Emoji,每种课程在 Mock 数据中都有专属的 Emoji 图标。

四、静态配置与 Mock 数据:开发期的数据基石

在应用开发的初期阶段,后端 API 往往尚未就绪。为了保证前端开发的持续推进,开发者通常会准备一套 Mock 数据来模拟真实数据。本应用将所有静态配置和 Mock 数据集中定义在文件中部,形成了一个清晰的数据层。

4.1 全局配置

const CONFIG: ScheduleConfig = {
  courseTypes: ['力量训练', '有氧运动', 'HIIT', '瑜伽', '搏击', '拉伸恢复', '功能性训练', '体态矫正'],
  statusOptions: ['待开始', '进行中', '已完成', '已取消'],
  intensityLevels: ['低强度', '中强度', '高强度', '极限'],
  pageSize: 20
}

在这里插入图片描述

这段代码定义了排课系统的全局配置常量 CONFIG,类型为前文定义的 ScheduleConfig 接口。courseTypes 数组列举了八种课程类型,涵盖了力量、有氧、高强度间歇、柔韧、搏击、恢复、功能性、体态八大训练维度,基本覆盖了现代健身房私教课程的全部分类。statusOptions 定义了课程状态的四级流转体系。intensityLevels 从低到高分为四档。pageSize 设为 20,表示列表每次加载 20 条数据。

4.2 教练档案

const COACH_PROFILE: CoachProfile = {
  name: '王猛教练',
  avatar: '🧑‍🏫',
  title: '金牌私教 / 营养师',
  gym: '闪电健身工坊',
  experience: 8,
  totalStudents: 42,
  totalCourses: 1280,
  monthlyIncome: 38600
}

这里定义了一位虚拟教练的完整档案。从数据中可以看出,这是一位拥有 8 年从业经验的资深教练,目前在训学员 42 人,累计授课 1280 节,月收入 38600 元。这些数据将在"我的"页面和数据概览页面中被引用展示。

4.3 图表数据

const WEEK_CHART: WeekChartItem[] = [
  { day: '周一', hours: 5 },
  { day: '周二', hours: 7 },
  { day: '周三', hours: 4 },
  { day: '周四', hours: 8 },
  { day: '周五', hours: 6 },
  { day: '周六', hours: 9 },
  { day: '周日', hours: 3 }
]

这是"每周课时分布"柱状图的数据源,记录了教练一周内每天的授课时长。从数据可以看出,周六最为繁忙(9 小时),周日最清闲(3 小时),这符合健身行业的典型客流规律——周末是会员训练的高峰期。

const PROGRESS_DATA: ProgressItem[] = [
  { name: '张强', progress: 92, change: '+8%' },
  { name: '李娜', progress: 85, change: '+5%' },
  { name: '陈刚', progress: 78, change: '+12%' },
  { name: '王芳', progress: 71, change: '+3%' },
  { name: '刘洋', progress: 65, change: '+6%' }
]

这是"学员进步排行"的数据源,按进度从高到低排列了五位学员。其中陈刚虽然排名第三,但进步幅度最大(+12%),这种数据设计体现了系统对"进步幅度"而非"绝对排名"的关注。

const INCOME_STATS: IncomeStat[] = [
  { week: '第1周', income: 8200 },
  { week: '第2周', income: 9600 },
  { week: '第3周', income: 10800 },
  { week: '第4周', income: 10000 }
]

这是"月度收入统计"的数据源。四周收入总和为 38600 元,与教练档案中的 monthlyIncome 字段一致,体现了数据的一致性设计。

4.4 排课 Mock 数据

const mockSchedules: ScheduleItem[] = [
  { id: 1, time: '06:00-07:00', studentName: '张强', courseType: '力量训练', status: '已完成', duration: 60, note: '深蹲日-加重5kg' },
  { id: 2, time: '07:30-08:30', studentName: '李娜', courseType: '瑜伽', status: '已完成', duration: 60, note: '核心流序列' },
  { id: 3, time: '09:00-10:00', studentName: '陈刚', courseType: 'HIIT', status: '已完成', duration: 60, note: '燃脂循环4组' },
  { id: 4, time: '10:30-11:30', studentName: '王芳', courseType: '体态矫正', status: '已完成', duration: 60, note: '圆肩驼背改善' },
  { id: 5, time: '12:00-13:00', studentName: '刘洋', courseType: '功能性训练', status: '已完成', duration: 60, note: '平衡协调训练' },
  { id: 6, time: '14:00-15:00', studentName: '赵敏', courseType: '搏击', status: '已完成', duration: 60, note: '组合拳法练习' },
  { id: 7, time: '15:30-16:30', studentName: '孙伟', courseType: '力量训练', status: '进行中', duration: 60, note: '胸推日' },
  { id: 8, time: '17:00-18:00', studentName: '周杰', courseType: '有氧运动', status: '进行中', duration: 60, note: '跑步机间歇' },
  { id: 9, time: '18:30-19:30', studentName: '吴静', courseType: '拉伸恢复', status: '待开始', duration: 60, note: '筋膜放松' },
  { id: 10, time: '19:45-20:45', studentName: '郑浩', courseType: 'HIIT', status: '待开始', duration: 60, note: 'Tabata训练' },
  { id: 11, time: '21:00-21:30', studentName: '黄磊', courseType: '体态矫正', status: '待开始', duration: 30, note: '骨盆前倾调整' },
  { id: 12, time: '06:00-07:00', studentName: '林峰', courseType: '力量训练', status: '待开始', duration: 60, note: '背阔肌专项' },
  { id: 13, time: '07:30-08:30', studentName: '何琪', courseType: '瑜伽', status: '待开始', duration: 60, note: '阴瑜伽放松' },
  { id: 14, time: '09:00-10:00', studentName: '曾柔', courseType: '搏击', status: '待开始', duration: 60, note: '防御技巧' },
  { id: 15, time: '10:30-11:30', studentName: '高翔', courseType: '功能性训练', status: '待开始', duration: 60, note: '敏捷梯训练' },
  { id: 16, time: '12:00-13:00', studentName: '马超', courseType: 'HIIT', status: '待开始', duration: 45, note: '短时高强度' },
  { id: 17, time: '14:00-15:00', studentName: '许晴', courseType: '体态矫正', status: '待开始', duration: 60, note: 'XO型腿改善' },
  { id: 18, time: '15:30-16:30', studentName: '邓超', courseType: '力量训练', status: '待开始', duration: 60, note: '腿部超级组' }
]

mockSchedules 数组包含了 18 条排课记录,覆盖了一整天的课程安排。数据设计上,前 6 条状态为"已完成"(上午的课程),第 7、8 条为"进行中"(下午正在进行的课程),第 9 至 18 条为"待开始"(晚间及次日的课程)。这种状态分布能够充分展示不同状态标签的颜色区分效果。每条记录的 note 字段都填写了具体的训练内容,如"深蹲日-加重5kg""圆肩驼背改善"等,使 Mock 数据具有极强的真实感。

4.5 学员与课程 Mock 数据

学员 Mock 数据(mockStudents)包含了 16 位学员的完整信息,每位学员都有不同的训练目标、进度、课时余额和等级。课程 Mock 数据(mockCourses)则定义了 12 种课程类型,涵盖力量、有氧、HIIT、瑜伽、搏击、拉伸、功能性、体态矫正、核心、TRX、战绳、普拉提等丰富的训练形式,每种课程都标注了时长、强度、热量消耗和适用人群。由于篇幅关系,这里不再逐条列举,但需要强调的是,这些 Mock 数据的字段值经过精心设计,能够充分触发各种条件分支和颜色映射逻辑,是测试 UI 渲染效果的重要保障。

五、枚举与纯函数:业务逻辑的抽象层

5.1 Tab 枚举定义

enum FitTab {
  SCHEDULE = 0,
  STUDENT = 1,
  COURSE = 2,
  DATA = 3,
  PROFILE = 4
}

FitTab 枚举定义了底部导航栏的五个页签,分别对应排课、学员、课程、数据、我的。使用枚举而非魔法数字(magic number),使得代码在条件判断时具有自文档化的可读性。枚举值从 0 开始递增,与数组索引天然对应,便于在 ForEach 循环中使用。

5.2 颜色映射纯函数

颜色映射是本应用视觉设计的关键环节。三个纯函数分别负责将业务状态映射为对应的霓虹色值:

function getScheduleStatusColor(status: string): string {
  if (status === '待开始') {
    return '#00FF87'
  } else if (status === '进行中') {
    return '#FFD600'
  } else if (status === '已完成') {
    return '#7C7C8A'
  } else if (status === '已取消') {
    return '#FF4757'
  }
  return '#7C7C8A'
}

getScheduleStatusColor 函数接收课程状态字符串,返回对应的颜色值。"待开始"映射为荧光绿(#00FF87),表示即将开始的活力感;"进行中"映射为金黄色(#FFD600),表示当前活跃的警示感;"已完成"映射为灰色(#7C7C8A),表示已结束的沉静感;"已取消"映射为红色(#FF4757),表示异常的警示感。函数末尾的 return '#7C7C8A' 是兜底逻辑,确保未知状态也有默认颜色。

function getIntensityColor(intensity: string): string {
  if (intensity === '极限') {
    return '#FF4757'
  } else if (intensity === '高强度') {
    return '#FF8C00'
  } else if (intensity === '中强度') {
    return '#00FF87'
  }
  return '#4FC3F7'
}

getIntensityColor 函数将训练强度映射为颜色。"极限"为红色、"高强度"为橙色、"中强度"为荧光绿,低强度(兜底)为天蓝色。颜色从暖到冷的变化,直观地传达了强度从高到低的递减关系,符合用户对色彩的直觉认知。

function getLevelColor(level: string): string {
  if (level === '高级') {
    return '#00FF87'
  } else if (level === '中级') {
    return '#FFD600'
  }
  return '#4FC3F7'
}

getLevelColor 函数将学员等级映射为颜色。"高级"为荧光绿、"中级"为金黄、初级(兜底)为天蓝色。这组颜色映射让教练在浏览学员列表时,能够通过标签颜色快速识别学员的训练水平。

5.3 数据格式化与计算函数

function formatIncome(amount: number): string {
  return `¥${amount.toLocaleString()}`
}

formatIncome 函数将数字金额格式化为带人民币符号和千位分隔符的字符串。例如,38600 会被格式化为"¥38,600"。toLocaleString() 是 JavaScript/TypeScript 内置方法,会根据运行环境的 locale 自动添加千位分隔符。

function calcMaxHours(): number {
  let maxVal: number = 0
  for (let item of WEEK_CHART) {
    if (item.hours > maxVal) {
      maxVal = item.hours
    }
  }
  return maxVal
}

calcMaxHours 函数遍历 WEEK_CHART 数组,找出本周最高的单日课时数。这个最大值用于柱状图中计算各柱子高度的相对比例——每根柱子的高度为 (item.hours / maxVal) * 100,确保最高的柱子占满 100 的高度单位,其余柱子按比例缩放。

function calcMaxIncome(): number {
  let maxVal: number = 0
  for (let item of INCOME_STATS) {
    if (item.income > maxVal) {
      maxVal = item.income
    }
  }
  return maxVal
}

calcMaxIncome 函数与 calcMaxHours 逻辑相同,只是作用于收入数据,用于计算月度收入柱状图的柱高比例。

function calcTotalWeekHours(): number {
  let total: number = 0
  for (let item of WEEK_CHART) {
    total += item.hours
  }
  return total
}

calcTotalWeekHours 函数累加一周内所有天的课时数,返回本周总课时。这个值在数据概览页面的"本周课时"统计卡片中展示。根据 Mock 数据,结果为 5+7+4+8+6+9+3=42 小时。

function calcTotalMonthIncome(): number {
  let total: number = 0
  for (let item of INCOME_STATS) {
    total += item.income
  }
  return total
}

calcTotalMonthIncome 函数累加四周的收入数据,返回本月总收入。这个值在数据概览页面的"本月收入"统计卡片中展示,结果为 8200+9600+10800+10000=38600 元。

值得注意的是,这六个函数全部是"纯函数"——它们不依赖外部可变状态,相同的输入始终产生相同的输出,且不产生副作用。这种设计使得函数易于测试、易于复用,是函数式编程思想在实际项目中的良好实践。

六、主入口组件:应用骨架的搭建

6.1 组件声明与状态定义

@Entry
struct CoachSchedulePage {
  @State activeTab: FitTab = FitTab.SCHEDULE

  private tabItems: FitTabItem[] = [
    { tab: FitTab.SCHEDULE, icon: '🗓️', label: '课表' },
    { tab: FitTab.STUDENT, icon: '💪', label: '学员' },
    { tab: FitTab.COURSE, icon: '🏋️', label: '课程' },
    { tab: FitTab.DATA, icon: '📊', label: '数据' },
    { tab: FitTab.PROFILE, icon: '👤', label: '我的' }
  ]

@Entry 装饰器标记 CoachSchedulePage 为应用的入口组件,即整个页面的根节点。@State activeTab 是核心状态变量,记录当前激活的 Tab 页签,初始值为 FitTab.SCHEDULE(排课页)。当 activeTab 的值发生变化时,框架会自动触发 contentArea 方法的重新执行,从而切换显示的内容区域。

tabItems 是一个私有数组,定义了底部导航栏的五个按钮配置。每个元素包含 tab(枚举值)、icon(Emoji 图标)、label(中文标签)三个字段。使用数组而非硬编码,使得底部导航栏可以通过 ForEach 动态渲染,后续增删 Tab 只需修改数组即可。

6.2 底部 Tab 按钮构建器

@Builder
bottomTabItem(tab: FitTab, icon: string, label: string) {
  Column() {
    Text(icon)
      .fontSize(22)
    Text(label)
      .fontSize(10)
      .fontColor(this.activeTab === tab ? '#00FF87' : '#7C7C8A')
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.activeTab = tab
  })
}

@Builder 装饰器定义了一个可复用的 UI 构建方法。bottomTabItem 方法接收三个参数,构建一个底部 Tab 按钮:

  • 外层 Column 容器使用 layoutWeight(1) 使五个按钮在 Row 中均分宽度,justifyContent(FlexAlign.Center) 使内容垂直居中。
  • Text(icon) 渲染 Emoji 图标,字号 22。
  • Text(label) 渲染中文标签,字号 10。关键在于 fontColor 的三目运算:当当前激活的 Tab 等于该按钮对应的 Tab 时,文字颜色为荧光绿;否则为灰色。这就是 Tab 切换时的"高亮"效果实现原理。
  • onClick 回调将 this.activeTab 设置为该按钮对应的枚举值,触发状态更新和 UI 刷新。

6.3 内容区域路由

@Builder
contentArea() {
  if (this.activeTab === FitTab.SCHEDULE) {
    ScheduleTabContent()
  } else if (this.activeTab === FitTab.STUDENT) {
    StudentTabContent()
  } else if (this.activeTab === FitTab.COURSE) {
    CourseTabContent()
  } else if (this.activeTab === FitTab.DATA) {
    DataTabContent()
  } else {
    ProfileTabContent()
  }
}

contentArea 方法是整个应用的路由分发器。通过一系列 if-else 条件判断,根据 activeTab 的当前值,渲染对应的子组件。这种基于条件渲染的页面切换方式,是 ArkTS 中实现简单 Tab 导航的常见模式。每次 activeTab 变化时,框架会重新执行此方法,销毁旧的子组件树并创建新的子组件树。

6.4 主布局构建

build() {
  Column() {
    Row() {
      Text('⚡ COACH PRO')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#00FF87')
      Column() {
      }
      .layoutWeight(1)
      Text(COACH_PROFILE.name)
        .fontSize(12)
        .fontColor('#7C7C8A')
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor('#16213E')

    Column() {
      this.contentArea()
    }
    .layoutWeight(1)
    .width('100%')

    Row() {
      ForEach(this.tabItems, (item: FitTabItem) => {
        this.bottomTabItem(item.tab, item.icon, item.label)
      }, (item: FitTabItem) => item.label)
    }
    .width('100%')
    .height(56)
    .backgroundColor('#16213E')
    .border({ width: { top: 1 }, color: '#0F3460' })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#1A1A2E')
}

build 方法构建了应用的整体布局结构,从外到内分为三层:

  • 最外层 Column 占满整个屏幕,背景色为深色基底 #1A1A2E
  • 顶部 Row 是标题栏,高度 56,背景色 #16213E。左侧是应用名称"⚡ COACH PRO",使用荧光绿色和粗体;中间是一个空的 Column 占位(layoutWeight(1)),将右侧内容推到最右;右侧显示教练姓名。
  • 中间的 Column 使用 layoutWeight(1) 占据剩余空间,内部调用 this.contentArea() 渲染当前 Tab 的内容。
  • 底部 Row 是导航栏,高度 56,背景色与标题栏一致。通过 ForEach 遍历 tabItems 数组,为每个 Tab 项调用 bottomTabItem 构建器。ForEach 的第三个参数是键值生成函数,使用 item.label 作为唯一键。底部还添加了 border 属性,仅在顶部绘制 1 像素的边框线,颜色为深蓝色,起到视觉分隔的作用。
interface FitTabItem {
  tab: FitTab
  icon: string
  label: string
}

这个接口定义了 Tab 项的数据结构,在主组件下方声明,供 tabItems 数组使用。

七、排课 Tab 组件:核心业务模块

排课 Tab 是整个应用的核心模块,承载了课程的展示、新增、编辑、删除等完整 CRUD 功能。

7.1 状态定义

@Component
struct ScheduleTabContent {
  @State scheduleList: ScheduleItem[] = mockSchedules
  @State showModal: boolean = false
  @State modalMode: string = 'add'
  @State editingItem: ScheduleItem = new ScheduleItem()
  • @Component 装饰器声明这是一个可复用的自定义组件。
  • @State scheduleList:排课列表数据,初始值为 mockSchedules。当此数组发生变化(如新增、删除)时,UI 会自动刷新。
  • @State showModal:控制弹窗显示的布尔值,初始为 false(隐藏)。
  • @State modalMode:弹窗模式,'add' 表示新增,'edit' 表示编辑。
  • @State editingItem:当前正在编辑的排课项实例,初始为一个空的 ScheduleItem 对象。

7.2 排课卡片构建器

@Builder
scheduleCard(item: ScheduleItem) {
  Row() {
    Column() {
      Text(item.time.split('-')[0])
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#00FF87')
      Text(item.time.split('-')[1])
        .fontSize(12)
        .fontColor('#7C7C8A')
      Text(item.duration + 'min')
        .fontSize(10)
        .fontColor('#7C7C8A')
        .margin({ top: 4 })
    }
    .width(70)
    .alignItems(HorizontalAlign.Center)

这是排课卡片的左侧时间区域。item.time 的格式为"HH:MM-HH:MM",通过 split('-') 方法以连字符为分隔符拆分为数组。[0] 取开始时间(如"09:00"),以荧光绿粗体展示;[1] 取结束时间(如"10:00"),以灰色小字展示。下方显示课程时长(如"60min")。整个左侧列宽固定 70,内容居中对齐。

    Column() {
      Row() {
        Text(item.studentName)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
        Text(item.courseType)
          .fontSize(10)
          .fontColor('#00FF87')
          .border({ width: 1, color: '#00FF87' })
          .padding({ left: 6, right: 6, top: 1, bottom: 1 })
          .borderRadius(4)
          .margin({ left: 8 })
        Column() {
        }
        .layoutWeight(1)
        Text(item.status)
          .fontSize(10)
          .fontColor(getScheduleStatusColor(item.status))
          .backgroundColor(getScheduleStatusColor(item.status).replace('#', 'rgba(').slice(0, -1) + ',0.15)')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8)
      }
      .width('100%')

右侧内容区的第一行展示学员姓名、课程类型标签和状态标签。课程类型标签使用荧光绿描边的小标签样式。中间的空 ColumnlayoutWeight(1))将状态标签推到最右侧。状态标签的颜色和背景色都通过 getScheduleStatusColor 函数动态获取——文字使用纯色,背景使用同色 15% 透明度的半透明色块。

这里有一个巧妙的字符串处理:getScheduleStatusColor(item.status).replace('#', 'rgba(').slice(0, -1) + ',0.15)'。以 #00FF87 为例,先替换 #rgba( 得到 rgba(00FF87,再 slice(0, -1) 去掉最后一个字符 7 得到 rgba(00FF8,最后拼接 ,0.15) 得到 rgba(00FF8,0.15)。这个实现虽然在 CSS 语法上并不完全规范,但体现了开发者尝试用纯字符串操作实现透明度叠加的思路。

      Text(item.note)
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 6 })

      Row() {
        Text('编辑')
          .fontSize(11)
          .fontColor('#00FF87')
          .margin({ right: 16 })
          .onClick(() => {
            this.editingItem = item
            this.modalMode = 'edit'
            this.showModal = true
          })
        Text('删除')
          .fontSize(11)
          .fontColor('#FF4757')
          .onClick(() => {
            this.scheduleList = this.scheduleList.filter((s: ScheduleItem) => s.id !== item.id)
          })
      }
      .margin({ top: 6 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    .margin({ left: 12 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#16213E')
  .borderRadius(12)
  .border({ width: 1, color: '#0F3460' })
  .margin({ bottom: 8 })
}

卡片中间展示训练备注(灰色小字),底部是"编辑"和"删除"两个操作按钮。"编辑"按钮的 onClick 回调将当前项赋值给 editingItem,设置模式为 'edit',并显示弹窗。"删除"按钮通过 filter 方法过滤掉当前项的 id,生成新数组赋值给 scheduleList,触发 UI 刷新。

整个卡片使用深蓝色背景(#16213E),圆角 12,深蓝色边框,底部留 8 的间距,形成卡片之间的视觉分隔。

7.3 排课页面布局

build() {
  Stack({ alignContent: Alignment.TopStart }) {
    Column() {
      Row() {
        Column() {
          Text('今日排课')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
          Text(this.scheduleList.length + '节课 | ' + this.scheduleList.filter((s: ScheduleItem) => s.status === '已完成').length + '节已完成')
            .fontSize(11)
            .fontColor('#7C7C8A')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('+ 新增')
          .fontSize(13)
          .fontColor('#1A1A2E')
          .backgroundColor('#00FF87')
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .borderRadius(20)
          .onClick(() => {
            this.editingItem = new ScheduleItem()
            this.modalMode = 'add'
            this.showModal = true
          })
      }

页面使用 Stack 作为根容器,alignContent 设为 TopStart,使内容从左上角开始排列。Stack 的作用是实现弹窗的层叠效果——弹窗会覆盖在主内容之上。

顶部标题栏左侧显示"今日排课"标题和统计信息(如"18节课 | 6节已完成")。统计信息通过 this.scheduleList.length 获取总节数,通过 filter 方法筛选"已完成"状态的课程数量。右侧是"+ 新增"按钮,使用荧光绿背景、深色文字的胶囊形按钮样式。

      Scroll() {
        Column() {
          ForEach(this.scheduleList, (item: ScheduleItem) => {
            this.scheduleCard(item)
          }, (item: ScheduleItem) => item.id.toString())
        }
        .padding({ left: 16, right: 16, bottom: 20 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)

课程列表使用 Scroll 组件包裹,使其在内容超出屏幕高度时可滚动浏览。scrollBar(BarState.Off) 隐藏滚动条,保持界面整洁。ForEach 遍历 scheduleList,为每条记录调用 scheduleCard 构建器,键值使用 item.id.toString()

7.4 新增/编辑弹窗

弹窗部分是排课模块中最复杂的 UI 结构,包含遮罩层和表单两个部分:

      if (this.showModal) {
        Column() {
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(0,0,0,0.7)')
        .onClick(() => {
          this.showModal = false
        })

showModaltrue 时,首先渲染一个全屏的半透明黑色遮罩层(70% 不透明度)。点击遮罩层会关闭弹窗。

        Column() {
          Text(this.modalMode === 'add' ? '⚡ 新增排课' : '⚡ 编辑排课')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00FF87')
            .margin({ bottom: 16 })

          Column() {
            Text('时间段')
              .fontSize(12)
              .fontColor('#7C7C8A')
            TextInput({ text: this.editingItem.time, placeholder: '如 09:00-10:00' })
              .fontSize(13)
              .height(40)
              .fontColor('#E0E0E0')
              .backgroundColor('#1A1A2E')
              .borderRadius(8)
              .margin({ top: 6 })
              .onChange((val: string) => {
                this.editingItem.time = val
              })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .margin({ bottom: 12 })

弹窗主体是一个 Column,宽度 86%,内边距 20,背景色 #16213E,圆角 16,荧光绿边框。标题根据 modalMode 动态显示"新增排课"或"编辑排课"。

表单的第一个字段是"时间段"输入框。TextInput 组件通过 text 参数绑定 editingItem.timeplaceholder 提供输入提示。onChange 回调在用户输入时实时更新 editingItem.time 的值。

          Row() {
            Column() {
              Text('学员姓名')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.studentName, placeholder: '姓名' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.studentName = val
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('课程类型')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.courseType, placeholder: '类型' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.courseType = val
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
          }
          .width('100%')
          .margin({ bottom: 12 })

学员姓名和课程类型使用 Row 横向排列,各占一半宽度(layoutWeight(1)),中间通过 margin({ left: 12 }) 留出间距。两个 TextInput 的结构与时间段输入框完全一致,只是绑定的字段不同。

          Row() {
            Column() {
              Text('时长(分钟)')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.duration.toString(), placeholder: '时长' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.duration = parseInt(val) || 60
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('状态')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.status, placeholder: '状态' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.status = val
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
          }
          .width('100%')
          .margin({ bottom: 12 })

时长和状态同样横向排列。时长输入框的 onChange 中使用了 parseInt(val) || 60,将字符串转换为整数,如果转换失败(如空字符串)则默认为 60。这是一个良好的防御性编程实践。

          Column() {
            Text('备注')
              .fontSize(12)
              .fontColor('#7C7C8A')
            TextInput({ text: this.editingItem.note, placeholder: '训练内容备注' })
              .fontSize(13)
              .height(40)
              .fontColor('#E0E0E0')
              .backgroundColor('#1A1A2E')
              .borderRadius(8)
              .margin({ top: 6 })
              .onChange((val: string) => {
                this.editingItem.note = val
              })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .margin({ bottom: 20 })

备注字段独占一行,结构与前述字段一致。

          Row() {
            Text('取消')
              .fontSize(14)
              .fontColor('#7C7C8A')
              .layoutWeight(1)
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor('#1A1A2E')
              .borderRadius(8)
              .onClick(() => {
                this.showModal = false
              })
            Column() {
            }
            .width(12)
            Text('确认')
              .fontSize(14)
              .fontColor('#1A1A2E')
              .layoutWeight(1)
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor('#00FF87')
              .borderRadius(8)
              .onClick(() => {
                if (this.modalMode === 'add') {
                  let newItem: ScheduleItem = new ScheduleItem()
                  newItem.id = this.scheduleList.length + 1
                  newItem.time = this.editingItem.time
                  newItem.studentName = this.editingItem.studentName
                  newItem.courseType = this.editingItem.courseType
                  newItem.duration = this.editingItem.duration
                  newItem.status = this.editingItem.status
                  newItem.note = this.editingItem.note
                  this.scheduleList.push(newItem)
                }
                this.showModal = false
              })
          }
          .width('100%')
        }
        .width('86%')
        .padding(20)
        .backgroundColor('#16213E')
        .borderRadius(16)
        .border({ width: 1, color: '#00FF87' })
        .constraintSize({ maxHeight: '80%' })
      }
    }
    .width('100%')
    .height('100%')
  }
}

底部是"取消"和"确认"两个按钮,各占一半宽度,中间有 12 的间距。"取消"按钮为深色背景灰色文字,点击关闭弹窗。"确认"按钮为荧光绿背景深色文字,点击时判断 modalMode:如果是 'add' 模式,创建一个新的 ScheduleItem 实例,将 editingItem 的各字段值复制到新实例,设置 id 为当前列表长度加一,然后通过 push 方法添加到列表末尾。最后关闭弹窗。

constraintSize({ maxHeight: '80%' }) 限制弹窗最大高度为屏幕的 80%,防止表单内容过多时超出屏幕。

八、学员 Tab 组件:学员档案的可视化管理

学员 Tab 负责展示所有在训学员的详细信息,包括头像、训练目标、课时统计和完成进度。

8.1 状态定义

@Component
struct StudentTabContent {
  @State studentList: StudentInfo[] = mockStudents

  @Builder
  studentCard(student: StudentInfo) {

状态非常简洁,只有一个 studentList 数组,初始值为 mockStudents

8.2 学员卡片头部

    Column() {
      Row() {
        Text(student.avatar)
          .fontSize(36)
          .width(56)
          .height(56)
          .textAlign(TextAlign.Center)
          .backgroundColor('#0F3460')
          .borderRadius(28)
          .margin({ right: 12 })

卡片头部左侧是学员头像。使用 Emoji 作为头像,字号 36,放置在一个 56x56 的深蓝色圆形容器中(borderRadius(28) 实现圆形),右侧留 12 的间距。

        Column() {
          Row() {
            Text(student.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E0E0E0')
            Text(student.level)
              .fontSize(9)
              .fontColor(getLevelColor(student.level))
              .border({ width: 1, color: getLevelColor(student.level) })
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .borderRadius(4)
              .margin({ left: 8 })
            Column() {
            }
            .layoutWeight(1)
            Text('📞 ' + student.phone)
              .fontSize(10)
              .fontColor('#7C7C8A')
          }
          .width('100%')

          Text('🎯 ' + student.goal)
            .fontSize(12)
            .fontColor('#00FF87')
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')

头部右侧是一个 Column,第一行展示学员姓名(白色粗体)、等级标签(通过 getLevelColor 获取颜色,使用描边样式)和联系电话(右侧灰色小字)。第二行展示训练目标,前缀 🎯 Emoji,荧光绿文字。空 ColumnlayoutWeight(1))将电话推到右侧。

8.3 课时统计行

      Row() {
        Column() {
          Text(student.totalSessions.toString())
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
          Text('总课时')
            .fontSize(9)
            .fontColor('#7C7C8A')
        }
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text(student.remainSessions.toString())
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00FF87')
          Text('剩余课时')
            .fontSize(9)
            .fontColor('#7C7C8A')
        }
        .alignItems(HorizontalAlign.Center)
        .margin({ left: 24 })

        Column() {
          Text(student.progress + '%')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFD600')
          Text('完成进度')
            .fontSize(9)
            .fontColor('#7C7C8A')
        }
        .alignItems(HorizontalAlign.Center)
        .margin({ left: 24 })

        Column() {
        }
        .layoutWeight(1)

课时统计行包含三组数据:总课时(白色)、剩余课时(荧光绿)、完成进度(金黄色)。每组数据上方是大号粗体数字,下方是 9 号灰色标签。三组数据之间通过 margin({ left: 24 }) 留出间距。最后的空 Column 占据剩余空间,为右侧的环形进度条留出位置。

8.4 环形进度条

        Column() {
          Stack() {
            Column() {
            }
            .width(48)
            .height(48)
            .borderRadius(24)
            .border({ width: 4, color: '#1A1A2E' })
            Column() {
            }
            .width(48)
            .height(48)
            .borderRadius(24)
            Column() {
            }
            .width(student.progress * 0.48)
            .height(student.progress * 0.48)
            .borderRadius(24)
            .border({ width: 4, color: '#00FF87' })
            Text(student.progress + '%')
              .fontSize(10)
              .fontColor('#00FF87')
              .fontWeight(FontWeight.Bold)
          }
          .width(48)
          .height(48)
        }
      }
      .width('100%')
      .margin({ top: 10 })

这是一个用 Stack 层叠实现的简易环形进度条。Stack 内部包含四个元素:

  • 第一层:48x48 的容器,4 像素深色边框,作为背景圆环。
  • 第二层:48x48 的透明容器,作为占位层(确保 Stack 的尺寸正确)。
  • 第三层:根据进度缩放的容器,宽高为 student.progress * 0.48(如进度为 85%,则宽高为 40.8),4 像素荧光绿边框,作为进度环。通过缩放内部容器的尺寸,使得边框形成的"环"在视觉上呈现进度填充的效果。
  • 第四层:居中显示进度百分比文字。

这种实现方式虽然不是标准的环形进度条,但通过容器缩放和边框层叠,巧妙地模拟了进度环的视觉效果。

8.5 线性进度条

      Row() {
        Column() {
        }
        .width(student.progress + '%')
        .height(4)
        .backgroundColor('#00FF87')
        .borderRadius(2)
        Column() {
        }
        .layoutWeight(1)
        .height(4)
        .backgroundColor('#0F3460')
        .borderRadius(2)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#16213E')
    .borderRadius(12)
    .border({ width: 1, color: '#0F3460' })
    .margin({ bottom: 8 })
  }

卡片底部是一个线性进度条,由两个 Column 组成:左侧荧光绿部分的宽度为 student.progress + '%',右侧深蓝色部分通过 layoutWeight(1) 占据剩余宽度。两者高度均为 4,圆角 2,组合形成一条完整的进度条。

8.6 学员列表布局

  build() {
    Column() {
      Row() {
        Text('学员管理')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .layoutWeight(1)
        Text('共' + this.studentList.length + '人')
          .fontSize(12)
          .fontColor('#7C7C8A')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 8 })

      Scroll() {
        Column() {
          ForEach(this.studentList, (student: StudentInfo) => {
            this.studentCard(student)
          }, (student: StudentInfo) => student.id.toString())
        }
        .padding({ left: 16, right: 16, bottom: 20 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
  }
}

布局结构与排课 Tab 类似:顶部标题栏("学员管理"标题和总人数统计),下方是可滚动的学员列表。ForEach 使用 student.id.toString() 作为键值。

九、课程 Tab 组件:课程库的展示

课程 Tab 展示了教练可提供的所有课程类型,每种课程附带时长、强度、热量消耗和适用人群信息。

9.1 课程卡片构建器

@Builder
courseCard(course: CourseType) {
  Column() {
    Row() {
      Text(course.icon)
        .fontSize(36)
        .width(52)
        .height(52)
        .textAlign(TextAlign.Center)
        .backgroundColor('#0F3460')
        .borderRadius(12)
        .margin({ right: 12 })

课程卡片头部左侧是课程图标,放置在 52x52 的深蓝色圆角方形容器中(borderRadius(12)),与学员头像的圆形容器形成视觉差异。

      Column() {
        Text(course.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
        Row() {
          Text(course.intensity)
            .fontSize(9)
            .fontColor(getIntensityColor(course.intensity))
            .border({ width: 1, color: getIntensityColor(course.intensity) })
            .padding({ left: 4, right: 4, top: 1, bottom: 1 })
            .borderRadius(4)
          Text(course.duration + 'min')
            .fontSize(10)
            .fontColor('#7C7C8A')
            .margin({ left: 8 })
          Text('🔥' + course.calories + 'kcal')
            .fontSize(10)
            .fontColor('#FF8C00')
            .margin({ left: 8 })
        }
        .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')

右侧上方是课程名称(白色粗体),下方一行依次排列三个标签:强度等级(通过 getIntensityColor 获取颜色,描边样式)、课程时长(灰色)、热量消耗(橙色,前缀 🔥)。三个标签通过 margin({ left: 8 }) 间隔排列。

    Row() {
      Text('👥 ' + course.target)
        .fontSize(11)
        .fontColor('#9E9E9E')
        .layoutWeight(1)
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#16213E')
  .borderRadius(12)
  .border({ width: 1, color: '#0F3460' })
  .margin({ bottom: 8 })
}

卡片底部展示适用人群信息,前缀 👥 Emoji,灰色小字。整体卡片样式与排课卡片和学员卡片保持一致,体现了统一的视觉设计语言。

9.2 课程列表布局

build() {
  Column() {
    Row() {
      Text('课程库')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E0E0E0')
        .layoutWeight(1)
      Text('共' + this.courseList.length + '种')
        .fontSize(12)
        .fontColor('#7C7C8A')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 10, bottom: 8 })

    Scroll() {
      Column() {
        ForEach(this.courseList, (course: CourseType) => {
          this.courseCard(course)
        }, (course: CourseType) => course.id.toString())
      }
      .padding({ left: 16, right: 16, bottom: 20 })
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
  .width('100%')
  .height('100%')
}

布局结构与其他 Tab 一致:标题栏显示"课程库"和课程种类总数,下方是可滚动的课程卡片列表。

十、数据 Tab 组件:可视化数据看板

数据 Tab 是应用中最具视觉冲击力的模块,集成了统计卡片、柱状图、进度条排行等多种数据可视化形式。

10.1 周课时柱状图构建器

@Builder
weekBar(item: WeekChartItem, maxVal: number) {
  Column() {
    Text(item.hours + 'h')
      .fontSize(9)
      .fontColor('#00FF87')
      .margin({ bottom: 4 })
    Column() {
    }
    .width(20)
    .height((item.hours / maxVal) * 100)
    .backgroundColor('#00FF87')
    .borderRadius({ topLeft: 4, topRight: 4 })

    Text(item.day)
      .fontSize(10)
      .fontColor('#7C7C8A')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
}

weekBar 构建器渲染单根柱子。顶部显示课时数值(如"5h"),中间是柱子本体——宽度固定 20,高度通过 (item.hours / maxVal) * 100 计算得出。maxVal 是本周最大课时数,确保最高的柱子高度为 100。柱子使用荧光绿色,仅顶部圆角。底部显示星期标签。整个 Column 使用 layoutWeight(1)Row 中均分宽度,内容居中对齐。

10.2 月收入柱状图构建器

@Builder
incomeBar(item: IncomeStat, maxVal: number) {
  Column() {
    Text((item.income / 1000).toFixed(1) + 'k')
      .fontSize(9)
      .fontColor('#00FF87')
      .margin({ bottom: 4 })
    Column() {
    }
    .width(36)
    .height((item.income / maxVal) * 100)
    .backgroundColor('#4FC3F7')
    .borderRadius({ topLeft: 4, topRight: 4 })

    Text(item.week)
      .fontSize(10)
      .fontColor('#7C7C8A')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
}

incomeBar 构建器与 weekBar 结构几乎相同,区别在于:柱子宽度为 36(收入数据只有四组,可以更宽),颜色为天蓝色(#4FC3F7),顶部数值显示为千为单位(如"8.2k"),通过 (item.income / 1000).toFixed(1) 将金额转换为保留一位小数的千值。

10.3 学员进步排行构建器

@Builder
progressRow(item: ProgressItem) {
  Row() {
    Text(item.name)
      .fontSize(13)
      .fontColor('#E0E0E0')
      .width(50)
    Column() {
    }
    .width(item.progress + '%')
    .height(8)
    .backgroundColor('#00FF87')
    .borderRadius(4)
    Column() {
    }
    .layoutWeight(1)
    .height(8)
    .backgroundColor('#0F3460')
    .borderRadius(4)
    Text(item.progress + '%')
      .fontSize(12)
      .fontColor('#00FF87')
      .width(36)
      .textAlign(TextAlign.End)
    Text(item.change)
      .fontSize(10)
      .fontColor('#FFD600')
      .width(40)
      .textAlign(TextAlign.End)
  }
  .width('100%')
  .margin({ bottom: 10 })
}

progressRow 构建器渲染单条进步排行记录。从左到右依次是:学员姓名(固定宽 50)、进度条(荧光绿部分宽度为 item.progress + '%',深蓝色部分占据剩余宽度,高度 8,圆角 4)、进度百分比数值(右对齐,宽 36)、变化幅度(金黄色,右对齐,宽 40)。

10.4 数据页面布局

build() {
  Scroll() {
    Column() {
      Text('📊 数据概览')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E0E0E0')
        .margin({ left: 16, top: 10, bottom: 10 })

整个数据页面使用 Scroll 包裹,因为内容较多需要滚动浏览。顶部是页面标题。

      Row() {
        Column() {
          Text(calcTotalWeekHours() + 'h')
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00FF87')
          Text('本周课时')
            .fontSize(11)
            .fontColor('#7C7C8A')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text(COACH_PROFILE.totalStudents.toString())
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4FC3F7')
          Text('在训学员')
            .fontSize(11)
            .fontColor('#7C7C8A')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text(formatIncome(calcTotalMonthIncome()))
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFD600')
          Text('本月收入')
            .fontSize(11)
            .fontColor('#7C7C8A')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
      }
      .width('92%')
      .padding(16)
      .backgroundColor('#16213E')
      .borderRadius(12)
      .border({ width: 1, color: '#0F3460' })
      .margin({ bottom: 12 })

第一个数据卡片是三列统计概览:本周课时(荧光绿,通过 calcTotalWeekHours() 计算)、在训学员(天蓝色,取自教练档案)、本月收入(金黄色,通过 calcTotalMonthIncome() 计算并经 formatIncome 格式化)。三列均分宽度,数据居中对齐。

      Column() {
        Text('📈 每周课时分布')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .margin({ bottom: 12 })

        Row() {
          ForEach(WEEK_CHART, (item: WeekChartItem) => {
            this.weekBar(item, calcMaxHours())
          }, (item: WeekChartItem) => item.day)
        }
        .width('100%')
        .height(140)
      }
      .width('92%')
      .padding(16)
      .backgroundColor('#16213E')
      .borderRadius(12)
      .border({ width: 1, color: '#0F3460' })
      .margin({ bottom: 12 })

第二个卡片是每周课时分布柱状图。ForEach 遍历 WEEK_CHART,为每个数据项调用 weekBar,传入 calcMaxHours() 的返回值作为最大值参考。整个 Row 高度 140,容纳柱子及其标签。

      Column() {
        Text('🏆 学员进步排行')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .margin({ bottom: 12 })

        ForEach(PROGRESS_DATA, (item: ProgressItem) => {
          this.progressRow(item)
        }, (item: ProgressItem) => item.name)
      }
      .width('92%')
      .padding(16)
      .backgroundColor('#16213E')
      .borderRadius(12)
      .border({ width: 1, color: '#0F3460' })
      .margin({ bottom: 12 })

第三个卡片是学员进步排行。ForEach 遍历 PROGRESS_DATA,为每条记录调用 progressRow 构建器。

      Column() {
        Text('💰 月度收入统计')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .margin({ bottom: 12 })

        Row() {
          ForEach(INCOME_STATS, (item: IncomeStat) => {
            this.incomeBar(item, calcMaxIncome())
          }, (item: IncomeStat) => item.week)
        }
        .width('100%')
        .height(140)
      }
      .width('92%')
      .padding(16)
      .backgroundColor('#16213E')
      .borderRadius(12)
      .border({ width: 1, color: '#0F3460' })
      .margin({ bottom: 20 })
    }
    .padding({ bottom: 20 })
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)
}

第四个卡片是月度收入统计柱状图。ForEach 遍历 INCOME_STATS,为每个数据项调用 incomeBar,传入 calcMaxIncome() 的返回值作为最大值参考。四个卡片使用统一的样式:宽度 92%、内边距 16、深蓝背景、圆角 12、深蓝边框。底部留 12 的间距(最后一个为 20),形成卡片之间的视觉间隔。

十一、我的 Tab 组件:个人中心与设置

我的 Tab 是教练的个人中心页面,展示教练档案、核心数据统计和设置入口列表。

11.1 状态定义

@Component
struct ProfileTabContent {
  @State coach: CoachProfile = COACH_PROFILE

  private settingItems: string[] = ['个人信息', '课程模板', '学员档案', '排课偏好', '收入明细', '消息中心', '帮助与反馈', '退出登录']

coach 状态绑定到全局的 COACH_PROFILE 常量。settingItems 是一个私有字符串数组,定义了设置列表的八个选项,从"个人信息"到"退出登录",覆盖了教练日常使用的所有功能入口。

11.2 统计卡片构建器

@Builder
statCard(value: string, label: string, color: string) {
  Column() {
    Text(value)
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor(color)
    Text(label)
      .fontSize(10)
      .fontColor('#7C7C8A')
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
}

statCard 是一个高度可复用的统计卡片构建器,接收数值、标签和颜色三个参数。数值使用大号粗体并着色,标签使用小号灰色文字。layoutWeight(1) 使多个卡片在 Row 中均分宽度。

11.3 设置行构建器

@Builder
settingRow(label: string) {
  Row() {
    Text(label)
      .fontSize(14)
      .fontColor('#E0E0E0')
      .layoutWeight(1)
    Text('›')
      .fontSize(18)
      .fontColor('#7C7C8A')
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 14, bottom: 14 })
  .backgroundColor('#16213E')
  .borderRadius(8)
  .margin({ bottom: 1 })
}

settingRow 构建器渲染单个设置项。左侧是设置项名称(白色),右侧是箭头符号"›"(灰色),点击后跳转到对应的功能页面(当前为展示用途,未实现跳转逻辑)。整行使用深蓝背景、圆角 8,底部留 1 的间距,使多个设置项形成连续的列表视觉效果。

11.4 个人主页布局

build() {
  Scroll() {
    Column() {
      Row() {
        Text(this.coach.avatar)
          .fontSize(48)
          .width(72)
          .height(72)
          .textAlign(TextAlign.Center)
          .backgroundColor('#0F3460')
          .borderRadius(36)
          .border({ width: 2, color: '#00FF87' })

页面顶部是教练档案卡片,左侧是头像——Emoji 字号 48,放置在 72x72 的深蓝色圆形容器中,外加 2 像素荧光绿边框,突出个人形象。

        Column() {
          Text(this.coach.name)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00FF87')
          Text(this.coach.title)
            .fontSize(12)
            .fontColor('#E0E0E0')
            .margin({ top: 4 })
          Text('📍 ' + this.coach.gym)
            .fontSize(11)
            .fontColor('#7C7C8A')
            .margin({ top: 2 })
        }
        .margin({ left: 16 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('92%')
      .padding(20)
      .backgroundColor('#16213E')
      .borderRadius(16)
      .border({ width: 1, color: '#0F3460' })
      .margin({ top: 10, bottom: 12 })

右侧从上到下依次是:教练姓名(荧光绿大号粗体)、职业头衔(白色小字)、所属健身房(灰色小字,前缀 📍)。整个档案卡片宽度 92%,内边距 20,圆角 16。

      Row() {
        this.statCard(this.coach.experience + '年', '教龄', '#00FF87')
        Column() {
        }
        .width(1)
        .height(30)
        .backgroundColor('#0F3460')
        this.statCard(this.coach.totalStudents.toString(), '学员', '#4FC3F7')
        Column() {
        }
        .width(1)
        .height(30)
        .backgroundColor('#0F3460')
        this.statCard(this.coach.totalCourses.toString(), '课时', '#FFD600')
        Column() {
        }
        .width(1)
        .height(30)
        .backgroundColor('#0F3460')
        this.statCard(formatIncome(this.coach.monthlyIncome), '月收入', '#FF8C00')
      }
      .width('92%')
      .padding(16)
      .backgroundColor('#16213E')
      .borderRadius(12)
      .border({ width: 1, color: '#0F3460' })
      .margin({ bottom: 12 })

统计卡片行展示了四组数据:教龄(荧光绿)、学员数(天蓝)、总课时(金黄)、月收入(橙色)。四组数据之间通过 1 像素宽、30 高的深蓝色分隔线隔开,形成清晰的列分隔效果。

      Text('⚙️ 设置')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E0E0E0')
        .margin({ left: 16, bottom: 8 })

      ForEach(this.settingItems, (label: string) => {
        this.settingRow(label)
      }, (label: string) => label)
    }
    .padding({ bottom: 20 })
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)
}

最后是设置列表区域。标题"⚙️ 设置"下方,ForEach 遍历 settingItems 数组,为每个设置项调用 settingRow 构建器,使用 label 本身作为键值。整个页面使用 Scroll 包裹,支持内容超出屏幕时滚动浏览。

十二、关键特性对比表

为了更直观地呈现系统中各模块的设计特点,下表从多个维度对五大 Tab 组件进行了对比总结:

维度 排课 Tab 学员 Tab 课程 Tab 数据 Tab 我的 Tab
核心功能 课程排期管理 学员档案展示 课程类型浏览 数据可视化统计 个人信息与设置
状态变量数量 4 个 1 个 1 个 0 个 2 个
是否支持滚动
是否含弹窗交互 是(新增/编辑表单)
是否支持增删改 是(完整 CRUD) 否(只读展示) 否(只读展示) 否(只读展示) 否(只读展示)
数据可视化形式 环形进度条 + 线性进度条 柱状图 + 进度条排行
@Builder 方法数量 1 个(scheduleCard) 1 个(studentCard) 1 个(courseCard) 3 个(weekBar/incomeBar/progressRow) 2 个(statCard/settingRow)
主色调 荧光绿 荧光绿 + 多色 多色(强度色阶) 荧光绿 + 天蓝 + 金黄 荧光绿 + 多色
数据来源 mockSchedules mockStudents mockCourses WEEK_CHART/PROGRESS_DATA/INCOME_STATS COACH_PROFILE
交互复杂度
业务耦合度 高(含状态流转)

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================
// 健身教练排课系统 - 461.ets
// 配色:深色霓虹风格
// ============================================

// ===== 1. interface 定义 =====
interface ScheduleConfig {
  courseTypes: string[]
  statusOptions: string[]
  intensityLevels: string[]
  pageSize: number
}

interface CoachProfile {
  name: string
  avatar: string
  title: string
  gym: string
  experience: number
  totalStudents: number
  totalCourses: number
  monthlyIncome: number
}

interface WeekChartItem {
  day: string
  hours: number
}

interface ProgressItem {
  name: string
  progress: number
  change: string
}

interface IncomeStat {
  week: string
  income: number
}

// ===== 2. @Observed class 数据模型 =====
@Observed
class ScheduleItem {
  id: number = 0
  time: string = ''
  studentName: string = ''
  courseType: string = ''
  status: string = ''
  duration: number = 60
  note: string = ''
}

@Observed
class StudentInfo {
  id: number = 0
  name: string = ''
  avatar: string = '💪'
  goal: string = ''
  progress: number = 0
  remainSessions: number = 0
  totalSessions: number = 0
  phone: string = ''
  level: string = ''
}

@Observed
class CourseType {
  id: number = 0
  name: string = ''
  duration: number = 60
  intensity: string = ''
  calories: number = 0
  target: string = ''
  icon: string = '🏋️'
}

// ===== 3. const CONFIG 静态配置 =====
const CONFIG: ScheduleConfig = {
  courseTypes: ['力量训练', '有氧运动', 'HIIT', '瑜伽', '搏击', '拉伸恢复', '功能性训练', '体态矫正'],
  statusOptions: ['待开始', '进行中', '已完成', '已取消'],
  intensityLevels: ['低强度', '中强度', '高强度', '极限'],
  pageSize: 20
}

const COACH_PROFILE: CoachProfile = {
  name: '王猛教练',
  avatar: '🧑‍🏫',
  title: '金牌私教 / 营养师',
  gym: '闪电健身工坊',
  experience: 8,
  totalStudents: 42,
  totalCourses: 1280,
  monthlyIncome: 38600
}

const WEEK_CHART: WeekChartItem[] = [
  { day: '周一', hours: 5 },
  { day: '周二', hours: 7 },
  { day: '周三', hours: 4 },
  { day: '周四', hours: 8 },
  { day: '周五', hours: 6 },
  { day: '周六', hours: 9 },
  { day: '周日', hours: 3 }
]

const PROGRESS_DATA: ProgressItem[] = [
  { name: '张强', progress: 92, change: '+8%' },
  { name: '李娜', progress: 85, change: '+5%' },
  { name: '陈刚', progress: 78, change: '+12%' },
  { name: '王芳', progress: 71, change: '+3%' },
  { name: '刘洋', progress: 65, change: '+6%' }
]

const INCOME_STATS: IncomeStat[] = [
  { week: '第1周', income: 8200 },
  { week: '第2周', income: 9600 },
  { week: '第3周', income: 10800 },
  { week: '第4周', income: 10000 }
]

// ===== 4. const mockData 数据数组 =====
const mockSchedules: ScheduleItem[] = [
  { id: 1, time: '06:00-07:00', studentName: '张强', courseType: '力量训练', status: '已完成', duration: 60, note: '深蹲日-加重5kg' },
  { id: 2, time: '07:30-08:30', studentName: '李娜', courseType: '瑜伽', status: '已完成', duration: 60, note: '核心流序列' },
  { id: 3, time: '09:00-10:00', studentName: '陈刚', courseType: 'HIIT', status: '已完成', duration: 60, note: '燃脂循环4组' },
  { id: 4, time: '10:30-11:30', studentName: '王芳', courseType: '体态矫正', status: '已完成', duration: 60, note: '圆肩驼背改善' },
  { id: 5, time: '12:00-13:00', studentName: '刘洋', courseType: '功能性训练', status: '已完成', duration: 60, note: '平衡协调训练' },
  { id: 6, time: '14:00-15:00', studentName: '赵敏', courseType: '搏击', status: '已完成', duration: 60, note: '组合拳法练习' },
  { id: 7, time: '15:30-16:30', studentName: '孙伟', courseType: '力量训练', status: '进行中', duration: 60, note: '胸推日' },
  { id: 8, time: '17:00-18:00', studentName: '周杰', courseType: '有氧运动', status: '进行中', duration: 60, note: '跑步机间歇' },
  { id: 9, time: '18:30-19:30', studentName: '吴静', courseType: '拉伸恢复', status: '待开始', duration: 60, note: '筋膜放松' },
  { id: 10, time: '19:45-20:45', studentName: '郑浩', courseType: 'HIIT', status: '待开始', duration: 60, note: 'Tabata训练' },
  { id: 11, time: '21:00-21:30', studentName: '黄磊', courseType: '体态矫正', status: '待开始', duration: 30, note: '骨盆前倾调整' },
  { id: 12, time: '06:00-07:00', studentName: '林峰', courseType: '力量训练', status: '待开始', duration: 60, note: '背阔肌专项' },
  { id: 13, time: '07:30-08:30', studentName: '何琪', courseType: '瑜伽', status: '待开始', duration: 60, note: '阴瑜伽放松' },
  { id: 14, time: '09:00-10:00', studentName: '曾柔', courseType: '搏击', status: '待开始', duration: 60, note: '防御技巧' },
  { id: 15, time: '10:30-11:30', studentName: '高翔', courseType: '功能性训练', status: '待开始', duration: 60, note: '敏捷梯训练' },
  { id: 16, time: '12:00-13:00', studentName: '马超', courseType: 'HIIT', status: '待开始', duration: 45, note: '短时高强度' },
  { id: 17, time: '14:00-15:00', studentName: '许晴', courseType: '体态矫正', status: '待开始', duration: 60, note: 'XO型腿改善' },
  { id: 18, time: '15:30-16:30', studentName: '邓超', courseType: '力量训练', status: '待开始', duration: 60, note: '腿部超级组' }
]

const mockStudents: StudentInfo[] = [
  { id: 1, name: '张强', avatar: '🏋️', goal: '增肌10kg', progress: 85, remainSessions: 12, totalSessions: 36, phone: '138****1111', level: '高级' },
  { id: 2, name: '李娜', avatar: '🧘', goal: '提升柔韧性', progress: 72, remainSessions: 8, totalSessions: 24, phone: '139****2222', level: '中级' },
  { id: 3, name: '陈刚', avatar: '💪', goal: '减脂15kg', progress: 90, remainSessions: 5, totalSessions: 30, phone: '137****3333', level: '高级' },
  { id: 4, name: '王芳', avatar: '🤸', goal: '体态矫正', progress: 60, remainSessions: 15, totalSessions: 20, phone: '135****4444', level: '初级' },
  { id: 5, name: '刘洋', avatar: '🏃', goal: '功能性提升', progress: 68, remainSessions: 10, totalSessions: 28, phone: '136****5555', level: '中级' },
  { id: 6, name: '赵敏', avatar: '🥊', goal: '搏击入门', progress: 55, remainSessions: 18, totalSessions: 12, phone: '134****6666', level: '初级' },
  { id: 7, name: '孙伟', avatar: '🦾', goal: '力量翻倍', progress: 78, remainSessions: 7, totalSessions: 33, phone: '133****7777', level: '高级' },
  { id: 8, name: '周杰', avatar: '🚴', goal: '心肺提升', progress: 65, remainSessions: 14, totalSessions: 16, phone: '132****8888', level: '中级' },
  { id: 9, name: '吴静', avatar: '🧘‍♀️', goal: '产后恢复', progress: 48, remainSessions: 20, totalSessions: 8, phone: '131****9999', level: '初级' },
  { id: 10, name: '郑浩', avatar: '🔥', goal: '极限减脂', progress: 82, remainSessions: 6, totalSessions: 34, phone: '130****0000', level: '高级' },
  { id: 11, name: '黄磊', avatar: '🧍', goal: '体态矫正', progress: 42, remainSessions: 22, totalSessions: 6, phone: '158****1212', level: '初级' },
  { id: 12, name: '林峰', avatar: '⚡', goal: '运动表现', progress: 75, remainSessions: 9, totalSessions: 31, phone: '159****3434', level: '高级' },
  { id: 13, name: '何琪', avatar: '🌸', goal: '减压放松', progress: 58, remainSessions: 16, totalSessions: 14, phone: '151****5656', level: '中级' },
  { id: 14, name: '曾柔', avatar: '🥋', goal: '搏击进阶', progress: 70, remainSessions: 11, totalSessions: 19, phone: '152****7878', level: '中级' },
  { id: 15, name: '高翔', avatar: '🎯', goal: '敏捷提升', progress: 63, remainSessions: 13, totalSessions: 17, phone: '153****9090', level: '中级' },
  { id: 16, name: '马超', avatar: '💎', goal: '快速塑形', progress: 88, remainSessions: 4, totalSessions: 26, phone: '154****1212', level: '高级' }
]

const mockCourses: CourseType[] = [
  { id: 1, name: '力量训练', duration: 60, intensity: '高强度', calories: 500, target: '增肌塑形人群', icon: '🏋️' },
  { id: 2, name: '有氧运动', duration: 45, intensity: '中强度', calories: 350, target: '减脂心肺人群', icon: '🏃' },
  { id: 3, name: 'HIIT燃脂', duration: 30, intensity: '极限', calories: 450, target: '快速减脂人群', icon: '🔥' },
  { id: 4, name: '舒缓瑜伽', duration: 60, intensity: '低强度', calories: 200, target: '柔韧放松人群', icon: '🧘' },
  { id: 5, name: '搏击训练', duration: 60, intensity: '高强度', calories: 600, target: '力量发泄人群', icon: '🥊' },
  { id: 6, name: '拉伸恢复', duration: 30, intensity: '低强度', calories: 120, target: '运动后恢复人群', icon: '🤸' },
  { id: 7, name: '功能性训练', duration: 45, intensity: '中强度', calories: 380, target: '运动表现提升', icon: '⚡' },
  { id: 8, name: '体态矫正', duration: 60, intensity: '低强度', calories: 150, target: '久坐办公人群', icon: '🧍' },
  { id: 9, name: '核心训练', duration: 30, intensity: '高强度', calories: 300, target: '核心力量提升', icon: '💎' },
  { id: 10, name: 'TRX悬挂', duration: 45, intensity: '中强度', calories: 320, target: '全身稳定训练', icon: '🎯' },
  { id: 11, name: '战绳训练', duration: 20, intensity: '极限', calories: 280, target: '爆发力训练', icon: '💥' },
  { id: 12, name: '普拉提', duration: 50, intensity: '低强度', calories: 180, target: '体态核心改善', icon: '🌸' }
]

// ===== 5. enum Tab 枚举 =====
enum FitTab {
  SCHEDULE = 0,
  STUDENT = 1,
  COURSE = 2,
  DATA = 3,
  PROFILE = 4
}

// ===== 6. 全局纯函数 =====
function getScheduleStatusColor(status: string): string {
  if (status === '待开始') {
    return '#00FF87'
  } else if (status === '进行中') {
    return '#FFD600'
  } else if (status === '已完成') {
    return '#7C7C8A'
  } else if (status === '已取消') {
    return '#FF4757'
  }
  return '#7C7C8A'
}

function getIntensityColor(intensity: string): string {
  if (intensity === '极限') {
    return '#FF4757'
  } else if (intensity === '高强度') {
    return '#FF8C00'
  } else if (intensity === '中强度') {
    return '#00FF87'
  }
  return '#4FC3F7'
}

function getLevelColor(level: string): string {
  if (level === '高级') {
    return '#00FF87'
  } else if (level === '中级') {
    return '#FFD600'
  }
  return '#4FC3F7'
}

function formatIncome(amount: number): string {
  return `¥${amount.toLocaleString()}`
}

function calcMaxHours(): number {
  let maxVal: number = 0
  for (let item of WEEK_CHART) {
    if (item.hours > maxVal) {
      maxVal = item.hours
    }
  }
  return maxVal
}

function calcMaxIncome(): number {
  let maxVal: number = 0
  for (let item of INCOME_STATS) {
    if (item.income > maxVal) {
      maxVal = item.income
    }
  }
  return maxVal
}

function calcTotalWeekHours(): number {
  let total: number = 0
  for (let item of WEEK_CHART) {
    total += item.hours
  }
  return total
}

function calcTotalMonthIncome(): number {
  let total: number = 0
  for (let item of INCOME_STATS) {
    total += item.income
  }
  return total
}

// ===== 7. @Entry struct 主组件 =====
@Entry
struct CoachSchedulePage {
  @State activeTab: FitTab = FitTab.SCHEDULE

  private tabItems: FitTabItem[] = [
    { tab: FitTab.SCHEDULE, icon: '🗓️', label: '课表' },
    { tab: FitTab.STUDENT, icon: '💪', label: '学员' },
    { tab: FitTab.COURSE, icon: '🏋️', label: '课程' },
    { tab: FitTab.DATA, icon: '📊', label: '数据' },
    { tab: FitTab.PROFILE, icon: '👤', label: '我的' }
  ]

  @Builder
  bottomTabItem(tab: FitTab, icon: string, label: string) {
    Column() {
      Text(icon)
        .fontSize(22)
      Text(label)
        .fontSize(10)
        .fontColor(this.activeTab === tab ? '#00FF87' : '#7C7C8A')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.activeTab = tab
    })
  }

  @Builder
  contentArea() {
    if (this.activeTab === FitTab.SCHEDULE) {
      ScheduleTabContent()
    } else if (this.activeTab === FitTab.STUDENT) {
      StudentTabContent()
    } else if (this.activeTab === FitTab.COURSE) {
      CourseTabContent()
    } else if (this.activeTab === FitTab.DATA) {
      DataTabContent()
    } else {
      ProfileTabContent()
    }
  }

  build() {
    Column() {
      Row() {
        Text('⚡ COACH PRO')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#00FF87')
        Column() {
        }
        .layoutWeight(1)
        Text(COACH_PROFILE.name)
          .fontSize(12)
          .fontColor('#7C7C8A')
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#16213E')

      Column() {
        this.contentArea()
      }
      .layoutWeight(1)
      .width('100%')

      Row() {
        ForEach(this.tabItems, (item: FitTabItem) => {
          this.bottomTabItem(item.tab, item.icon, item.label)
        }, (item: FitTabItem) => item.label)
      }
      .width('100%')
      .height(56)
      .backgroundColor('#16213E')
      .border({ width: { top: 1 }, color: '#0F3460' })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1A1A2E')
  }
}

interface FitTabItem {
  tab: FitTab
  icon: string
  label: string
}

// ===== 8. 课表Tab组件 =====
@Component
struct ScheduleTabContent {
  @State scheduleList: ScheduleItem[] = mockSchedules
  @State showModal: boolean = false
  @State modalMode: string = 'add'
  @State editingItem: ScheduleItem = new ScheduleItem()

  @Builder
  scheduleCard(item: ScheduleItem) {
    Row() {
      Column() {
        Text(item.time.split('-')[0])
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#00FF87')
        Text(item.time.split('-')[1])
          .fontSize(12)
          .fontColor('#7C7C8A')
        Text(item.duration + 'min')
          .fontSize(10)
          .fontColor('#7C7C8A')
          .margin({ top: 4 })
      }
      .width(70)
      .alignItems(HorizontalAlign.Center)

      Column() {
        Row() {
          Text(item.studentName)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
          Text(item.courseType)
            .fontSize(10)
            .fontColor('#00FF87')
            .border({ width: 1, color: '#00FF87' })
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
            .borderRadius(4)
            .margin({ left: 8 })
          Column() {
          }
          .layoutWeight(1)
          Text(item.status)
            .fontSize(10)
            .fontColor(getScheduleStatusColor(item.status))
            .backgroundColor(getScheduleStatusColor(item.status).replace('#', 'rgba(').slice(0, -1) + ',0.15)')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(8)
        }
        .width('100%')

        Text(item.note)
          .fontSize(12)
          .fontColor('#9E9E9E')
          .margin({ top: 6 })

        Row() {
          Text('编辑')
            .fontSize(11)
            .fontColor('#00FF87')
            .margin({ right: 16 })
            .onClick(() => {
              this.editingItem = item
              this.modalMode = 'edit'
              this.showModal = true
            })
          Text('删除')
            .fontSize(11)
            .fontColor('#FF4757')
            .onClick(() => {
              this.scheduleList = this.scheduleList.filter((s: ScheduleItem) => s.id !== item.id)
            })
        }
        .margin({ top: 6 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#16213E')
    .borderRadius(12)
    .border({ width: 1, color: '#0F3460' })
    .margin({ bottom: 8 })
  }

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Column() {
        Row() {
          Column() {
            Text('今日排课')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E0E0E0')
            Text(this.scheduleList.length + '节课 | ' + this.scheduleList.filter((s: ScheduleItem) => s.status === '已完成').length + '节已完成')
              .fontSize(11)
              .fontColor('#7C7C8A')
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('+ 新增')
            .fontSize(13)
            .fontColor('#1A1A2E')
            .backgroundColor('#00FF87')
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(20)
            .onClick(() => {
              this.editingItem = new ScheduleItem()
              this.modalMode = 'add'
              this.showModal = true
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 10, bottom: 10 })

        Scroll() {
          Column() {
            ForEach(this.scheduleList, (item: ScheduleItem) => {
              this.scheduleCard(item)
            }, (item: ScheduleItem) => item.id.toString())
          }
          .padding({ left: 16, right: 16, bottom: 20 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
      }

      if (this.showModal) {
        Column() {
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(0,0,0,0.7)')
        .onClick(() => {
          this.showModal = false
        })

        Column() {
          Text(this.modalMode === 'add' ? '⚡ 新增排课' : '⚡ 编辑排课')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00FF87')
            .margin({ bottom: 16 })

          Column() {
            Text('时间段')
              .fontSize(12)
              .fontColor('#7C7C8A')
            TextInput({ text: this.editingItem.time, placeholder: '如 09:00-10:00' })
              .fontSize(13)
              .height(40)
              .fontColor('#E0E0E0')
              .backgroundColor('#1A1A2E')
              .borderRadius(8)
              .margin({ top: 6 })
              .onChange((val: string) => {
                this.editingItem.time = val
              })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .margin({ bottom: 12 })

          Row() {
            Column() {
              Text('学员姓名')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.studentName, placeholder: '姓名' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.studentName = val
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('课程类型')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.courseType, placeholder: '类型' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.courseType = val
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
          }
          .width('100%')
          .margin({ bottom: 12 })

          Row() {
            Column() {
              Text('时长(分钟)')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.duration.toString(), placeholder: '时长' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.duration = parseInt(val) || 60
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('状态')
                .fontSize(12)
                .fontColor('#7C7C8A')
              TextInput({ text: this.editingItem.status, placeholder: '状态' })
                .fontSize(13)
                .height(40)
                .fontColor('#E0E0E0')
                .backgroundColor('#1A1A2E')
                .borderRadius(8)
                .margin({ top: 6 })
                .onChange((val: string) => {
                  this.editingItem.status = val
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
          }
          .width('100%')
          .margin({ bottom: 12 })

          Column() {
            Text('备注')
              .fontSize(12)
              .fontColor('#7C7C8A')
            TextInput({ text: this.editingItem.note, placeholder: '训练内容备注' })
              .fontSize(13)
              .height(40)
              .fontColor('#E0E0E0')
              .backgroundColor('#1A1A2E')
              .borderRadius(8)
              .margin({ top: 6 })
              .onChange((val: string) => {
                this.editingItem.note = val
              })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .margin({ bottom: 20 })

          Row() {
            Text('取消')
              .fontSize(14)
              .fontColor('#7C7C8A')
              .layoutWeight(1)
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor('#1A1A2E')
              .borderRadius(8)
              .onClick(() => {
                this.showModal = false
              })
            Column() {
            }
            .width(12)
            Text('确认')
              .fontSize(14)
              .fontColor('#1A1A2E')
              .layoutWeight(1)
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor('#00FF87')
              .borderRadius(8)
              .onClick(() => {
                if (this.modalMode === 'add') {
                  let newItem: ScheduleItem = new ScheduleItem()
                  newItem.id = this.scheduleList.length + 1
                  newItem.time = this.editingItem.time
                  newItem.studentName = this.editingItem.studentName
                  newItem.courseType = this.editingItem.courseType
                  newItem.duration = this.editingItem.duration
                  newItem.status = this.editingItem.status
                  newItem.note = this.editingItem.note
                  this.scheduleList.push(newItem)
                }
                this.showModal = false
              })
          }
          .width('100%')
        }
        .width('86%')
        .padding(20)
        .backgroundColor('#16213E')
        .borderRadius(16)
        .border({ width: 1, color: '#00FF87' })
        .constraintSize({ maxHeight: '80%' })
      }
    }
    .width('100%')
    .height('100%')
  }
}

// ===== 9. 学员Tab组件 =====
@Component
struct StudentTabContent {
  @State studentList: StudentInfo[] = mockStudents

  @Builder
  studentCard(student: StudentInfo) {
    Column() {
      Row() {
        Text(student.avatar)
          .fontSize(36)
          .width(56)
          .height(56)
          .textAlign(TextAlign.Center)
          .backgroundColor('#0F3460')
          .borderRadius(28)
          .margin({ right: 12 })

        Column() {
          Row() {
            Text(student.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E0E0E0')
            Text(student.level)
              .fontSize(9)
              .fontColor(getLevelColor(student.level))
              .border({ width: 1, color: getLevelColor(student.level) })
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .borderRadius(4)
              .margin({ left: 8 })
            Column() {
            }
            .layoutWeight(1)
            Text('📞 ' + student.phone)
              .fontSize(10)
              .fontColor('#7C7C8A')
          }
          .width('100%')

          Text('🎯 ' + student.goal)
            .fontSize(12)
            .fontColor('#00FF87')
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')

      Row() {
        Column() {
          Text(student.totalSessions.toString())
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
          Text('总课时')
            .fontSize(9)
            .fontColor('#7C7C8A')
        }
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text(student.remainSessions.toString())
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00FF87')
          Text('剩余课时')
            .fontSize(9)
            .fontColor('#7C7C8A')
        }
        .alignItems(HorizontalAlign.Center)
        .margin({ left: 24 })

        Column() {
          Text(student.progress + '%')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFD600')
          Text('完成进度')
            .fontSize(9)
            .fontColor('#7C7C8A')
        }
        .alignItems(HorizontalAlign.Center)
        .margin({ left: 24 })

        Column() {
        }
        .layoutWeight(1)

        Column() {
          Stack() {
            Column() {
            }
            .width(48)
            .height(48)
            .borderRadius(24)
            .border({ width: 4, color: '#1A1A2E' })
            Column() {
            }
            .width(48)
            .height(48)
            .borderRadius(24)
            Column() {
            }
            .width(student.progress * 0.48)
            .height(student.progress * 0.48)
            .borderRadius(24)
            .border({ width: 4, color: '#00FF87' })
            Text(student.progress + '%')
              .fontSize(10)
              .fontColor('#00FF87')
              .fontWeight(FontWeight.Bold)
          }
          .width(48)
          .height(48)
        }
      }
      .width('100%')
      .margin({ top: 10 })

      Row() {
        Column() {
        }
        .width(student.progress + '%')
        .height(4)
        .backgroundColor('#00FF87')
        .borderRadius(2)
        Column() {
        }
        .layoutWeight(1)
        .height(4)
        .backgroundColor('#0F3460')
        .borderRadius(2)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#16213E')
    .borderRadius(12)
    .border({ width: 1, color: '#0F3460' })
    .margin({ bottom: 8 })
  }

  build() {
    Column() {
      Row() {
        Text('学员管理')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .layoutWeight(1)
        Text('共' + this.studentList.length + '人')
          .fontSize(12)
          .fontColor('#7C7C8A')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 8 })

      Scroll() {
        Column() {
          ForEach(this.studentList, (student: StudentInfo) => {
            this.studentCard(student)
          }, (student: StudentInfo) => student.id.toString())
        }
        .padding({ left: 16, right: 16, bottom: 20 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
  }
}

// ===== 10. 课程Tab组件 =====
@Component
struct CourseTabContent {
  @State courseList: CourseType[] = mockCourses

  @Builder
  courseCard(course: CourseType) {
    Column() {
      Row() {
        Text(course.icon)
          .fontSize(36)
          .width(52)
          .height(52)
          .textAlign(TextAlign.Center)
          .backgroundColor('#0F3460')
          .borderRadius(12)
          .margin({ right: 12 })

        Column() {
          Text(course.name)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
          Row() {
            Text(course.intensity)
              .fontSize(9)
              .fontColor(getIntensityColor(course.intensity))
              .border({ width: 1, color: getIntensityColor(course.intensity) })
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .borderRadius(4)
            Text(course.duration + 'min')
              .fontSize(10)
              .fontColor('#7C7C8A')
              .margin({ left: 8 })
            Text('🔥' + course.calories + 'kcal')
              .fontSize(10)
              .fontColor('#FF8C00')
              .margin({ left: 8 })
          }
          .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')

      Row() {
        Text('👥 ' + course.target)
          .fontSize(11)
          .fontColor('#9E9E9E')
          .layoutWeight(1)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#16213E')
    .borderRadius(12)
    .border({ width: 1, color: '#0F3460' })
    .margin({ bottom: 8 })
  }

  build() {
    Column() {
      Row() {
        Text('课程库')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .layoutWeight(1)
        Text('共' + this.courseList.length + '种')
          .fontSize(12)
          .fontColor('#7C7C8A')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 8 })

      Scroll() {
        Column() {
          ForEach(this.courseList, (course: CourseType) => {
            this.courseCard(course)
          }, (course: CourseType) => course.id.toString())
        }
        .padding({ left: 16, right: 16, bottom: 20 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
  }
}

// ===== 11. 数据Tab组件 =====
@Component
struct DataTabContent {
  @Builder
  weekBar(item: WeekChartItem, maxVal: number) {
    Column() {
      Text(item.hours + 'h')
        .fontSize(9)
        .fontColor('#00FF87')
        .margin({ bottom: 4 })
      Column() {
      }
      .width(20)
      .height((item.hours / maxVal) * 100)
      .backgroundColor('#00FF87')
      .borderRadius({ topLeft: 4, topRight: 4 })

      Text(item.day)
        .fontSize(10)
        .fontColor('#7C7C8A')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  incomeBar(item: IncomeStat, maxVal: number) {
    Column() {
      Text((item.income / 1000).toFixed(1) + 'k')
        .fontSize(9)
        .fontColor('#00FF87')
        .margin({ bottom: 4 })
      Column() {
      }
      .width(36)
      .height((item.income / maxVal) * 100)
      .backgroundColor('#4FC3F7')
      .borderRadius({ topLeft: 4, topRight: 4 })

      Text(item.week)
        .fontSize(10)
        .fontColor('#7C7C8A')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  progressRow(item: ProgressItem) {
    Row() {
      Text(item.name)
        .fontSize(13)
        .fontColor('#E0E0E0')
        .width(50)
      Column() {
      }
      .width(item.progress + '%')
      .height(8)
      .backgroundColor('#00FF87')
      .borderRadius(4)
      Column() {
      }
      .layoutWeight(1)
      .height(8)
      .backgroundColor('#0F3460')
      .borderRadius(4)
      Text(item.progress + '%')
        .fontSize(12)
        .fontColor('#00FF87')
        .width(36)
        .textAlign(TextAlign.End)
      Text(item.change)
        .fontSize(10)
        .fontColor('#FFD600')
        .width(40)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .margin({ bottom: 10 })
  }

  build() {
    Scroll() {
      Column() {
        Text('📊 数据概览')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .margin({ left: 16, top: 10, bottom: 10 })

        Row() {
          Column() {
            Text(calcTotalWeekHours() + 'h')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor('#00FF87')
            Text('本周课时')
              .fontSize(11)
              .fontColor('#7C7C8A')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(COACH_PROFILE.totalStudents.toString())
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4FC3F7')
            Text('在训学员')
              .fontSize(11)
              .fontColor('#7C7C8A')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(formatIncome(calcTotalMonthIncome()))
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFD600')
            Text('本月收入')
              .fontSize(11)
              .fontColor('#7C7C8A')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('92%')
        .padding(16)
        .backgroundColor('#16213E')
        .borderRadius(12)
        .border({ width: 1, color: '#0F3460' })
        .margin({ bottom: 12 })

        Column() {
          Text('📈 每周课时分布')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
            .margin({ bottom: 12 })

          Row() {
            ForEach(WEEK_CHART, (item: WeekChartItem) => {
              this.weekBar(item, calcMaxHours())
            }, (item: WeekChartItem) => item.day)
          }
          .width('100%')
          .height(140)
        }
        .width('92%')
        .padding(16)
        .backgroundColor('#16213E')
        .borderRadius(12)
        .border({ width: 1, color: '#0F3460' })
        .margin({ bottom: 12 })

        Column() {
          Text('🏆 学员进步排行')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
            .margin({ bottom: 12 })

          ForEach(PROGRESS_DATA, (item: ProgressItem) => {
            this.progressRow(item)
          }, (item: ProgressItem) => item.name)
        }
        .width('92%')
        .padding(16)
        .backgroundColor('#16213E')
        .borderRadius(12)
        .border({ width: 1, color: '#0F3460' })
        .margin({ bottom: 12 })

        Column() {
          Text('💰 月度收入统计')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0E0E0')
            .margin({ bottom: 12 })

          Row() {
            ForEach(INCOME_STATS, (item: IncomeStat) => {
              this.incomeBar(item, calcMaxIncome())
            }, (item: IncomeStat) => item.week)
          }
          .width('100%')
          .height(140)
        }
        .width('92%')
        .padding(16)
        .backgroundColor('#16213E')
        .borderRadius(12)
        .border({ width: 1, color: '#0F3460' })
        .margin({ bottom: 20 })
      }
      .padding({ bottom: 20 })
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
}

// ===== 12. 我的Tab组件 =====
@Component
struct ProfileTabContent {
  @State coach: CoachProfile = COACH_PROFILE

  private settingItems: string[] = ['个人信息', '课程模板', '学员档案', '排课偏好', '收入明细', '消息中心', '帮助与反馈', '退出登录']

  @Builder
  statCard(value: string, label: string, color: string) {
    Column() {
      Text(value)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(color)
      Text(label)
        .fontSize(10)
        .fontColor('#7C7C8A')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  settingRow(label: string) {
    Row() {
      Text(label)
        .fontSize(14)
        .fontColor('#E0E0E0')
        .layoutWeight(1)
      Text('›')
        .fontSize(18)
        .fontColor('#7C7C8A')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor('#16213E')
    .borderRadius(8)
    .margin({ bottom: 1 })
  }

  build() {
    Scroll() {
      Column() {
        Row() {
          Text(this.coach.avatar)
            .fontSize(48)
            .width(72)
            .height(72)
            .textAlign(TextAlign.Center)
            .backgroundColor('#0F3460')
            .borderRadius(36)
            .border({ width: 2, color: '#00FF87' })

          Column() {
            Text(this.coach.name)
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#00FF87')
            Text(this.coach.title)
              .fontSize(12)
              .fontColor('#E0E0E0')
              .margin({ top: 4 })
            Text('📍 ' + this.coach.gym)
              .fontSize(11)
              .fontColor('#7C7C8A')
              .margin({ top: 2 })
          }
          .margin({ left: 16 })
          .alignItems(HorizontalAlign.Start)
        }
        .width('92%')
        .padding(20)
        .backgroundColor('#16213E')
        .borderRadius(16)
        .border({ width: 1, color: '#0F3460' })
        .margin({ top: 10, bottom: 12 })

        Row() {
          this.statCard(this.coach.experience + '年', '教龄', '#00FF87')
          Column() {
          }
          .width(1)
          .height(30)
          .backgroundColor('#0F3460')
          this.statCard(this.coach.totalStudents.toString(), '学员', '#4FC3F7')
          Column() {
          }
          .width(1)
          .height(30)
          .backgroundColor('#0F3460')
          this.statCard(this.coach.totalCourses.toString(), '课时', '#FFD600')
          Column() {
          }
          .width(1)
          .height(30)
          .backgroundColor('#0F3460')
          this.statCard(formatIncome(this.coach.monthlyIncome), '月收入', '#FF8C00')
        }
        .width('92%')
        .padding(16)
        .backgroundColor('#16213E')
        .borderRadius(12)
        .border({ width: 1, color: '#0F3460' })
        .margin({ bottom: 12 })

        Text('⚙️ 设置')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0E0E0')
          .margin({ left: 16, bottom: 8 })

        ForEach(this.settingItems, (label: string) => {
          this.settingRow(label)
        }, (label: string) => label)
      }
      .padding({ bottom: 20 })
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
}


十三、总结

本文对一套基于 ArkTS 声明式 UI 范式开发的健身教练排课管理系统进行了全面、深入的代码级解析。从接口定义到数据模型,从静态配置到 Mock 数据,从纯函数到组件实现,我们逐一拆解了系统的每一个组成部分。

在这里插入图片描述

在数据架构层面,系统通过五个 interface 定义了类型契约,三个 @Observed 类构建了可观察的数据模型,六组常量和 Mock 数据为开发期提供了完整的数据支撑。这种"类型先行、数据集中"的组织方式,使得后续对接真实后端时,只需替换数据源而无需修改组件逻辑。

在业务逻辑层面,系统提取了六个全局纯函数,分别负责颜色映射和数据计算。这些函数不依赖外部状态,可独立测试、可任意复用,是函数式编程思想在实际项目中的落地实践。颜色映射函数将业务语义(状态、强度、等级)转换为视觉语义(颜色),是连接数据层与视图层的关键桥梁。

在 UI 架构层面,系统采用"主入口组件 + 五个子组件"的两层结构。主入口组件通过 @State activeTab 驱动条件渲染实现页面切换,各子组件职责单一、互不依赖。@Builder 方法的广泛使用使得卡片、进度条、柱状图等 UI 片段得以高度复用。特别是数据 Tab 中的柱状图和进度条,完全基于 ColumnRowStack 等基础容器通过尺寸计算和颜色填充实现,无需引入任何图表库,体现了"轻量实现"的工程理念。

在交互设计层面,排课 Tab 实现了完整的 CRUD 功能:列表展示支持滚动浏览,卡片内嵌编辑和删除操作,弹窗表单支持新增和编辑两种模式,遮罩层点击关闭弹窗。这种交互模式覆盖了教练日常排课的全部操作场景。弹窗表单通过 onChange 回调实时同步用户输入到 editingItem,确认时再统一提交到列表,实现了"编辑缓冲"的设计模式。

在视觉设计层面,系统采用深色霓虹风格,以深蓝近黑为底色,荧光绿为主强调色,辅以金黄、橙红、天蓝等点缀色。所有卡片统一使用深蓝背景(#16213E)、圆角 12、深蓝边框(#0F3460)的样式规范,形成了一致的视觉语言。状态标签、强度标签、等级标签通过颜色映射函数动态着色,使得信息的语义层次通过色彩直观传达。

在工程实践层面,代码组织遵循了清晰的分段注释结构(从"1. interface 定义"到"12. 我的 Tab 组件"),每个功能模块都有明确的起止标记。ForEach 的键值生成函数使用 id.toString() 或唯一字符串属性,确保了列表渲染的正确性和性能。防御性编程体现在 parseInt(val) || 60 的默认值兜底、颜色映射函数的 return '#7C7C8A' 兜底等细节中。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐