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

实例:健康数据(Health)|风格:数据仪表盘

一、设计理念:健康页要「一眼看懂」

健康数据的 UI 目标是运动手环/健康 App——一屏看懂「今天状态 + 本周趋势」。实例 14 的风格「数据仪表盘」:概览卡片(大数字)+ 趋势柱状图(柱条)+ 明细列表,信息密度高但层级分明。

页面信息架构:

  1. 标题栏:健康数据 + 标语 + 刷新;
  2. 今日概览卡:体重/步数/睡眠/心率四指标大数字——「今天」的仪表盘;
  3. 本周趋势卡:体重周变化(↑↓箭头)+ 近 7 天睡眠柱状图(Text 柱条模拟);
  4. 本周汇总卡:步数日均/饮水日均/运动日均——周汇总数字;
  5. 记录列表:近 7 天健康记录明细(日期 + 六指标);
  6. 记录表单:新增/编辑当日记录(六指标输入)。

二、页面骨架与状态定义

@State records: HealthRecord[] = [];
@State todayRecord: HealthRecord | null = null;
@State weekAvgWeight: number = 0;
@State weekAvgSteps: number = 0;
@State weekAvgSleep: number = 0;
@State weekAvgWater: number = 0;
@State weekAvgExercise: number = 0;
@State lastWeekWeight: number = 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 = '';

状态组与数据组对应:records(列表)、todayRecord(今日概览)、weekAvg*(周汇总)、lastWeekWeight(趋势对比)——页面数据流 = 今日 + 本周 + 上周三类查询结果。

三、今日概览卡:四指标大数字

顶部「今日健康」卡片,四格大数字:

Row({ space: 8 }) {
  MetricPanel('体重', `${this.todayRecord?.weight ?? '-'}kg`)
  MetricPanel('步数', `${this.todayRecord?.steps ?? '-'}`)
  MetricPanel('睡眠', `${this.todayRecord?.sleepHours ?? '-'}h`)
  MetricPanel('心率', `${this.todayRecord?.heartRate ?? '-'}bpm`)
}.width('94%').margin({ top: 10 })

MetricPanel 抽成 @Builder(本实例重要的 UI 复用):

@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)
}

@Builder 复用:四个指标卡用同一个 Builder 渲染(label + value),值用数据驱动——「体重 73.5kg / 步数 8321 / 睡眠 7.5h / 心率 65bpm」。无今日记录时显示 ‘-’(null 兜底)。

四、本周趋势卡:体重变化 + 睡眠柱状图

本周趋势卡包含两个可视化:

1. 体重周变化(对比上周)

Row() {
  Text('体重趋势').fontSize(14).fontWeight(FontWeight.Bold)
  Text(this.trendText).fontSize(12).fontColor(this.trendColor).layoutWeight(1).textAlign(TextAlign.End)
}.width('100%')

trendText 与 trendColor 的计算:

private trendText(): string {
  const diff = this.weekAvgWeight - this.lastWeekWeight;
  if (this.weekAvgWeight === 0 || this.lastWeekWeight === 0) {
    return '数据不足';
  }
  return diff > 0 ? `↑ 较上周 +${diff.toFixed(1)}kg` : `↓ 较上周 ${diff.toFixed(1)}kg`;
}

趋势语义:本周均值 vs 上周均值——正值(涨)红色、负值(跌)绿色(减重是健康目标)。趋势 = 对比查询:两组均值相减。

2. 近 7 天睡眠柱状图(Text 柱条模拟)

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)
}

柱状图的零依赖实现:每根柱 = 一个 Column().height(value * 8)——高度按睡眠时长缩放(7.5h → 60 高度),今天高亮(深蓝)。用布局高度画柱状图——不引入图表库,纯 ArkUI 组件拼出趋势视觉。SleepBar 接口{ date: string; value: number }

五、本周汇总卡:五指标日均

本周汇总(日均)数字排:

