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



所有评论(0)