食材百科不只是把几十条文案放进卡片。用户真正的路径通常是:先判断“大虾属于哪一类”,再搜索名称或营养关键词,最后进入详情核对选购和储存方法。如果分类、检索与路由各自维护一套数据,功能一多就会出现索引错位、重复 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()期间反复读取参数并修改状态。
二十四、数据契约与页面边界
推荐边界如下:
| 模块 | 责任 |
|---|---|
IngredientData | 22 条真实食材数据 |
IngredientRepository | ID 索引、全量读取、重复校验 |
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 | 缺少路由参数 | 显示可返回错误态 |
| I13 | ID 不存在 | 显示内容不存在,不崩溃 |
| I14 | 320vp 小屏 | 名称、标签和按钮不重叠 |
| I15 | 大字体 | 卡片可增长,详情文本可达 |
| I16 | 深浅色切换 | 选中、展开和正文均可读 |
| I17 | 连续快速切分类 | 结果计数和空态同步 |
| I18 | 返回百科 | 分类与关键词按产品规则恢复 |
三十三、可复核检索样例
使用真实数据执行以下检查:
const seafood = filterIngredients(INGREDIENTS, { categoryId: 'seafood', keyword: '' }) const coldStorage = filterIngredients(INGREDIENTS, { categoryId: 'all', keyword: '冷藏' })seafood.length应为 4;coldStorage应命中储存字段中包含“冷藏”的多条食材。再搜索“大虾”,精确名称分值应高于只在描述中包含关键词的结果。
三十四、验收步骤
34.1 数据与分类
- 启动页面,核对 5 个业务分类共 22 条数据。
- 检查每个 ID 唯一,分类 ID 均存在。
- 连续切换全部分类,确认结果数量与数据源一致。
34.2 检索
- 分别搜索名称、时令、营养、选购和储存关键词。
- 组合分类和关键词,核对交集结果。
- 输入空格、无结果词和超长词,确认状态可解释。
- 结果改变时检查展开项不会错位。
34.3 路由
- 点击详情按钮,记录传入的稳定 ID。
- 正常打开详情并返回,确认列表状态符合产品约定。
- 手动构造缺失 ID 和不存在 ID,验证错误态。
- 检查
main_pages.json已注册且名称大小写一致。
34.4 适配
- 检查手机小屏、横屏、平板和 2in1 窗口。
- 检查大字体、长营养文本和键盘弹出状态。
- 检查深浅色模式、主题色和系统返回操作。
三十五、常见故障定位
35.1 搜索后展开了另一项
检查展开状态是否仍保存数组下标。改为保存ingredientId,并让ForEachkey 同样使用 ID。
35.2 搜索结果数量正确但顺序跳动
检查评分相同结果是否缺少稳定次级排序,以及过滤函数是否原地修改源数组。
35.3 点击详情没有反应
确认目标页面已写入main_pages.json,URL 与文件路径完全一致,并查看router.pushUrl()的错误回调或运行日志。
35.4 详情页白屏
检查router.getParams()是否缺失、类型错误或 ID 不存在。不要使用未校验的非空断言。
35.5 主题色下文字看不清
不要默认白字适配任意自定义色。由主题层提供经过对比度检查的前景色和背景色组合。
三十六、落地顺序
- 提取 22 条食材为独立数据源,并增加稳定 ID。
- 将分类状态从索引改为
categoryId,展开状态改为ingredientId。 - 实现规范化、组合过滤、评分和稳定排序纯函数。
- 增加搜索框、结果计数和条件化空态。
- 新建详情页、注册路由、只传 ID 并校验参数。
- 执行 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