【健康菜谱助手|03】HarmonyOS ArkTS 冰箱食材推荐实战:从本地食材选择生成可解释的候选菜谱

"冰箱里有鸡蛋和土豆,今天能做什么?"这是菜谱应用中非常实用的一类入口。它不要求用户先知道菜名,而是从已有食材反推候选菜谱。看起来只要把食材关键词和菜谱文本做一次匹配即可,真正落地时却会遇到数据覆盖、别名归一、主辅料权重、缺失食材解释、排序稳定性和空结果等问题。

本文基于"健康菜谱助手"1.0.5 的真实 HarmonyOS ArkTS 工程展开。FridgeRecipePage.ets 已经提供 21 个常用食材、默认选中的鸡蛋与土豆、自定义食材输入,以及最多 12 条候选菜谱;项目总菜谱数是 60,但页面内的 recipeIngredientIndex 只覆盖 recipeId 1 到 20。换句话说,剩余 40 道菜没有结构化食材索引,只能偶然通过菜名、分类、描述或标签中的文字被命中。

冰箱推荐不是搜索框换了一个外观,而是把"我有什么、菜谱需要什么、还缺什么、为什么排在前面"计算成一份可解释结果。

HEALTHY-RECIPE-ONE7-FRIDGE-MATCH-20260730

本文将完成以下内容:

  • 还原当前食材选择、文本匹配和候选排序链路。
  • 解释 60 道菜与 20 条食材索引之间的数据覆盖缺口。
  • 设计食材归一、别名映射和去重规则。
  • 把"命中几项"升级为覆盖率、主料命中和缺失清单。
  • 修复 duration.localeCompare() 带来的字符串排序错误。
  • 给出纯本地、可复核、无需联网的推荐模型。
  • 覆盖空选择、无结果、重复输入和多设备布局测试。

一、真实页面目前做了什么

页面只维护两个可变字段:

ts 复制代码
@State selectedIngredients: string[] = ['鸡蛋', '土豆']
@State customIngredient: string = ''

常用食材按蛋奶、肉类、水产、蔬菜和主食组织,共 21 项:

ts 复制代码
private commonIngredients: FridgeIngredient[] = [
  { name: '鸡蛋', group: '蛋奶' },
  { name: '豆腐', group: '蛋奶' },
  { name: '牛奶', group: '蛋奶' },
  { name: '鸡胸肉', group: '肉类' },
  // ...共 21 项
  { name: '面条', group: '主食' }
]

点击标签时使用不可变数组更新,能触发 ArkUI 状态刷新:

ts 复制代码
toggleIngredient(name: string): void {
  const index = this.selectedIngredients.indexOf(name)
  if (index >= 0) {
    const next = [...this.selectedIngredients]
    next.splice(index, 1)
    this.selectedIngredients = next
  } else {
    this.selectedIngredients = [...this.selectedIngredients, name]
  }
}

这个交互基础是正确的。问题主要出在候选数据和评分规则,而不是标签本身。

二、当前推荐算法的完整链路

页面把菜名、菜系、描述、标签和食材索引拼成一个字符串:

ts 复制代码
getMatchCount(recipe: RecipeModel): number {
  const searchableText = `${recipe.name} ${recipe.category} ${recipe.description} `
    + `${recipe.tags.join(' ')} ${this.getIngredientText(recipe.id)}`
  let count = 0
  for (const ingredient of this.selectedIngredients) {
    if (searchableText.indexOf(ingredient) >= 0) {
      count++
    }
  }
  return count
}

随后按命中数量降序、时长字符串升序排列:

ts 复制代码
source.sort((a: RecipeModel, b: RecipeModel) => {
  const scoreDiff = this.getMatchCount(b) - this.getMatchCount(a)
  if (scoreDiff !== 0) {
    return scoreDiff
  }
  return a.duration.localeCompare(b.duration)
})

有选择时保留命中数大于 0 的前 12 条;没有选择时直接返回排序后的前 8 条。这条链路足够演示"点食材后列表会变化",却不能解释一条菜为什么排在另一条前面。

三、先量化真实数据缺口