Row({ space: 8 }) {
  Column() { Text(String(this.weekAvgSteps)).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#F59E0B'); Text('日均步数').fontSize(10).fontColor('#6B7280') }.layoutWeight(1)
  Column() { Text(`${this.weekAvgWater}ml`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0EA5E9'); Text('日均饮水').fontSize(10).fontColor('#6B7280') }.layoutWeight(1)
  Column() { Text(`${this.weekAvgExercise}min`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#059669'); Text('日均运动').fontSize(10).fontColor('#6B7280') }.layoutWeight(1)
}
.padding(14).backgroundColor(Color.White).borderRadius(12).width('94%').margin({ top: 10 })

周汇总数字:日均步数/饮水/运动——由 DAO 的周 AVG 查询供给(14-3 详解)。一行三格的紧凑汇总,颜色区分指标。

六、记录列表与记录表单

记录列表(近 7 天明细):

List({ space: 8 }) {
  ForEach(this.records, (r: HealthRecord) => {
    ListItem() {
      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('100%').padding(12).backgroundColor(Color.White).borderRadius(10)
      .onClick(() => this.openEdit(r))
    }
  }, (r: HealthRecord) => r.recordDate)
}
.width('94%').layoutWeight(1).margin({ top: 10 }).scrollBar(BarState.Off)

每行「日期 + 备注 + 体重/步数/睡眠」——点行编辑。

记录表单(新增/编辑六指标):

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%' })
}

InputType.Number:数字键盘——六指标都是数值输入。保存逻辑(新增与编辑分支):

async onSave(): Promise<void> {
  const date = this.editing ? this.editing.recordDate : HealthDao.today();
  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}` });
  }
}

parseFloat/parseInt + || 0:字符串转数值,非法输入兜底 0。同日重复新增:insert 时 DAO 检查 UNIQUE 冲突(14-3 详解)——页面提示「今天已有记录,请编辑」。

七、UI 风格要素一览

风格项 取值 说明
页面背景 #F0F5FA 极浅蓝 健康清爽
主色 #0EA5E9 天蓝 健康指标
概览卡 白底四格大数字 仪表盘
柱状图 Text 柱条 + 高度缩放 零依赖趋势
趋势箭头 ↑ 红 / ↓ 绿 体重对比
数字输入 InputType.Number 数值键盘

八、文章小结

本篇文章完成了实例 14 的 UI 层:今日概览卡(四指标大数字 + @Builder 复用)+ 本周趋势卡(体重对比箭头 + 柱状图)+ 周汇总卡 + 记录列表 + 六指标表单。设计核心是「仪表盘感」——大数字概览 + 柱状趋势一眼看懂;技术亮点是零依赖柱状图(Column 高度按值缩放)与 @Builder 抽复用(MetricPanel 四卡同构)。健康页像运动手环,数据可视化全靠 ArkUI 基础组件。

下一篇(14-3)深入数据层,讲解周汇总聚合查询与趋势对比——AVG/SUM + 日期范围是本实例的技术核心。

九、补充:仪表盘布局细节

1. MetricPanel 复用再深入:为什么是 @Builder 而不是自定义组件

四个指标卡(体重/步数/睡眠/心率)结构完全相同——「值 + 标签」上下排列。用 @Builder 抽出来后,build() 里四行调用即可;若改用 @Component 自定义组件,还需额外声明 @Prop 传参,样板代码更多。@Builder 适合「轻量、无状态」的 UI 片段——MetricPanel 恰好如此:只接收 label/value 两个入参、没有内部状态,复用成本最低。

方案 适用场景 本项目选择
@Builder 方法 无状态的 UI 片段、多处复用 ✅ MetricPanel
@Component 组件 有状态、需独立生命周期的单元 ❌ 概览卡无此需求

2. 柱状图高度缩放的边界处理

Math.max(bar.value * 8, 2) 的两个数字各有讲究:8 是缩放系数——睡眠 7.5h → 高度 60,柱条在卡片内比例协调;2 是下限兜底——某天睡眠为 0(未记录)时,0 * 8 = 0 会让柱条不可见,视觉上像「缺了一天」,用 2 保证柱条始终可辨。若数值异常(如 24h),高度 192 也仍在卡片内,无需再设上限。

3. 表单预填逻辑:编辑与新增共用一套状态

fWeight 等六个 @State 字符串同时服务「新增」与「编辑」——openEdit(r) 时把记录的六项数值转成字符串填入输入框,新增时则清空。核心代码:

openEdit(r: HealthRecord) {
  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;
}

预填的意义:编辑时用户看到的是当前值而非空输入框,改一个指标即可保存——表单的「可感知性」是编辑体验的关键。而 onSave 里 parseFloat(this.fWeight) || 0 与预填的 String 转换正好构成「数字 ↔ 字符串」的双向转换闭环。

4. FAQ

Q1:四个指标卡直接写四个 Column 不行吗?为什么要 @Builder?
可以,但四个 Column 会重复约 12 行结构代码;抽成 @Builder 后 build() 只剩四行调用,且日后想加角标、改配色只需改一处。复用的收益在「变更成本」——结构越相似,越值得抽。

Q2:柱状图为什么用 height 缩放而不是 Canvas 绘制?
Canvas 需要自己管理坐标、刻度、标签,代码量是 Text 柱条的 3 倍以上;而本页柱状图只有 7 根柱、无坐标轴需求,Column().height(value * 8) 一行即完成「数值 → 高度」映射。零依赖方案的取舍:功能够用、代码最少,复杂图表再上 Canvas 或图表库。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