统计 Tab 从最初的四维数字卡片扩展成了三个区块——概览卡片、月历活跃图、心情分布柱状图。月历用日期网格展示每天封印了多少胶囊,心情分布用水平进度条对比不同情绪的使用频率。这两个可视化组件把"数字"变成了"图形",让用户一眼就能感知自己的记录习惯和情绪模式。

完整效果
在这里插入图片描述

一、统计页面的三个区块

区块 内容 数据来源
概览卡片 总数/已开启/等待中/可开启 StatsSummary
本月活跃 日历网格 + 每日计数 WeeklyMood / Calendar
心情分布 水平进度条 + 计数 TagCount

新增的数据类型

从 import 推断,统计页需要这些数据结构:

// 从 model/Database.ets 导入
interface WeeklyMood { ... }    // 周心情数据
interface TagCount { ... }      // 标签/心情计数
interface Calendar { ... }      // 月历数据

二、概览卡片的 2×2 网格

Flex({ wrap: FlexWrap.Wrap }) {
  Column() {
    Text(String(this.statsSummary.total))
      .fontSize(FontSize.hero).fontColor(this.getColors().primary)
      .fontWeight(FontWeight.Bold)
    Text('总数').fontSize(FontSize.xs)
      .fontColor(this.getColors().textSecondary)
  }
  .width('48%').padding(Spacing.md)
  .margin({ right: '2%', bottom: Spacing.sm })
  .backgroundColor(this.getColors().surface)
  .borderRadius(BorderRadius.md)
  .border({ width: 1, color: this.getColors().border })
  .alignItems(HorizontalAlign.Center)

  // opened / waiting / ready 同结构...
}
.width('100%')

在这里插入图片描述

Flex 2×2 网格的布局原理

Flex(wrap: Wrap)
  ├→ Column(width: 48%) + margin-right: 2%  ← 第一列
  ├→ Column(width: 48%)                      ← 第二列
  ├→ Column(width: 48%) + margin-right: 2%  ← 第二行第一列
  └→ Column(width: 48%)                      ← 第二行第二列

48% + 2% margin = 50%,两列刚好占满一行。 FlexWrap.Wrap 自动换行——前两个元素占第一行,后两个元素换到第二行。这是 ArkTS 里实现网格布局的标准方式——不需要 Grid 组件,Flex 就够了。

四个数字的颜色编码

卡片 颜色 含义
总数 primary(主色) 所有胶囊的总和
已开启 #10B981 绿色 成功、完成
等待中 #F59E0B 黄色 进行中、等待
可开启 #8B5CF6 紫色 可操作、特殊

颜色不只是装饰——它传递语义。 绿色=完成,黄色=进行中,紫色=特殊状态。用户扫一眼颜色就能理解数字的含义,不需要读文字标签。

三、本月活跃的月历视图

月历的渲染结构

Column(本月活跃区块)
  ├→ Row(标题行)
  │    └→ Text('本月活跃')
  └→ Column(日期网格)
       ├→ Row(星期头)日 一 二 三 四 五 六
       ├→ Row(第一周)1  2  3  4  5  6  7
       ├→ Row(第二周)8  9  10 11 12 13 14
       ├→ Row(第三周)15 16 17 18 19 20 21
       ├→ Row(第四周)22 23 24 25 26 27 28
       └→ Row(第五周)29 30 31

日期单元格的三种状态

状态 条件 背景色 文字色
今天 isToday(day) primary 白色
有记录 dayCount > 0 primary + ‘20’ primary
无记录 dayCount === 0 transparent textSecondary

今天最醒目——主色实心背景。 有记录的日期用 20% 透明度主色背景——和 MoodPicker 的选中背景用同样的透明度。无记录的日期最弱——只有灰色文字。

月历数据的计算

private getDaysInMonth(): number {
  const now = new Date()
  return new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
}