源码中的数量关系如下:

数据 数量 覆盖情况
RECIPE_LIST 60 道菜 完整列表
常用食材 21 项 只用于输入
recipeIngredientIndex 20 条 只覆盖 recipeId 1--20
候选上限 12 条 有食材时
空选择上限 8 条 无食材时

后 40 道菜的 getIngredientText() 都返回空字符串:

ts 复制代码
getIngredientText(recipeId: number): string {
  for (const item of this.recipeIngredientIndex) {
    if (item.recipeId === recipeId) {
      return item.ingredients.join('、')
    }
  }
  return ''
}

例如"油焖大虾"是 recipeId 26。用户选择"大虾"时,这道菜没有结构化索引;只有当名称、描述或标签恰好出现"大虾"时才可能命中。推荐是否成功取决于文案措辞,而不是菜谱真实所需食材。

数据覆盖率只有:

text 复制代码
20 / 60 = 33.3%

这应当作为上线前的硬检查,而不是由 UI 静默掩盖。

四、推荐模型要先从字符串变成结构化数据

建议把食材需求放到独立模型中:

ts 复制代码
export type IngredientRole = 'primary' | 'secondary' | 'seasoning'

export interface RecipeIngredientRequirement {
  ingredientId: string
  displayName: string
  role: IngredientRole
  required: boolean
  amount?: string
  aliases?: string[]
}

export interface RecipeIngredientProfile {
  recipeId: number
  ingredients: RecipeIngredientRequirement[]
}

主料、辅料和调味料不能完全等权。用户有鸡胸肉,对宫保鸡丁的意义显然高于用户有大葱;盐、油、生抽等基础调味料也不应让每道菜都获得大量分数。

一个实际条目可以写成:

ts 复制代码
const GONG_BAO_PROFILE: RecipeIngredientProfile = {
  recipeId: 1,
  ingredients: [
    { ingredientId: 'chicken_breast', displayName: '鸡胸肉', role: 'primary', required: true },
    { ingredientId: 'peanut', displayName: '花生米', role: 'secondary', required: true },
    { ingredientId: 'dried_chili', displayName: '干辣椒', role: 'secondary', required: false },
    { ingredientId: 'scallion', displayName: '大葱', role: 'seasoning', required: false },
    { ingredientId: 'ginger', displayName: '生姜', role: 'seasoning', required: false }
  ]
}

这样算法不再从一整段自然语言中猜食材。

五、食材输入必须归一化

用户可能输入"西红柿""番茄""小番茄",也可能输入带空格的" 鸡蛋 "。当前代码只执行 trim(),重复判断仍然是精确字符串比较:

ts 复制代码
if (this.selectedIngredients.indexOf(name) < 0) {
  this.selectedIngredients = [...this.selectedIngredients, name]
}

可以建立本地别名字典:

ts 复制代码
const INGREDIENT_ALIASES: Record<string, string> = {
  '西红柿': '番茄',
  '小番茄': '番茄',
  '圣女果': '番茄',
  '马铃薯': '土豆',
  '洋芋': '土豆',
  '鸡肉丁': '鸡胸肉',
  '虾仁': '大虾'
}

function normalizeIngredientName(input: string): string {
  const compact = input.trim().replace(/\s+/g, '')
  return INGREDIENT_ALIASES[compact] ?? compact
}

归一后再判断重复:

ts 复制代码
addCustomIngredient(): void {
  const normalized = normalizeIngredientName(this.customIngredient)
  if (!normalized) {
    promptAction.showToast({ message: '先输入一种食材' })
    return
  }
  if (!this.selectedIngredients.includes(normalized)) {
    this.selectedIngredients = [...this.selectedIngredients, normalized]
  }
  this.customIngredient = ''
}

别名表必须可审查,不能把不等价食材强行合并。例如"鱼头"不能直接等同于"草鱼","鸡腿"也不应默认等同于"鸡胸肉"。

六、把用户输入转成集合

评分时反复调用 indexOf 会让语义散落。可以在计算开始时生成规范集合:

