每次转盘抽签后,结果会自动写入历史记录。历史页就是这些记录的展示窗口——按时间倒序排列,显示被选中的食物名称、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 中结构最简单的——没有动画、没有轮播、没有弹窗输入,只有列表和筛选。

994

被折叠的 条评论
为什么被折叠?