private getFirstDayOfWeek(): number {
  const now = new Date()
  return new Date(now.getFullYear(), now.getMonth(), 1).getDay()
}

在这里插入图片描述

new Date(year, month+1, 0).getDate() 是获取某月天数的技巧。 传入下个月第 0 天——自动回退到本月最后一天,返回日期就是本月天数。

getFirstDayOfWeek() 返回本月第一天是星期几。 用于确定日期网格的起始偏移——如果本月 1 号是星期三,前面需要空 3 个格子。

每日活跃度的颜色深浅

从截图看,有胶囊记录的日期背景色深浅不同——记录越多颜色越深:

private getDayOpacity(count: number): number {
  if (count === 0) return 0
  if (count === 1) return 0.2
  if (count === 2) return 0.4
  return 0.6
}

透明度随记录数量递增——0/20%/40%/60%。 但有上限(60%)——太深会影响日期数字的可读性。这种"热力图"式的设计让用户一眼看出哪天记录最多。

四、心情分布的水平进度条

渲染结构

Column(心情分布区块)
  ├→ Text('心情分布')
  └→ ForEach(MOOD_OPTIONS)
       Row(一行)
         ├→ Text(opt.emoji)         心情图标(36px 宽)
         ├→ Text(opt.label)         心情名称(48px 宽)
         ├→ Row (进度条容器)         弹性宽度
         │    └→ Row (进度条填充)     宽度按比例
         └→ Text(count)             计数(28px 宽)

进度条的宽度计算

private getMoodBarPct(emoji: string): string {
  const count = this.getMoodCount(emoji)
  const max = this.maxMoodVal()
  const pct = max > 0 ? Math.floor(count / max * 100) : 0
  return String(pct) + '%'
}

private maxMoodVal(): number {
  let m = 0
  const keys = Object.keys(this.moodStats)
  for (let i = 0; i < keys.length; i++) {
    if (this.moodStats[keys[i]] > m) m = this.moodStats[keys[i]]
  }
  return m > 0 ? m : 1
}

归一化计算——以最大值为 100%。 如果"开心"有 5 次(最多),它的进度条占 100% 宽度。"难过"有 2 次,进度条占 40%。这样进度条的长度反映的是"相对频率"而非绝对数量。

进度条的渐变效果

Row().height(14)
  .width(this.getMoodBarPct(emoji))
  .backgroundColor(this.getMoodCount(emoji) > 0
    ? this.getColors().primary : this.getColors().border)
  .borderRadius(BorderRadius.sm)

有记录时用主色背景,无记录时用边框灰色。 进度条高度 14px——和统计卡片的数字相比更矮,因为它不是核心信息,只是辅助可视化。

五种心情的固定顺序

ForEach(MOOD_OPTIONS, (opt: MoodOption) => { ... })

心情顺序固定——和 MOOD_OPTIONS 定义的顺序一致。 不按使用频率排序——因为心情有固定的逻辑顺序(开心→难过→思考→生气→庆祝),打乱顺序会降低辨识度。

五、数据库新增的统计方法

从 import 推断

// 从 model/Database.ets 导入
interface CapsuleStats {
  total: number
  opened: number
  sealed: number    // 等待中
  ready: number     // 可开启
  moodCount: Record<string, number>  // 心情分布
}

新增的统计维度

维度 数据结构 用途
心情分布 Record<string, number> 心情进度条
月历数据 Calendar 日期网格
周数据 WeeklyMood 可能用于周视图

getStats 的扩展

getStats(): CapsuleStats {
  const now = Date.now()
  let total = this.capsules.length
  let opened = 0
  let sealed = 0
  let ready = 0
  const moodCount: Record<string, number> = {}

  for (let i = 0; i < this.capsules.length; i++) {
    const c = this.capsules[i]
    if (c.isOpened) {
      opened++
    } else if (c.unlockAt > now) {
      sealed++
    } else {
      ready++
    }
    // 统计心情
    const mood = c.mood
    moodCount[mood] = (moodCount[mood] || 0) + 1
  }

  return { total, opened, sealed, ready, moodCount }
}