ts 复制代码
function buildPantrySet(selected: string[]): Set<string> {
  const pantry = new Set<string>()
  selected.forEach((item) => {
    const normalized = normalizeIngredientName(item)
    if (normalized) pantry.add(normalized)
  })
  return pantry
}

Set 同时解决去重和 O(1) 查找。对 21 个常用食材而言性能不是瓶颈,但统一数据形态能减少规则分叉。

七、定义一份可解释的 MatchResult

不要让 UI 只拿到一个数字。推荐函数应返回命中、缺失和解释:

ts 复制代码
export interface IngredientMatch {
  ingredientId: string
  name: string
  role: IngredientRole
}

export interface RecipeMatchResult {
  recipe: RecipeModel
  score: number
  coverage: number
  matched: IngredientMatch[]
  missingRequired: IngredientMatch[]
  missingOptional: IngredientMatch[]
  reason: string
  durationMinutes: number
}

这样卡片可以展示:

text 复制代码
已具备 3/4 项核心食材
命中:土豆、茄子、青椒
还缺:大蒜
预计 15 分钟

这比"3项匹配"更能帮助用户做决定。

八、给主料、辅料和调味料不同权重

可以使用简单透明的本地规则:

ts 复制代码
const ROLE_WEIGHT: Record<IngredientRole, number> = {
  primary: 5,
  secondary: 2,
  seasoning: 0.5
}

function scoreIngredient(
  item: RecipeIngredientRequirement,
  pantry: Set<string>
): number {
  return pantry.has(item.displayName) ? ROLE_WEIGHT[item.role] : 0
}

随后增加完整度和缺失主料惩罚:

ts 复制代码
function calculateScore(
  profile: RecipeIngredientProfile,
  pantry: Set<string>
): number {
  let score = 0
  let missingPrimary = 0
  for (const item of profile.ingredients) {
    if (pantry.has(item.displayName)) {
      score += ROLE_WEIGHT[item.role]
    } else if (item.required && item.role === 'primary') {
      missingPrimary++
    }
  }
  return score - missingPrimary * 4
}

该规则不是"AI 黑盒",每一分都能解释。对于纯本地菜谱应用,这种透明度往往比复杂模型更重要。

九、覆盖率应该只计算相关食材

如果把盐、油、葱姜蒜都算入分母,覆盖率会被调味料稀释。可以只对 required 项计算:

ts 复制代码
function calculateCoverage(
  profile: RecipeIngredientProfile,
  pantry: Set<string>
): number {
  const required = profile.ingredients.filter((item) => item.required)
  if (required.length === 0) return 0
  const matched = required.filter((item) => pantry.has(item.displayName)).length
  return matched / required.length
}

UI 展示时转成百分比,但排序保留原始浮点值。推荐条件可以定义为:

  • 至少命中一个主料;或
  • required 覆盖率达到 50%;或
  • 没有主料的轻食菜谱命中两个辅料。

规则应写进测试,不要藏在多个页面判断里。

十、修复时长字符串排序

当前代码使用:

ts 复制代码
a.duration.localeCompare(b.duration)

duration 的真实值覆盖 15 至 180 分钟,并包含 120分钟、180分钟 等数字分钟格式。字符串按字符顺序比较时,120分钟 会排在 20分钟 前,因为字符 '1' 小于 '2'。这与"更快完成"完全不同。

最小修复是解析数字:

ts 复制代码
function parseDurationMinutes(duration: string): number {
  const hourMatch = duration.match(/(\d+(?:\.\d+)?)\s*小时/)
  const minuteMatch = duration.match(/(\d+)\s*分钟/)
  const hours = hourMatch ? Number(hourMatch[1]) * 60 : 0
  const minutes = minuteMatch ? Number(minuteMatch[1]) : 0
  const total = hours + minutes
  return Number.isFinite(total) && total > 0 ? total : Number.MAX_SAFE_INTEGER
}

当前数据都是分钟格式,也可以在 RecipeModel 中直接保存 durationMinutes: number,展示层再格式化。结构化数值优于每次排序时解析字符串。

十一、稳定排序需要完整优先级

