【健康菜谱助手|08】HarmonyOS ArkTS 食材百科实战:实现分类、检索和详情跳转

发布时间:2026/10/27 9:58:05
【健康菜谱助手|08】HarmonyOS ArkTS 食材百科实战:实现分类、检索和详情跳转

食材百科不只是把几十条文案放进卡片。用户真正的路径通常是:先判断“大虾属于哪一类”,再搜索名称或营养关键词,最后进入详情核对选购和储存方法。如果分类、检索与路由各自维护一套数据,功能一多就会出现索引错位、重复 key、详情参数失效和空态含义不清等问题。

本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。IngredientWikiPage.ets当前内置蔬菜、肉类、海鲜、调料、谷物 5 个分类,共 22 条食材;页面支持横向分类切换和卡片展开,展开后显示选购技巧、储存方法与营养价值。真实源码尚未提供搜索输入框和独立详情页,因此本文会先还原现状,再给出可落地的“分类 + 检索 + 详情路由”升级方案,不把设计稿当成已完成代码。

食材百科的唯一真源应该是带稳定 ID 的食材数据;分类、搜索、展开状态和详情路由都只传递 ID,不复制整份对象,也不依赖随过滤变化的数组下标。

HEALTHY-RECIPE-ONE7-INGREDIENT-WIKI-20260730

本文将完成以下内容:

  • 核对 5 个分类、22 条食材和当前折叠卡片交互。
  • 将页面内嵌数据提取为可复用、可测试的数据模块。
  • 设计名称、分类、时令、营养、选购和储存多字段检索。
  • 解决过滤后expandedItem索引错位与重复 key 风险。
  • 使用稳定ingredientId完成详情跳转与参数校验。
  • 补齐加载、空结果、无效路由、长文本和大字体状态。
  • 给出 ArkTS 代码、测试矩阵和可复核验收步骤。

一、真实页面的功能边界

IngredientWikiPage当前有三个状态:

@StorageLink('appThemeColor') appThemeColor: string = '#4CAF50' @StorageLink('appFontSize') appFontSize: number = 14 @State selectedCategory: number = 0 @State expandedItem: number = -1

主题色和字号来自AppStorage,分类与展开项属于页面临时状态。分类条使用水平Scroll,列表使用垂直Scroll。点击分类时更新selectedCategory并把expandedItem设为-1;点击食材卡片则在当前索引和折叠状态之间切换。

这套实现适合静态原型,但当搜索结果改变列表顺序时,expandedItem: number就不再可靠。

二、5 个分类与 22 条真实数据

真实源码中的数量如下:

分类数量示例
蔬菜6大白菜、西兰花、番茄、胡萝卜、大蒜、辣椒
肉类4猪五花肉、鸡胸肉、牛里脊、羊腿肉
海鲜4鲈鱼、大虾、螃蟹、海参
调料5食盐、食用油、生抽、白糖、料酒
谷物3大米、玉米、红薯

每条数据包含emoji、name、category、season、nutrition、tips、storage七个字段。数据虽然不多,但已经能覆盖分类过滤、多字段检索和详情展示。

三、当前展开卡片的工作方式

列表 key 使用分类索引与名称:

