乒乓球口袋教练 HarmonyOS 学习应用(05):搜索与分类组合过滤
一、把关键词和专项筛选放进同一个查询入口
课程多起来以后,用户常常先输入“反手”,再把范围切到相持模块。若关键词和模块按钮分别在页面各自过滤,列表条数、课程标签和点击后的详情很容易不一致。本项目把两个条件交给 SearchService.search:keyword 先 trim 并转小写,moduleId 决定课程范围,再对标题、描述、关键词和子分类标签做同一轮匹配。
// 简单全字段匹配:标题 + 描述 + 关键词 + 子分类标签 export class SearchService { static search(keyword: string, moduleId?: string): SearchHit[] { const kw: string = keyword.trim().toLowerCase(); return COURSES .filter(c => moduleId === undefined || moduleId === 'all' || c.moduleId === moduleId) .filter(c => SearchService.matches(c, kw)) .map<SearchHit>((c: Course): SearchHit => { const m: LearnModule | undefined = findModule(c.moduleId); return { course: c, moduleTitle: m?.title ?? '' } as SearchHit; }); } private static matches(c: Course, kw: string): boolean { if (kw.length === 0) return true; if (c.title.toLowerCase().indexOf(kw) >= 0) return true; if (c.desc.toLowerCase().indexOf(kw) >= 0) return true; if (c.subCategoryLabel.indexOf(kw) >= 0) return true; for (const k of c.keywords) { if (k.toLowerCase().indexOf(kw) >= 0) return true; } return false; }二、空关键词并不等于跳过模块边界
模块条件的 all 不是删除筛选逻辑,而是显式表示“允许所有 moduleId”。因此空关键词时仍会返回当前模块的完整课程集合;输入词后也不会意外跨到发球或战术模块。这个约束让顶部筛选按钮和搜索框是两个输入,却只有一个可解释的结果集合。
| 参与者 | 输入 | 输出或约束 |
|---|---|---|
| 模型或配置 | 稳定标识、模式或模块字段 | 给出可追溯的工程事实 |
| 服务或系统能力 | 经过归一化的请求 | 返回明确结果或失败原因 |
| 页面 | 回读后的结果 | 只渲染,不保存第二份事实 |
} private hits(): SearchHit[] { return SearchService.search(this.keyword, this.moduleFilter); } @Builder resultSection() { Column() { if (this.hits().length === 0) { EmptyState({ emoji: '🔍', title: '未找到相关内容', subtitle: '换个关键词试试,或在分类里浏览' }); } else { this.resultTitle(); Column({ space: AppSizes.s2 }) { ForEach(this.hits(), (h: SearchHit) => { this.hitRow(h); }, (h: SearchHit) => h.course.id); } .padding({ left: AppSizes.s4, right: AppSizes.s4 });三、命中结果为何要带回匹配字段
SearchHit 不只保存课程对象,还保留 matchedFields。页面可以提示用户命中的是标题、描述还是关键词,而不是把所有结果都说成标题匹配。对于“旋转”这类可能出现在描述中的词,这比只调用 title.includes 更接近用户的真实预期,也让后续高亮有稳定来源。
Column({ space: AppSizes.s2 }) { ForEach(this.hits(), (h: SearchHit) => { this.hitRow(h); }, (h: SearchHit) => h.course.id); } .padding({ left: AppSizes.s4, right: AppSizes.s4 }); } } .width('100%'); } @Builder resultTitle() { Row() { Text('搜索结果(' + this.hits().length + ')') .fontSize(AppText.fsTitle) .fontWeight(AppText.fwSemi) .fontColor(this.palette.textPrimary) .layoutWeight(1); } .width('100%') .padding({ left: AppSizes.s4, right: AppSizes.s4, top: AppSizes.s2, bottom: AppSizes.s2 }); }四、搜索页和课程详情怎样交接
点击命中项后,SearchPage 传递的是 course.id;课程详情再按 id 查回模型。这样搜索结果不会把一份旧课程对象塞进路由,也不会因为返回搜索页而保留已经失效的对象。找不到 id 时应留在可解释的结果或空态,不能拼一个默认课程继续播放。
| 情况 | 容易出现的错误 | 本文采用的处理 |
|---|---|---|
| 数据或配置缺项 | 伪造默认成功状态 | 停在可解释的失败或空态 |
| 页面重进 | 使用上一页残留对象 | 从模型、服务或系统重新回读 |
| 重复动作 | 再写一遍相同业务事实 | 由稳定入口或回调收敛 |
import { packagedVideoFileName } from './CourseVideoAssets'; export interface Course { id: string; moduleId: string; subCategory: string; subCategoryLabel: string; title: string; desc: string; thumbKey: string; videoSrc?: string; durationSec: number; order: number; intro: Block[]; keyPoints: Block[]; mistakes: Block[]; training: Block[]; keywords: string[]; externalVideoRefs?: ExternalVideoRef[]; } export interface SubCategory { id: string;五、筛选回归要观察哪些具体结果
回归时先在全部范围搜索一个只出现在关键词中的动作术语,再切到不含该术语的专项确认结果归零;随后清空关键词,确认当前专项课程恢复;最后点击一条命中课程、返回并重进搜索页,检查标题、专项标签和命中字段仍来自同一条查询链路。
| 验收阶段 | 实际动作 | 回读重点 |
|---|---|---|
| 前置确认 | 启动正确 bundle 或打开目标页 | 标题、入口与模块身份 |
| 主题操作 | 执行搜索、切换、完成或跳转 | 服务/系统返回的结果 |
| 重进检查 | 返回、重启或切换范围后再进入 | 事实没有依赖旧页面残留 |
六、实现边界与维护顺序
搜索词、专项模块和课程字段在同一条查询链路中收敛,避免页面分别筛选后出现标题、分类与结果数量互相矛盾。 新增需求时应先补齐模型、配置或服务合同,再调整页面入口;把同一个判断复制到多个组件,短期看似方便,后续会使结果无法回读。ArkTS 状态管理的基础机制可参考 HarmonyOS 官方文档。
七、继续扩展时的约束
搜索条件的归一化必须早于排序。先把关键词转换为可比较的值,再确定模块范围,最后计算匹配字段,结果才能稳定复现。若把排序写在页面中,用户从课程详情返回后可能看到同一关键词却是另一种顺序,测试也无法知道变化来自课程数据还是渲染时机。
对于不存在的 moduleId,服务不应该宽容地退回全部课程;那会让路由或按钮传错参数时看起来仍有结果。明确返回空集合配合页面空态,更容易暴露调用链错误。真正需要全量搜索时,调用方显式传 all,使阅读代码的人一眼能看懂扩大范围是有意的。
后续若加入历史搜索或远程建议词,历史项只保存用户输入,不缓存旧 SearchHit。重新打开历史词时仍需经过当前 SearchService,这样课程下架、关键词修改和模块调整才能立刻反映到结果中。
结果数量为何必须从命中集合计算
搜索页标题中的数量直接来自 hits().length,而不是由输入框事件维护一个计数器。前者会随关键词、模块和课程模型重算,后者容易在清空输入、返回页面或课程数据变化后留下旧数字。对于用户而言,数量、每一行课程和命中说明应始终是同一集合的三个投影。
匹配规则改变时怎样验收
增加一个关键词字段后,要准备标题不含该词但 keywords 含该词的课程,确认它被召回;再准备同词但不同模块的课程,确认模块筛选排除它。两组数据分别覆盖匹配能力和范围能力,不能只用一条“搜索成功”的截图混在一起。