
每次转盘抽签后结果会自动写入历史记录。历史页就是这些记录的展示窗口——按时间倒序排列显示被选中的食物名称、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.historyCatc.key?#FFFFFF:this.gc().text).backgroundColor(this.historyCatc.key?this.gc().primary:this.gc().surface).borderRadius(BorderRadius.full).onClick(()this.historyCatc.key)})}和转盘 Tab、管理 Tab 的标签完全一致——选中态白字主色背景未选中深色字surface 背景。三套标签用同一个样式但变量不同pickCat / newCat / historyCat。筛选逻辑privategetFilteredHistory():PickRecord[]{letlistthis.history;if(this.historyCat!all){listlist.filter((r:PickRecord)r.categorythis.historyCat);}returnlist;}和转盘 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.keyr.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.keyr.category)?.name??)history 里存的是 category key如 ‘chinese’不是中文名如 ‘中餐’。显示时需要从 CATEGORIES 里查找对应的 name。?.name ?? 防止查找失败时崩溃。时间格式化privatefmtTime(ts:number):string{constdnewDate(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.length0){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 空状态对比Tabemoji标题操作引导转盘 80px“还没有美食”有按钮管理️ 48px“还没有美食”无按钮历史 48px“还没有历史记录”有描述文字设置——无始终有内容转盘空状态有按钮是因为开始按钮在页面底部用户可能看不到。历史空状态用描述文字引导——不需要按钮因为操作在转盘 Tab。五、清空历史的功能按钮结构if(this.history.length0){Button(清空历史记录).width(90%).height(44).fontSize(FontSize.sm).fontColor(#FFFFFF).backgroundColor(#EF4444).borderRadius(BorderRadius.md).margin({top:Spacing.md,bottom:Spacing.lg}).onClick(()this.showCleartrue)}红色按钮——和删除食物一样的警告色。this.history.length 0控制显示——没有历史时不需要清空按钮。清空操作的两步确认// 点击按钮 → 弹出确认弹窗.onClick(()this.showCleartrue)// 确认弹窗AlertDialog({title:确认清空,message:清空后无法恢复确定要清空所有历史记录吗,primaryButton:{value:取消,action:()this.showClearfalse},secondaryButton:{value:清空,fontColor:#EF4444,action:()this.clr()}})先弹窗确认再执行清空。清空是不可逆操作——删除食物可以重新添加但历史记录清空后无法恢复。clr 方法privateasyncclr():Promisevoid{if(this.db){awaitthis.db.clearHistory();this.showClearfalse;this.load();}}清空后立刻关闭弹窗、刷新列表。三步操作顺序不能乱——先清数据再关弹窗最后刷新 UI。六、清空按钮的显示控制if(this.history.length0){Button(清空历史记录)...}有历史才显示按钮——空状态时没有清空按钮。这是防御性设计——防止用户在空页面看到一个无意义的清空按钮。和管理页删除的对比特性历史清空食物删除确认弹窗有无不可逆是可重新添加按钮颜色红色️ emoji操作风险高低风险越高确认越严格。清空所有历史是高风险操作需要弹窗确认。删除单个食物是低风险操作直接执行。七、State 变量的分工变量作用影响范围history历史记录数组列表渲染historyCat分类筛选筛选结果showClear弹窗控制AlertDialog 显示三个变量各管一件事——数据、筛选、弹窗。比转盘 Tab 的 8 个 State 少很多——因为历史页功能简单不需要动画和轮播。八、fmtTime 方法的位置privatefmtTime(ts:number):string{constdnewDate(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 方法asyncpick(cat?:string):PromiseFoodItem|null{constpoolthis.getFoods(cat);if(pool.length0)returnnull;constppool[Math.floor(Math.random()*pool.length)];p.pickCount;p.lastPickedDate.now();awaitthis.saveFoods();this.history.push({id:h_Date.now(),foodName:p.name,emoji:p.emoji,category:p.category,pickedAt:Date.now()}asPickRecord);awaitthis.saveHistory();returnp;}每次抽签自动写入历史——不需要用户操作。history 的写入和 pickCount 的更新在同一个方法里——一次抽签同时更新两个数据。写入的数据结构{id:h_Date.now(),// 唯一 IDfoodName: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 中结构最简单的——没有动画、没有轮播、没有弹窗输入只有列表和筛选。