如果你想做一个症状追踪App,需要用到关系型数据库和日历热图
如果你身边有华为手机或者鸿蒙设备,欢迎去鸿蒙应用市场搜索「更年记」,下载体验一下这个症状追踪App。用数据记录身体变化,用热力日历看清趋势。
写在前面
大家好,我是一名鸿蒙开发者。今天要聊的App叫「更年记」,是一个面向更年期女性的症状追踪工具。你可能觉得这个话题有点小众,但实际上身体症状的日常追踪是一个很实用的需求——不管是更年期症状、还是过敏反应、慢性疼痛、情绪波动,都需要长期的记录和回看,才能发现规律、辅助就医。
更年记的核心功能是:每天记录自己的症状(潮热、出汗、失眠、情绪波动等)和严重程度,然后用一个热力日历来展示——有症状的日期颜色深,没症状的日期颜色浅,一眼就能看到症状的分布规律。这篇文章重点讲关系型数据库的复杂查询(按日期范围查询、聚合统计)以及日历数据结构的实现。
这篇文章聊什么
- 第一步:设计数据库表结构,搞清楚要存什么数据
- 第二步:React 版本的日历热图和症状记录逻辑
- 第三步:ArkTS 实现数据库初始化和建表
- 第四步:ArkTS 实现症状录入和查询
- 第五步:ArkTS 实现日历热力图组件
- 第六步:ArkTS 实现聚合统计查询
- 流程图
- React vs ArkTS 对比表
第一步:数据库表结构设计
在写代码之前,先想清楚数据怎么组织。我们需要存三样东西:
1. 症状记录(symptoms 表):每天可能有多条记录,每条包含日期、症状类型、严重程度、持续时间、备注。
2. 情绪标签(moods 表):记录当天的整体情绪状态。
3. 潮热记录(hot_flashes 表):潮热是更年期最典型的症状,单独一张表来详细记录每次潮热的时间、持续时长、触发因素。
表结构如下:
-- 症状记录表
CREATE TABLE IF NOT EXISTS symptoms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
symptom_type TEXT NOT NULL,
severity INTEGER DEFAULT 1,
duration_min INTEGER DEFAULT 0,
note TEXT,
timestamp INTEGER
);
-- 情绪标签表
CREATE TABLE IF NOT EXISTS moods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
mood_type TEXT NOT NULL,
energy_level INTEGER DEFAULT 5,
note TEXT,
timestamp INTEGER
);
-- 潮热记录表
CREATE TABLE IF NOT EXISTS hot_flashes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
start_time TEXT,
duration_sec INTEGER DEFAULT 0,
trigger_factor TEXT,
severity INTEGER DEFAULT 1,
timestamp INTEGER
);
第二步:React 版本的前端实现
// GengJiApp.jsx - React版本
import React, { useState, useEffect } from 'react';
const SYMPTOM_TYPES = ['潮热', '盗汗', '失眠', '头痛', '情绪波动', '关节疼痛', '疲劳', '心悸'];
const MOOD_TYPES = ['平静', '开心', '焦虑', '低落', '烦躁', '疲倦'];
function GengJiApp() {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [symptoms, setSymptoms] = useState([]);
const [moods, setMoods] = useState([]);
const [selectedDate, setSelectedDate] = useState(null);
const [showRecordForm, setShowRecordForm] = useState(false);
// 生成当月日历网格数据
const generateCalendarGrid = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const grid = [];
for (let i = 0; i < firstDay; i++) grid.push(null);
for (let d = 1; d <= daysInMonth; d++) grid.push(new Date(year, month, d));
return grid;
};
// 获取某天的症状严重度(用于热力图颜色深浅)
const getDateSeverity = (date) => {
if (!date) return 0;
const dateStr = date.toISOString().split('T')[0];
const daySymptoms = symptoms.filter(s => s.date === dateStr);
if (daySymptoms.length === 0) return 0;
const maxSeverity = Math.max(...daySymptoms.map(s => s.severity));
return maxSeverity;
};
// 按日期查询症状
const queryByDateRange = (startDate, endDate) => {
return symptoms.filter(s => s.date >= startDate && s.date <= endDate);
};
// 统计本月各症状出现次数
const getMonthlyStats = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const prefix = `${year}-${String(month + 1).padStart(2, '0')}`;
const monthSymptoms = symptoms.filter(s => s.date.startsWith(prefix));
return SYMPTOM_TYPES.map(type => ({
type,
count: monthSymptoms.filter(s => s.symptom_type === type).length,
avgSeverity: monthSymptoms.filter(s => s.symptom_type === type).length > 0
? (monthSymptoms.filter(s => s.symptom_type === type).reduce((sum, s) => sum + s.severity, 0) / monthSymptoms.filter(s => s.symptom_type === type).length).toFixed(1)
: 0,
})).sort((a, b) => b.count - a.count);
};
// 添加症状记录
const addSymptom = (symptomType, severity) => {
const newRecord = {
id: Date.now(),
date: selectedDate ? selectedDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
symptom_type: symptomType,
severity,
duration_min: 0,
note: '',
timestamp: Date.now(),
};
setSymptoms([...symptoms, newRecord]);
};
const calendarGrid = generateCalendarGrid();
const monthlyStats = getMonthlyStats();
return (
<div style={{ padding: 16, fontFamily: 'Arial', maxWidth: 500, margin: '0 auto' }}>
<h2>更年记 - 症状追踪</h2>
{/* 月份切换 */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<button onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}>上月</button>
<span>{currentMonth.getFullYear()}年{currentMonth.getMonth() + 1}月</span>
<button onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}>下月</button>
</div>
{/* 日历热力图 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, marginBottom: 16 }}>
{['日', '一', '二', '三', '四', '五', '六'].map(d => (
<div key={d} style={{ textAlign: 'center', fontSize: 12, color: '#999' }}>{d}</div>
))}
{calendarGrid.map((date, i) => {
const severity = getDateSeverity(date);
const color = severity === 0 ? '#f5f5f5' : severity <= 2 ? '#FFF9C4' : severity <= 4 ? '#FFCC80' : severity <= 6 ? '#EF9A9A' : '#E57373';
return (
<div key={i} onClick={() => date && setSelectedDate(date)}
style={{ aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 8, backgroundColor: color, fontSize: 14, cursor: date ? 'pointer' : 'default',
border: selectedDate && date && selectedDate.getDate() === date.getDate() ? '2px solid #2196F3' : 'none' }}>
{date ? date.getDate() : ''}
</div>
);
})}
</div>
{/* 选中日期的症状 */}
{selectedDate && (
<div style={{ marginBottom: 16 }}>
<h3>{selectedDate.toISOString().split('T')[0]}</h3>
{SYMPTOM_TYPES.map(type => (
<button key={type} onClick={() => addSymptom(type, 1)} style={{ margin: 2, padding: '4px 8px', fontSize: 13 }}>{type}</button>
))}
<div>
{symptoms.filter(s => s.date === selectedDate.toISOString().split('T')[0]).map(s => (
<div key={s.id} style={{ padding: 4, borderBottom: '1px solid #eee', fontSize: 13 }}>
{s.symptom_type} - 严重度: {s.severity}
</div>
))}
</div>
</div>
)}
{/* 本月统计 */}
<div style={{ padding: 16, backgroundColor: '#fff3e0', borderRadius: 8 }}>
<h3>本月统计</h3>
{monthlyStats.filter(s => s.count > 0).map(s => (
<div key={s.type} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span>{s.type}</span>
<span>{s.count}次 / 平均严重度 {s.avgSeverity}</span>
</div>
))}
</div>
</div>
);
}
export default GengJiApp;
React 版本的要点:日历网格用 grid 布局实现,热力颜色根据症状严重度映射,按日期范围查询用 filter,统计用 reduce 聚合。
第三步:ArkTS 版本 - 数据库初始化
// entry/src/main/ets/utils/GengJiDB.ets
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
export class GengJiDBHelper {
private rdbStore: relationalStore.RdbStore | null = null;
private readonly DB_NAME: string = 'gengji.db';
async init(context: common.UIAbilityContext) {
this.rdbStore = await relationalStore.getRdbStore(context, {
name: this.DB_NAME,
securityLevel: relationalStore.SecurityLevel.S2,
});
await this.createTables();
}
private async createTables() {
if (!this.rdbStore) return;
// 症状记录表
await this.rdbStore.executeSql(`
CREATE TABLE IF NOT EXISTS symptoms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
symptom_type TEXT NOT NULL,
severity INTEGER DEFAULT 1,
duration_min INTEGER DEFAULT 0,
note TEXT,
timestamp INTEGER
)
`);
// 情绪标签表
await this.rdbStore.executeSql(`
CREATE TABLE IF NOT EXISTS moods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
mood_type TEXT NOT NULL,
energy_level INTEGER DEFAULT 5,
note TEXT,
timestamp INTEGER
)
`);
// 潮热记录表
await this.rdbStore.executeSql(`
CREATE TABLE IF NOT EXISTS hot_flashes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
start_time TEXT,
duration_sec INTEGER DEFAULT 0,
trigger_factor TEXT,
severity INTEGER DEFAULT 1,
timestamp INTEGER
)
`);
console.info('更年记:数据库建表完成');
}
getStore(): relationalStore.RdbStore {
if (!this.rdbStore) throw new Error('数据库未初始化');
return this.rdbStore;
}
}
export const gengjiDB = new GengJiDBHelper();
第四步:ArkTS 版本 - 症状录入和按日期查询
// entry/src/main/ets/pages/Index.ets
import { relationalStore } from '@kit.ArkData';
import { promptAction } from '@kit.ArkUI';
import { gengjiDB } from '../utils/GengJiDB';
const SYMPTOM_TYPES: string[] = ['潮热', '盗汗', '失眠', '头痛', '情绪波动', '关节疼痛', '疲劳', '心悸'];
const MOOD_TYPES: string[] = ['平静', '开心', '焦虑', '低落', '烦躁', '疲倦'];
interface SymptomRecord {
id: number;
date: string;
symptomType: string;
severity: number;
durationMin: number;
note: string;
timestamp: number;
}
interface DaySummary {
date: string;
count: number;
maxSeverity: number;
}
@Entry
@Component
struct Index {
// 日历状态
@State currentYear: number = new Date().getFullYear();
@State currentMonth: number = new Date().getMonth() + 1; // 1-12
@State selectedDate: string = '';
@State calendarDays: (number | null)[] = [];
// 症状数据
@State symptomList: SymptomRecord[] = [];
@State daySymptoms: SymptomRecord[] = [];
@State monthSummary: Map<string, DaySummary> = new Map();
// 月度统计
@State monthlyStats: { type: string; count: number; avgSeverity: number }[] = [];
// 表单状态
@State selectedSymptom: string = '';
@State selectedSeverity: number = 3;
// ===== 生成日历网格 =====
// 返回一个数组,每个元素是日期数字或null(空白占位)
generateCalendar(): (number | null)[] {
// 先算这个月第一天是星期几(0=周日)
const firstDayOfWeek = new Date(this.currentYear, this.currentMonth - 1, 1).getDay();
// 再算这个月有多少天
const daysInMonth = new Date(this.currentYear, this.currentMonth, 0).getDate();
const days: (number | null)[] = [];
// 前面的空白用null填充
for (let i = 0; i < firstDayOfWeek; i++) {
days.push(null);
}
// 1到N的日期数字
for (let d = 1; d <= daysInMonth; d++) {
days.push(d);
}
return days;
}
// ===== 日期格式化:年-月-日 =====
formatDate(year: number, month: number, day: number): string {
const mStr = month < 10 ? `0${month}` : `${month}`;
const dStr = day < 10 ? `0${day}` : `${day}`;
return `${year}-${mStr}-${dStr}`;
}
// ===== 按日期范围查询症状 =====
// 这是关系型数据库的核心能力:条件查询
async loadMonthSymptoms() {
try {
const store = gengjiDB.getStore();
// 生成当月的日期范围
// 比如当前是2026年6月, startDate = '2026-06-01',endDate = '2026-06-30'
const startDate = this.formatDate(this.currentYear, this.currentMonth, 1);
const endDate = this.formatDate(this.currentYear, this.currentMonth, 31);
// 用 between 查询日期范围内的记录
// between('date', startDate, endDate) 等价于 SQL 的 WHERE date >= startDate AND date <= endDate
const predicates = new relationalStore.RdbPredicates('symptoms');
predicates.between('date', startDate, endDate);
predicates.orderByAsc('date');
const resultSet = await store.query(predicates, null);
const symptoms: SymptomRecord[] = [];
if (resultSet.goToFirstRow()) {
do {
symptoms.push({
id: resultSet.getLong(resultSet.getColumnIndex('id')),
date: resultSet.getString(resultSet.getColumnIndex('date')),
symptomType: resultSet.getString(resultSet.getColumnIndex('symptom_type')),
severity: resultSet.getLong(resultSet.getColumnIndex('severity')),
durationMin: resultSet.getLong(resultSet.getColumnIndex('duration_min')),
note: resultSet.getString(resultSet.getColumnIndex('note')),
timestamp: resultSet.getLong(resultSet.getColumnIndex('timestamp')),
});
} while (resultSet.goToNextRow());
}
resultSet.close();
this.symptomList = symptoms;
// 构建每日汇总 Map
// 遍历所有症状记录,按日期分组统计数量和最大严重度
const summary = new Map<string, DaySummary>();
for (const s of symptoms) {
const existing = summary.get(s.date);
if (existing) {
summary.set(s.date, {
date: s.date,
count: existing.count + 1,
maxSeverity: Math.max(existing.maxSeverity, s.severity),
});
} else {
summary.set(s.date, { date: s.date, count: 1, maxSeverity: s.severity });
}
}
this.monthSummary = summary;
} catch (err) {
console.error('加载月度症状失败:' + JSON.stringify(err));
}
}
// ===== 加载指定日期的症状 =====
async loadDaySymptoms(date: string) {
try {
const store = gengjiDB.getStore();
const predicates = new relationalStore.RdbPredicates('symptoms');
predicates.equalTo('date', date);
predicates.orderByDesc('timestamp');
const resultSet = await store.query(predicates, null);
const symptoms: SymptomRecord[] = [];
if (resultSet.goToFirstRow()) {
do {
symptoms.push({
id: resultSet.getLong(resultSet.getColumnIndex('id')),
date: resultSet.getString(resultSet.getColumnIndex('date')),
symptomType: resultSet.getString(resultSet.getColumnIndex('symptom_type')),
severity: resultSet.getLong(resultSet.getColumnIndex('severity')),
durationMin: resultSet.getLong(resultSet.getColumnIndex('duration_min')),
note: resultSet.getString(resultSet.getColumnIndex('note')),
timestamp: resultSet.getLong(resultSet.getColumnIndex('timestamp')),
});
} while (resultSet.goToNextRow());
}
resultSet.close();
this.daySymptoms = symptoms;
} catch (err) {
console.error('加载日症状失败:' + JSON.stringify(err));
}
}
// ===== 添加症状记录 =====
async addSymptom() {
if (!this.selectedDate || !this.selectedSymptom) {
this.showToast('请选择日期和症状类型');
return;
}
try {
const store = gengjiDB.getStore();
await store.insert('symptoms', {
date: this.selectedDate,
symptom_type: this.selectedSymptom,
severity: this.selectedSeverity,
duration_min: 0,
note: '',
timestamp: Date.now(),
});
this.showToast(`已记录: ${this.selectedSymptom}`);
await this.loadMonthSymptoms();
await this.loadDaySymptoms(this.selectedDate);
} catch (err) {
console.error('添加症状失败:' + JSON.stringify(err));
}
}
// ===== 计算月度统计(聚合查询) =====
// 统计每种症状在当月出现的次数和平均严重度
calculateMonthlyStats() {
const stats: { type: string; count: number; totalSeverity: number; avgSeverity: number }[] = [];
for (const type of SYMPTOM_TYPES) {
// 过滤出当月该类型的症状
const typeSymptoms = this.symptomList.filter((s) => s.symptomType === type);
const count = typeSymptoms.length;
const totalSeverity = typeSymptoms.reduce((sum, s) => sum + s.severity, 0);
stats.push({
type,
count,
totalSeverity,
avgSeverity: count > 0 ? Math.round(totalSeverity / count * 10) / 10 : 0,
});
}
// 按次数倒序排列,只显示有记录的
this.monthlyStats = stats.filter((s) => s.count > 0).sort((a, b) => b.count - a.count);
}
// ===== 获取日期的热力颜色 =====
getHeatColor(day: number): string {
const dateStr = this.formatDate(this.currentYear, this.currentMonth, day);
const summary = this.monthSummary.get(dateStr);
if (!summary) return '#F5F5F5';
const max = summary.maxSeverity;
if (max <= 2) return '#FFF9C4'; // 浅黄 - 轻微
if (max <= 4) return '#FFCC80'; // 浅橙 - 中等
if (max <= 6) return '#EF9A9A'; // 浅红 - 较重
return '#E57373'; // 深红 - 严重
}
// ===== 切换月份 =====
async switchMonth(offset: number) {
this.currentMonth += offset;
if (this.currentMonth > 12) {
this.currentMonth = 1;
this.currentYear += 1;
} else if (this.currentMonth < 1) {
this.currentMonth = 12;
this.currentYear -= 1;
}
this.calendarDays = this.generateCalendar();
this.selectedDate = '';
this.daySymptoms = [];
await this.loadMonthSymptoms();
this.calculateMonthlyStats();
}
// ===== 选择日期 =====
async selectDate(day: number) {
const dateStr = this.formatDate(this.currentYear, this.currentMonth, day);
this.selectedDate = dateStr;
await this.loadDaySymptoms(dateStr);
}
// ===== 删除症状记录 =====
async deleteSymptom(id: number) {
try {
const store = gengjiDB.getStore();
const predicates = new relationalStore.RdbPredicates('symptoms');
predicates.equalTo('id', id);
await store.delete(predicates);
await this.loadMonthSymptoms();
if (this.selectedDate) {
await this.loadDaySymptoms(this.selectedDate);
}
this.calculateMonthlyStats();
this.showToast('已删除');
} catch (err) {
console.error('删除症状失败:' + JSON.stringify(err));
}
}
showToast(message: string) {
this.getUIContext().getPromptAction().openToast({ message, duration: 2000, bottom: 100 });
}
async aboutToAppear() {
this.calendarDays = this.generateCalendar();
this.selectedDate = this.formatDate(this.currentYear, this.currentMonth, new Date().getDate());
await this.loadMonthSymptoms();
await this.loadDaySymptoms(this.selectedDate);
this.calculateMonthlyStats();
}
build() {
Column() {
Text('更年记 - 症状追踪')
.fontSize(24).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, right: 16, top: 16 })
Scroll() {
Column({ space: 12 }) {
// 月份切换
Row() {
Button('<').width(48).height(40).onClick(() => { this.switchMonth(-1); })
Text(`${this.currentYear}年${this.currentMonth}月`)
.fontSize(18).fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)
Button('>').width(48).height(40).onClick(() => { this.switchMonth(1); })
}
.width('100%').justifyContent(FlexAlign.SpaceAround).padding({ left: 16, right: 16 })
// 日历热力图
Column() {
// 星期标题行
Row() {
ForEach(['日', '一', '二', '三', '四', '五', '六'], (day: string) => {
Text(day).fontSize(12).fontColor('#999999').layoutWeight(1).textAlign(TextAlign.Center)
}, (day: string) => day)
}
.width('100%')
// 日期格子(7列网格)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.calendarDays, (day: number | null) => {
if (day === null) {
// 空白占位
Text('').width('14.2%').aspectRatio(1)
} else {
Text(`${day}`)
.width('14.2%')
.aspectRatio(1)
.fontSize(14)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor(this.getHeatColor(day))
.border({
width: this.selectedDate === this.formatDate(this.currentYear, this.currentMonth, day) ? 2 : 0,
color: '#2196F3',
})
.onClick(() => { this.selectDate(day); })
}
}, (day: number | null, index: number) => `${index}`)
}
.width('100%')
}
.padding(16).backgroundColor('#FFFFFF').borderRadius(12)
// 选中日期区域
if (this.selectedDate) {
Column({ space: 8 }) {
Text(`日期: ${this.selectedDate}`).fontSize(18).fontWeight(FontWeight.Medium).alignSelf(ItemAlign.Start)
// 添加症状
Text('记录症状').fontSize(15).fontWeight(FontWeight.Medium).alignSelf(ItemAlign.Start)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(SYMPTOM_TYPES, (type: string) => {
Text(type)
.fontSize(14).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.selectedSymptom === type ? '#4CAF50' : '#EEEEEE')
.fontColor(this.selectedSymptom === type ? '#FFFFFF' : '#333333')
.borderRadius(16).margin({ right: 6, bottom: 6 })
.onClick(() => { this.selectedSymptom = type; })
}, (type: string) => type)
}
.width('100%')
// 严重度选择
Text(`严重度: ${this.selectedSeverity}/10`).fontSize(14).alignSelf(ItemAlign.Start)
Slider({ value: this.selectedSeverity, min: 1, max: 10, step: 1 })
.width('100%')
.onChange((value) => { this.selectedSeverity = value; })
Button('添加记录').width('100%').height(40)
.backgroundColor('#4CAF50').fontColor(Color.White)
.onClick(() => { this.addSymptom(); })
// 当天症状列表
if (this.daySymptoms.length > 0) {
Text('当日记录').fontSize(15).fontWeight(FontWeight.Medium).alignSelf(ItemAlign.Start)
ForEach(this.daySymptoms, (record: SymptomRecord) => {
Row() {
Column({ space: 2 }) {
Text(`${record.symptomType}`)
.fontSize(14).fontWeight(FontWeight.Medium)
Text(`严重度: ${record.severity}/10 | ${new Date(record.timestamp).toLocaleTimeString()}`)
.fontSize(12).fontColor('#999999')
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('删').fontSize(12).fontColor('#F44336').onClick(() => { this.deleteSymptom(record.id); })
}
.width('100%').padding(8)
}, (record: SymptomRecord) => `${record.id}`)
}
}
.padding(16).backgroundColor('#E8F5E9').borderRadius(12)
}
// 月度统计
if (this.monthlyStats.length > 0) {
Column({ space: 8 }) {
Text('本月统计').fontSize(18).fontWeight(FontWeight.Medium).alignSelf(ItemAlign.Start)
ForEach(this.monthlyStats, (stat: { type: string; count: number; avgSeverity: number }) => {
Row() {
Text(stat.type).fontSize(14).width(80)
Text(`${stat.count}次`).fontSize(14).fontColor('#666666').layoutWeight(1)
Text(`均${stat.avgSeverity}`).fontSize(14).fontColor('#FF9800').width(60).textAlign(TextAlign.End)
}
.width('100%').padding({ top: 4, bottom: 4 })
}, (stat: { type: string; count: number }) => stat.type)
}
.padding(16).backgroundColor('#FFF3E0').borderRadius(12)
}
// 热力图图例
Row({ space: 8 }) {
Text('无').fontSize(12).fontColor('#999')
Text('轻微').fontSize(12).fontColor('#999')
Text('中等').fontSize(12).fontColor('#999')
Text('较重').fontSize(12).fontColor('#999')
Text('严重').fontSize(12).fontColor('#999')
}
.width('100%')
.padding(12)
.justifyContent(FlexAlign.SpaceAround)
}
.padding({ left: 16, right: 16, bottom: 24 })
}
.layoutWeight(1)
}
.height('100%').width('100%').backgroundColor('#FAFAFA')
}
}
流程图
flowchart TD
A[App启动] --> B[初始化数据库]
B --> C[生成当月日历网格]
C --> D[查询当月全部症状 between startDate endDate]
D --> E[构建 monthSummary Map]
E --> F[渲染热力日历]
F --> G[计算月度统计]
G --> H{用户点击日期}
H --> I[查询当天症状 equalTo date]
I --> J[显示当日记录列表]
J --> K{用户添加症状}
K --> L[insert 到 symptoms 表]
L --> M[重新查询月度数据]
M --> F
G --> N{用户切换月份}
N --> O[更新 currentYear/currentMonth]
O --> C
React vs ArkTS 对比表
| 对比项 | React (Web) | ArkTS (鸿蒙) |
|---|---|---|
| 日历网格 | CSS Grid + gridTemplateColumns | Flex + FlexWrap + 宽度 14.2% |
| 按日期范围查询 | array.filter(s => s.date >= start && s.date <= end) | RdbPredicates.between('date', start, end) |
| 聚合统计 | array.reduce() 遍历计算 | 查询结果集遍历 + Map 分组 |
| 热力颜色映射 | 条件表达式 + backgroundColor | 函数封装 getHeatColor() |
| 月份切换 | setDate() | 修改 currentYear/currentMonth |
总结
这篇文章我们用鸿蒙关系型数据库实现了「更年记」症状追踪App,重点讲了:
按日期范围查询:用 RdbPredicates.between('date', start, end) 查询某个时间段的记录。
聚合统计:虽然 SQLite 支持 GROUP BY 等聚合函数,但在 ArkTS 中通过结果集遍历 + Map 分组也能实现灵活的统计逻辑。
日历热力图:根据每日症状的最大严重度映射到不同颜色深浅,用 Flex 布局实现 7 列网格。
日历数据结构:先算月份首日是星期几,用 null 填充前面的空白,再填入日期数字,形成完整的网格数组。
下一篇我们来做「贺年记」新年贺卡App,核心是图片处理和剪贴板分享。