食材百科不只是把几十条文案放进卡片。用户真正的路径通常是:先判断"大虾属于哪一类",再搜索名称或营养关键词,最后进入详情核对选购和储存方法。如果分类、检索与路由各自维护一套数据,功能一多就会出现索引错位、重复 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 当前有三个状态:
ts
@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 使用分类索引与名称:
ts
ForEach(
this.categories[this.selectedCategory].items,
(item: Ingredient, index: number) => {
// card
},
(item: Ingredient, index: number) => `${this.selectedCategory}_${item.name}`
)
展开状态则保存数组下标:
ts
this.expandedItem = this.expandedItem === index ? -1 : index
在没有搜索和排序时,下标与当前列表一致。加入搜索后,如果原列表第 4 项变成结果列表第 0 项,旧下标会展开错误卡片。根因不是 ArkUI,而是把"位置"当成了"身份"。
四、现有源码没有搜索和详情路由
工程检索结果显示:
main_pages.json注册了pages/IngredientWikiPage。- 没有
IngredientDetailPage。 IngredientWikiPage只调用router.back()。- 没有
TextInput搜索状态和检索函数。
因此本文标题中的"实现分类、检索和详情跳转"属于增量设计目标。文章后续代码会明确需要新增的模型、服务、页面与路由配置。
五、先为食材补稳定 ID
名称可读但不适合长期充当主键。名称可能改文案,也可能在不同地区或品种下重复。模型应增加稳定 ID:
ts
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。
六、分类也需要稳定身份
ts
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 条食材直接写在组件私有字段中,详情页无法复用。建议拆成:
text
models/IngredientModel.ets
data/IngredientData.ets
services/IngredientSearchService.ets
pages/IngredientWikiPage.ets
pages/IngredientDetailPage.ets
数据模块导出只读数组和按 ID 查询函数;页面不再拥有数据副本。这样列表、搜索、详情和测试共享同一事实来源。
八、建立 ID 索引
ts
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。出现重复时不能后值静默覆盖前值,否则详情路由会指向不确定对象。
九、搜索输入先做规范化
中文检索至少要处理首尾空格和连续空白:
ts
export function normalizeKeyword(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase()
}
空关键词代表"不过滤",不是错误。若后续支持拼音、同义词或分词,应作为独立索引能力增加,不能让页面里堆叠越来越长的 includes()。
十、多字段可检索文本
用户可能搜"维C""冷藏""秋季"或"高蛋白",不仅会搜名称。可以构建可检索文本:
ts
export function searchableText(item: IngredientModel): string {
return [
item.name,
item.categoryName,
item.season,
item.nutrition,
item.tips,
item.storage,
item.aliases.join(' ')
].join(' ').toLowerCase()
}
检索范围应在页面上通过占位文案说明,例如"搜索食材、时令、营养或储存方法"。
十一、分类与关键词要组合过滤

ts
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 能筛选,却无法让名称精确匹配排在前面。可以计算简单分值:
ts
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
ts
@State private expandedItemId: string = ''
private toggleExpanded(id: string): void {
this.expandedItemId = this.expandedItemId === id ? '' : id
}
渲染判断改为 expandedItemId === item.id。筛选后如果该 ID 不在结果中,可以在刷新结果时清空;如果仍在结果中,展开状态保持稳定。
十四、分类切换是否清空关键词
这是产品规则,不是技术必然。推荐保留关键词:用户搜索"高蛋白"后切换肉类和海鲜,可以快速比较不同分类。页面应显示"海鲜 · 高蛋白 · 2 条结果",让过滤条件可见。
切换分类时只需要:
ts
private selectCategory(id: string): void {
this.selectedCategoryId = id
this.expandedItemId = ''
this.refreshResults()
}
若产品决定切换分类清空关键词,也应显式调用 clearKeyword(),不要依赖组件重建的偶然行为。
十五、输入更新要避免业务散落在 UI 树
ts
TextInput({
placeholder: '搜索食材、时令、营养或储存方法',
text: this.keyword
})
.onChange((value: string) => {
this.keyword = value
this.refreshResults()
})
22 条本地数据可以即时过滤。数据增长后可增加 150--250 ms 防抖,但防抖只减少计算频率,不能改变最终结果。页面退出时要清理定时器。
十六、用 ViewData 表达结果状态
ts
export interface IngredientListViewData {
items: IngredientModel[]
resultText: string
emptyTitle: string
emptyDescription: string
showClearKeyword: boolean
}
页面一次计算 ViewData,ArkUI 只负责渲染。不要在 ForEach、空态条件和结果计数中分别调用过滤函数,否则一次重建可能做三遍相同工作。
十七、空结果要解释当前条件
空数据至少有两种:
- 数据源为空:百科内容未加载或版本异常。
- 筛选无结果:数据正常,但当前分类和关键词没有匹配。
筛选无结果可以显示:"在海鲜中没有找到'番茄'",并提供"清除搜索"和"查看全部"两个明确动作。不能只放一个"暂无数据"。
十八、卡片展开与详情页的职责不同
卡片展开适合快速扫读选购和储存摘要;独立详情页适合展示完整营养说明、时令、注意事项、来源和关联菜谱。两种交互可以并存:
- 点击卡片正文展开摘要。
- 点击明确的详情按钮进入详情页。
不要让整张卡片同时承担"展开"和"跳转",否则用户无法预测点击结果。
十九、详情路由只传稳定 ID
ts
private openDetail(item: IngredientModel): void {
router.pushUrl({
url: 'pages/IngredientDetailPage',
params: { ingredientId: item.id }
})
}
不要把整份 IngredientModel 作为路由参数。对象可能变大、版本变化,也会让详情页拿到与仓库不同步的副本。ID 是跨页面契约,详情页再向仓库取当前数据。
二十、新详情页必须注册路由
新增页面后,在 main_pages.json 的 src 中加入:
json
"pages/IngredientDetailPage"
URL 大小写、目录名和文件名必须完全一致。只创建 .ets 文件却不注册,会在点击时才暴露路由错误。
二十一、严格解析路由参数
ts
interface IngredientRouteParams {
ingredientId?: string
}
function readIngredientId(): string {
const params = router.getParams() as IngredientRouteParams
return typeof params.ingredientId === 'string'
? params.ingredientId.trim()
: ''
}
即使 TypeScript 断言通过,运行时参数仍可能缺失。详情页不能直接使用未检查字段。
二十二、详情页处理三种状态
ts
type DetailState = 'ready' | 'missing-param' | 'not-found'
interface IngredientDetailViewData {
state: DetailState
item?: IngredientModel
}
ready:正常展示。missing-param:没有ingredientId,提供返回。not-found:ID 不存在或数据版本变化,说明内容不可用并允许回到百科。
无效参数不能导致空白页、数组越界或非空断言崩溃。
二十三、详情页读取数据的时机
ts
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
ts
ForEach(this.viewData.items, (item: IngredientModel) => {
this.IngredientCard(item)
}, (item: IngredientModel) => item.id)
搜索、分类和排序会改变位置,但不会改变 ID。稳定 key 可以避免错误复用展开状态和不必要的节点重建。
二十六、分类选择不能越界
原实现通过 categories[selectedCategory] 取数据。若远端配置、版本迁移或开发修改导致分类数组变化,索引可能越界。改用 categoryId 后,找不到分类时回退到 all:
ts
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 | 返回百科 | 分类与关键词按产品规则恢复 |
三十三、可复核检索样例
使用真实数据执行以下检查:
ts
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,并让 ForEach key 同样使用 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、storage 7 个字段 |
增加稳定 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