每次转盘抽签后,结果会自动写入历史记录。历史页就是这些记录的展示窗口——按时间倒序排列,显示被选中的食物名称、emoji、分类和时间。页面顶部有分类标签用于筛选,底部有清空按钮。这个页面的逻辑不复杂,但有一个细节值得拆解:历史记录里冗余存了食物名称和 emoji——不存食物 ID 引用。这个设计决策让历史记录在食物被删除后仍然有效。

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

在这里插入图片描述

一、历史页的整体结构

从代码看,历史页分四个区块:

Column
  ├→ Text ("历史记录")              标题
  ├→ 分类标签 (ForEach CATEGORIES)  筛选
  ├→ List + ForEach                 记录列表
  └→ 清空按钮                       底部操作

四个区块各司其职——标题告诉用户这是什么,标签筛选范围,列表展示内容,按钮提供操作。 和管理页一样的三段式布局。

二、分类标签的筛选逻辑

标签结构

Row() {
  ForEach(CATEGORIES.slice(0, 6), (c: FoodCategory) => {
    Text(c.emoji + ' ' + c.name)
      .fontSize(FontSize.xs).height(28)
      .fontColor(this.historyCat === c.key ? '#FFFFFF' : this.gc().text)
      .backgroundColor(this.historyCat === c.key ? this.gc().primary : this.gc().surface)
      .borderRadius(BorderRadius.full)
      .onClick(() => this.historyCat = c.key)
  })
}

在这里插入图片描述

和转盘 Tab、管理 Tab 的标签完全一致——选中态白字+主色背景,未选中深色字+surface 背景。 三套标签用同一个样式,但变量不同(pickCat / newCat / historyCat)。

筛选逻辑

private getFilteredHistory(): PickRecord[] {
  let list = this.history;
  if (this.historyCat !== 'all') {
    list = list.filter((r: PickRecord) => r.category === this.historyCat);
  }
  return list;
}

和转盘 Tab 的时间线筛选逻辑一样——先赋值原数组,有筛选条件就 filter。 但这里只有一层筛选(分类),比转盘 Tab 的三层筛选简单。

CATEGORIES.slice(0, 6) 的含义

CATEGORIES.slice(0, 6)  // 含"全部",共 6 项

历史页需要"全部"标签——因为用户可能想看所有记录,不限分类。 这和转盘 Tab 一样(slice(0, 6)),但和管理 Tab 不同(slice(1) 跳过"全部")。

三个 Tab 的标签对比

Tab 数据源 含"全部" 变量 作用
转盘 CATEGORIES.slice(0, 6) pickCat 筛选转盘范围
管理 CATEGORIES.slice(1) newCat 选择新增分类
历史 CATEGORIES.slice(0, 6) historyCat 筛选记录范围

同一个 CATEGORIES 常量,三个 Tab 用不同 slice 和变量。 转盘和历史需要"全部",管理不需要。

三、记录列表的渲染

列表结构

List() {
  ForEach(this.getFilteredHistory(), (r: PickRecord) => {
    ListItem() {
      Row() {
        Column() {
          Text(r.emoji).fontSize(28)
          Text(r.foodName).fontSize(FontSize.sm)
            .fontColor(this.gc().text).margin({ top: Spacing.xs })
        }
        Blank()
        Column() {
          Text(r.pickedAt).fontSize(FontSize.xs)
            .fontColor(this.gc().textSecondary)
          Text(CATEGORIES.find((c: FoodCategory) => c.key === r.category)?.name ?? '')
            .fontSize(FontSize.xs)
            .fontColor(this.gc().primary)
            .margin({ top: Spacing.xs })
        }
      }
      .width('100%').padding(Spacing.md)
      .backgroundColor(this.gc().surface)
      .borderRadius(BorderRadius.md)
      .margin({ bottom: Spacing.sm, left: Spacing.md, right: Spacing.md })
    }
  })
}

每行的元素排列

[emoji] [食物名称]          ── Blank ──          [时间] [分类名]
  左侧:被选中的食物信息                          右侧:元数据

左侧是"发生了什么"——emoji + 名称。右侧是"什么时候、什么分类"——时间 + 分类。 信息层次清晰:先看到是什么食物,再看到时间和分类。

分类名的查找

Text(CATEGORIES.find((c: FoodCategory) => c.key === r.category)?.name ?? '')

history 里存的是 category key(如 ‘chinese’),不是中文名(如 ‘中餐’)。 显示时需要从 CATEGORIES 里查找对应的 name。?.name ?? '' 防止查找失败时崩溃。

时间格式化

