HarmonyOS APP开发---「更年记」症状追踪App,需要用到关系型数据库和日历热图

2 阅读9分钟

如果你想做一个症状追踪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 + gridTemplateColumnsFlex + 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,核心是图片处理和剪贴板分享。