在这里插入图片描述
在这里插入图片描述

实例:健康数据(Health)|收官文章

一、文件清单

文件 职责 行数
database/HealthDao.ets 数据层:周汇总、趋势对比、范围查询、日期工具、种子 约 260 行
pages/samples/HealthPage.ets UI 层:仪表盘(概览卡 + 柱状图 + 汇总 + 列表 + 表单) 约 300 行
resources/base/profile/main_pages.json 路由注册:pages/samples/HealthPage 追加一行
pages/Index.ets 首页入口按钮 追加一个按钮

本篇文章完整展示可编译运行的 HealthPage 代码(含 @Builder 抽取),最后描述运行效果。

二、HealthPage 完整代码

import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { HealthDao, HealthRecord, WeekSummary } from '../../database/HealthDao';

interface SleepBar {
  date: string;
  value: number;
}

@Entry
@Component
struct HealthPage {
  @State records: HealthRecord[] = [];
  @State todayRecord: HealthRecord | null = null;
  @State sleepBars: SleepBar[] = [];
  @State avgWeight: number = 0;
  @State lastWeight: number = 0;
  @State summary: WeekSummary = { avgWeight: 0, avgSteps: 0, avgSleep: 0, avgWater: 0, avgExercise: 0 };
  @State formVisible: boolean = false;
  @State editing: HealthRecord | null = null;
  @State fWeight: string = '';
  @State fSteps: string = '';
  @State fSleep: string = '';
  @State fHeart: string = '';
  @State fWater: string = '';
  @State fExercise: string = '';
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;

  aboutToAppear(): void {
    this.refresh();
  }

  async refresh(): Promise<void> {
    try {
      await HealthDao.initSeedData(this.context);
      this.records = await HealthDao.queryRange(this.context, HealthDao.daysAgo(6), HealthDao.today());
      this.todayRecord = await HealthDao.queryByDate(this.context, HealthDao.today());
      const trend = await HealthDao.weightTrend(this.context);
      this.avgWeight = trend.current;
      this.lastWeight = trend.last;
      this.summary = await HealthDao.weekSummary(this.context);
      this.sleepBars = this.records.map((r: HealthRecord) => {
        const bar: SleepBar = { date: r.recordDate, value: r.sleepHours };
        return bar;
      });
    } catch (e) {
      promptAction.showToast({ message: `加载失败: ${e}` });
    }
  }

  /** 体重趋势文案与颜色 */
  private trendText(): string {
    if (this.avgWeight === 0 || this.lastWeight === 0) {
      return '数据不足';
    }
    const diff = this.avgWeight - this.lastWeight;
    return diff > 0 ? `↑ 较上周 +${diff.toFixed(1)}kg` : `↓ 较上周 ${diff.toFixed(1)}kg`;
  }

  private trendColor(): string {
    if (this.avgWeight === 0 || this.lastWeight === 0) {
      return '#9CA3AF';
    }
    return this.avgWeight - this.lastWeight > 0 ? '#EF4444' : '#059669';
  }

  private isToday(date: string): boolean {
    return date === HealthDao.today();
  }

  openAdd(): void {
    this.editing = null;
    this.fWeight = '';
    this.fSteps = '';
    this.fSleep = '';
    this.fHeart = '';
    this.fWater = '';
    this.fExercise = '';
    this.formVisible = true;
  }

  openEdit(r: HealthRecord): void {
    this.editing = r;
    this.fWeight = String(r.weight);
    this.fSteps = String(r.steps);
    this.fSleep = String(r.sleepHours);
    this.fHeart = String(r.heartRate);
    this.fWater = String(r.water);
    this.fExercise = String(r.exerciseMinutes);
    this.formVisible = true;
  }

  async onSave(): Promise<void> {
    const date = this.editing ? this.editing.recordDate : HealthDao.today();
    if (!this.editing) {
      const existing = await HealthDao.queryByDate(this.context, date);
      if (existing) {
        promptAction.showToast({ message: '今天已有记录,请编辑' });
        return;
      }
    }
    const record: HealthRecord = {
      id: this.editing ? this.editing.id : 0,
      recordDate: date,
      weight: parseFloat(this.fWeight) || 0,
      steps: parseInt(this.fSteps) || 0,
      sleepHours: parseFloat(this.fSleep) || 0,
      heartRate: parseInt(this.fHeart) || 0,
      water: parseInt(this.fWater) || 0,
      exerciseMinutes: parseInt(this.fExercise) || 0,
      remark: '',
    };
    try {
      if (this.editing) {
        await HealthDao.update(this.context, record);
      } else {
        await HealthDao.insert(this.context, record);
      }
      this.formVisible = false;
      await this.refresh();
      promptAction.showToast({ message: '💾 健康记录已保存' });
    } catch (e) {
      promptAction.showToast({ message: `保存失败: ${e}` });
    }
  }

