HarmonyOS厨房助手实战第6篇:食材库存、保质期状态与收藏笔记
摘要
本文基于 HarmonyOS 厨房助手项目,实现两个经常被低估但很考验状态设计的模块:食材库存和食谱收藏。库存模块包含新增食材、保质期计算、即将过期筛选、删除与空状态;收藏模块包含收藏切换、按食谱查询、笔记更新、缓存同步和数据概览联动。
重点不是堆出两个列表页面,而是回答几个工程问题:
- 保质期状态应该保存还是动态计算?
- 为什么日期使用
yyyy-MM-dd字符串仍然可以比较? - 收藏按钮怎样避免 UI 与磁盘结果不一致?
- 多页面共享数据后如何通知统计卡片刷新?
- Service 单例缓存何时更新、何时失效?
一、从业务规则开始建模
库存条目包含下面这些字段:
exportenumInventoryStatus{Sufficient='sufficient',ExpiringSoon='expiring_soon',Expired='expired'}exportinterfaceInventoryItem{id:string;name:string;category:string;amount:string;unit:string;expireDate:string;status:InventoryStatus;createdAt:number;updatedAt:number;schemaVersion:number;}这里把数量设计为字符串,是因为厨房场景并不总是纯数字:
半颗 2-3片 适量 约500如果后续要做精确采购计算,可以拆成amountValue、amountText和unit。在当前版本中,库存用于提醒和展示,保留用户原始输入更实用。
二、状态是保存还是派生
Expired和ExpiringSoon会随日期变化。如果把状态只在创建时写入文件,过几天后它就会过期但仍显示“充足”。
因此项目每次读取列表时重新派生状态:
constEXPIRING_SOON_DAYS:number=3;functionderiveStatus(expireDate:string):InventoryStatus{if(expireDate.length===0){returnInventoryStatus.Sufficient;}consttoday:string=DateUtil.today();if(expireDate<today){returnInventoryStatus.Expired;}constsoonLimit:string=DateUtil.addDays(today,EXPIRING_SOON_DAYS);if(expireDate<=soonLimit){returnInventoryStatus.ExpiringSoon;}returnInventoryStatus.Sufficient;}结论是:磁盘中的status可以作为兼容字段保留,但界面使用的状态必须按当前日期重新计算。
三、日期字符串为什么可以直接比较
当格式固定为yyyy-MM-dd,并且月份和日期始终补零时,字符串字典序与时间先后顺序一致:
2026-06-07 < 2026-06-08 2026-06-30 < 2026-07-01 2026-12-31 < 2027-01-01这种比较简单、稳定,也避免不必要的时区换算。但必须满足三个前提:
- 固定四位年份;
- 月和日使用两位;
- 不混入时分秒和其他格式。
如果输入允许2026/6/7或自然语言,就应先解析和标准化,不能直接比较。
四、Service 统一处理创建逻辑
页面只收集表单值,Service 负责清洗和补业务字段:
exportinterfaceSaveInventoryPayload{name:string;category:string;amount:string;unit:string;expireDate:string;}asynccreate(payload:SaveInventoryPayload):Promise<InventoryItem>{constnow:number=Date.now();constitem:InventoryItem={id:IdUtil.next('inv'),name:payload.name.trim(),category:payload.category.trim(),amount:payload.amount.trim(),unit:payload.unit.trim(),expireDate:payload.expireDate,status:deriveStatus(payload.expireDate),createdAt:now,updatedAt:now,schemaVersion:SchemaVersion.current};constnext:InventoryItem[]=(awaitthis.list()).concat([item]);constok:boolean=awaitthis.repo.saveAll(next);if(ok){this.cache=next;}returnitem;}这里有三个值得保留的细节:
trim()在业务入口统一执行;- id、时间戳和版本号不由页面生成;
- 磁盘保存成功后才替换缓存。
更严格的接口可以在保存失败时抛出异常或返回SaveResult,这样页面不会误提示“已添加”。
五、ArkUI 新增表单的状态组织
页面使用独立的@State保存草稿:
@StateshowAdd:boolean=false;@StatenewName:string='';@StatenewCategory:string='';@StatenewAmount:string='';@StatenewUnit:string='';@StatenewExpire:string='';提交时先做最小校验:
privateasyncsubmitNew():Promise<void>{if(this.newName.trim().length===0){promptAction.showToast({message:'请填写名称'});return;}constctx=getContext(this)ascommon.UIAbilityContext;awaitInventoryService.ensure(ctx).create({name:this.newName,category:this.newCategory,amount:this.newAmount,unit:this.newUnit,expireDate:this.newExpire});this.resetForm();this.showAdd=false;awaitthis.refresh();}不要在输入框的每次onChange中写磁盘。表单草稿属于页面状态,用户确认后才转成业务实体。
六、筛选使用派生数组
页面保留完整列表和当前筛选项:
enumFilterKey{All='all',ExpiringSoon='expiring_soon',Expired='expired'}@Stateitems:InventoryItem[]=[];@Statefilter:FilterKey=FilterKey.All;privatefiltered():InventoryItem[]{if(this.filter===FilterKey.ExpiringSoon){returnthis.items.filter((it:InventoryItem)=>it.status===InventoryStatus.ExpiringSoon);}if(this.filter===FilterKey.Expired){returnthis.items.filter((it:InventoryItem)=>it.status===InventoryStatus.Expired);}returnthis.items;}筛选不是另一份持久化数据,不需要额外缓存。只要原数组规模不大,构建时计算就足够清晰。
当数据量增加时,可以把结果放到可观察状态,并在列表或筛选变化时更新,避免高频重复过滤。
七、列表 Key 要包含真正影响视图的字段
库存卡片会根据状态改变颜色。ForEach的 key 如果只用id,某些复杂场景下框架可能复用旧节点。
项目使用:
ForEach(this.filtered(),(it:InventoryItem)=>{this.ItemCard(it)},(it:InventoryItem)=>`${it.id}-${it.status}-${it.expireDate}`)key 应稳定、唯一,并能反映需要重建节点的身份变化。也不要把随机数或当前时间放进 key,否则每次刷新都会重建所有卡片。
八、状态标签与颜色语义
状态文案和颜色集中在辅助函数中:
privatestatusLabel(status:InventoryStatus):string{if(status===InventoryStatus.Expired){return'已过期';}if(status===InventoryStatus.ExpiringSoon){return'即将过期';}return'充足';}privatestatusColor(status:InventoryStatus):ResourceStr{if(status===InventoryStatus.Expired){returnThemeColor.warn;}if(status===InventoryStatus.ExpiringSoon){returnThemeColor.brand;}returnThemeColor.success;}颜色不能是唯一信息。标签文本仍然需要保留,因为用户可能使用深色模式、色觉辅助设置或低质量屏幕。
九、删除操作与二次确认
当前项目的删除动作直接执行:
privateasyncremove(id:string):Promise<void>{constctx=getContext(this)ascommon.UIAbilityContext;awaitInventoryService.ensure(ctx).remove(id);awaitthis.refresh();}在正式产品中,建议根据可恢复性决定是否确认:
| 删除结果 | 推荐交互 |
|---|---|
| 可撤销、影响小 | 直接删除并提供撤销 |
| 不可恢复、影响大 | 二次确认 |
| 批量删除 | 明确数量和范围 |
库存单条删除可以使用 Toast + 撤销,比每次弹确认框更高效。覆盖导入则必须明确提示,因为它会替换全部数据。
十、收藏模型为什么独立存在
不要直接在 Recipe 上增加favorite: boolean。收藏有自己的生命周期和附加信息:
exportinterfaceFavorite{id:string;recipeId:string;note:string;createdAt:number;updatedAt:number;schemaVersion:number;}独立模型有三个好处:
- 收藏笔记不污染食谱主体;
- 删除收藏不需要重写整个 Recipe;
- 以后可以增加收藏分组、置顶和排序。
这也是轻量关系模型:Favorite 通过recipeId引用 Recipe。
十一、实现幂等的收藏切换
收藏按钮通常需要返回切换后的状态:
asynctoggle(recipeId:string):Promise<boolean>{constall:Favorite[]=awaitthis.list();constindex:number=all.findIndex((x:Favorite)=>x.recipeId===recipeId);if(index>=0){constnext:Favorite[]=all.slice();next.splice(index,1);constok:boolean=awaitthis.repo.saveAll(next);if(ok){this.cache=next;returnfalse;}returntrue;}constnow:number=Date.now();constcreated:Favorite={id:IdUtil.next('fav'),recipeId:recipeId,note:'',createdAt:now,updatedAt:now,schemaVersion:SchemaVersion.current};constnext:Favorite[]=all.concat([created]);constok:boolean=awaitthis.repo.saveAll(next);if(ok){this.cache=next;returntrue;}returnfalse;}返回值表示磁盘成功后的真实状态。页面可以这样调用:
this.favorite=awaitFavoritesService.ensure(ctx).toggle(this.recipe.id);不要先在 UI 中反转图标,再无条件忽略持久化结果。
十二、按食谱查询收藏
Service 提供语义化方法:
asyncfindByRecipe(recipeId:string):Promise<Favorite|null>{constall:Favorite[]=awaitthis.list();constresult:Favorite|undefined=all.find((item:Favorite)=>item.recipeId===recipeId);returnresult===undefined?null:result;}asyncisFavorite(recipeId:string):Promise<boolean>{return(awaitthis.findByRecipe(recipeId))!==null;}页面不需要知道收藏数组怎样存储,也不应该自己写findIndex。Service 方法名表达的是业务意图。
十三、收藏笔记更新
更新时保留创建时间,只改变内容和更新时间:
asyncupdateNote(recipeId:string,note:string):Promise<void>{constall:Favorite[]=awaitthis.list();constindex:number=all.findIndex((x:Favorite)=>x.recipeId===recipeId);if(index<0){return;}constcurrent:Favorite=all[index];constnext:Favorite[]=all.slice();next[index]={id:current.id,recipeId:current.recipeId,note:note.trim(),createdAt:current.createdAt,updatedAt:Date.now(),schemaVersion:current.schemaVersion};constok:boolean=awaitthis.repo.saveAll(next);if(ok){this.cache=next;}}笔记输入建议采用“失焦保存”或显式保存按钮。每输入一个字符就写整个收藏文件,会产生不必要的 I/O。
十四、跨页面统计如何刷新
设置页需要显示食谱、计划、购物、库存和收藏数量。仅靠页面生命周期并不总能及时刷新,因此项目增加一个轻量信号:
exportclassDataOverviewSignal{staticsetInventoryCount(value:number):void{AppStorage.setOrCreate('inventoryCount',value);}staticsetFavoriteCount(value:number):void{AppStorage.setOrCreate('favoriteCount',value);}}Service 在列表加载或保存成功后更新:
DataOverviewSignal.setFavoriteCount(this.cache.length);设置页通过@StorageProp读取:
@StorageProp(DataOverviewSignal.favoriteCountKey)favoriteCount:number=0;这个方案适合少量全局计数。复杂应用应使用更明确的状态管理方式,避免把所有业务状态都放进全局存储。
十五、错误与空状态设计
库存页面至少有四种状态:
loading 正在读取 empty 完全没有库存 filtered 当前筛选无结果 content 展示条目“库存为空”和“即将过期筛选无结果”不是同一种文案:
if(this.items.length===0){EmptyView({title:'库存为空',hint:'点击右上角添加食材'})}elseif(this.filtered().length===0){Text('当前筛选下无条目')}错误状态也不应被当成空状态,否则用户会误以为自己的数据被清空。Repository 如果能返回结构化错误,页面就可以展示“加载失败,点击重试”。
十六、边界条件
实现库存和收藏时需要主动验证:
- 保质期为空;
- 保质期就是今天;
- 日期格式非法;
- 同名食材重复添加;
- 删除不存在的 id;
- 收藏引用的食谱已被删除;
- 连续快速点击收藏按钮;
- 笔记为空或非常长;
- 导入备份后缓存仍是旧数据;
- 深色模式下状态颜色是否可读。
对于孤立收藏,可以在读取时过滤不存在的recipeId,也可以保留并在备份修复工具中处理。关键是明确策略。
十七、可以继续扩展的功能
库存模块可以自然扩展为:
- 扫码或图片识别录入;
- 按分类聚合;
- 过期提醒;
- 从购物清单一键入库;
- 烹饪后自动扣减;
- 同名食材合并;
- 数量单位换算。
收藏模块可以扩展为:
- 收藏分组;
- 自定义标签;
- 最近使用排序;
- 笔记全文搜索;
- 收藏数据导出;
- 跨设备同步。
这些功能都建立在当前独立模型和 Service 边界之上,不需要推翻页面结构。
十八、测试清单
库存
- 新增后列表和统计数量同时增加;
- 过期日早于今天显示“已过期”;
- 三天内显示“即将过期”;
- 无日期显示“充足”;
- 删除后磁盘和缓存都移除;
- 切换筛选不会修改原始数组。
收藏
- 首次切换创建 Favorite;
- 第二次切换删除同一 Favorite;
- 保存失败时图标状态不误变;
- 更新笔记保留 createdAt;
- 删除食谱后孤立收藏有明确处理;
- 备份导入后
invalidate()生效。
十九、总结
库存和收藏看似只是两个小功能,实际上覆盖了移动端本地应用最常见的状态问题:
动态派生状态 表单草稿 列表筛选 持久化成功后更新缓存 跨页面统计信号 关联模型一致性 空状态与错误状态设计时把“随时间变化的状态”和“需要长期保存的数据”分开,把页面交互和业务规则分开,后续增加提醒、搜索、同步时会轻松很多。
常见问题
1. 即将过期为什么设为三天?
这是当前产品规则,应集中成常量或设置项。不同食材类别未来可以使用不同阈值。
2. 是否要禁止同名库存?
不一定。两个“牛奶”可能有不同保质期。可以按名称、单位和保质期组合判断,而不是只看名称。
3. 收藏按钮是否需要防抖?
需要避免并发保存。最简单的方式是在请求期间禁用按钮;更完整的方式是在 Service 中串行化写操作。
4. 为什么不把收藏笔记存在 Recipe?
Recipe 是食谱主体,Favorite 是用户关系和个人信息。拆开后删除收藏、备份收藏和扩展分组都更自然。