欢迎光临
我们一直在努力

【健康菜谱助手|07】HarmonyOS ArkTS 营养页实战:用可读指标展示热量与营养结构

营养页很容易做成四条彩色进度条:热量、蛋白质、脂肪、碳水各算一个平均值,再把结果除以固定目标。这样的界面看起来完整,但只要追问三个问题,数据口径就可能站不住:页面里的“本周”从哪一天开始?同一道菜做了两次是否应该算两餐?600 kcal 与 25 g 蛋白质究竟是健康建议、产品参考线,还是某个用户的个性化目标?

本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。NutritionPage.ets 通过 @StorageLink 读取 cookedRecords 和 favoriteRecords,关联 RECIPE_LIST 后计算四类营养均值,同时展示菜品热量排行和四条营养提示。代码入口清楚,视觉层次也已经形成,但“本周已做”的时间过滤、重复烹饪次数、空态语义、目标来源与计算边界仍需要补齐。

营养页的可信度不取决于进度条有多漂亮,而取决于数据集、统计窗口、份量口径和目标来源能否被明确解释并重复计算。

HEALTHY-RECIPE-ONE7-NUTRITION-20260730

HarmonyOS ArkTS 营养页实战封面

本文将完成以下工作:

  • 还原 NutritionPage、UserDataManager、RecipeModel 和 RECIPE_LIST 的真实链路。
  • 解释为什么当前“本周已做”实际统计的是历史去重集合。
  • 设计带时间范围、样本数和目标来源的 NutritionViewData。
  • 把平均值、进度比例、排序与空态变成可单测的纯函数。
  • 处理无效记录、重复菜谱、未来时间、周起点和数值异常。
  • 给出 ArkUI 渲染、性能、可访问性与测试验收方案。

一、真实页面目前展示了什么

NutritionPage 由三个主体区域组成:

  • “本周已做 / 收藏均值”两个页签。
  • 热量、蛋白质、脂肪、碳水四条平均营养进度。
  • 菜品热量排行与四条营养小贴士。
  • 页面状态来自两个全局键:

    @StorageLink('cookedRecords') cookedRecords: CookedRecord[] = []
    @StorageLink('favoriteRecords') favoriteRecords: FavoriteRecord[] = []
    @State selectedTab: number = 0

    CookedRecord 只有 recipeId 和 cookedAt,FavoriteRecord 只有 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 时直接跳过。用户只看到样本变少,却无法知道发生了什么。更可复核的关联结果应该同时返回有效样本和缺失 ID:

    export interface RecipeJoinResult {
    recipes: RecipeModel[]
    missingRecipeIds: number[]
    }

    export function joinRecipes(
    records: CookedRecordV2[],
    recipeMap: Map<number, 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[]): Map<number, RecipeModel> {
    const map = new Map<number, 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: Map<number, 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 Set<number>()
    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 根据页签生成 NutritionViewData
    NutritionPage 页签、空态、列表和导航

    依赖方向保持为 Page -> ViewModel -> Analyzer/Repository。纯计算模块不依赖 ArkUI 和 AppStorage,因此可以在普通测试中验证。

    二十六、减少 build 阶段的重复计算

    当前页面在构建树中多次调用 getRecipes(),排行条件和排行数据又分别计算。数据量小时不明显,但状态变化会触发重建。可以在记录或页签变化时更新 @State viewData:

    @State 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 有效样本继续计算,缺失数为 1
    N07 目标为 0 进度为 0,不出现 NaN%
    N08 值高于目标 宽度为 100%,文本显示超出
    N09 值为负数 清洗或标记异常,不产生负宽度
    N10 相同热量菜谱 按 ID 稳定排序
    N11 重复收藏记录 收藏均值按菜谱去重
    N12 页签快速切换 标题、样本、排行同步更新
    N13 存储解析失败 显示错误态而非空态
    N14 320vp 小屏长菜名 文本不遮挡热量和蛋白质
    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 排行刷新时跳动

    为相同热量增加稳定次级排序,并确认 ForEach key 使用稳定实体 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),只限制上界 同时限制下界并处理 target<=0、NaN、Infinity
    空数据 getEmptyNutrients() 返回四个 value=0 的指标;排行显示“暂无数据” 使用独立 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
    赞(0)
    未经允许不得转载:171主机测评 » 【健康菜谱助手|07】HarmonyOS ArkTS 营养页实战:用可读指标展示热量与营养结构
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址