  deleteRecord(r: HealthRecord): void {
    promptAction.showDialog({
      title: '删除记录',
      message: `删除 ${r.recordDate} 的健康记录?`,
      buttons: [
        { text: '取消', color: '#808080' },
        { text: '删除', color: '#EF4444' },
      ],
    }).then((res: promptAction.ShowDialogSuccessResponse) => {
      if (res.index === 1) {
        HealthDao.delete(this.context, r.id).then(async () => {
          await this.refresh();
          promptAction.showToast({ message: '🗑 已删除' });
        });
      }
    });
  }

  @Builder
  MetricPanel(label: string, value: string) {
    Column() {
      Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0EA5E9')
      Text(label).fontSize(11).fontColor('#6B7280').margin({ top: 4 })
    }
    .layoutWeight(1).padding({ top: 14, bottom: 14 })
    .backgroundColor(Color.White).borderRadius(10)
  }

  build() {
    Stack() {
      Column() {
        // ===== 标题栏 =====
        Row() {
          Column() {
            Text('❤️ 健康数据').fontSize(22).fontWeight(FontWeight.Bold)
            Text('记录每一天 · 看见变化').fontSize(11).fontColor('#999999').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Text('↻').fontSize(22).onClick(() => this.refresh())
        }.width('100%').padding({ left: 16, right: 16, top: 12 })

        Scroll() {
          Column() {
            // ===== 今日概览卡 =====
            Text('今日健康').fontSize(14).fontWeight(FontWeight.Bold).margin({ top: 10 })
            Row({ space: 8 }) {
              this.MetricPanel('体重', `${this.todayRecord?.weight ?? '-'}kg`)
              this.MetricPanel('步数', `${this.todayRecord?.steps ?? '-'}`)
              this.MetricPanel('睡眠', `${this.todayRecord?.sleepHours ?? '-'}h`)
              this.MetricPanel('心率', `${this.todayRecord?.heartRate ?? '-'}bpm`)
            }.width('94%').margin({ top: 8 })

            // ===== 本周趋势卡 =====
            Text('本周趋势').fontSize(14).fontWeight(FontWeight.Bold).margin({ top: 14 })
            Column() {
              Row() {
                Text('体重趋势').fontSize(13).fontWeight(FontWeight.Bold)
                Text(this.trendText()).fontSize(12).fontColor(this.trendColor()).layoutWeight(1).textAlign(TextAlign.End)
              }.width('100%')
              Text('近 7 天睡眠').fontSize(13).fontWeight(FontWeight.Bold).margin({ top: 12 })
              Row({ space: 4 }) {
                ForEach(this.sleepBars, (bar: SleepBar) => {
                  Column() {
                    Column()
                      .width(18)
                      .height(Math.max(bar.value * 8, 2))
                      .borderRadius(4)
                      .backgroundColor(this.isToday(bar.date) ? '#0EA5E9' : '#BAE6FD')
                    Text(bar.date.substring(8, 10)).fontSize(9).fontColor('#9CA3AF').margin({ top: 4 })
                  }
                }, (bar: SleepBar) => bar.date)
              }.width('100%').justifyContent(FlexAlign.SpaceBetween).margin({ top: 10 })
            }
            .padding(14).backgroundColor(Color.White).borderRadius(12).width('94%').margin({ top: 8 })

            // ===== 周汇总卡 =====
            Text('本周汇总').fontSize(14).fontWeight(FontWeight.Bold).margin({ top: 14 })
            Row({ space: 8 }) {
              Column() {
                Text(String(Math.round(this.summary.avgSteps))).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#F59E0B')
                Text('日均步数').fontSize(10).fontColor('#6B7280').margin({ top: 2 })
              }.layoutWeight(1)
              Column() {
                Text(`${Math.round(this.summary.avgWater)}ml`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0EA5E9')
                Text('日均饮水').fontSize(10).fontColor('#6B7280').margin({ top: 2 })
              }.layoutWeight(1)
              Column() {
                Text(`${Math.round(this.summary.avgExercise)}min`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#059669')
                Text('日均运动').fontSize(10).fontColor('#6B7280').margin({ top: 2 })
              }.layoutWeight(1)
            }
            .padding(14).backgroundColor(Color.White).borderRadius(12).width('94%').margin({ top: 8 })

            // ===== 记录列表 =====
            Text('近 7 天记录').fontSize(14).fontWeight(FontWeight.Bold).margin({ top: 14 })
            ForEach(this.records, (r: HealthRecord) => {
              Row() {
                Column() {
                  Text(r.recordDate).fontSize(13).fontWeight(FontWeight.Bold)
                  Text(r.remark || '—').fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
                }.alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(`体重 ${r.weight}kg`).fontSize(12)
                Text(`步数 ${r.steps}`).fontSize(12).margin({ left: 10 })
                Text(`睡眠 ${r.sleepHours}h`).fontSize(12).margin({ left: 10 })
              }
              .width('94%').padding(12).backgroundColor(Color.White).borderRadius(10)
              .margin({ top: 8 }).onClick(() => this.openEdit(r))
            }, (r: HealthRecord) => r.recordDate)
          }
          .padding({ bottom: 20 })
        }
        .width('100%').layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%').backgroundColor('#F0F5FA')

      // ===== 悬浮新增按钮 =====
      Text('+')
        .fontSize(26).fontColor(Color.White)
        .width(52).height(52).borderRadius(26)
        .backgroundColor('#0EA5E9')
        .textAlign(TextAlign.Center)
        .position({ x: '82%', y: '84%' })
        .onClick(() => this.openAdd())

      // ===== 记录表单弹窗 =====
      if (this.formVisible) {
        Column() {
          Text(this.editing ? '编辑记录' : '新增记录').fontSize(18).fontWeight(FontWeight.Bold)
          TextInput({ placeholder: '体重 kg', text: this.fWeight }).margin({ top: 10 }).type(InputType.Number).onChange((v: string) => this.fWeight = v)
          TextInput({ placeholder: '步数', text: this.fSteps }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fSteps = v)
          TextInput({ placeholder: '睡眠时长 h', text: this.fSleep }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fSleep = v)
          TextInput({ placeholder: '静息心率 bpm', text: this.fHeart }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fHeart = v)
          TextInput({ placeholder: '饮水 ml', text: this.fWater }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fWater = v)
          TextInput({ placeholder: '运动时长 min', text: this.fExercise }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fExercise = v)
          Row({ space: 8 }) {
            Button('取消').layoutWeight(1).backgroundColor('#EEF2F7').fontColor('#555555')
              .onClick(() => this.formVisible = false)
            Button('保存').layoutWeight(1).backgroundColor('#0EA5E9')
              .onClick(() => this.onSave())
          }.margin({ top: 16 })
        }
        .padding(20).borderRadius(16).backgroundColor(Color.White).width('90%')
        .position({ x: '5%', y: '8%' })
      }
    }
    .width('100%').height('100%')
  }
}

三、注册与运行

  1. main_pages.json 追加 "pages/samples/HealthPage"
  2. Index.ets 追加:
    Button('❤️ 14 健康数据').fontSize(15).width('70%')
      .onClick(() => this.getUIContext().getRouter().pushUrl({ url: 'pages/samples/HealthPage' }))
    
  3. 构建验证 → BUILD SUCCESSFUL

四、运行效果描述

进入「❤️ 14 健康数据」:

第一屏:标题栏「❤️ 健康数据 · 记录每一天 · 看见变化」;「今日健康」四格(体重 72.3kg / 步数 8300 / 睡眠 7.5h / 心率 65bpm 天蓝色大数字);「本周趋势」卡——体重趋势「↓ 较上周 -1.1kg」绿色 + 近 7 天睡眠柱状图(7 根蓝柱,今天深蓝,柱高随睡眠 6.0~8.0h 起伏);「本周汇总」三格(日均步数 8457 / 日均饮水 1585ml / 日均运动 32min);下方近 7 天记录列表(日期 + 备注 + 体重/步数/睡眠);右下角天蓝色悬浮「+」。

交互一(编辑记录):点 6 天前那行 → 弹窗预填数值 → 把睡眠 6.8 改成 8.0 → 保存 → 柱状图那根柱变高、今日概览不变、Toast「💾 健康记录已保存」。

交互二(新增冲突提示):点悬浮「+」→ 弹窗空表单 → 保存 → Toast「今天已有记录,请编辑」(今日记录已存在,UNIQUE 防线)。

交互三(删除记录):长按某行 → 确认框 → 删除 → 列表少一行、周汇总与柱状图同步刷新。

交互四(趋势联动):编辑多日记录让体重下降更多 → 趋势箭头数字变化(-1.1 → -1.5kg)。

五、代码质量要点回顾

关注点 本实例做法
周日均 五 AVG 一次查询
趋势对比 两子查询均值之差 + 区间互斥
柱状图 Column 高度缩放 + 今天高亮
@Builder 复用 MetricPanel 四卡
同日去重 页面先查 + UNIQUE 约束
数值输入 InputType.Number + parseFloat 兜底

六、文章小结

实例 14「健康数据」收官。五篇文章覆盖:多指标字段与日期维度(14-1)→ 数据仪表盘 UI(14-2)→ 周汇总与趋势对比(14-3)→ 14 天种子(14-4)→ 全量代码(14-5)。核心技术是日期范围的聚合查询(五 AVG 一次查询)、趋势对比(两周均值之差 + 区间互斥边界)、零依赖柱状图(Column 高度缩放)。这是「数值统计类应用」的完整范式——健康、财务统计、传感器数据的通用模型。

Logo

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

更多推荐