购物清单是菜谱应用中最容易被低估的功能。界面通常只有名称、用量、勾选和删除,看起来比菜谱详情简单得多;但一旦同一种食材来自多道菜、用户又能手动添加,系统就必须回答:鸡蛋和"土鸡蛋"是否相同、500 克与 2 斤如何合并、删除最后一项后是否保持空清单、勾选失败是否还能提示成功、从菜谱批量加入时如何保留来源。
本文基于"健康菜谱助手"1.0.5 的真实 HarmonyOS ArkTS 工程展开。ShoppingListPage.ets 内置 20 条默认采购项,支持按分类和按状态查看、勾选、删除、重置与手动添加;RecipeDetailPage.ets 还能把当前菜谱食材批量加入清单。但手动添加不查重,菜谱批量加入只按原始名称精确比较,命中后直接跳过,数量与来源都不会合并;当用户删除全部项目后再次进入页面,aboutToAppear() 又会重新写入 20 条默认项,导致"空清单"无法持久存在。
购物清单的真源不是页面里的一组字符串,而是由稳定 itemId、规范 ingredientKey、数量结构、来源集合和购买状态组成的可持久化记录。
HEALTHY-RECIPE-ONE7-SHOPPING-LIST-20260730

本文将完成以下内容:
- 还原默认项、手动添加、菜谱批量加入与 Preferences 保存链路。
- 解释为什么删除全部后会被默认数据重新填满。
- 统一页面间重复定义的
ShoppingItem。 - 用 ingredientKey 去重,保留 displayName 与别名。
- 设计可合并与不可合并的数量策略。
- 让勾选、删除、重置、清空和撤销具有明确语义。
- 让保存结果可观察,避免 UI 提示假成功。
- 给出空态、多设备、重复点击和数据迁移测试。
一、真实页面目前有哪些数据
页面内定义的模型是:
ts
interface ShoppingItem {
id: number
name: string
amount: string
category: string
checked: boolean
}
页面状态包括:
ts
@StorageLink('shoppingItems') items: ShoppingItem[] = []
@State showAddPanel: boolean = false
@State newName: string = ''
@State newAmount: string = ''
@State newCategory: string = '蔬菜'
@State nextId: number = 21
@State viewMode: number = 0
默认清单有 20 项,覆盖肉类、蛋奶、豆制品、蔬菜和调味料。例如鸡胸肉 500 克、鸡蛋 10 个、牛奶 1 升、嫩豆腐 2 盒。页面还提供 8 个分类:肉类、蛋奶、豆制品、蔬菜、调味料、水果、主食和其他。
这套模型足以绘制列表,却缺少跨功能一致性所需的 ingredientKey、来源、创建时间和结构化数量。
二、删除全部后为什么又出现默认清单
进入页面时执行:
ts
aboutToAppear(): void {
if (this.items.length === 0) {
this.items = ShoppingListPage.getDefaultItems()
UserDataManager.persistShoppingItems(this.items)
}
// 计算 nextId
}
items.length === 0 同时表示两种完全不同的状态:
- 第一次安装,尚未初始化购物清单。
- 用户主动删除了全部项目,希望保留空清单。
页面无法区分二者,于是第二种也被当作第一次启动。解决方式不是取消默认数据,而是保存独立初始化标记:
ts
interface ShoppingListStore {
schemaVersion: number
initialized: boolean
items: ShoppingItem[]
updatedAt: number
}
只有 initialized === false 才写默认项;initialized === true && items.length === 0 应渲染空态。
三、ShoppingItem 不应在两个页面重复定义
ShoppingListPage.ets 和 RecipeDetailPage.ets 都声明了各自的 ShoppingItem。字段暂时相同,但未来某一页增加 sourceRecipeIds 或 quantity 后,另一个页面很容易继续写旧结构。
应把模型移到共享文件:
ts
export interface ShoppingItem {
itemId: string
ingredientKey: string
displayName: string
category: IngredientCategory
quantity: IngredientQuantity
checked: boolean
sourceRecipeIds: number[]
createdAt: number
updatedAt: number
}
页面只导入模型,写入逻辑统一由 ShoppingListService 处理。
四、itemId 和 ingredientKey 解决的是不同问题
当前 id 既用于列表 key,也用于查找项目。手动新增使用递增整数,菜谱批量加入使用 Date.now() + addedCount,两个入口的 ID 策略不同。
建议明确区分:
itemId:标识一条清单记录,稳定且唯一。ingredientKey:标识"这是哪一种食材",用于去重和合并。
ts
function createItemId(now: number, sequence: number): string {
return `shopping_${now}_${sequence}`
}
如果产品规定同一种食材只能有一条记录,ingredientKey 也可成为业务唯一键;itemId 仍适合 UI key 和迁移。
五、名称必须先规范化再去重
当前菜谱加入逻辑是:
ts
const existed = nextItems.find(
(item: ShoppingItem) => item.name === ingredient.name
)
if (existed) {
continue
}
手动添加则完全不检查重复。因此"西红柿"和"番茄"、"鸡蛋"和" 鸡蛋 "可能分别存在。
可以使用本地别名表生成 ingredientKey:
ts
const INGREDIENT_KEY_MAP: Record<string, string> = {
'西红柿': 'tomato',
'番茄': 'tomato',
'小番茄': 'tomato',
'马铃薯': 'potato',
'土豆': 'potato',
'嫩豆腐': 'tofu_tender',
'老豆腐': 'tofu_firm'
}
function normalizeIngredientName(input: string): string {
return input.trim().replace(/\s+/g, '')
}
function resolveIngredientKey(input: string): string {
const name = normalizeIngredientName(input)
return INGREDIENT_KEY_MAP[name] ?? `custom:${name}`
}
注意嫩豆腐与老豆腐并不等价,不能为了去重把所有"豆腐"合并。

