资讯动态

【健康菜谱助手|01】HarmonyOS ArkTS 菜谱详情页实战:组织食材、营养、步骤和收藏状态

发布时间:2026/10/24 2:04:06 来源:尧图企业网站定制
菜谱详情页看起来只是“头图 标题 食材 做法”的长页面实际却是健康菜谱应用中数据密度最高、状态交叉最多的页面。它需要同时回答路由传入的是哪道菜、食材是否与菜谱一致、营养值按什么口径展示、收藏按钮是否与全局列表同步、进入做饭模式后何时记为“已做”、购物清单写盘失败时用户会看到什么。本文基于“健康菜谱助手”1.0.5 的真实 ArkTS 工程展开。项目的RECIPE_LIST有 60 条菜谱CookingStepsData.ets为 60 个 ID 准备了 369 条步骤但RecipeDetailPage.ets内部的食材映射只覆盖 ID 1 到 20另外 40 条菜谱会退回“主料 300g、调味料 2 汤勺”等通用内容。这不是简单的文案缺口而是详情页数据被拆散后失去完整性约束的结果。详情页不是把字段从上到下堆出来而是让同一个 recipeId 在基础信息、食材、步骤、收藏和持久化之间保持一致。HEALTHY-RECIPE-ONE7-DETAIL-SNAPSHOT-20260730本文将完成以下内容还原RecipeDetailPage的真实加载、渲染和交互链路。解释 60 条菜谱、20 组食材和 60 组步骤之间的数据闭合问题。把无效路由从“静默显示第一道菜”改为明确的加载、内容与错误状态。统一收藏按钮、收藏记录和 Preferences 持久化结果。为营养值补充份量与口径避免把估算数据写成医学建议。调整“进入做饭模式即记为已做”的业务时机。给出手机、平板和 2in1 的响应式详情页结构与验证表。一、先看真实详情页承担了什么当前页面持有以下状态State recipe: RecipeModel | null null State isFavorite: boolean false State ingredients: IngredientItem[] [] StorageLink(favoriteRecords) favoriteRecords: FavoriteRecord[] [] StorageLink(historyRecords) historyRecords: HistoryRecord[] [] StorageLink(cookedRecords) cookedRecords: CookedRecord[] [] StorageLink(shoppingItems) shoppingItems: ShoppingItem[] [] StorageLink(appThemeColor) appThemeColor: string #4CAF50这里已经出现三类不同性质的数据类型示例生命周期页面输入recipeId、当前 recipe进入详情页到离开领域内容食材、营养、做法随菜谱版本发布用户状态收藏、历史、已做、购物清单跨页面并持久化如果把三类数据都堆在页面中任何一个字段变化都会迫使 UI、路由和存储一起修改。更稳的设计是先建立“详情快照”页面只消费一个明确结果。二、RecipeModel 只描述了基础信息工程中的模型如下export interface RecipeModel { id: number name: string image: string category: string tags: string[] difficulty: string duration: string calories: number fat: number carbs: number protein: number description: string tips: string rating: number ratingCount: string }它能支撑列表卡片和详情头部却没有ingredients、steps、servings或营养口径。于是食材被放进页面函数步骤被放进另一个文件基础信息在MockData.ets。同一个菜谱被分成三个平行数据源编译器无法保证它们数量一致。三、用 recipeId 建立聚合快照可以先定义一个不带 UI 状态的领域对象export interface NutritionFacts { basis: per_serving | per_100g servings: number calories: number protein: number fat: number carbs: number } export interface RecipeDetailSnapshot { recipe: RecipeModel ingredients: IngredientItem[] steps: CookingStep[] nutrition: NutritionFacts }快照的边界很重要菜谱内容属于领域数据收藏属于用户状态两者不要混成一个可持久化对象。这样更新菜谱内容不会覆盖用户收藏用户取消收藏也不会改变菜谱本身。四、当前最大缺口是食材只覆盖 20 道菜项目有 60 条RecipeModel做法分支也覆盖 1 到 60详情页getIngredients()却只声明 1 到 20未匹配时返回return ingredientMap[recipeId] || [ { name: 主料, amount: 300g }, { name: 调味料, amount: 2汤勺 }, { name: 食用油, amount: 30ml }, { name: 盐, amount: 3g } ]这意味着用户打开第 21 到 60 道菜时菜名、图片和营养属于目标菜谱食材却是通用回退。页面没有报错反而更难发现。数据源覆盖范围缺失行为RECIPE_LIST60无CookingStepsData60default stepsgetIngredients20通用四项食材对于菜谱这种强调准确性的内容通用回退应该只用于开发占位不能作为正式数据呈现。五、用完整性脚本阻止缺食材的数据进入页面将食材移入独立数据文件后可以在构建前运行完整性检查interface RecipeDataIndex { recipes: RecipeModel[] ingredientMap: Recordnumber, IngredientItem[] stepMap: Recordnumber, CookingStep[] } export function findIncompleteRecipes(data: RecipeDataIndex): number[] { const missing: number[] [] for (const recipe of data.recipes) { const ingredients data.ingredientMap[recipe.id] ?? [] const steps data.stepMap[recipe.id] ?? [] if (ingredients.length 0 || steps.length 0) { missing.push(recipe.id) } } return missing }发布前要求返回空数组。它比页面里的默认值更可靠因为缺失会在数据进入用户界面前暴露。六、路由参数不能无效时静默换菜当前aboutToAppear()在找不到菜谱时回退RECIPE_LIST[0]const params router.getParams() as RecipeRouterParams if (params params.recipeId) { this.recipe getRecipeById(params.recipeId) || null } if (!this.recipe) { this.recipe RECIPE_LIST[0] }假设上游错误地传入999用户原本点击“麻婆豆腐”页面却可能显示“宫保鸡丁”。这不是容错而是隐藏导航错误。更稳的做法是验证参数并进入错误状态。type DetailStatus loading | content | not_found function parseRecipeId(params: Object | undefined): number | null { const value (params as Recordstring, Object | undefined)?.recipeId if (typeof value ! number || !Number.isInteger(value) || value 0) { return null } return value }错误页可以提供“返回菜谱列表”同时在日志中记录无效 ID便于追踪调用方。七、页面状态应显式区分三种结果State status: DetailStatus loading State snapshot: RecipeDetailSnapshot | null null private loadDetail(): void { const recipeId parseRecipeId(router.getParams()) if (recipeId null) { this.status not_found return } const result RecipeRepository.getDetail(recipeId) if (result null) { this.status not_found return } this.snapshot result this.status content }UI 树再根据status渲染骨架、正文或错误提示。这样“数据正在准备”和“菜谱不存在”不会都表现为空白。八、详情页渲染顺序来自用户任务当前页面顺序是头图、基础信息、营养、食材、小贴士和底部操作。这个顺序基本合理因为用户通常先判断是否想做再核对食材最后进入步骤。建议把内容分成四段识别区图片、菜名、标签、难度、时长。决策区营养摘要、份量和提示。准备区食材清单、购物清单入口。执行区步骤预览、做饭模式和完成记录。每段都回答一个用户问题避免把所有信息做成相同样式的卡片。九、营养数据必须说明计算口径模型保存calories、protein、fat、carbs页面分别显示 kcal 和 g但没有说明这些值是每份、整道菜还是每 100g。对于健康类界面这个缺失会让数字显得比实际更确定。interface NutritionDisplay { label: string value: number unit: kcal | g basisText: string } function toNutritionDisplay(facts: NutritionFacts): NutritionDisplay[] { const basisText facts.basis per_100g ? 每100g估算 : 每份估算共${facts.servings}份 return [ { label: 热量, value: facts.calories, unit: kcal, basisText }, { label: 蛋白质, value: facts.protein, unit: g, basisText }, { label: 脂肪, value: facts.fat, unit: g, basisText }, { label: 碳水, value: facts.carbs, unit: g, basisText } ] }页面还应提示营养值为参考估算不用于疾病诊断或个体化饮食治疗。十、四个营养项在窄屏上要稳定当前Row()一次放四个NutritionItem手机大字体模式下可能拥挤。固定四列适合宽屏窄屏可以使用两列 Gridprivate nutritionColumns(): string { return this.screenWidth 600 ? 1fr 1fr 1fr 1fr : 1fr 1fr } Grid() { ForEach(this.nutritionItems, (item: NutritionDisplay) { GridItem() { NutritionCell({ item }) } }, (item: NutritionDisplay) item.label) } .columnsTemplate(this.nutritionColumns()) .columnsGap(8) .rowsGap(8)稳定列数和间距后字体放大不会挤压单位或覆盖下一项。十一、标签区不能假设一行放得下源码使用Row({ space: 6 })渲染全部标签。当前每道菜大多只有两个短标签但数据扩展后“低脂高蛋白”“儿童友好”“30 分钟以内”等文本会溢出。可以使用支持换行的 FlexFlex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { ForEach(this.recipe.tags, (tag: string) { Text(tag) .fontSize(12) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .margin({ right: 6, bottom: 6 }) }, (tag: string) tag) } .width(100%)菜名与分类也应设置layoutWeight、maxLines和textOverflow避免长名称把分类标签推到屏幕外。十二、收藏记录是事实isFavorite 是派生状态页面同时保存favoriteRecords和isFavorite。首次进入时通过UserDataManager.isFavorite()计算点击后又执行this.favoriteRecords UserDataManager.toggleFavorite( this.favoriteRecords, this.recipe.id ) this.isFavorite !this.isFavorite问题在于isFavorite只是对前一个布尔值取反而不是根据新记录重新计算。如果另一个页面修改了收藏或持久化恢复改变了数组两份状态可能分叉。更稳的写法是把返回记录作为唯一事实private toggleCurrentFavorite(): void { if (!this.recipe) return const next UserDataManager.toggleFavorite( this.favoriteRecords, this.recipe.id ) this.favoriteRecords next this.isFavorite UserDataManager.isFavorite(next, this.recipe.id) }顶部心形按钮和底部“加入收藏”按钮都调用同一个方法避免复制业务逻辑。十三、收藏持久化结果需要可观测UserDataManager.persist()在 Preferences 未初始化时直接返回putSync()的同步异常会被空catch吞掉flush()返回的异步结果也没有被等待。调用方仍会更新内存并显示收藏成功因此应用重启后状态可能消失而页面无法知道失败发生在哪一步。interface SaveResult { ok: boolean reason?: not_initialized | write_failed } async function persistFavorites(records: FavoriteRecord[]): PromiseSaveResult { if (!FavoriteRepository.isReady()) { return { ok: false, reason: not_initialized } } try { await FavoriteRepository.save(records) return { ok: true } } catch (_) { return { ok: false, reason: write_failed } } }页面应在成功后确认状态在失败时恢复旧值并给出重试提示不能把“内存已变化”当成“已经保存”。十四、浏览历史去重逻辑值得保留addHistory()先过滤相同recipeId再把新记录放到首位const filtered records.filter(r r.recipeId ! recipeId) const result: HistoryRecord[] [ { recipeId, viewedAt: dateStr }, ...filtered ]这比每次访问都追加更适合“最近浏览”。需要补充的只是容量上限例如只保留最近 100 条避免长期使用后 Preferences 字符串持续增长。return [{ recipeId, viewedAt: dateStr }, ...filtered].slice(0, 100)十五、购物清单按名称去重会丢失份量语义详情页加入购物清单时用item.name ingredient.name判断重复。假设两个菜谱都使用“鸡胸肉”一个需要 250g另一个需要 400g第二次加入会被跳过用户看不到合并后的 650g。购物项至少应记录来源interface ShoppingItemV2 { id: string name: string amountText: string category: string checked: boolean sourceRecipeIds: number[] }如果单位可解析就按单位合并如果是“少许”“一根”等自然语言则保留多条来源或展示组合文本不能假装完成精确加法。十六、食材分类应数据化而不是靠字符串猜测getIngredientCategory()通过名称包含“鸡、猪、鱼、虾、油、盐”等关键词分类并把鱼、虾、海参和鲍鱼也归到“肉类”。这种规则能快速展示但会在“鱼露”“鸡蛋”“豆豉”等边界上产生歧义。更适合的模型是直接给食材一个稳定 ID 和类别interface IngredientItemV2 { ingredientId: string name: string amount: string category: meat | seafood | vegetable | dairy_egg | staple | seasoning | other }详情页不再猜分类购物清单、冰箱推荐和食材百科也能复用同一个类别。十七、做饭模式不应在进入时就记为已做当前点击“做饭模式”时先调用addCooked()随后跳转步骤页。用户即使立刻返回也已经进入“本周已做”和营养统计。this.cookedRecords UserDataManager.addCooked( this.cookedRecords, this.recipe.id ) router.pushUrl({ url: pages/CookingStepsPage, params: { recipeId: this.recipe.id } })更准确的状态应至少区分 started 和 completed。进入步骤页只记录开始用户在最后一步点击“完成”后才写入CookedRecord。十八、步骤页应通过结果回传完成状态interface CookingSession { recipeId: number startedAt: string currentStep: number completedAt?: string } function completeCooking(session: CookingSession): CookingSession { return { ...session, completedAt: new Date().toISOString() } }步骤页已有“上一步、下一步、完成”和语音朗读生命周期。将完成记录放到最后一步后营养分析中的“本周已做”才表示真实完成而不是打开过页面。十九、RecipeImage 要负责图片失败而不是业务内容失败图片组件适合处理本地图片不存在、加载失败和占位图但不能用图片回退掩盖菜谱数据缺失。两种错误要分开错误组件责任页面反馈图片文件缺失RecipeImage显示统一占位图记录资源名recipeId 不存在Repository / 页面状态显示菜谱不存在并返回食材为空Domain 校验阻止正式数据进入详情页步骤为空Domain 校验显示内容不完整不进入做饭模式占位图片能保证布局稳定但不代表详情数据完整。二十、详情页结构按四层拆分建议的职责边界如下RecipeDetailPage - 读取路由、管理 loading/content/not_found - 响应收藏、购物清单、做饭模式操作 RecipeRepository - 根据 recipeId 组装 RecipeDetailSnapshot - 校验菜谱、食材和步骤完整性 UserRecipeService - 收藏、历史、购物清单、烹饪会话 - 返回可观测保存结果 PreferencesRepository - JSON 编解码、版本迁移、flush 与错误映射页面不直接决定数据是否完整存储层也不负责渲染 Toast。二十一、响应式布局不能只放大手机页面源码主模块支持 phone、tablet 和 2in1。详情页在宽屏上可以采用双栏左侧固定头图和基础信息右侧滚动营养、食材与步骤预览手机保持单列。宽度结构操作区小于 600vp单列 Scroll底部固定两行按钮600–839vp单列宽卡片或 2 列营养底部操作栏限制最大宽度840vp 以上左图右内容双栏右栏内固定主要操作头图的固定高度260在 2in1 上可能显得扁建议使用aspectRatio和最大高度约束。顶部返回与收藏按钮还要根据系统安全区计算不应永远依赖top: 44。二十二、底部操作栏要防止重复点击收藏、加入购物清单和做饭模式都可能触发状态写入或导航。连续点击时需要明确行为收藏一次点击只产生一次目标状态切换。购物清单按钮处理中禁用完成后显示新增数量。做饭模式导航发起后立即锁定防止重复打开步骤页。State actionBusy: boolean false private async openCookingMode(): Promisevoid { if (this.actionBusy || !this.recipe) return this.actionBusy true try { await CookingSessionService.start(this.recipe.id) await router.pushUrl({ url: pages/CookingStepsPage, params: { recipeId: this.recipe.id } }) } finally { this.actionBusy false } }锁只覆盖动作周期不要把按钮永久禁用。二十三、数据升级要考虑已有 Preferences项目用字符串 JSON 保存收藏、历史、已做和购物清单。购物项从 V1 升级到带sourceRecipeIds的 V2 时需要兼容旧数据function migrateShoppingItem(raw: Recordstring, Object): ShoppingItemV2 { return { id: String(raw.id ?? legacy_${Date.now()}), name: String(raw.name ?? ), amountText: String(raw.amountText ?? raw.amount ?? ), category: String(raw.category ?? other), checked: Boolean(raw.checked), sourceRecipeIds: Array.isArray(raw.sourceRecipeIds) ? raw.sourceRecipeIds as number[] : [] } }迁移失败应只重置损坏的键不要因为一个购物项 JSON 异常就清空收藏、历史和已做记录。二十四、详情页验证矩阵场景操作预期正常 ID打开 recipeId1宫保鸡丁、对应食材和步骤一致第 21 道菜打开 recipeId21不出现通用四项食材最大 ID打开 recipeId60菜谱、食材、步骤完整无效 ID打开 recipeId999显示不存在不切到第一道菜收藏顶部收藏后返回列表列表与底部按钮状态一致连续收藏快速点击两次最终状态可预测无重复记录加购物清单两道菜含同名食材数量或来源不会静默丢失做饭模式打开后立即返回不计入已做完成做饭最后一步点击完成只新增一条去重后的已做记录存储失败模拟 Preferences 写入失败恢复 UI 状态并提示重试大字体系统字体放大营养、标签、按钮不重叠2in1宽屏打开详情双栏稳定操作可达二十五、Hypium 单测先守住纯逻辑describe(RecipeDetailDomain, () { it(rejects invalid recipe id, 0, () { expect(parseRecipeId({ recipeId: 0 })).assertNull() expect(parseRecipeId({ recipeId: 999 })).assertEqual(999) }) it(finds recipes without ingredients or steps, 0, () { const missing findIncompleteRecipes(testData) expect(missing.length).assertEqual(0) }) it(derives favorite state from records, 0, () { const records: FavoriteRecord[] [{ recipeId: 1, createdAt: 2026-07-17 }] expect(UserDataManager.isFavorite(records, 1)).assertTrue() expect(UserDataManager.isFavorite(records, 2)).assertFalse() }) })无效 ID 是否存在要由 Repository 判断parseRecipeId()只负责类型和范围基础校验。测试名要准确表达各层职责。二十六、常见问题与修复顺序现象根因修复多道菜显示相同食材ingredientMap 未覆盖全部 ID建立聚合数据和完整性脚本错误链接显示第一道菜无效 ID 静默回退显式 not_found 状态收藏图标与列表不一致布尔值和记录双份维护从新记录派生收藏状态重启后收藏消失持久化失败被吞掉返回 SaveResult 并回滚 UI营养数字含义不清缺少份量与口径增加 basis、servings 和说明打开步骤就算已做记录时机过早最后一步完成后写入同名食材数量不对仅按名称去重记录来源并按可解析单位合并长标签溢出Row 不换行使用 Flex Wrap宽屏留白过大手机单列直接拉伸840vp 以上切换双栏先修数据闭合和状态语义再调整视觉否则页面越精致错误内容越难被察觉。二十七、交付前清单[ ] 60 条菜谱都有非空食材和非空步骤。[ ] 营养值标明每份或每 100g并附参考说明。[ ] 无效 recipeId 显示错误状态不回退其他菜谱。[ ] 顶部和底部收藏按钮调用同一动作。[ ] 收藏成功以持久化结果为准失败可重试。[ ] 浏览历史去重并限制最大长度。[ ] 购物清单保留同名食材的份量或来源。[ ] 做饭记录只在完成最后一步后产生。[ ] 图片失败与菜谱数据失败分别处理。[ ] 手机、平板、2in1 和大字体布局均可操作。[ ] 连续点击不会重复导航或产生不可预测状态。[ ] Preferences 旧数据有迁移路径。二十八、源码事实与建议实现的边界本文的代码片段既包含当前工程源码也包含用于说明改造方向的建议实现。为了避免把设计稿误认为已经合入的能力下面按可复核证据分开列出关注点当前源码事实建议实现与验证菜谱规模RECIPE_LIST中有 60 条菜谱记录保留 ID 唯一性检查发布前校验基础字段非空食材数据getIngredients()只显式覆盖 ID 1 至 20其他 ID 返回通用四项食材建立独立ingredientMap为 60 个 ID 补齐真实内容并让完整性检查返回空数组做法步骤CookingStepsData.ets有 60 个分支、共 369 条步骤把菜谱、食材和步骤组装成RecipeDetailSnapshot步骤缺失时阻止进入做饭模式无效路由找不到菜谱时静默回退RECIPE_LIST[0]增加loading/content/not_found状态错误时显示返回入口并记录调用方收藏状态页面同时维护favoriteRecords与isFavorite点击后直接反转布尔值让新记录成为唯一事实顶部与底部按钮复用同一动作收藏持久化persist()返回void未初始化和写入异常不可观察flush()结果未等待Repository 返回SaveResult失败时回滚 UI 并提示重试浏览历史当前实现会按recipeId去重并置顶增加容量上限这是建议不代表现有源码已经限制为 100 条购物清单当前仅按食材名称去重同名食材不会合并份量或来源引入稳定食材 ID、来源菜谱和可解析单位自然语言份量保留原始文本已做记录详情页在导航到步骤页之前调用addCooked()进入时只创建会话最后一步“完成”后再写已做记录步骤完成CookingStepsPage最后一步只释放语音并router.back()增加明确完成回传和幂等写入当前源码尚未实现响应式布局当前详情页是单列长页头图高度 260顶部按钮使用固定top: 44用断点、双栏、安全区和大字体场景验证 phone、tablet、2in1测试与构建本文未执行改造后的构建、Hypium 或设备测试完成实现后运行工程构建、纯逻辑单测和详情页设备矩阵保留真实结果这张表也给出了一条实施顺序先补齐内容数据再修路由与持久化语义最后做响应式布局。否则视觉优化会把错误食材和错误完成状态包装得更难发现。二十九、结语菜谱详情页的工程价值不在于放了多少张卡片而在于能否用一个recipeId形成完整、可信、可操作的数据快照。健康菜谱助手当前已经有 60 条菜谱、60 组做法、营养展示、收藏、历史、购物清单和做饭模式基础能力并不薄真正需要优先解决的是食材只覆盖 20 条、无效路由静默换菜、收藏状态双份维护和完成语义提前。把 RecipeModel、食材和步骤组合到 Repository把用户状态交给 Service 与 Preferences再让页面只处理明确状态详情页就会从“字段展示页”变成稳定的业务枢纽。之后无论增加份量换算、冰箱推荐、多人菜单还是营养筛选都能建立在同一份一致数据上。参考资料Huawei DeveloperArkUIhttps://developer.huawei.com/consumer/cn/arkui/Huawei DeveloperHarmonyOS 数据管理概述https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-mgmt-overviewHuawei Developer应用拥有的状态管理https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-appstorage项目源码entry/src/main/ets/pages/RecipeDetailPage.ets项目模型entry/src/main/ets/models/RecipeModel.ets项目数据entry/src/main/ets/models/MockData.ets项目步骤entry/src/main/ets/models/CookingStepsData.ets用户数据entry/src/main/ets/common/utils/UserDataManager.etsAI 辅助声明本文由 AI 辅助完成源码梳理、结构校对与配图设计技术结论均以文中列出的本地 ArkTS 源码和数据文件为依据并由作者复核后发布。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