
营养页很容易做成四条彩色进度条热量、蛋白质、脂肪、碳水各算一个平均值再把结果除以固定目标。这样的界面看起来完整但只要追问三个问题数据口径就可能站不住页面里的“本周”从哪一天开始同一道菜做了两次是否应该算两餐600 kcal 与 25 g 蛋白质究竟是健康建议、产品参考线还是某个用户的个性化目标本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。NutritionPage.ets通过StorageLink读取cookedRecords和favoriteRecords关联RECIPE_LIST后计算四类营养均值同时展示菜品热量排行和四条营养提示。代码入口清楚视觉层次也已经形成但“本周已做”的时间过滤、重复烹饪次数、空态语义、目标来源与计算边界仍需要补齐。营养页的可信度不取决于进度条有多漂亮而取决于数据集、统计窗口、份量口径和目标来源能否被明确解释并重复计算。HEALTHY-RECIPE-ONE7-NUTRITION-20260730本文将完成以下工作还原NutritionPage、UserDataManager、RecipeModel和RECIPE_LIST的真实链路。解释为什么当前“本周已做”实际统计的是历史去重集合。设计带时间范围、样本数和目标来源的NutritionViewData。把平均值、进度比例、排序与空态变成可单测的纯函数。处理无效记录、重复菜谱、未来时间、周起点和数值异常。给出 ArkUI 渲染、性能、可访问性与测试验收方案。一、真实页面目前展示了什么NutritionPage由三个主体区域组成“本周已做 / 收藏均值”两个页签。热量、蛋白质、脂肪、碳水四条平均营养进度。菜品热量排行与四条营养小贴士。页面状态来自两个全局键StorageLink(cookedRecords) cookedRecords: CookedRecord[] [] StorageLink(favoriteRecords) favoriteRecords: FavoriteRecord[] [] State selectedTab: number 0CookedRecord只有recipeId和cookedAtFavoriteRecord只有recipeId和createdAt。真正的热量与宏量营养素并不保存在记录中而是通过recipeId回查静态RECIPE_LIST。这意味着营养页展示的是菜谱模型中的估算值不是用户实际称重、实际份量或设备采集结果。二、四项营养指标的现有计算方式当前getAvgNutrients()先取得菜谱数组再累加calories、protein、fat、carbs最后除以菜谱数量let totalCal 0 let totalPro 0 let totalFat 0 let totalCarb 0 for (const recipe of recipes) { totalCal recipe.calories totalPro recipe.protein totalFat recipe.fat totalCarb recipe.carbs } const count recipes.length const avgCalories Math.round(totalCal / count) const avgProtein Math.round(totalPro / count * 10) / 10热量取整数三类宏量营养素保留一位小数。四个固定目标分别为 600 kcal、25 g、20 g、75 g。进度条宽度使用Math.min(value / target * 100, 100)超过目标后只显示满格。这段计算对“一个数组的算术平均值”是正确的问题在于传入数组是否真的代表页面标题所说的数据集合。三、“本周已做”当前并没有周过滤getCookedRecipes()只按recipeId关联菜谱static getCookedRecipes(records: CookedRecord[]): RecipeModel[] { const result: RecipeModel[] [] for (const record of records) { const recipe RECIPE_LIST.find(item item.id record.recipeId) if (recipe ! undefined) { result.push(recipe) } } return result }这里没有读取cookedAt自然也没有判断记录是否落在当前周。只要用户曾经做过一道菜它就可能长期出现在所谓“本周已做”的统计里。页面标题与计算口径不一致是营养页最需要先修复的问题。更隐蔽的一点在addCooked()新记录写入前会过滤掉相同recipeId。因此同一道菜本周做了三次记录中通常只保留最新一次。当前均值不是“本周每餐平均”而更接近“历史上做过且尚未清除的不同菜谱平均”。四、先定义产品口径再写计算代码营养页可以有多种合理口径但必须选定一个视图样本单位时间范围重复处理本周每餐平均一次烹饪记录本周一至当前时间同菜多次分别计算本周菜谱均值不同菜谱本周一至当前时间同菜去重收藏菜谱均值当前收藏菜谱无时间限制按菜谱去重如果产品确实要展示“本周每餐平均”数据层就不能在写入时删除同菜历史。更稳妥的做法是让每次烹饪都生成独立事件 ID统计时再根据业务需要聚合。收藏天然是集合仍可保持按recipeId去重。本文采用“本周每餐平均”作为已做页签口径并保留“收藏菜谱均值”作为收藏页签口径。五、把时间从字符串升级为可比较字段当前日期格式类似2026-07-17 19:30。它便于阅读却没有时区信息也不适合跨地区或夏令时场景。新记录建议保存毫秒时间戳export interface CookedRecordV2 { id: string recipeId: number cookedAtMs: number }id保证每一次烹饪事件都可追踪cookedAtMs直接用于范围比较。已有字符串记录可以在仓库迁移时解析解析失败的记录要计入诊断信息而不是默默参与或默默丢弃。六、明确一周的起止边界周统计最容易在周日、月末、跨年和零点附近出错。可以把“周一 00:00”封装为纯函数并通过传入nowMs让测试不依赖系统当前时间export interface TimeRange { startMs: number endMs: number } export function currentWeekRange(nowMs: number): TimeRange { const now new Date(nowMs) const day now.getDay() const offsetToMonday day 0 ? -6 : 1 - day const start new Date( now.getFullYear(), now.getMonth(), now.getDate() offsetToMonday, 0, 0, 0, 0 ) return { startMs: start.getTime(), endMs: nowMs } }这里采用设备本地时区并把endMs设为当前时刻。产品如果要求自然周完整区间或服务端统一时区应在需求中明确不能让页面自己猜。七、过滤本周记录时要处理未来数据本周过滤不是只判断大于起点还要限制不超过当前时间export function filterRecordsInRange( records: CookedRecordV2[], range: TimeRange ): CookedRecordV2[] { return records.filter((record: CookedRecordV2) Number.isFinite(record.cookedAtMs) record.cookedAtMs range.startMs record.cookedAtMs range.endMs ) }未来时间可能来自设备时间被修改、迁移错误或测试数据。如果不限制上界它会污染当前周结果。过滤函数不修改原数组便于重复调用和单元测试。八、关联菜谱时保留无效记录诊断当前getCookedRecipes()找不到recipeId时直接跳过。用户只看到样本变少却无法知道发生了什么。更可复核的关联结果应该同时返回有效样本和缺失 IDexport interface RecipeJoinResult { recipes: RecipeModel[] missingRecipeIds: number[] } export function joinRecipes( records: CookedRecordV2[], recipeMap: Mapnumber, RecipeModel ): RecipeJoinResult { const recipes: RecipeModel[] [] const missingRecipeIds: number[] [] for (const record of records) { const recipe recipeMap.get(record.recipeId) if (recipe undefined) { missingRecipeIds.push(record.recipeId) } else { recipes.push(recipe) } } return { recipes, missingRecipeIds } }页面不一定要直接展示缺失 ID但调试日志或诊断状态应能说明“3 条记录中有 1 条无法关联”。九、用 Map 避免重复线性查找RECIPE_LIST.find()每关联一条记录都要扫描数组。当前只有 60 道菜时影响不大但模型增长后会变成O(records × recipes)。在服务初始化时建立Map关联复杂度更稳定export function buildRecipeMap(recipes: RecipeModel[]): Mapnumber, RecipeModel { const map new Mapnumber, RecipeModel() for (const recipe of recipes) { map.set(recipe.id, recipe) } return map }这也让测试可以注入三五条最小样本不必依赖整份MockData。十、定义营养计算的输入和输出不要让 ArkUI 页面直接维护四个累加变量。先定义纯数据契约export interface NutritionValue { calories: number protein: number fat: number carbs: number } export interface NutritionAverage extends NutritionValue { sampleCount: number }sampleCount不是附属信息。均值 220 kcal 来自 1 道菜还是 20 餐可信程度和用户理解完全不同界面应展示样本数。十一、用纯函数计算均值export function averageNutrition(recipes: RecipeModel[]): NutritionAverage { if (recipes.length 0) { return { calories: 0, protein: 0, fat: 0, carbs: 0, sampleCount: 0 } } let calories 0 let protein 0 let fat 0 let carbs 0 for (const recipe of recipes) { calories safeNonNegative(recipe.calories) protein safeNonNegative(recipe.protein) fat safeNonNegative(recipe.fat) carbs safeNonNegative(recipe.carbs) } const count recipes.length return { calories: round(calories / count, 0), protein: round(protein / count, 1), fat: round(fat / count, 1), carbs: round(carbs / count, 1), sampleCount: count } }其中safeNonNegative()把非有限值和负数归零或标记为无效具体策略取决于数据质量要求。技术上不能让NaN继续进入进度条宽度。十二、统一数值清洗与舍入规则export function safeNonNegative(value: number): number { return Number.isFinite(value) value 0 ? value : 0 } export function round(value: number, digits: number): number { const factor Math.pow(10, digits) return Math.round(value * factor) / factor }热量显示整数、克数显示一位小数是界面格式不应散落在每个字段的表达式里。统一函数能确保测试、列表和详情页采用同一规则。十三、目标值必须带来源当前目标 600、25、20、75 直接写在getAvgNutrients()中。用户可能把它理解为医疗或个性化建议。更合适的数据结构是export interface NutritionTarget { calories: number protein: number fat: number carbs: number sourceLabel: string scopeLabel: string } const DEFAULT_REFERENCE: NutritionTarget { calories: 600, protein: 25, fat: 20, carbs: 75, sourceLabel: 应用内每餐参考线, scopeLabel: 仅用于菜谱比较不构成个性化健康建议 }页面标题应写“每餐参考”而不是笼统写“推荐”。如果未来引入年龄、性别、体重、目标或疾病信息就涉及敏感健康数据、隐私说明与专业规则不应只在前端偷偷换一组数字。十四、进度比例要同时限制上下界当前只用Math.min(..., 100)限制上界。完整函数应处理目标为零、负数和非有限值export function ratioPercent(value: number, target: number): number { if (!Number.isFinite(value) || !Number.isFinite(target) || target 0) { return 0 } const percent value / target * 100 return Math.max(0, Math.min(percent, 100)) }更重要的是满格并不等于“达标”或“更健康”。脂肪和热量超过参考线时继续使用普通颜色会掩盖超出状态。可以单独返回below、near、over再由 UI 选择文案和颜色。十五、不要用颜色独自表达状态营养项目前依赖橙、蓝、黄、绿区分。对于色觉差异用户颜色不能承担唯一语义。每行应同时展示指标名称与单位。当前均值和参考值。“约为参考线的 73%”或“高于参考线 12%”的文本。可读的进度条或图标。颜色用于辅助扫描文字负责准确解释。正文文本与背景对比度应保持可读进度轨道也不能淡到无法识别。十六、空数据不是四个零当前无样本时营养卡仍展示四个0和“推荐每餐”排行区才显示“暂无数据”。这会让用户误以为系统计算出零营养。更合理的空态是export interface NutritionEmptyState { title: string description: string actionLabel: string } const COOKED_EMPTY: NutritionEmptyState { title: 本周还没有烹饪记录, description: 完成一道菜后这里会按每次烹饪记录计算平均营养。, actionLabel: 去选菜谱 }收藏页签则使用“先收藏感兴趣的菜谱”。两个空态的原因和下一步不同不能共用一句“暂无数据”。十七、构造页面需要的 ViewData页面不应反复调用getRecipes()、getAvgNutrients()和排序函数。一次生成完整视图数据export interface NutrientRowViewData { key: string label: string valueText: string referenceText: string percent: number stateText: string color: string } export interface NutritionViewData { title: string scopeText: string sampleCount: number rows: NutrientRowViewData[] ranking: RecipeModel[] missingRecordCount: number empty: boolean }这样 ArkUI 只渲染不再承担统计业务。页签切换时重新计算一次记录数组变化时再刷新一次。十八、本周视图的完整计算链路export function buildWeeklyCookedView( records: CookedRecordV2[], recipeMap: Mapnumber, RecipeModel, nowMs: number, target: NutritionTarget ): NutritionViewData { const range currentWeekRange(nowMs) const weeklyRecords filterRecordsInRange(records, range) const joined joinRecipes(weeklyRecords, recipeMap) const average averageNutrition(joined.recipes) return buildNutritionViewData( 本周每餐平均, formatRange(range), joined.recipes, average, target, joined.missingRecipeIds.length ) }nowMs、recipeMap和target都由外部注入测试可以固定输入并得到确定输出。十九、收藏视图与已做视图不能共用错误口径收藏均值不需要周过滤但要先对recipeId去重避免历史迁移产生重复收藏export function uniqueFavoriteIds(records: FavoriteRecord[]): number[] { const ids new Setnumber() for (const record of records) { ids.add(record.recipeId) } return Array.from(ids) }收藏视图文案应说明“按当前收藏菜谱估算”而不是暗示用户真的吃过这些菜。相同的四个营养数值在两个页签里代表不同含义scopeText必须跟随切换。二十、热量排行必须使用副本排序当前写法是this.getRecipes().sort((a, b) b.calories - a.calories)现阶段getRecipes()返回新数组所以不会直接改写RECIPE_LIST。但这个安全性依赖调用者知道实现细节。更稳妥的排序函数明确复制并增加相同热量的稳定规则export function rankByCalories(recipes: RecipeModel[]): RecipeModel[] { return [...recipes].sort((left: RecipeModel, right: RecipeModel) { const calorieDelta right.calories - left.calories return calorieDelta ! 0 ? calorieDelta : left.id - right.id }) }固定次级排序可以避免数据相同时列表顺序在刷新后跳动。二十一、排行要说明比较对象“菜品热量排行”容易被理解为全部菜谱排行。实际上它只比较当前页签的数据集。标题可以改为“本周记录热量排行”或“收藏菜谱热量排行”并显示样本数。若只展示前五名还需写“共 N 道展示前 5 道”。热量排行也不代表健康排行不能用冠军、优胜等价值判断。它只是按模型中的calories数值降序排列。二十二、同一道菜多次烹饪如何统计如果本周做了两次清蒸鲈鱼和一次红烧肉“每餐平均”应包含三个样本。记录层必须保留三个事件关联后得到三个RecipeModel引用是允许的。若产品要“不同菜谱平均”再在统计层按recipeId去重。把去重放在统计层而不是写入层能保留原始事实并支持更多分析。原始事件一旦被覆盖后续无法恢复真实次数。二十三、份量口径是营养数字的前提RecipeModel目前只有热量和三类营养数字没有servingCount、servingGram或“每 100 克”字段。页面只能把它们视为“每份菜谱模型值”。建议补充export interface NutritionBasis { servingCount: number servingGram?: number source: string updatedAt: string }在没有份量信息前界面文案应使用“菜谱估算值”不要写成精确摄入量。技术实现不能替产品做医学结论。二十四、营养小贴士需要内容边界当前四条小贴士包含蔬菜摄入、蛋白质、饮水和控盐数字。它们属于健康信息而不是普通 UI 文案。至少应做到标注参考来源与更新时间。避免“适合所有人”的绝对表达。对儿童、孕妇、慢性病患者等特殊人群提示遵循专业建议。不根据收藏或浏览行为推断疾病与健康状态。如果没有可靠来源宁可改成烹饪方法提示例如少油烹饪、查看食材份量也不要制造权威感。二十五、ArkUI 页面只负责状态选择与渲染建议将页面拆成以下边界层职责NutritionRepository读取记录和菜谱执行版本迁移NutritionAnalyzer时间过滤、关联、均值、比例与排行NutritionViewModel根据页签生成NutritionViewDataNutritionPage页签、空态、列表和导航依赖方向保持为Page - ViewModel - Analyzer/Repository。纯计算模块不依赖 ArkUI 和AppStorage因此可以在普通测试中验证。二十六、减少 build 阶段的重复计算当前页面在构建树中多次调用getRecipes()排行条件和排行数据又分别计算。数据量小时不明显但状态变化会触发重建。可以在记录或页签变化时更新State viewDataState private viewData: NutritionViewData EMPTY_VIEW_DATA private refreshView(): void { this.viewData this.selectedTab 0 ? this.viewModel.buildWeeklyCooked(this.cookedRecords, Date.now()) : this.viewModel.buildFavorites(this.favoriteRecords) }页签点击后更新selectedTab并调用refreshView()。若记录在其他页面改变需要通过明确的生命周期或状态监听刷新不能只依赖偶然重建。二十七、进度条组件需要稳定尺寸营养条应有固定高度、明确轨道和受控宽度。动态文本再长也不能挤压到数值区Row() { Text(row.label) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Blank() Text(row.valueText) .maxLines(1) } .width(100%) Stack({ alignContent: Alignment.Start }) { Row().width(100%).height(8) Row().width(${row.percent}%).height(8) } .width(100%) .height(8)平板和 2in1 可以限制内容最大宽度避免进度条横跨过长小屏上要确保参考文案换行后不遮挡下一项。二十八、列表 key 与状态身份营养行使用label作为 key在当前四项固定且唯一时可行。排行使用rank_${recipe.id}同一菜谱若按每餐样本重复出现就会产生重复 key。如果排行按菜谱聚合recipe.id足够如果排行按烹饪事件展示应使用CookedRecordV2.id。key 必须对应业务实体身份不能使用会变化的排名或数组下标。二十九、可访问性与可读反馈营养行可以提供组合描述例如“蛋白质平均每餐 18.5 克参考线 25 克约 74%”。页签需要清晰的选中状态不能只靠底部绿色线。返回按钮应使用标准图标和足够触控区域并提供可识别描述。正文最小字号、辅助文本颜色和白底对比度需要分别检查。Sizes.SMALL_FONT为 10若用于关键解释应提高到更易读的字号而不是把重要口径藏在极小文字里。三十、错误状态不能伪装成空态UserDataManager.init()解析失败时会回退为空数组。对首页可能是安全降级但营养页会把存储损坏表现成“没有记录”。建议仓库返回明确状态export type LoadState loading | ready | empty | error export interface NutritionLoadResult { state: LoadState viewData?: NutritionViewData message?: string }用户数据确实为空时显示引导读取失败时显示重试和错误说明。两者不能混为一谈。三十一、测试矩阵编号输入预期N01周一 00:00 的记录计入本周N02上周日 23:59 的记录不计入本周N03当前时间之后的记录被过滤并诊断N04本周同菜两次每餐口径计为 2 个样本N05空记录显示引导空态不显示四个伪零值N06无效recipeId有效样本继续计算缺失数为 1N07目标为 0进度为 0不出现NaN%N08值高于目标宽度为 100%文本显示超出N09值为负数清洗或标记异常不产生负宽度N10相同热量菜谱按 ID 稳定排序N11重复收藏记录收藏均值按菜谱去重N12页签快速切换标题、样本、排行同步更新N13存储解析失败显示错误态而非空态N14320vp 小屏长菜名文本不遮挡热量和蛋白质N15深色模式正文、进度轨道和状态色可辨认N16大字体指标与参考文案可换行且操作可达三十二、可复核样例准备三条菜谱const recipes: RecipeModel[] [ makeRecipe(1, A, 300, 20, 10, 40), makeRecipe(2, B, 600, 30, 20, 80), makeRecipe(3, C, 150, 10, 5, 20) ]如果本周三次烹饪分别对应 A、B、A按每餐平均应为热量(300 600 300) / 3 400 kcal。蛋白质(20 30 20) / 3 23.3 g。脂肪(10 20 10) / 3 13.3 g。碳水(40 80 40) / 3 53.3 g。如果错误地按菜谱去重A 只算一次结果会变为 450 kcal。这个样例可以直接揭示记录层提前去重造成的口径偏差。三十三、验收步骤33.1 数据口径清空测试数据分别写入上周、本周和未来记录。固定nowMs确认页面样本数只包含本周有效事件。同一道菜写入两次确认每餐平均会计算两次。切换收藏页签确认不受本周时间范围影响。33.2 数值与排行使用可手算的三条菜谱数据核对四项均值。将某项设为参考线两倍确认条宽不溢出且文本显示超出。构造相同热量菜谱连续刷新后顺序保持一致。删除一条菜谱模型确认缺失记录不会导致页面崩溃。33.3 界面状态验证 loading、empty、error、content 四种状态。检查手机小屏、平板横屏和 2in1 窗口缩放。检查大字体、长菜名、深浅色模式和页签焦点状态。返回列表再进入确认统计范围和样本数没有漂移。三十四、常见故障定位34.1 标题写本周旧菜仍出现检查cookedAt是否真的参与过滤以及旧字符串是否迁移为时间戳。只调用getCookedRecipes()不会产生周语义。34.2 同一道菜做两次仍只算一次检查addCooked()是否按recipeId覆盖旧记录。若产品口径是每餐必须保留独立事件。34.3 进度条消失或宽度异常打印value、target和percent检查是否存在NaN、负数或目标为零。不要把未经清洗的表达式直接拼入宽度。34.4 排行刷新时跳动为相同热量增加稳定次级排序并确认ForEachkey 使用稳定实体 ID。34.5 空态显示四个零不要调用“空营养数组”伪造统计结果。由sampleCount 0进入独立空态分支。三十五、落地顺序先确认“本周每餐平均”与“收藏菜谱均值”的产品口径。将烹饪记录升级为带唯一事件 ID 和毫秒时间戳的模型。提取周范围、过滤、关联、均值、比例和排序纯函数。增加NutritionViewData让页面只渲染一次计算结果。补空态、错误态、样本数、参考来源和范围文案。执行 N01–N16 测试与多设备可读性检查。这个顺序先修正数据事实再优化视觉表达。否则界面越精致错误口径传播得越可信。三十六、源码事实与建议实现的边界营养指标涉及健康语义尤其需要避免把固定展示值包装成已经验证的个性化建议。当前源码事实与本文建议如下维度当前源码事实本文建议不代表已经实现页签只有“本周已做”和“收藏均值”两个页签为不同口径生成独立 ViewData 与范围说明数据来源通过StorageLink读取 cookedRecords、favoriteRecords再关联 RECIPE_LIST增加无效关联诊断、加载态和错误态“本周”getCookedRecipes()没有任何周起止过滤使用可注入 nowMs 和明确 weekStart/weekEnd 过滤烹饪次数addCooked()会先按 recipeId 移除旧记录同一道菜只保留最近一条如果口径是“每餐”改为保存独立烹饪事件均值对关联后的菜谱计算热量、蛋白质、脂肪和碳水算术平均显示样本数、范围、份量口径与异常数据处理固定目标四个 target 分别为 600 kcal、25 g、20 g、75 g把目标来源和适用范围作为显式元数据进度宽度使用Math.min(value / target * 100, 100)只限制上界同时限制下界并处理 target0、NaN、Infinity空数据getEmptyNutrients()返回四个 value0 的指标排行显示“暂无数据”使用独立 empty 状态不把“无样本”解释为摄入为零排行对getRecipes()返回的关联结果按 calories 降序排序key 使用rank_${recipe.id}使用副本和稳定次级排序明确比较对象提示内容四条营养小贴士直接写在页面代码中内容来源、适用人群与免责声明需要单独治理验证状态本文完成源码级复核、配图检查和文章质量检查文中的周过滤、事件模型和 N01–N16 测试未被宣称已构建或通过所以NutritionViewData、时间范围过滤、事件级烹饪记录、纯函数统计和错误状态都属于建议改造。当前 1.0.5 页面实际展示的仍是去重后的已做菜谱集合或收藏集合的菜谱级均值不能把它直接解释为用户本周真实摄入也不能把固定 target 当作医疗或个体化营养结论。三十七、总结健康菜谱助手的营养页已经具备页签、四项营养均值、进度条、热量排行和提示卡片作为产品原型是完整的。工程化升级的重点不是再加一种图表而是让每一个数字都能回答“统计了谁、统计多久、按什么份量、与什么目标比较”。需要守住六条原则“本周”必须由明确的时间范围计算不能只写在标题里。每餐统计要保留每次烹饪事件去重应由分析口径决定。均值必须展示样本数空数据必须使用独立空态。固定目标要标注来源与适用范围不能伪装成个性化建议。比例、排序和关联都应是可测试、不修改输入的纯函数。ArkUI 页面负责表达状态数据与统计规则留在服务层。当用户能看到“本周一至今天共 5 餐菜谱估算平均热量 420 kcal”并能理解这个数字的来源与限制时营养页才真正从彩色进度条升级为可信、可复核的产品功能。参考资料HarmonyOS ArkUI 概述HarmonyOS ArkTS 概述HarmonyOS 数据管理概述HarmonyOS 深浅色适配工程源码NutritionPage.ets、UserDataManager.ets、RecipeModel.ets、MockData.ets、ThemeConstants.ets