菜谱详情页看起来只是"头图 + 标题 + 食材 + 做法"的长页面,实际却是健康菜谱应用中数据密度最高、状态交叉最多的页面。它需要同时回答:路由传入的是哪道菜、食材是否与菜谱一致、营养值按什么口径展示、收藏按钮是否与全局列表同步、进入做饭模式后何时记为"已做"、购物清单写盘失败时用户会看到什么。
本文基于"健康菜谱助手"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 渲染骨架、正文或错误提示。这样"数据正在准备"和"菜谱不存在"不会都表现为空白。
八、详情页渲染顺序来自用户任务
当前页面顺序是头图、基础信息、营养、食材、小贴士和底部操作。这个顺序基本合理,因为用户通常先判断是否想做,再核对食材,最后进入步骤。

建议把内容分成四段:
- 识别区:图片、菜名、标签、难度、时长。
- 决策区:营养摘要、份量和提示。
- 准备区:食材清单、购物清单入口。
- 执行区:步骤预览、做饭模式和完成记录。
每段都回答一个用户问题,避免把所有信息做成相同样式的卡片。
九、营养数据必须说明计算口径
模型保存 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,再让页面只处理明确状态,详情页就会从"字段展示页"变成稳定的业务枢纽。之后无论增加份量换算、冰箱推荐、多人菜单还是营养筛选,都能建立在同一份一致数据上。
参考资料
- Huawei Developer,ArkUI:
https://developer.huawei.com/consumer/cn/arkui/ - Huawei Developer,HarmonyOS 数据管理概述:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-mgmt-overview - Huawei 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.ets
AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码和数据文件为依据,并由作者复核后发布。