六、数量字符串为什么难以合并
当前 amount 是自由文本,例如:
text
500克
10个
1升
2盒
适量
菜谱详情发现同名项目时直接跳过,因此已有"鸡蛋 10 个",再加入需要 2 个鸡蛋的菜谱,清单仍显示 10 个;系统既没有合并,也没有记录第二道菜的来源。
数量应至少拆成数值、单位和原始文本:
ts
export interface IngredientQuantity {
value?: number
unit?: 'g' | 'kg' | 'ml' | 'l' | 'piece' | 'box' | 'bag'
rawText: string
}
可解析的同单位数量才能直接相加;适量、少许 等保持原始文本,不做伪精确计算。
七、建立安全的数量解析器
ts
const UNIT_ALIASES: Record<string, IngredientQuantity['unit']> = {
'克': 'g',
'千克': 'kg',
'公斤': 'kg',
'毫升': 'ml',
'升': 'l',
'个': 'piece',
'盒': 'box',
'袋': 'bag'
}
function parseQuantity(input: string): IngredientQuantity {
const rawText = input.trim() || '适量'
const match = rawText.match(/^(\d+(?:\.\d+)?)\s*(克|千克|公斤|毫升|升|个|盒|袋)$/)
if (!match) return { rawText }
return {
value: Number(match[1]),
unit: UNIT_ALIASES[match[2]],
rawText
}
}
解析失败不等于输入错误。页面可以保存 rawText,但合并时显示"存在多个用量说明"。
八、统一单位后再合并
ts
function toBaseQuantity(quantity: IngredientQuantity): IngredientQuantity {
if (quantity.value === undefined || quantity.unit === undefined) return quantity
if (quantity.unit === 'kg') {
return { value: quantity.value * 1000, unit: 'g', rawText: quantity.rawText }
}
if (quantity.unit === 'l') {
return { value: quantity.value * 1000, unit: 'ml', rawText: quantity.rawText }
}
return quantity
}
只有 base unit 相同才相加:
ts
function mergeQuantity(
current: IngredientQuantity,
incoming: IngredientQuantity
): IngredientQuantity {
const left = toBaseQuantity(current)
const right = toBaseQuantity(incoming)
if (left.value !== undefined && right.value !== undefined && left.unit === right.unit) {
const value = left.value + right.value
return { value, unit: left.unit, rawText: `${value}${formatUnit(left.unit)}` }
}
return {
rawText: [current.rawText, incoming.rawText]
.filter((value, index, all) => all.indexOf(value) === index)
.join(' + ')
}
}
"2 个 + 500 克"不能强行相加,应保留两个说明并提示用户确认。
九、来源信息决定了能否正确撤销
从一道菜加入食材后,用户可能又从另一道菜加入同一种食材。只保存合并后的总量,后续移除第一道菜时无法知道应该扣掉多少。
更完整的做法是保存来源明细:
ts
export interface ShoppingSource {
sourceId: string
recipeId?: number
quantity: IngredientQuantity
addedAt: number
}
export interface ShoppingItem {
itemId: string
ingredientKey: string
displayName: string
category: IngredientCategory
checked: boolean
sources: ShoppingSource[]
}
页面展示数量由 sources 汇总。删除某个来源不会误伤其他菜谱需求,手动添加也可使用 sourceId = manual_xxx。
十、添加操作应该返回结构化结果
当前页面添加后直接 Toast。服务层可以返回:
ts
export interface AddShoppingResult {
item: ShoppingItem
action: 'created' | 'merged' | 'unchanged'
mergedSourceCount: number
}
对应提示更准确:
- created:已添加鸡蛋。
- merged:已合并鸡蛋用量,共来自 2 道菜。
- unchanged:这项需求已在清单中。
十一、勾选更新应保持稳定 key
当前勾选时复制数组和目标对象,这符合 ArkUI 状态更新习惯:
ts
this.items = this.items.map((item) =>
item.id === id ? { ...item, checked: !item.checked } : item
)
但列表 key 使用 ${item.id}_${item.checked},勾选后 key 变化,会让 ArkUI 把它视作新节点。更稳定的 key 是 itemId:
ts
ForEach(this.items, (item: ShoppingItem) => {
this.ShoppingRow(item)
}, (item: ShoppingItem) => item.itemId)
状态变化不应改变身份,尤其当行内未来增加编辑输入框或动画时。
十二、删除按钮与整行点击要隔离
当前每一行都可点击切换 checked,行内又有"删除"文字。必须在真机上验证子控件点击是否会同时触发行点击;即使当前 ArkUI 事件行为未冒泡,交互区域也容易误触。
更清晰的结构是:
- 左侧独立勾选按钮只负责 toggle。
- 中间内容区域打开编辑。
- 右侧删除图标只负责删除。
- 不让整行同时承担三个命令。
删除应使用熟悉的垃圾桶图标并提供可访问标签,而不是把"删除"文字挤在用量旁边。
十三、删除和清空要支持撤销
当前删除立即持久化,已购买视图中删除甚至没有 Toast。对购物清单这种高频误触场景,短时撤销比每次确认对话框更顺畅:
ts
interface DeleteSnapshot {
item: ShoppingItem
index: number
deletedAt: number
}
删除后显示"已删除大葱 · 撤销"。撤销时按原 index 插回并重新保存。清空全部属于更大范围动作,应二次确认或提供更长撤销窗口。
十四、"重置"和"清空"必须是两个命令
顶部当前文案是"重置",实际行为只是把全部 checked 改成 false,并不删除项目。建议改名为"全部标为待购买"。
产品可提供三个明确命令:
| 命令 | 结果 | 是否保留项目 |
|---|---|---|
| 全部标为待购买 | checked=false | 是 |
| 清除已购买 | 删除 checked=true | 部分保留 |
| 清空清单 | 删除全部 | 否 |
命令名称必须与结果一致。
十五、默认数据初始化与用户清空分离
初始化逻辑可以放进仓库:
ts
async function loadShoppingStore(): Promise<ShoppingListStore> {
const stored = await repository.read()
if (stored) return migrateStore(stored)
const initial: ShoppingListStore = {
schemaVersion: 2,
initialized: true,
items: createDefaultItems(),
updatedAt: Date.now()
}
await repository.save(initial)
return initial
}
只要存储中存在 initialized: true,空数组就是合法用户结果。
十六、nextId 不应由页面维护
页面进入时扫描 maxId,再设置 nextId = maxId + 1。这对单页面递增 ID 有效,但菜谱详情页同时使用时间戳 ID,ID 策略分裂。
ID 生成应放在服务层;页面只提交名称、数量和分类:
ts
export interface AddManualItemCommand {
name: string
amountText: string
category: IngredientCategory
}
服务层创建 itemId、ingredientKey、sourceId 和时间戳。
十七、分类应由领域常量约束
当前 category 是任意 string,页面用 switch 返回 emoji 与颜色。旧数据或拼写错误会落到"其他",但没有明确迁移。
ts
export type IngredientCategory =
| '肉类' | '蛋奶' | '豆制品' | '蔬菜'
| '调味料' | '水果' | '主食' | '其他'
分类配置集中定义:
ts
interface CategoryMeta {
value: IngredientCategory
emoji: string
color: string
order: number
}
页面按 order 排序,不再依赖项目第一次出现的顺序。
十八、按分类和按状态都是派生视图
当前 build() 中多次执行 filter() 和 getItemsByCategory()。数据量 20 时没有性能压力,但逻辑重复使空态和计数容易不一致。
可以一次生成视图模型:
ts
interface ShoppingViewState {
total: number
checked: number
pending: ShoppingItem[]
completed: ShoppingItem[]
groups: CategoryGroup[]
}
function buildShoppingView(items: ShoppingItem[]): ShoppingViewState {
const pending = items.filter((item) => !item.checked)
const completed = items.filter((item) => item.checked)
return {
total: items.length,
checked: completed.length,
pending,
completed,
groups: groupByCategory(items)
}
}
进度条、百分比、状态列表和分类标题都消费同一份派生结果。
十九、进度计算要处理空清单
当前代码已在 items 为空时返回 0%,这是正确的边界:
ts
const percent = items.length > 0
? Math.round(checkedCount / items.length * 100)
: 0
需要补充产品语义:空清单不应显示"采购进度 0 / 0"后留下一大片空白,而应显示"清单是空的,从菜谱加入食材或手动添加"。
二十、保存结果不能被吞掉
UserDataManager.persistShoppingItems() 最终调用返回 void 的 persist();prefs 未初始化或写入异常时直接结束,页面仍提示"已添加""已删除"或"已重置"。
仓库应返回:
ts
export interface SaveShoppingResult {
ok: boolean
revision?: number
reason?: 'not_initialized' | 'serialize_failed' | 'write_failed'
}
页面更新后保存失败,可以回滚旧数组,或保留本次内存状态并显示"未保存,点击重试"。不能只用成功 Toast。
二十一、避免连续操作造成旧写覆盖新写
用户可能快速勾选三项。若 flush() 异步完成顺序不稳定,较旧快照可能覆盖较新快照。可使用串行保存队列或 revision:
ts
interface ShoppingListStore {
schemaVersion: number
revision: number
initialized: boolean
items: ShoppingItem[]
updatedAt: number
}
每次变更 revision + 1,仓库只接受不小于当前 revision 的写入;简单版本也可让所有保存进入同一 Promise 链。
二十二、添加面板需要输入状态与错误状态
当前名称为空时 Toast,数量为空使用"适量"。可以进一步明确:
ts
interface AddItemDraft {
name: string
amountText: string
category: IngredientCategory
nameError: string
amountWarning: string
isSaving: boolean
}
重复名称不应无声新增,可展示"清单已有番茄,是否合并用量";保存期间禁用确定按钮,避免重复提交。
二十三、页面、服务、仓库如何分工