推荐列表建议按以下顺序比较:

  1. 缺失必需主料数量升序。
  2. required 覆盖率降序。
  3. 评分降序。
  4. 烹饪分钟数升序。
  5. recipeId 升序,作为稳定兜底。
ts 复制代码
function compareMatches(a: RecipeMatchResult, b: RecipeMatchResult): number {
  const missing = a.missingRequired.length - b.missingRequired.length
  if (missing !== 0) return missing
  const coverage = b.coverage - a.coverage
  if (coverage !== 0) return coverage
  const score = b.score - a.score
  if (score !== 0) return score
  const duration = a.durationMinutes - b.durationMinutes
  if (duration !== 0) return duration
  return a.recipe.id - b.recipe.id
}

最后的 recipeId 保证相同条件下每次打开顺序一致,便于用户形成预期,也便于截图测试。

十二、不要让"未选择食材"偷偷使用推荐排序

当前空选择时仍对全部菜谱排序,只是所有匹配分都为 0,然后按错误的时长字符串取前 8 条。页面文案写"先展示常用快手菜",算法却没有"常用"数据,也没有正确的数值时长。

可以选择一种明确策略:

  • 展示标签包含"快手菜"的菜谱,再按数值时长排序;
  • 展示用户历史中常做的菜;
  • 展示固定编辑精选;
  • 只显示空态,引导至少选择一种食材。

如果当前产品没有历史统计,建议采用第一种:

ts 复制代码
function getQuickRecipes(recipes: RecipeModel[]): RecipeModel[] {
  return recipes
    .filter((recipe) => recipe.tags.includes('快手菜') || parseDurationMinutes(recipe.duration) <= 20)
    .sort((a, b) => parseDurationMinutes(a.duration) - parseDurationMinutes(b.duration))
    .slice(0, 8)
}

这与"常用快手菜"的用户文案一致,也能被验证。

十三、60 道菜的食材索引必须闭合

推荐质量的第一门槛不是算法,而是数据完整。可以在开发阶段加入检查:

ts 复制代码
function validateIngredientProfiles(
  recipes: RecipeModel[],
  profiles: RecipeIngredientProfile[]
): string[] {
  const issues: string[] = []
  const profileIds = new Set(profiles.map((item) => item.recipeId))
  recipes.forEach((recipe) => {
    if (!profileIds.has(recipe.id)) {
      issues.push(`recipeId=${recipe.id} ${recipe.name} 缺少食材索引`)
    }
  })
  profiles.forEach((profile) => {
    if (!recipes.some((recipe) => recipe.id === profile.recipeId)) {
      issues.push(`recipeId=${profile.recipeId} 没有对应菜谱`)
    }
    if (!profile.ingredients.some((item) => item.role === 'primary')) {
      issues.push(`recipeId=${profile.recipeId} 缺少主料`)
    }
  })
  return issues
}

基线应该是 60 个菜谱、60 个 profile、0 个孤立 ID。索引未补齐前,页面可以给旧的 20 道菜显示推荐结果,对其余菜谱明确标记"食材数据待完善",而不是假装算法覆盖完整。

十四、把计算移出 ArkUI build

当前 ForEach(this.getMatchedRecipes()) 在 UI 构建过程中执行排序,而卡片内部又多次调用 getMatchCount(recipe)。状态刷新时,同一批字符串会被重复拼接和扫描。

更清晰的做法是在选择变化时重算:

ts 复制代码
@State matchResults: RecipeMatchResult[] = []

private refreshMatches(): void {
  const pantry = buildPantrySet(this.selectedIngredients)
  this.matchResults = this.recommendationService
    .recommend(pantry)
    .slice(0, 12)
}

toggleIngredient(name: string): void {
  // 更新 selectedIngredients
  this.refreshMatches()
}

build() 只消费 matchResults。推荐逻辑可以脱离 HarmonyOS UI 单独测试,页面也不会在每个卡片中重复计算。

十五、输入和结果状态要完整

页面至少需要以下状态:

ts 复制代码
type RecommendationState =
  'idle' | 'calculating' | 'content' | 'empty' | 'error'

