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

菜谱详情页看起来只是"头图 + 标题 + 食材 + 做法"的长页面,实际却是健康菜谱应用中数据密度最高、状态交叉最多的页面。它需要同时回答:路由传入的是哪道菜、食材是否与菜谱一致、营养值按什么口径展示、收藏按钮是否与全局列表同步、进入做饭模式后何时记为"已做"、购物清单写盘失败时用户会看到什么。

本文基于"健康菜谱助手"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 的响应式详情页结构与验证表。

一、先看真实详情页承担了什么

当前页面持有以下状态:

ts 复制代码
@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 只描述了基础信息

工程中的模型如下:

ts 复制代码
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 状态的领域对象:

ts 复制代码
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,未匹配时返回:

ts 复制代码
return ingredientMap[recipeId] || [
  { name: '主料', amount: '300g' },
  { name: '调味料', amount: '2汤勺' },
  { name: '食用油', amount: '30ml' },
  { name: '盐', amount: '3g' }
]

这意味着用户打开第 21 到 60 道菜时,菜名、图片和营养属于目标菜谱,食材却是通用回退。页面没有报错,反而更难发现。

数据源 覆盖范围 缺失行为
RECIPE_LIST 60 无
CookingStepsData 60 default steps
getIngredients 20 通用四项食材

对于菜谱这种强调准确性的内容,通用回退应该只用于开发占位,不能作为正式数据呈现。

五、用完整性脚本阻止缺食材的数据进入页面

将食材移入独立数据文件后,可以在构建前运行完整性检查:

ts 复制代码
interface RecipeDataIndex {
  recipes: RecipeModel[]
  ingredientMap: Record<number, IngredientItem[]>
  stepMap: Record<number, 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]:

ts 复制代码
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,用户原本点击"麻婆豆腐",页面却可能显示"宫保鸡丁"。这不是容错,而是隐藏导航错误。更稳的做法是验证参数并进入错误状态。

ts 复制代码
type DetailStatus = 'loading' | 'content' | 'not_found'

function parseRecipeId(params: Object | undefined): number | null {
  const value = (params as Record<string, Object> | undefined)?.recipeId
  if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
    return null
  }
  return value
}

错误页可以提供"返回菜谱列表",同时在日志中记录无效 ID,便于追踪调用方。

七、页面状态应显式区分三种结果

ts 复制代码
@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 渲染骨架、正文或错误提示。这样"数据正在准备"和"菜谱不存在"不会都表现为空白。

八、详情页渲染顺序来自用户任务

当前页面顺序是头图、基础信息、营养、食材、小贴士和底部操作。这个顺序基本合理,因为用户通常先判断是否想做,再核对食材,最后进入步骤。

建议把内容分成四段:

  1. 识别区:图片、菜名、标签、难度、时长。
  2. 决策区:营养摘要、份量和提示。
  3. 准备区:食材清单、购物清单入口。
  4. 执行区:步骤预览、做饭模式和完成记录。

每段都回答一个用户问题,避免把所有信息做成相同样式的卡片。

九、营养数据必须说明计算口径

模型保存 calories、protein、fat、carbs,页面分别显示 kcal 和 g,但没有说明这些值是每份、整道菜还是每 100g。对于健康类界面,这个缺失会让数字显得比实际更确定。

ts 复制代码
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,手机大字体模式下可能拥挤。固定四列适合宽屏,窄屏可以使用两列 Grid:

ts 复制代码
private 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 分钟以内"等文本会溢出。

可以使用支持换行的 Flex:

ts 复制代码
Flex({ 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() 计算,点击后又执行:

ts 复制代码
this.favoriteRecords = UserDataManager.toggleFavorite(
  this.favoriteRecords,
  this.recipe.id
)
this.isFavorite = !this.isFavorite

问题在于 isFavorite 只是对前一个布尔值取反,而不是根据新记录重新计算。如果另一个页面修改了收藏,或持久化恢复改变了数组,两份状态可能分叉。

更稳的写法是把返回记录作为唯一事实:

ts 复制代码
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() 返回的异步结果也没有被等待。调用方仍会更新内存并显示收藏成功,因此应用重启后状态可能消失,而页面无法知道失败发生在哪一步。

ts 复制代码
interface SaveResult {
  ok: boolean
  reason?: 'not_initialized' | 'write_failed'
}

async function persistFavorites(records: FavoriteRecord[]): Promise<SaveResult> {
  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,再把新记录放到首位:

ts 复制代码
const filtered = records.filter(r => r.recipeId !== recipeId)
const result: HistoryRecord[] = [
  { recipeId, viewedAt: dateStr },
  ...filtered
]

这比每次访问都追加更适合"最近浏览"。需要补充的只是容量上限,例如只保留最近 100 条,避免长期使用后 Preferences 字符串持续增长。

ts 复制代码
return [{ recipeId, viewedAt: dateStr }, ...filtered].slice(0, 100)

十五、购物清单按名称去重会丢失份量语义

详情页加入购物清单时用 item.name === ingredient.name 判断重复。假设两个菜谱都使用"鸡胸肉",一个需要 250g,另一个需要 400g,第二次加入会被跳过,用户看不到合并后的 650g。

购物项至少应记录来源:

ts 复制代码
interface ShoppingItemV2 {
  id: string
  name: string
  amountText: string
  category: string
  checked: boolean
  sourceRecipeIds: number[]
}

如果单位可解析,就按单位合并;如果是"少许""一根"等自然语言,则保留多条来源或展示组合文本,不能假装完成精确加法。

十六、食材分类应数据化而不是靠字符串猜测

getIngredientCategory() 通过名称包含"鸡、猪、鱼、虾、油、盐"等关键词分类,并把鱼、虾、海参和鲍鱼也归到"肉类"。这种规则能快速展示,但会在"鱼露""鸡蛋""豆豉"等边界上产生歧义。

更适合的模型是直接给食材一个稳定 ID 和类别:

ts 复制代码
interface IngredientItemV2 {
  ingredientId: string
  name: string
  amount: string
  category: 'meat' | 'seafood' | 'vegetable' |
    'dairy_egg' | 'staple' | 'seasoning' | 'other'
}

详情页不再猜分类,购物清单、冰箱推荐和食材百科也能复用同一个类别。

十七、做饭模式不应在进入时就记为已做

当前点击"做饭模式"时先调用 addCooked(),随后跳转步骤页。用户即使立刻返回,也已经进入"本周已做"和营养统计。

ts 复制代码
this.cookedRecords = UserDataManager.addCooked(
  this.cookedRecords,
  this.recipe.id
)
router.pushUrl({
  url: 'pages/CookingStepsPage',
  params: { recipeId: this.recipe.id }
})

更准确的状态应至少区分 started 和 completed。进入步骤页只记录开始;用户在最后一步点击"完成"后,才写入 CookedRecord。

十八、步骤页应通过结果回传完成状态

ts 复制代码
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 校验 显示内容不完整,不进入做饭模式

占位图片能保证布局稳定,但不代表详情数据完整。

二十、详情页结构按四层拆分

建议的职责边界如下:

text 复制代码
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。

二十二、底部操作栏要防止重复点击

收藏、加入购物清单和做饭模式都可能触发状态写入或导航。连续点击时需要明确行为:

  • 收藏:一次点击只产生一次目标状态切换。
  • 购物清单:按钮处理中禁用,完成后显示新增数量。
  • 做饭模式:导航发起后立即锁定,防止重复打开步骤页。
ts 复制代码
@State actionBusy: boolean = false

private async openCookingMode(): Promise<void> {
  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 时,需要兼容旧数据:

ts 复制代码
function migrateShoppingItem(raw: Record<string, 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 打开 recipeId=1 宫保鸡丁、对应食材和步骤一致
第 21 道菜 打开 recipeId=21 不出现通用四项食材
最大 ID 打开 recipeId=60 菜谱、食材、步骤完整
无效 ID 打开 recipeId=999 显示不存在,不切到第一道菜
收藏 顶部收藏后返回列表 列表与底部按钮状态一致
连续收藏 快速点击两次 最终状态可预测,无重复记录
加购物清单 两道菜含同名食材 数量或来源不会静默丢失
做饭模式 打开后立即返回 不计入已做
完成做饭 最后一步点击完成 只新增一条去重后的已做记录
存储失败 模拟 Preferences 写入失败 恢复 UI 状态并提示重试
大字体 系统字体放大 营养、标签、按钮不重叠
2in1 宽屏打开详情 双栏稳定,操作可达

二十五、Hypium 单测先守住纯逻辑

ts 复制代码
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,再让页面只处理明确状态,详情页就会从"字段展示页"变成稳定的业务枢纽。之后无论增加份量换算、冰箱推荐、多人菜单还是营养筛选,都能建立在同一份一致数据上。

参考资料

  1. Huawei Developer,ArkUI:https://developer.huawei.com/consumer/cn/arkui/
  2. Huawei Developer,HarmonyOS 数据管理概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-mgmt-overview
  3. Huawei Developer,应用拥有的状态管理:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-appstorage
  4. 项目源码:entry/src/main/ets/pages/RecipeDetailPage.ets
  5. 项目模型:entry/src/main/ets/models/RecipeModel.ets
  6. 项目数据:entry/src/main/ets/models/MockData.ets
  7. 项目步骤:entry/src/main/ets/models/CookingStepsData.ets
  8. 用户数据:entry/src/main/ets/common/utils/UserDataManager.ets

AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码和数据文件为依据,并由作者复核后发布。

相关推荐
一目Leizi8 小时前
折叠屏适配踩坑实录:从“一展开就变形“到一套代码适配多种形态【鸿蒙心迹】
华为·harmonyos
传奇开心果编程8 小时前
【Jetpack Compose娓娓道来】 第25课:ViewModel就是UI界面的大脑
android·kotlin·移动开发·jetpack compose·编程语言理论·编程框架·声明式ui
马剑威(威哥爱编程)9 小时前
【HarmonyOS 7 小艺智能体实战】03:第一次建智能体——在小艺开放平台把骨架立起来
人工智能·华为·harmonyos
KevinChaos10 小时前
HarmonyOS 7.0 端侧3DGS实验⑤:空洞、漂浮伪影与视角稳定
harmonyos
fellow9910 小时前
DSH Desktop - HarmonyOS Edition
harmonyos·openharmony·deepseek·deepseekharness
鸿蒙开发11 小时前
鸿蒙表单异步校验实战:@hmkit/validator 1.3 的取消、超时与任务清理
harmonyos
Flutter_OH11 小时前
Flutter OHOS 核心对象架构原理说明
harmonyos
贾伟康12 小时前
【健康菜谱助手|04】HarmonyOS ArkTS 一周膳食计划实战:管理日期、餐次与菜谱替换的联动
软件测试·移动开发·harmonyos·arkts·arkui·1024程序员节