23.1 ShoppingListPage
负责模式切换、弹层、输入草稿、展示列表和操作反馈。
23.2 ShoppingListService
负责名称归一、去重、数量合并、来源管理、toggle、delete、reset 和 clear。
23.3 ShoppingListRepository
负责 Preferences 的读取、迁移、保存、revision 和错误映射。
23.4 Shared Model
统一 ShoppingListPage 与 RecipeDetailPage 的数据合同,禁止页面复制接口。
二十四、旧数据迁移
现有数据只有 id/name/amount/category/checked。迁移时:
ts
function migrateLegacyItem(item: LegacyShoppingItem, index: number): ShoppingItem {
const now = Date.now()
return {
itemId: `legacy_${item.id}_${index}`,
ingredientKey: resolveIngredientKey(item.name),
displayName: normalizeIngredientName(item.name),
category: normalizeCategory(item.category),
quantity: parseQuantity(item.amount),
checked: item.checked,
sources: [{
sourceId: `legacy_source_${item.id}_${index}`,
quantity: parseQuantity(item.amount),
addedAt: now
}]
}
}
迁移后若多个旧项目得到同一 ingredientKey,应谨慎合并:同单位相加,不同单位保留来源明细;checked 可采用"全部来源都已购买才为 true"的规则,或迁移时保留为项目级状态。
二十五、多设备与安全区
手机适合单列分类卡片和底部添加面板。浮动添加按钮当前位于 bottom 24,可能贴近系统手势区;应使用系统导航避让区与 28vp 中较大值。
平板和 2in1 可左侧显示分类,右侧显示待购买/已购买列表;添加面板使用限宽对话框而非拉满全屏。鼠标环境下要提供 hover、焦点和键盘提交;长食材名称与长数量文本必须 maxLines + ellipsis,不得挤掉删除按钮。
二十六、回归测试矩阵
| 编号 | 场景 | 预期 |
|---|---|---|
| L01 | 首次安装 | 初始化 20 项并写入 initialized=true |
| L02 | 删除全部后重进 | 保持空清单,不恢复默认项 |
| L03 | 手动添加鸡蛋两次 | 第二次提示合并,不新增重复记录 |
| L04 | 输入" 西红柿 "和番茄 | 得到同一 ingredientKey |
| L05 | 嫩豆腐与老豆腐 | 保持不同 ingredientKey |
| L06 | 500克 + 1千克 | 合并为 1500克 |
| L07 | 2个 + 500克 | 保留两个来源,不做错误相加 |
| L08 | 菜谱批量加入 | 新项目带 recipeId 来源 |
| L09 | 同食材来自两道菜 | 合并项目并保留两个 source |
| L10 | 勾选项目 | itemId 不变,checked 更新并保存 |
| L11 | 快速勾选三项 | 最终 revision 最大,不被旧快照覆盖 |
| L12 | 点击删除 | 不同时触发勾选,提供撤销 |
| L13 | 全部标为待购买 | 只修改 checked,不删除项目 |
| L14 | 清除已购买 | 只删除 checked=true 的项目 |
| L15 | 清空清单 | 明确确认或撤销,重进仍为空 |
| L16 | 保存失败 | 不显示成功提示,可重试或回滚 |
| L17 | 空清单 | 显示引导空态和添加入口 |
| L18 | 切换分类/状态 | 总数、已买数和分组计数一致 |
二十七、可复核验收步骤
27.1 初始化与空态
清除应用数据后首次打开,确认 20 条默认项只初始化一次。删除全部、退出并重启,确认 items 仍为 0 且 initialized 为 true。
27.2 去重与数量
分别添加"西红柿 500克""番茄 1千克",确认只有一条 tomato 项,总量为 1500克,sources 为 2。再加入"番茄 2个",确认不做克与个的换算。
27.3 菜谱联动
从两道都需要大葱的菜谱加入清单,确认项目只出现一次、来源包含两个 recipeId。移除一个来源后,另一个来源与数量仍保留。
27.4 持久化
勾选、添加、删除各执行一次,重启后确认 itemId、ingredientKey、quantity、sources 和 checked 恢复。模拟 Preferences 失败,检查 UI 没有假成功。
27.5 多设备
在手机竖屏、横屏、平板和 2in1 下检查分类卡片、状态模式、长文本、添加面板、键盘弹出和底部安全区;深色模式下检查次要文字、删除图标、选中态与分类色对比度。
二十八、常见问题与定位
28.1 清单删空后又出现 20 项
原因: items.length === 0 被当作未初始化。
处理: 保存 initialized 标记,空数组作为合法用户状态。
28.2 同一种食材出现多条
原因: 手动添加不查重,菜谱入口只按原始 name 精确比较。
处理: 统一 resolveIngredientKey,所有入口都调用服务层。
28.3 同名食材数量没有增加
原因: 菜谱入口发现 existed 后直接 continue。
处理: 保存来源数量,同单位合并,不同单位保留明细。
28.4 删除时项目被勾选
原因: 删除控件位于整行点击区域内,事件目标不够独立。
处理: 拆分勾选、内容和删除点击区,并做真机回归。
28.5 页面提示已保存,重启却丢失
原因: persist 返回 void 并吞掉异常。
处理: 仓库返回 SaveShoppingResult,Toast 根据真实结果显示。
28.6 勾选后行闪动或输入状态丢失
原因: ForEach key 包含 checked,状态变化导致节点身份改变。
处理: key 只使用稳定 itemId。
二十九、落地顺序
- 先增加 initialized,修复空清单无法保存的问题。
- 抽取共享 ShoppingItem 与 ingredientKey。
- 让手动添加和菜谱加入共用 ShoppingListService。
- 引入 quantity 与 sources,完成安全合并。
- 让仓库返回保存结果并串行化写入。
- 拆分重置、清除已购买和清空语义。
- 最后补撤销、旧数据迁移和多设备布局。
每一步都可用 L01--L18 的相关用例独立验证。
三十、源码事实与建议实现的边界
为避免把设计方案误写成已经落地的功能,下面将当前工程可以直接复核的事实与本文建议严格分开:
| 维度 | 当前源码事实 | 本文建议,不代表已经实现 |
|---|---|---|
| 默认数据 | getDefaultItems() 返回 20 项,覆盖肉类、蛋奶、豆制品、蔬菜、调味料 5 类 |
增加独立 initialized 标记,允许空清单持久存在 |
| 数据模型 | 每项只有 id/name/amount/category/checked 5 个字段 |
增加 itemId、ingredientKey、结构化数量和来源明细 |
| 添加入口 | 手动添加只校验名称非空;数量为空时写入"适量" | 所有入口统一经过服务层,返回 created/merged/unchanged |
| 菜谱联动 | RecipeDetailPage 仅按原始名称精确比较,重名时直接 continue |
名称归一后去重,并按来源安全合并数量 |
| ID 策略 | 清单页扫描最大整数并加一;菜谱页使用 Date.now() + addedCount |
统一由服务层生成稳定 ID |
| 勾选与列表 key | 勾选时复制数组;分类视图 key 包含 checked,状态变化会改变 key |
列表 key 只使用稳定 itemId |
| 重置与删除 | 顶部"重置"只把全部项目改为未购买;删除会立即持久化 | 将重置、清除已购买、清空清单拆成不同命令并支持撤销 |
| 本地保存 | persistShoppingItems() 委托 persist();prefs 为空时直接返回,异常被捕获,flush() 结果未被页面观察 |
仓库返回结构化保存结果,并串行处理连续写入 |
| 运行验证 | 本文只进行了源码级复核与文章质量检查 | 构建、安装、重启恢复和多设备测试仍需在真实工程环境执行 |
因此,本文中的 ShoppingListService、ShoppingListRepository、revision、迁移函数、撤销快照和测试矩阵都属于可落地的改造设计,而不是对当前 1.0.5 工程能力的虚构描述。读者复核时,应以 ShoppingListPage.ets、RecipeDetailPage.ets 与 UserDataManager.ets 的实际代码为准。
三十一、总结
健康菜谱助手的购物清单页已经具备 20 项默认数据、分类视图、状态视图、进度、手动添加、勾选、删除和菜谱批量加入,功能入口并不少。真正影响可靠性的,是空数组被误判为未初始化、页面间模型重复、名称去重不统一、数量只有字符串,以及持久化失败仍提示成功。
把购物清单做稳,需要守住五条原则:
- initialized 与 items.length 分开,允许用户拥有真正空清单。
- itemId 表示记录身份,ingredientKey 表示食材身份。
- 数量按结构化单位安全合并,不可换算时保留来源明细。
- 所有入口共用服务层,勾选、删除和清空语义明确。
- UI 反馈以真实保存结果为准,失败可重试或回滚。
当用户从两道菜加入同一种食材、在超市勾选后重启应用,仍能看到正确数量和状态时,这个清单才真正从展示页面变成可依赖的采购工具。
参考资料
- HarmonyOS ArkUI 概述
- HarmonyOS ArkTS 概述
- HarmonyOS 数据管理概述
- HarmonyOS AppStorage 开发指导
- 工程源码:
ShoppingListPage.ets、RecipeDetailPage.ets、UserDataManager.ets
AI 辅助声明:本文由 AI 辅助完成源码统计、结构校对与配图设计,当前能力结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布;建议模型、迁移方案和测试矩阵不代表工程已经构建、安装或上线。