HarmonyOS时光胶囊——统计页面的月历与心情可视化

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

完整效果

一、统计页面的三个区块

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

新增的数据类型

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

// 从 model/Database.ets 导入interfaceWeeklyMood{...}// 周心情数据interfaceTagCount{...}// 标签/心情计数interfaceCalendar{...}// 月历数据

二、概览卡片的 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 > 0primary + ‘20’primary
无记录dayCount === 0transparenttextSecondary

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

月历数据的计算

privategetDaysInMonth():number{constnow=newDate()returnnewDate(now.getFullYear(),now.getMonth()+1,0).getDate()}privategetFirstDayOfWeek():number{constnow=newDate()returnnewDate(now.getFullYear(),now.getMonth(),1).getDay()}

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

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

每日活跃度的颜色深浅

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

privategetDayOpacity(count:number):number{if(count===0)return0if(count===1)return0.2if(count===2)return0.4return0.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 宽)

进度条的宽度计算

privategetMoodBarPct(emoji:string):string{constcount=this.getMoodCount(emoji)constmax=this.maxMoodVal()constpct=max>0?Math.floor(count/max*100):0returnString(pct)+'%'}privatemaxMoodVal():number{letm=0constkeys=Object.keys(this.moodStats)for(leti=0;i<keys.length;i++){if(this.moodStats[keys[i]]>m)m=this.moodStats[keys[i]]}returnm>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 导入interfaceCapsuleStats{total:numberopened:numbersealed:number// 等待中ready:number// 可开启moodCount:Record<string,number>// 心情分布}

新增的统计维度

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

getStats 的扩展

getStats():CapsuleStats{constnow=Date.now()lettotal=this.capsules.lengthletopened=0letsealed=0letready=0constmoodCount:Record<string,number>={}for(leti=0;i<this.capsules.length;i++){constc=this.capsules[i]if(c.isOpened){opened++}elseif(c.unlockAt>now){sealed++}else{ready++}// 统计心情constmood=c.mood moodCount[mood]=(moodCount[mood]||0)+1}return{total,opened,sealed,ready,moodCount}}

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

六、月历的热力图原理

日期计数的获取

privategetDayCount(day:number):number{constnow=newDate()constyear=now.getFullYear()constmonth=now.getMonth()letcount=0for(leti=0;i<this.capsules.length;i++){constc=this.capsules[i]constd=newDate(c.createdAt)if(d.getFullYear()===year&&d.getMonth()===month&&d.getDate()===day){count++}}returncount}

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

今天日期的判断

privateisToday(day:number):boolean{constnow=newDate()returnday===now.getDate()}

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

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

// 首页底部导航栏点击.onClick(()=>{this.currentTab=tab.keyif(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×24 个数字
月历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

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

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