一次遍历统计所有维度——不需要多次循环。 遍历一次 capsules 数组,同时统计状态和心情。这是性能优化的基本原则——减少循环次数。

六、月历的热力图原理

日期计数的获取

private getDayCount(day: number): number {
  const now = new Date()
  const year = now.getFullYear()
  const month = now.getMonth()
  let count = 0
  for (let i = 0; i < this.capsules.length; i++) {
    const c = this.capsules[i]
    const d = new Date(c.createdAt)
    if (d.getFullYear() === year && d.getMonth() === month && d.getDate() === day) {
      count++
    }
  }
  return count
}

遍历所有胶囊,匹配年月日。 这是最直接的实现——性能不是问题(胶囊数量通常在百级别)。如果数据量大,可以用 Map 预计算每日计数。

今天日期的判断

private isToday(day: number): boolean {
  const now = new Date()
  return day === now.getDate()
}

只比较日期——不比较年月。 因为月历只显示当月,年月已经确定。只比较 getDate() 即可。

七、统计 Tab 的数据刷新时机

// 首页底部导航栏点击
.onClick(() => {
  this.currentTab = tab.key
  if (tab.key === 'stats' || tab.key === 'timeline') {
    this.loadStats()
  }
})

切换到统计 Tab 时自动刷新——保证数据最新。 如果用户在封印 Tab 新增了胶囊,切换到统计 Tab 时总数和心情分布都会更新。

onPageShow 也刷新

onPageShow(): void {
  if (this.database) {
    this.capsules = this.database.getAllCapsules()
    this.loadStats()
  }
}

从其他页面返回时也刷新。 比如用户从详情页返回,如果在详情页开启了胶囊,统计 Tab 的"已开启"数量需要更新。

八、统计页面的嵌套结构

Column                              页面容器
  ├→ Row (标题)                     "统计"
  └→ Scroll                         可滚动
       └→ Column                    垂直排列
            ├→ Flex (概览卡片)       2×2 网格
            ├→ Column (本月活跃)     日历网格
            └→ Column (心情分布)     进度条列表

嵌套深度 4 层——和首页一样。 Scroll 包裹所有内容——月历在小屏手机上可能需要滚动。

九、三个可视化组件的设计对比

组件 布局方式 数据维度 交互
概览卡片 Flex 2×2 4 个数字
月历 Row×N 网格 31 个日期
心情进度条 ForEach 列表 5 种心情

三个组件都没有交互——统计页是纯展示页面。 用户不需要点击统计图表(目前没有"点击日期查看详情"的功能)。这是原型阶段的设计——先展示数据,后续再加交互。

十、从数字到图形的演进

基础版统计

总数: 12
已开启: 5
等待中: 6
可开启: 1

优化版统计

┌──────┐ ┌──────┐
│  12  │ │   5  │
│ 总数 │ │已开启│
├──────┤ ├──────┤
│   6  │ │   1  │
│等待中│ │可开启│
└──────┘ └──────┘

本月活跃              心情分布
日 一 二 三 四 五 六   😊 开心  ████████ 5
   1  2  3  4  5  6   😢 难过  ████     2
 7  8  9 10 11 12 13   🤔 思考  ██████   3
14 15 16 17 18 19 20   😤 生气  █        1
21 22 23 24 25 26 27   🥳 庆祝  ██       2
28 29 30 31

在这里插入图片描述

从纯数字到图形化展示——信息密度提升,认知成本下降。 用户不需要逐个阅读数字再在脑子里汇总——月历一眼看出哪天记录多,心情条一眼看出哪种情绪最多。

这是数据可视化的核心价值: 不是让数据"更好看",而是让数据"更容易理解"。

Logo

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

更多推荐