ForEach( this.categories[this.selectedCategory].items, (item: Ingredient, index: number) => { // card }, (item: Ingredient, index: number) => `${this.selectedCategory}_${item.name}` )

展开状态则保存数组下标:

this.expandedItem = this.expandedItem === index ? -1 : index

在没有搜索和排序时,下标与当前列表一致。加入搜索后,如果原列表第 4 项变成结果列表第 0 项,旧下标会展开错误卡片。根因不是 ArkUI,而是把“位置”当成了“身份”。

四、现有源码没有搜索和详情路由

工程检索结果显示:

  • main_pages.json注册了pages/IngredientWikiPage。
  • 没有IngredientDetailPage。
  • IngredientWikiPage只调用router.back()。
  • 没有TextInput搜索状态和检索函数。

因此本文标题中的“实现分类、检索和详情跳转”属于增量设计目标。文章后续代码会明确需要新增的模型、服务、页面与路由配置。

五、先为食材补稳定 ID

名称可读但不适合长期充当主键。名称可能改文案,也可能在不同地区或品种下重复。模型应增加稳定 ID:

export interface IngredientModel { id: string emoji: string name: string categoryId: string categoryName: string season: string nutrition: string tips: string storage: string aliases: string[] }

示例 ID 可以是vegetable-tomato,一旦发布就不要随中文名称变化。aliases用于“西红柿/番茄”等同义词,但别把别名偷偷拼进 UI。

六、分类也需要稳定身份

export interface IngredientCategoryModel { id: string name: string icon: string } export const INGREDIENT_CATEGORIES: IngredientCategoryModel[] = [ { id: 'all', name: '全部', icon: '📚' }, { id: 'vegetable', name: '蔬菜', icon: '🥬' }, { id: 'meat', name: '肉类', icon: '🥩' }, { id: 'seafood', name: '海鲜', icon: '🐟' }, { id: 'seasoning', name: '调料', icon: '🧂' }, { id: 'grain', name: '谷物', icon: '🌾' } ]

增加“全部”后,搜索可以跨分类进行。页面保存selectedCategoryId,而不是保存容易越界的数组索引。

七、把静态数据移出页面

当前 22 条食材直接写在组件私有字段中,详情页无法复用。建议拆成:

models/IngredientModel.ets data/IngredientData.ets services/IngredientSearchService.ets pages/IngredientWikiPage.ets pages/IngredientDetailPage.ets

数据模块导出只读数组和按 ID 查询函数;页面不再拥有数据副本。这样列表、搜索、详情和测试共享同一事实来源。

八、建立 ID 索引

export class IngredientRepository { private readonly byId: Map<string, IngredientModel> = new Map() constructor(private readonly items: IngredientModel[]) { for (const item of items) { this.byId.set(item.id, item) } } getAll(): IngredientModel[] { return [...this.items] } getById(id: string): IngredientModel | undefined { return this.byId.get(id) } }

构造时还应检测重复 ID。出现重复时不能后值静默覆盖前值,否则详情路由会指向不确定对象。

九、搜索输入先做规范化

中文检索至少要处理首尾空格和连续空白:

export function normalizeKeyword(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase() }

空关键词代表“不过滤”,不是错误。若后续支持拼音、同义词或分词,应作为独立索引能力增加,不能让页面里堆叠越来越长的includes()。

十、多字段可检索文本

用户可能搜“维C”“冷藏”“秋季”或“高蛋白”,不仅会搜名称。可以构建可检索文本:

export function searchableText(item: IngredientModel): string { return [ item.name, item.categoryName, item.season, item.nutrition, item.tips, item.storage, item.aliases.join(' ') ].join(' ').toLowerCase() }

检索范围应在页面上通过占位文案说明,例如“搜索食材、时令、营养或储存方法”。

十一、分类与关键词要组合过滤

export interface IngredientFilter { categoryId: string keyword: string } export function filterIngredients( items: IngredientModel[], filter: IngredientFilter ): IngredientModel[] { const keyword = normalizeKeyword(filter.keyword) return items.filter((item: IngredientModel) => { const categoryMatched = filter.categoryId === 'all' || item.categoryId === filter.categoryId const keywordMatched = keyword.length === 0 || searchableText(item).includes(keyword) return categoryMatched && keywordMatched }) }

这个函数不修改输入,可以独立验证“海鲜 + 高蛋白”“全部 + 冷藏”等组合条件。

十二、搜索结果需要稳定排序

纯includes能筛选,却无法让名称精确匹配排在前面。可以计算简单分值:

export function matchScore(item: IngredientModel, keyword: string): number { if (keyword.length === 0) return 0 if (item.name.toLowerCase() === keyword) return 100 if (item.name.toLowerCase().startsWith(keyword)) return 80 if (item.aliases.some((alias: string) => alias.toLowerCase() === keyword)) return 70 if (item.nutrition.toLowerCase().includes(keyword)) return 40 if (item.tips.toLowerCase().includes(keyword)) return 30 if (item.storage.toLowerCase().includes(keyword)) return 20 return 10 }

排序时复制数组,并用原始数据顺序或 ID 作为次级规则,保证同分结果不会刷新跳动。

十三、保存 expandedItemId 而不是 index

@State private expandedItemId: string = '' private toggleExpanded(id: string): void { this.expandedItemId = this.expandedItemId === id ? '' : id }

渲染判断改为expandedItemId === item.id。筛选后如果该 ID 不在结果中,可以在刷新结果时清空;如果仍在结果中,展开状态保持稳定。

十四、分类切换是否清空关键词

这是产品规则,不是技术必然。推荐保留关键词:用户搜索“高蛋白”后切换肉类和海鲜,可以快速比较不同分类。页面应显示“海鲜 · 高蛋白 · 2 条结果”,让过滤条件可见。

切换分类时只需要:

private selectCategory(id: string): void { this.selectedCategoryId = id this.expandedItemId = '' this.refreshResults() }

若产品决定切换分类清空关键词,也应显式调用clearKeyword(),不要依赖组件重建的偶然行为。

十五、输入更新要避免业务散落在 UI 树

TextInput({ placeholder: '搜索食材、时令、营养或储存方法', text: this.keyword }) .onChange((value: string) => { this.keyword = value this.refreshResults() })

22 条本地数据可以即时过滤。数据增长后可增加 150–250 ms 防抖,但防抖只减少计算频率,不能改变最终结果。页面退出时要清理定时器。

十六、用 ViewData 表达结果状态

export interface IngredientListViewData { items: IngredientModel[] resultText: string emptyTitle: string emptyDescription: string showClearKeyword: boolean }

页面一次计算ViewData,ArkUI 只负责渲染。不要在ForEach、空态条件和结果计数中分别调用过滤函数,否则一次重建可能做三遍相同工作。

十七、空结果要解释当前条件

空数据至少有两种:

  • 数据源为空:百科内容未加载或版本异常。
  • 筛选无结果:数据正常,但当前分类和关键词没有匹配。

筛选无结果可以显示:“在海鲜中没有找到‘番茄’”,并提供“清除搜索”和“查看全部”两个明确动作。不能只放一个“暂无数据”。

十八、卡片展开与详情页的职责不同

卡片展开适合快速扫读选购和储存摘要;独立详情页适合展示完整营养说明、时令、注意事项、来源和关联菜谱。两种交互可以并存:

  • 点击卡片正文展开摘要。
  • 点击明确的详情按钮进入详情页。

不要让整张卡片同时承担“展开”和“跳转”,否则用户无法预测点击结果。

十九、详情路由只传稳定 ID

private openDetail(item: IngredientModel): void { router.pushUrl({ url: 'pages/IngredientDetailPage', params: { ingredientId: item.id } }) }

不要把整份IngredientModel作为路由参数。对象可能变大、版本变化,也会让详情页拿到与仓库不同步的副本。ID 是跨页面契约,详情页再向仓库取当前数据。

二十、新详情页必须注册路由

新增页面后,在main_pages.json的src中加入:

"pages/IngredientDetailPage"

URL 大小写、目录名和文件名必须完全一致。只创建.ets文件却不注册,会在点击时才暴露路由错误。

二十一、严格解析路由参数

interface IngredientRouteParams { ingredientId?: string } function readIngredientId(): string { const params = router.getParams() as IngredientRouteParams return typeof params.ingredientId === 'string' ? params.ingredientId.trim() : '' }

即使 TypeScript 断言通过,运行时参数仍可能缺失。详情页不能直接使用未检查字段。

二十二、详情页处理三种状态

type DetailState = 'ready' | 'missing-param' | 'not-found' interface IngredientDetailViewData { state: DetailState item?: IngredientModel }
  • ready:正常展示。
  • missing-param:没有ingredientId,提供返回。
  • not-found:ID 不存在或数据版本变化,说明内容不可用并允许回到百科。

无效参数不能导致空白页、数组越界或非空断言崩溃。

二十三、详情页读取数据的时机

aboutToAppear(): void { const id = readIngredientId() if (id.length === 0) { this.viewData = { state: 'missing-param' } return } const item = this.repository.getById(id) this.viewData = item === undefined ? { state: 'not-found' } : { state: 'ready', item } }

初始化逻辑放在生命周期方法或 ViewModel 中,不要在build()期间反复读取参数并修改状态。

二十四、数据契约与页面边界

推荐边界如下:

模块责任
IngredientData22 条真实食材数据
IngredientRepositoryID 索引、全量读取、重复校验
IngredientSearchService规范化、过滤、评分与排序
IngredientWikiViewModel分类、关键词、结果和空态
IngredientWikiPage搜索框、分类条、卡片和详情按钮
IngredientDetailPage参数校验和完整详情

依赖方向保持为页面调用 ViewModel,ViewModel 调用仓库和检索服务;数据层不依赖 ArkUI。

二十五、ForEach key 使用 item.id

ForEach(this.viewData.items, (item: IngredientModel) => { this.IngredientCard(item) }, (item: IngredientModel) => item.id)

搜索、分类和排序会改变位置,但不会改变 ID。稳定 key 可以避免错误复用展开状态和不必要的节点重建。

二十六、分类选择不能越界

原实现通过categories[selectedCategory]取数据。若远端配置、版本迁移或开发修改导致分类数组变化,索引可能越界。改用categoryId后,找不到分类时回退到all:

function safeCategoryId(id: string, categories: IngredientCategoryModel[]): string { return categories.some((item: IngredientCategoryModel) => item.id === id) ? id : 'all' }

二十七、主题色与文字对比度

当前选中分类使用appThemeColor背景和白字,分类标签文字则通过getThemeTextColor()取得深色变体。主题来自用户设置,不能假设任意颜色与白字都有足够对比度。

更稳妥的主题服务应同时产出背景色和前景色 token。展开卡片的alpha(theme, '08')很浅,边框和正文仍要依赖高对比度语义色,不要只靠淡色背景表示展开。

二十八、字号变化与动态布局

页面使用appFontSize - 2和固定fontSize(10)。如果用户把基础字号设得很小,减法会让关键信息难以阅读;设得很大时,名称、分类和时令三个标签可能挤在一行。

应为字号设置上下限,并允许标签区换行或拆成两行。食材名称和详情按钮保持可达,长营养描述用合理maxLines与展开状态控制。

二十九、搜索框需要完整交互状态

一个可用搜索框应包含:

  • 输入关键词。
  • 一键清空图标。
  • 键盘搜索操作。
  • 无结果说明。
  • 当前结果数量。
  • 输入法弹出后的可滚动与安全区域。

清空按钮使用熟悉的关闭图标,并提供可识别描述,不需要用大段说明文字占页面。

三十、本地数据也要有加载与错误边界

当前数据编译进应用,不需要网络权限,这是一个清晰优势。但未来若数据迁移到 JSON 资源或数据库,页面仍应区分loading、ready、empty和error。离线不等于永远不会失败,资源解析和版本迁移同样可能出错。

三十一、健康信息的内容边界

22 条食材文本包含营养、摄入和健康效果描述,例如维生素含量、蛋白质数值和每日用量。技术页面应显示来源、更新时间和适用范围,避免把静态科普文本包装成诊断或个体化建议。

搜索服务只匹配文本,不应根据用户关键词推断健康状况,也不应上传搜索记录,除非产品明确需要并完成隐私说明。

三十二、测试矩阵

编号场景预期
I01默认进入选中全部或约定默认分类,列表稳定
I02选择海鲜只显示 4 条真实海鲜数据
I03搜索“番茄”名称精确匹配置顶
I04搜索“冷藏”匹配储存字段中的食材
I05搜索“秋季”匹配时令字段
I06海鲜中搜索“番茄”显示带条件说明的空态
I07输入前后空格规范化后结果不变
I08清空关键词恢复当前分类全部结果
I09展开后改变搜索不展开错误卡片
I10同名不同分类ID 和 key 仍唯一
I11点击详情只传ingredientId
I12缺少路由参数显示可返回错误态
I13ID 不存在显示内容不存在,不崩溃
I14320vp 小屏名称、标签和按钮不重叠
I15大字体卡片可增长,详情文本可达
I16深浅色切换选中、展开和正文均可读
I17连续快速切分类结果计数和空态同步
I18返回百科分类与关键词按产品规则恢复

三十三、可复核检索样例

使用真实数据执行以下检查:

const seafood = filterIngredients(INGREDIENTS, { categoryId: 'seafood', keyword: '' }) const coldStorage = filterIngredients(INGREDIENTS, { categoryId: 'all', keyword: '冷藏' })

seafood.length应为 4;coldStorage应命中储存字段中包含“冷藏”的多条食材。再搜索“大虾”,精确名称分值应高于只在描述中包含关键词的结果。

三十四、验收步骤

34.1 数据与分类

  1. 启动页面,核对 5 个业务分类共 22 条数据。
  2. 检查每个 ID 唯一,分类 ID 均存在。
  3. 连续切换全部分类,确认结果数量与数据源一致。

34.2 检索

  1. 分别搜索名称、时令、营养、选购和储存关键词。
  2. 组合分类和关键词,核对交集结果。
  3. 输入空格、无结果词和超长词,确认状态可解释。
  4. 结果改变时检查展开项不会错位。

34.3 路由

  1. 点击详情按钮,记录传入的稳定 ID。
  2. 正常打开详情并返回,确认列表状态符合产品约定。
  3. 手动构造缺失 ID 和不存在 ID,验证错误态。
  4. 检查main_pages.json已注册且名称大小写一致。

34.4 适配

  1. 检查手机小屏、横屏、平板和 2in1 窗口。
  2. 检查大字体、长营养文本和键盘弹出状态。
  3. 检查深浅色模式、主题色和系统返回操作。

三十五、常见故障定位

35.1 搜索后展开了另一项

检查展开状态是否仍保存数组下标。改为保存ingredientId,并让ForEachkey 同样使用 ID。

35.2 搜索结果数量正确但顺序跳动

检查评分相同结果是否缺少稳定次级排序,以及过滤函数是否原地修改源数组。

35.3 点击详情没有反应

确认目标页面已写入main_pages.json,URL 与文件路径完全一致,并查看router.pushUrl()的错误回调或运行日志。

35.4 详情页白屏

检查router.getParams()是否缺失、类型错误或 ID 不存在。不要使用未校验的非空断言。

35.5 主题色下文字看不清

不要默认白字适配任意自定义色。由主题层提供经过对比度检查的前景色和背景色组合。

三十六、落地顺序

  1. 提取 22 条食材为独立数据源,并增加稳定 ID。
  2. 将分类状态从索引改为categoryId,展开状态改为ingredientId。
  3. 实现规范化、组合过滤、评分和稳定排序纯函数。
  4. 增加搜索框、结果计数和条件化空态。
  5. 新建详情页、注册路由、只传 ID 并校验参数。
  6. 执行 I01–I18 和多设备适配验收。

这个顺序先建立数据契约,再加 UI 控件,能避免搜索和路由建立在不稳定索引上。

三十七、源码事实与建议实现的边界

为了避免把教程中的目标架构误读为现有功能,下面把可直接从IngredientWikiPage.ets复核的事实与后续建议分开列出。

维度当前源码事实建议实现与验证
数据规模5 个分类,共 22 条食材抽成独立数据模块,并增加 schemaVersion
数据字段每条数据只有emoji、name、category、season、nutrition、tips、storage7 个字段增加稳定ingredientId和可追溯的内容元数据
数据位置数据硬编码在页面类内部由 Repository 统一读取,页面不直接持有原始数据集
当前交互只有分类标签切换与卡片展开/收起增加搜索输入、结果计数、空态与详情入口
搜索能力没有搜索输入框、关键词状态和过滤函数增加规范化、多字段匹配、分类交集与稳定排序
详情能力没有独立详情路由,点击行只切换展开状态注册详情页,只传递并严格校验ingredientId
展开状态expandedItem保存数组下标改为保存稳定 ID,避免过滤或排序后的错位
列表 key分类使用名称,食材使用分类索引与名称组合分类和食材都使用稳定 ID 作为 key
页面状态没有显式 loading、empty、error 状态为资源解析、无结果、无效 ID 分别建模
健康内容营养、选购和储存文字是页面中的静态内容标注来源、更新时间与适用边界,不作诊断或个体化建议

本文实际执行并可复核的是源码阅读、数据统计、文章检查和配图检查。文中的检索服务、详情路由、数据迁移与测试矩阵属于建议实现,不代表当前工程已经完成构建、安装、真机运行或上线。

三十八、总结

真实IngredientWikiPage已有 5 个分类、22 条本地食材、主题适配和展开详情,是一个可运行的百科列表原型。下一步的关键不是简单插入一个TextInput,而是先把“食材身份”从数组位置升级为稳定 ID,再让分类、检索、展开和详情路由共享同一数据源。

需要守住六条原则:

  • 食材和分类使用稳定 ID,位置只用于展示。
  • 检索是可测试的纯函数,同时处理分类和关键词。
  • 展开状态保存 ID,过滤后不发生索引错位。
  • 详情路由只传 ID,目标页重新读取并校验数据。
  • 空结果、无效参数和数据错误必须是不同状态。
  • 健康文本标注来源与边界,搜索记录默认留在本地。

当用户可以从“海鲜”筛到 4 条数据,搜索“冷藏”找到真正匹配的储存说明,再通过稳定 ID 打开详情且无效链接也不会白屏时,食材百科才形成完整、可信、可维护的浏览闭环。

参考资料

  • HarmonyOS ArkUI 概述
  • HarmonyOS ArkTS 概述
  • HarmonyOS Ability Kit 概述
  • HarmonyOS 深浅色适配
  • 工程源码:IngredientWikiPage.ets、ThemeConstants.ets、main_pages.json