虽然当前算法完全本地,计算几乎瞬时完成,但 empty 仍非常重要。用户输入"榴莲"后如果没有任何菜谱,不能只留下空白区域:

ts 复制代码
if (this.state === 'empty') {
  Column({ space: 10 }) {
    Text('暂时没有匹配菜谱')
    Text('可以减少一种食材,或换成番茄、鸡蛋等常用食材')
    Button('清空重选').onClick(() => this.clearSelected())
  }
}

错误态用于数据索引加载失败或结构校验失败;不要用"暂无结果"掩盖程序错误。

十六、卡片要解释推荐,不只显示分数

当前卡片显示菜名、N项匹配、完整食材字符串、时长、难度和热量。结构化结果后,建议把最有决策价值的信息放在前两行:

text 复制代码
地三鲜                      75% 已具备
已有:土豆、茄子、青椒
还缺:大蒜                  15 分钟 · 简单

可使用以下字段生成文案:

ts 复制代码
function buildReason(result: RecipeMatchResult): string {
  const matched = result.matched.map((item) => item.name).slice(0, 3).join('、')
  if (result.missingRequired.length === 0) {
    return `核心食材已齐:${matched}`
  }
  const missing = result.missingRequired.map((item) => item.name).slice(0, 2).join('、')
  return `已有 ${matched},还缺 ${missing}`
}

不要使用"智能推荐""最适合"等无法验证的绝对表述。页面只应陈述本地规则算出的事实。

十七、服务层如何组织

建议拆成三个关注点:

17.1 IngredientNormalizer

负责空格清理、别名映射和去重,不知道任何页面信息。

17.2 RecipeIngredientRepository

按 recipeId 提供食材 profile,并在启动或测试阶段执行 60/60 完整性检查。

17.3 FridgeRecommendationService

接收 pantry 集合,输出 RecipeMatchResult[]。它只做匹配、评分、解释和排序,不负责导航详情页。

接口可以保持很小:

ts 复制代码
export interface FridgeRecommendationService {
  recommend(selectedIngredients: string[]): RecipeMatchResult[]
  getQuickRecipes(limit: number): RecipeMatchResult[]
}

页面继续负责 router.pushUrl(),服务层不依赖 ArkUI 路由。

十八、隐私与离线边界

冰箱食材是用户生活信息,但当前功能完全可以本地完成:

  • 不上传食材列表。
  • 不需要账号和云端画像。
  • 不请求相机、相册或定位权限。
  • 不把输入词发送到第三方推荐接口。
  • 若未来保存历史,需要在隐私政策中说明本地存储、清除方式和保存范围。

当前项目已经声明 INTERNET 权限,但这个页面本身不需要网络。文章和应用说明不应把本地规则描述成云端 AI;若未来接入联网模型,要重新评估权限、隐私、失败降级和费用。

十九、多设备布局

手机上适合"输入区---已选食材---结果列表"的纵向结构。平板与 2in1 可以采用左右分栏:左侧固定食材选择,右侧滚动候选,避免用户每次改一项都上下往返。

设备宽度 建议布局 关键约束
小于 600vp 单列 输入框与加入按钮不挤压,标签可换行
600--960vp 左 2/5、右 3/5 两侧独立滚动,结果卡片限宽
大于 960vp 左侧固定 360--420vp 支持鼠标、键盘焦点和窗口缩放

已选食材文案当前最多两行并省略,选择较多时用户无法确认全部内容。可以改成可折叠标签区或横向滚动 Chip 列表,而不是把所有名称拼成一个 Text。

二十、测试矩阵