private fmtTime(ts: number): string {
  const d = new Date(ts);
  return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

输出格式如 “7/17 14:30”——月/日 时:分。 不显示年份——历史记录通常在同一年内,省略年份更简洁。padStart(2, ‘0’) 保证小时和分钟两位数显示。

四、空状态的设计

if (this.history.length === 0) {
  Column() {
    Text('📜').fontSize(48)
    Text('还没有历史记录')
      .fontSize(FontSize.md).fontColor(this.gc().textSecondary)
    Text('去转盘抽签吧')
      .fontSize(FontSize.sm).fontColor(this.gc().textTertiary)
      .margin({ top: Spacing.xs })
  }
  .width('100%').layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

在这里插入图片描述

三层次引导——48px 卷轴 emoji + “还没有历史记录” 标题 + “去转盘抽签吧” 描述。 和转盘 Tab 的空状态结构一致。

四个 Tab 空状态对比

Tab emoji 标题 操作引导
转盘 🎰 80px “还没有美食” 有按钮
管理 🍽️ 48px “还没有美食” 无按钮
历史 📜 48px “还没有历史记录” 有描述文字
设置 无(始终有内容)

转盘空状态有按钮是因为"开始"按钮在页面底部,用户可能看不到。 历史空状态用描述文字引导——不需要按钮,因为操作在转盘 Tab。

五、清空历史的功能

按钮结构

if (this.history.length > 0) {
  Button('清空历史记录')
    .width('90%').height(44)
    .fontSize(FontSize.sm)
    .fontColor('#FFFFFF')
    .backgroundColor('#EF4444')
    .borderRadius(BorderRadius.md)
    .margin({ top: Spacing.md, bottom: Spacing.lg })
    .onClick(() => this.showClear = true)
}

红色按钮——和删除食物一样的警告色。 this.history.length > 0 控制显示——没有历史时不需要清空按钮。

清空操作的两步确认

// 点击按钮 → 弹出确认弹窗
.onClick(() => this.showClear = true)

// 确认弹窗
AlertDialog({
  title: '确认清空',
  message: '清空后无法恢复,确定要清空所有历史记录吗?',
  primaryButton: {
    value: '取消',
    action: () => this.showClear = false
  },
  secondaryButton: {
    value: '清空',
    fontColor: '#EF4444',
    action: () => this.clr()
  }
})

先弹窗确认,再执行清空。 清空是不可逆操作——删除食物可以重新添加,但历史记录清空后无法恢复。

clr 方法

private async clr(): Promise<void> {
  if (this.db) {
    await this.db.clearHistory();
    this.showClear = false;
    this.load();
  }
}

清空后立刻关闭弹窗、刷新列表。 三步操作顺序不能乱——先清数据,再关弹窗,最后刷新 UI。

六、清空按钮的显示控制

if (this.history.length > 0) {
  Button('清空历史记录')...
}

有历史才显示按钮——空状态时没有清空按钮。 这是防御性设计——防止用户在空页面看到一个无意义的清空按钮。

和管理页删除的对比

特性 历史清空 食物删除
确认弹窗
不可逆 可重新添加
按钮颜色 红色 🗑️ emoji
操作风险

风险越高,确认越严格。 清空所有历史是高风险操作,需要弹窗确认。删除单个食物是低风险操作,直接执行。

七、@State 变量的分工

变量 作用 影响范围
history 历史记录数组 列表渲染
historyCat 分类筛选 筛选结果
showClear 弹窗控制 AlertDialog 显示

三个变量各管一件事——数据、筛选、弹窗。 比转盘 Tab 的 8 个 @State 少很多——因为历史页功能简单,不需要动画和轮播。

八、fmtTime 方法的位置

private fmtTime(ts: number): string {
  const d = new Date(ts);
  return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

fmtTime 是 Index 的私有方法——不是数据库层的方法。 时间格式化是 UI 逻辑,不是数据逻辑。数据库层只存时间戳,格式化由显示层决定。

时间戳存储的好处

存储方式 优点 缺点
时间戳 (Date.now()) 精确、可排序、可计算 不直观,需要格式化
格式化字符串 直观 不可排序、不可计算

存时间戳,显示时格式化——存储和显示分离。 这是标准做法。

九、历史记录的写入时机

写入位置

// Database.ets 的 pick 方法
async pick(cat?: string): Promise<FoodItem | null> {
  const pool = this.getFoods(cat);
  if (pool.length === 0) return null;
  const p = pool[Math.floor(Math.random() * pool.length)];
  p.pickCount++; p.lastPicked = Date.now();
  await this.saveFoods();
  this.history.push({ id: 'h_' + Date.now(), foodName: p.name, emoji: p.emoji, category: p.category, pickedAt: Date.now() } as PickRecord);
  await this.saveHistory();
  return p;
}

每次抽签自动写入历史——不需要用户操作。 history 的写入和 pickCount 的更新在同一个方法里——一次抽签同时更新两个数据。

写入的数据结构

{
  id: 'h_' + Date.now(),      // 唯一 ID
  foodName: p.name,            // 食物名称(冗余)
  emoji: p.emoji,              // 表情(冗余)
  category: p.category,        // 分类(冗余)
  pickedAt: Date.now()         // 时间戳
}

冗余存储 foodName 和 emoji——不存 foodId 引用。 如果存 foodId,删除食物后历史记录就断链了。冗余存储让历史记录独立于食物数据——即使食物被删除,历史仍然完整。

十、整体嵌套结构

Column                              HistoryTab 容器
  ├→ Text ("历史记录")              标题
  ├→ Scroll + Row (分类标签)        横向滚动的分类选择
  ├→ 条件渲染
  │    ├→ 空状态 Column             无历史时
  │    └→ List + ForEach           有历史时
  │         └→ ListItem + Row      每行:emoji+名称 ── 时间+分类
  └→ 清空按钮                       有历史时才显示

嵌套深度 4 层——List → ListItem → Row → Column/Text。 历史页是四个 Tab 中结构最简单的——没有动画、没有轮播、没有弹窗输入,只有列表和筛选。

Logo

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

更多推荐