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

如果后续要做精确采购计算,可以拆成 amountValueamountTextunit。在当前版本中,库存用于提醒和展示,保留用户原始输入更实用。

二、状态是保存还是派生

ExpiredExpiringSoon 会随日期变化。如果把状态只在创建时写入文件,过几天后它就会过期但仍显示“充足”。

因此项目每次读取列表时重新派生状态:

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

这种比较简单、稳定,也避免不必要的时区换算。但必须满足三个前提:

  1. 固定四位年份;
  2. 月和日使用两位;
  3. 不混入时分秒和其他格式。

如果输入允许 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;
}

独立模型有三个好处:

  1. 收藏笔记不污染食谱主体;
  2. 删除收藏不需要重写整个 Recipe;
  3. 以后可以增加收藏分组、置顶和排序。

这也是轻量关系模型: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 如果能返回结构化错误,页面就可以展示“加载失败,点击重试”。

十六、边界条件

实现库存和收藏时需要主动验证:

  1. 保质期为空;
  2. 保质期就是今天;
  3. 日期格式非法;
  4. 同名食材重复添加;
  5. 删除不存在的 id;
  6. 收藏引用的食谱已被删除;
  7. 连续快速点击收藏按钮;
  8. 笔记为空或非常长;
  9. 导入备份后缓存仍是旧数据;
  10. 深色模式下状态颜色是否可读。

对于孤立收藏,可以在读取时过滤不存在的 recipeId,也可以保留并在备份修复工具中处理。关键是明确策略。

十七、可以继续扩展的功能

库存模块可以自然扩展为:

  • 扫码或图片识别录入;
  • 按分类聚合;
  • 过期提醒;
  • 从购物清单一键入库;
  • 烹饪后自动扣减;
  • 同名食材合并;
  • 数量单位换算。

收藏模块可以扩展为:

  • 收藏分组;
  • 自定义标签;
  • 最近使用排序;
  • 笔记全文搜索;
  • 收藏数据导出;
  • 跨设备同步。

这些功能都建立在当前独立模型和 Service 边界之上,不需要推翻页面结构。

十八、测试清单

库存

  • 新增后列表和统计数量同时增加;
  • 过期日早于今天显示“已过期”;
  • 三天内显示“即将过期”;
  • 无日期显示“充足”;
  • 删除后磁盘和缓存都移除;
  • 切换筛选不会修改原始数组。

收藏

  • 首次切换创建 Favorite;
  • 第二次切换删除同一 Favorite;
  • 保存失败时图标状态不误变;
  • 更新笔记保留 createdAt;
  • 删除食谱后孤立收藏有明确处理;
  • 备份导入后 invalidate() 生效。

十九、总结

库存和收藏看似只是两个小功能,实际上覆盖了移动端本地应用最常见的状态问题:

动态派生状态
表单草稿
列表筛选
持久化成功后更新缓存
跨页面统计信号
关联模型一致性
空状态与错误状态

设计时把“随时间变化的状态”和“需要长期保存的数据”分开,把页面交互和业务规则分开,后续增加提醒、搜索、同步时会轻松很多。

常见问题

1. 即将过期为什么设为三天?

这是当前产品规则,应集中成常量或设置项。不同食材类别未来可以使用不同阈值。

2. 是否要禁止同名库存?

不一定。两个“牛奶”可能有不同保质期。可以按名称、单位和保质期组合判断,而不是只看名称。

3. 收藏按钮是否需要防抖?

需要避免并发保存。最简单的方式是在请求期间禁用按钮;更完整的方式是在 Service 中串行化写操作。

4. 为什么不把收藏笔记存在 Recipe?

Recipe 是食谱主体,Favorite 是用户关系和个人信息。拆开后删除收藏、备份收藏和扩展分组都更自然。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