编号 输入 预期
F01 默认鸡蛋、土豆 返回包含对应主辅料的候选,并显示命中原因
F02 西红柿 归一为番茄,不产生两个重复标签
F03 鸡蛋 去除空格并与已有鸡蛋去重
F04 只选大葱 调味料不能让大量菜谱获得高分
F05 选鸡胸肉 宫保鸡丁主料命中,排名高于只命中葱姜的菜
F06 选土豆、茄子、青椒 地三鲜展示高覆盖率和缺少大蒜
F07 输入榴莲 显示空态与重选入口,不留白
F08 清空选择 展示真正的快手菜,不使用字符串时长排序
F09 比较 120 与 20 分钟 20 分钟候选排序更靠前
F10 recipeId 21--60 每道菜都有结构化食材 profile
F11 重复点击标签 状态稳定切换,不出现重复食材
F12 选择 15 项以上 已选区域可查看全部项,布局不溢出
F13 相同分数 按覆盖率、时长、recipeId 稳定排序
F14 profile 缺主料 完整性检查报告明确错误
F15 打开结果 recipeId 正确传给详情页

二十一、可复核的验证步骤

21.1 数据覆盖

  1. 读取 RECIPE_LIST 的全部 60 个 ID。
  2. 读取全部 RecipeIngredientProfile。
  3. 比较 ID 集合,确认无缺失、无重复、无孤立 profile。
  4. 确认每道菜至少有一个 primary 食材。
  5. 记录基线:60 道菜、60 个 profile、覆盖率 100%。

21.2 排序验证

构造三条相同评分、不同覆盖率和时长的数据,运行 compareMatches()。验证缺主料更少者优先,覆盖率更高者优先,最后才比较数值时长;重复执行顺序必须一致。

21.3 解释一致性

对每条结果检查:matched 与用户 pantry 有交集,missingRequired 不在 pantry 中,coverage 等于命中 required 数除以 required 总数,reason 只引用实际字段。

21.4 UI 验证

在手机竖屏、横屏、平板和 2in1 窗口下选择 0、2、10 和 21 项食材。检查输入框、Chip、已选区域、空态、结果卡和底部安全区。深色模式下检查标签选中态、次要文字和缺失食材提示的对比度。

二十二、常见问题与定位

22.1 明明有大虾,却不推荐油焖大虾

原因: 食材索引只覆盖 recipeId 1--20,油焖大虾的 recipeId 是 26。

处理: 为 21--60 补齐 profile,并把 60/60 覆盖作为构建检查。

22.2 120 分钟的菜排在 20 分钟前

原因: 使用 localeCompare() 比较展示字符串。

处理: 在模型中保存数值分钟,或先用 parseDurationMinutes() 转换。

22.3 只选葱姜却出现很多"高匹配"

原因: 主料、辅料与调味料每命中一项都加 1 分。

处理: 按 role 加权,并对缺失主料设置惩罚或过滤条件。

22.4 番茄和西红柿被当成两个食材

原因: 当前去重只比较原始字符串。

处理: 先归一再去重,所有 Chip 和自定义输入共用同一函数。

22.5 没有结果时页面空白

原因: 结果数组为空但没有 empty 状态。

处理: 显示原因、调整建议和清空入口;数据异常应进入 error 而不是 empty。

22.6 相同条件下顺序偶尔变化

原因: 比较器没有最终稳定键,或 UI 构建中重复计算。

处理: 末级比较 recipeId,并在状态变化时一次性生成结果。

二十三、落地顺序

  1. 先把 duration 改为数值排序,消除明确错误。
  2. 建立 RecipeIngredientProfile,补齐 60 道菜的食材数据。
  3. 加入归一与别名字典,统一常用标签和自定义输入。
  4. 输出 RecipeMatchResult,让 UI 展示命中与缺失。
  5. 将算法移到服务层并补齐 F01--F15 测试。
  6. 最后调整平板分栏和已选食材交互。

每一步都能独立验证,不需要一次性重写整个页面。

二十四、源码事实与建议实现的边界

本文既复盘当前页面,也给出推荐模型的演进方案。为避免把方案误写成已经上线的能力,下面按源码快照逐项区分。当前事实可从 FridgeRecipePage.ets、MockData.ets 与 RecipeModel.ets 直接复核。

检查项 当前源码事实 本文建议,不代表已经落地
菜谱总量 RECIPE_LIST 有 60 条菜谱 建立 60 个结构化 RecipeIngredientProfile
常用食材 页面定义 21 个 Chip,默认选中鸡蛋和土豆 为别名、分类和展示顺序建立可测试配置
食材索引 recipeIngredientIndex 只有 20 条,仅覆盖 recipeId 1--20 补齐 21--60,并把 60/60 覆盖作为硬检查
自定义输入 只执行 trim(),再按原始字符串精确去重 先做别名归一和空白压缩,再用集合去重
匹配规则 在菜名、分类、描述、标签和索引文本中执行 indexOf(),每命中一个选择项加 1 使用主料、辅料、调味料角色和透明权重
排序规则 先按命中数降序,再用 duration.localeCompare() 比较展示字符串 使用数值分钟、覆盖率、缺失主料和 recipeId 稳定排序
结果数量 有选择时过滤零命中并取前 12;空选择时取排序后的前 8 空选择展示明确的快手菜、历史菜或编辑精选
推荐解释 卡片显示"N项匹配"和索引食材字符串,不计算缺失清单 输出 RecipeMatchResult,展示命中、缺失、覆盖率和理由
计算位置 build() 中调用 getMatchedRecipes(),卡片渲染时再次计算命中数 选择变化时由服务层一次性生成结果,UI 只渲染
网络与隐私 FridgeRecipePage 没有网络调用,也不请求相机、相册或定位;工程整体声明了 INTERNET 权限 若未来接入联网模型,重新核对权限、隐私、失败降级和费用说明
构建与设备验证 本文读取并统计了本地源码,没有声称执行安装包构建或真机测试 在 phone、tablet、2in1 上验证长 Chip、空态、分栏和键鼠焦点

因此,"可解释评分""60/60 食材 profile""别名归一"和"服务层缓存"都属于建议实现。它们只有在代码落地、硬检查通过并完成设备验证后,才能写成当前应用能力。

二十五、总结

当前冰箱食材推荐页已经具备完整的交互外壳:21 个常用食材、可输入自定义项、选中态、清空操作和最多 12 条候选卡片。限制它实用性的,是 60 道菜只有 20 条食材索引、文本包含匹配无法区分主辅料、时长采用字符串排序,以及结果只显示命中数量而不说明缺什么。

一个可靠的本地推荐器不需要神秘模型,但需要四项工程基础:

  • 60 道菜与 60 份结构化食材 profile 完整闭合。
  • 输入经过别名归一和去重。
  • 评分区分主料、辅料、调味料并使用数值时长。
  • 每条候选都能说明已具备、仍缺少和排序理由。

当推荐结果能被用户理解、被测试复算、被开发者定位时,"冰箱有什么"才真正从关键词筛选升级为一个可信的日常决策工具。

参考资料

AI 辅助声明:本文由 AI 辅助完成源码统计、结构校对与配图设计,所有当前能力判断均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布;建议算法、测试矩阵和多设备方案不代表工程已经构建或上线。

相关推荐
Liaiyang661 小时前
空圈容错视角 :AI 几乎就是一个“天然的小空圈”
人工智能·大模型·1024程序员节·认知科学·ai幻觉·自我意识·空圈容错理论
2601_967979123 小时前
Opteryx Python库 macOS版本0.0.0a12
python·macos·1024程序员节·wheel·opteryx·alpha
万物智能信息科技3 小时前
LoRa串口透传驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·嵌入式硬件·华为·开源·harmonyos
贾伟康12 小时前
【化学实验室|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
前端·移动开发·harmonyos·arkts·arkui
贾伟康16 小时前
【化学实验室|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
安全·移动开发·harmonyos·arkts·arkui
贾伟康17 小时前
【化学实验室|20】HarmonyOS ArkTS AppGallery 发布复查实战:核对包名、版本、设备、素材和离线声明
软件测试·移动开发·harmonyos·arkts·arkui
Flutter_OH17 小时前
Flutter OH 应用构建与运行指导
harmonyos
Flutter_OH17 小时前
Flutter OHOS Flutter Engine 异步创建与销毁说明文档
harmonyos
传奇开心果编程19 小时前
【Compose Multiplatform跨端声明式UI实用技术】第20课:CMP跨平台经典示例深度解析
kotlin·移动开发·跨平台·编程语言理论·开发范式·编程框架·声明式ui