搜索页在学习类应用里经常被低估。用户真正需要的不是一个输入框,而是从"我想找某个题库""我想搜一道题里的关键词""我从分类页点进来想看某类题"这几种入口里,都能得到稳定反馈。若状态边界不清楚,搜索页很容易出现三个问题:还没搜索就显示无结果、分类筛选和关键词互相污染、结果为空时没有明确解释。
句匠项目的 SearchPage.ets 是一个轻量但完整的本地搜索页。它不做云端检索,不建全文索引,而是基于本地 BANKS、REGIONS 和 getQuestions(),把题库名称、题目题干和分类类型收束到同一套状态:keyword、categoryType、bankResults、questionResults、searched。本文基于真实源码 D:\huawei\one18-11\entry\src\main\ets\pages\SearchPage.ets,并结合 BankDetailPage.ets 的题库资料边界,复盘 HarmonyOS 5.0+ ArkTS 搜索页如何支持关键词、分类和无结果反馈。

正文唯一复核标记:com.jiaweikang.one18。本文只讨论源码可复核能力:SearchParams、aboutToAppear()、doSearch()、SearchHeader()、EmptySearchHome()、NoResult()、ResultList()、QuestionResultCard()、BankDetailProfile 和本地题库数据。它不声称当前版本实现了云端搜索、拼音搜索、语义搜索、服务端排序或真实用户搜索数据统计。
1. 搜索页先区分"未搜索"和"无结果"
搜索页最容易犯的错,是把初始页面和无结果页面都写成空数组判断。句匠源码里单独用了 searched:
ts
@State keyword: string = ''
@State categoryType: string = ''
@State categoryName: string = ''
@State bankResults: Bank[] = []
@State questionResults: Question[] = []
@State searched: boolean = false
页面主体根据 searched 和两个结果数组分三段渲染:
ts
if (!this.searched) {
this.EmptySearchHome()
} else if (this.bankResults.length === 0 && this.questionResults.length === 0) {
this.NoResult()
} else {
this.ResultList()
}
这条边界很关键。初始页应该引导用户搜索,空结果页应该告诉用户换关键词。两者都可能是 bankResults=[] 和 questionResults=[],但用户感知完全不同。searched 让页面可以明确表达"你还没搜"和"已经搜过但没有匹配"。
2. 路由分类入口会自动触发搜索
SearchPage 支持从分类页或其他入口带参数进入。参数模型很小:
ts
interface SearchParams {
categoryType?: string
categoryName?: string
}
页面出现时,如果路由参数带了 categoryType,就把分类写入状态,并把 keyword 设置为分类名,随后执行搜索:
ts
aboutToAppear(): void {
const params = router.getParams() as SearchParams | undefined
if (params && params.categoryType) {
this.categoryType = params.categoryType
this.categoryName = params.categoryName || params.categoryType
this.keyword = this.categoryName
this.doSearch()
}
}
这段逻辑说明分类入口不是简单地预填输入框,而是一次完整搜索。这样用户从"介词搭配""地道表达"等分类点进来时,不需要再手动点击搜索按钮。
需要注意边界:分类搜索真正使用的是 q.type === this.categoryType,不是 keyword 文本匹配。keyword = categoryName 主要用于输入框展示,让用户知道当前筛选来自哪个分类。

3. doSearch() 把题库和题目拆成两类结果
核心搜索函数如下:
ts
private doSearch(): void {
if (this.keyword.trim().length === 0 && this.categoryType.length === 0) {
this.bankResults = []
this.questionResults = []
this.searched = false
return
}
this.searched = true
const kw = this.keyword.trim().toLowerCase()
this.bankResults = this.categoryType.length > 0
? []
: BANKS.filter(b => b.name.toLowerCase().includes(kw))
const qResults: Question[] = []
for (const bank of BANKS) {
const qs = getQuestions(bank.id)
for (const q of qs) {
if ((this.categoryType.length > 0 && q.type === this.categoryType) ||
(this.categoryType.length === 0 && q.stem.toLowerCase().includes(kw))) {
qResults.push(q)
if (qResults.length >= 20) break
}
}
if (qResults.length >= 20) break
}
this.questionResults = qResults
}
这段代码有三个明确决策:
| 决策 | 源码表现 | 作用 |
|---|---|---|
| 空输入不算搜索 | 清空结果并 searched=false |
避免初始页误显示无结果 |
| 分类模式不搜题库 | categoryType.length > 0 ? [] : ... |
分类页只展示题目,不混入题库卡片 |
| 题目最多 20 条 | qResults.length >= 20 |
控制本地遍历后的列表长度 |
本地搜索没有建立索引,所以结果收集上限很重要。句匠题库规模不大,直接遍历 BANKS -> getQuestions(bank.id) 足够;但如果后续题量上万,就应该把搜索逻辑从页面里抽到服务层或索引模块。
4. 分类和关键词不能同时长期生效
搜索框 onChange 有一段状态清理:
ts
.onChange((value: string) => {
this.keyword = value
if (this.categoryType.length > 0 && value !== this.categoryName) {
this.categoryType = ''
this.categoryName = ''
}
})
这解决了一个常见交互问题:用户从分类入口进来后,输入框显示分类名。如果用户开始手动修改输入内容,页面就不应该继续按原分类筛选,否则输入"receive"却还在展示"介词搭配"分类结果,会让人误判搜索失效。
因此源码采用的规则是:分类入口进入时使用 categoryType;一旦用户输入内容不等于原分类名,就清除分类状态,回到关键词搜索。
这个规则简单但有效。它避免了"分类筛选 + 关键词筛选"的组合复杂度。当前页面没有实现多条件联合过滤,所以不应该在文章或产品文案里声称支持多维组合筛选。
5. 搜索头部同时承载返回、输入、提交和分类提示
SearchHeader() 不是只放一个输入框,它还包含返回按钮、搜索按钮和分类提示条。
ts
TextInput({ placeholder: '搜索地区、题库、题目', text: this.keyword })
.layoutWeight(1)
.height(42)
.fontSize(Sizes.BODY_FONT)
.fontColor(Colors.INPUT_TEXT)
.placeholderColor(Colors.INPUT_PLACEHOLDER)
.caretColor(Colors.PRIMARY)
.backgroundColor(Colors.INPUT_BG)
.onChange((value: string) => {
this.keyword = value
if (this.categoryType.length > 0 && value !== this.categoryName) {
this.categoryType = ''
this.categoryName = ''
}
})
.onSubmit(() => { this.doSearch() })
搜索按钮也直接调用同一个函数:
ts
Text('搜索')
.fontSize(Sizes.BODY_FONT)
.fontColor(Colors.PRIMARY)
.fontWeight(FontWeight.Bold)
.onClick(() => { this.doSearch() })
键盘提交和按钮提交共用 doSearch(),这点要保留。否则很容易出现键盘搜索和按钮搜索结果不一致。
分类提示条则只在 categoryType.length > 0 时显示:
ts
if (this.categoryType.length > 0) {
Row() {
Text(`当前分类:${this.categoryName}`)
Blank()
Text('清除')
.onClick(() => {
this.categoryType = ''
this.categoryName = ''
this.keyword = ''
this.doSearch()
})
}
}
清除分类后调用 doSearch(),由于关键词和分类都为空,页面会回到未搜索状态,而不是展示空结果。
6. 初始页用热门地区做搜索入口
未搜索时,页面展示 EmptySearchHome()。它使用 REGIONS 渲染热门搜索词:
ts
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REGIONS, (region: Region) => {
Text(region.name)
.fontSize(Sizes.BODY_FONT)
.fontColor(Colors.TEXT_SECONDARY)
.height(38)
.padding({ left: 16, right: 16 })
.backgroundColor(Colors.SURFACE)
.borderRadius(19)
.margin({ right: 10, bottom: 10 })
.onClick(() => {
this.keyword = region.name
this.doSearch()
})
}, (region: Region) => region.id)
}
这类入口不是装饰,它承担了两个作用:让首次进入搜索页的用户知道可以搜什么;给本地搜索提供可复现输入。点击地区名会写入 keyword 并立即搜索。
源码下方还有一句说明:
ts
Text('可以搜索题库名称、语法点、关键词,也可以从分类页进入题型结果。')
这个说明与实现基本一致,但要注意精确表达:源码当前题目搜索匹配的是 q.stem,题库搜索匹配的是 b.name。它没有搜索 analysis、options、chapter.name 或 BankDetailProfile.focusTags。
7. 无结果页要给出下一步动作
NoResult() 使用图片和两行文案:
ts
@Builder
NoResult() {
Column({ space: 12 }) {
Image($r('app.media.img_empty_default'))
.width(120)
.height(120)
.objectFit(ImageFit.Contain)
.opacity(0.6)
Text('未找到相关内容')
.fontSize(Sizes.BODY_FONT)
.fontColor(Colors.TEXT_HINT)
Text('换个关键词试试')
.fontSize(Sizes.CAPTION_FONT)
.fontColor(Colors.TEXT_HINT)
}
}
空状态的重点是避免用户困惑。由于当前搜索只覆盖题库名和题干关键词,用户搜一个选项文本或解析里的词,可能不会命中。无结果页不应该暗示系统故障,而应该提示换关键词。
如果后续扩展搜索范围,建议同步调整无结果文案。例如支持解析搜索后,可以提示"试试语法点、例句或解析关键词";支持拼音搜索后,可以提示"支持中文、英文或拼音首字母"。
8. 结果列表按题库和题目分区
ResultList() 先渲染题库结果,再渲染题目结果:
ts
if (this.bankResults.length > 0) {
this.ResultTitle(`题库 (${this.bankResults.length})`)
ForEach(this.bankResults, (bank: Bank) => {
Column() {
BankCard({ bank: bank })
}
.padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
}, (bank: Bank) => bank.id)
}
if (this.questionResults.length > 0) {
this.ResultTitle(`题目 (${this.questionResults.length})`)
ForEach(this.questionResults, (q: Question) => {
this.QuestionResultCard(q)
}, (q: Question) => q.id)
}
分区的价值是让用户知道自己搜到的是"题库入口"还是"具体题目"。题库卡片使用已有 BankCard,题目结果则走专门的 QuestionResultCard。
这种分区也保留了后续扩展空间。比如后面可以加入"章节""收藏""错题"分区,但每个分区都应该从明确的数据源来,不能把不同类型混成一个没有来源标识的列表。

9. 题目结果卡片只展示必要信息
QuestionResultCard() 展示题干、题型、题库名和练习入口:
ts
@Builder
QuestionResultCard(q: Question) {
Column({ space: 10 }) {
Text(q.stem)
.fontSize(Sizes.BODY_FONT)
.fontWeight(FontWeight.Medium)
.fontColor(Colors.TEXT_PRIMARY)
.lineHeight(21)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Row({ space: 8 }) {
Text(questionTypeLabel(q.type))
Text(this.bankName(q.bankId))
Blank()
Text('练习')
}
}
.onClick(() => {
router.pushUrl({
url: 'pages/PracticePage',
params: { bankId: q.bankId, mode: 'random' }
})
})
}
这里的 maxLines(3) 和 textOverflow 很必要。题干可能包含英文句子、中文说明和下划线空缺,小屏上如果不限制行数,结果列表会变得很难扫读。
点击题目结果后进入 PracticePage,参数是 { bankId: q.bankId, mode: 'random' }。这说明当前源码并不是直接打开这道题,而是进入对应题库的随机练习。文章必须如实说明这一点,不能写成"点击搜索结果直接定位到该题并开始作答"。如果未来要支持精确定位,需要给 PracticePage 增加 questionId 参数和定位逻辑。
10. 题库详情页提供分类语义,但不参与搜索计算
BankDetailPage.ets 中定义了 BankDetailProfile:
ts
interface BankDetailProfile {
subtitle: string
intro: string
cultureNote: string
focusTags: string[]
sceneTags: string[]
}
不同题库有不同资料,例如介词搭配、地道表达、真题语法等。它们用于题库详情页展示:
ts
private profile(): BankDetailProfile {
const profile = BANK_DETAIL_PROFILES.get(this.bankId())
return profile ? profile : DEFAULT_BANK_DETAIL_PROFILE
}
这部分和搜索页的关系要说清楚:BankDetailProfile 提供题库语义展示,但当前 SearchPage.doSearch() 并没有搜索 focusTags、sceneTags 或 cultureNote。用户能通过题库名或题干关键词搜索,而不是通过题库详情文案全文搜索。
这个边界对后续迭代有指导意义。如果产品想让"地道表达""邮件用词""高频错词"等标签可搜索,就应该把 BankDetailProfile 或分类数据纳入搜索数据源,而不是只改 placeholder。
11. 本地搜索的真实边界
当前实现适合轻量题库搜索,但有明确边界:
| 能力 | 当前源码状态 |
|---|---|
| 题库名称搜索 | 已实现,使用 BANKS.filter |
| 题干关键词搜索 | 已实现,使用 q.stem.toLowerCase().includes(kw) |
| 分类题型搜索 | 已实现,使用 q.type === categoryType |
| 结果数量限制 | 已实现,题目结果最多 20 条 |
| 无结果反馈 | 已实现,NoResult() |
| 拼音/首字母搜索 | 未实现 |
| 解析/选项全文搜索 | 未实现 |
| 云端搜索/服务端排序 | 未实现 |
| 点击结果定位到具体题 | 未实现,当前进入题库随机练习 |
把边界写清楚,是技术文章和上架材料都需要遵守的基本要求。尤其是"搜索"这个词很容易被理解成全量检索,源码没有做的能力不应该过度包装。
12. 可复核测试清单
可以按下面方式验证搜索链路:
| 场景 | 操作 | 预期 |
|---|---|---|
| 初始进入 | 打开搜索页不输入 | 展示热门搜索,不显示无结果 |
| 空搜索 | 输入空白后点击搜索 | 清空结果并回到未搜索状态 |
| 题库名搜索 | 输入题库名称的一部分 | bankResults 出现题库卡片 |
| 题干关键词 | 输入题干中存在的英文词 | questionResults 出现题目卡片 |
| 分类入口 | 带 categoryType 跳转 |
自动搜索并显示当前分类条 |
| 修改分类关键词 | 分类入口后手动改输入 | 清除 categoryType,改为关键词搜索 |
| 无命中 | 输入不存在的词 | 展示 NoResult() |
| 点击题目 | 点击题目卡片 | 进入对应题库的随机练习页 |
调试时建议先看 searched,再看两个结果数组。很多 UI 误判不是搜索逻辑错,而是页面把"未搜索"和"已搜索无结果"混在一起。
13. 常见问题与处理建议
| 问题 | 可能原因 | 处理方向 |
|---|---|---|
| 初始页显示"未找到" | 只按结果数组判断,没有使用 searched |
保留三段式渲染:未搜索、无结果、有结果 |
| 分类入口后输入关键词无效 | categoryType 没有被清除 |
在 onChange 中判断输入是否偏离 categoryName |
| 搜索结果过多卡顿 | 本地遍历没有上限 | 保留 20 条上限,题量变大后抽服务层或索引 |
| 搜选项文本搜不到 | 当前只搜 q.stem |
若有需求,扩展到 options/analysis/chapter |
| 点击题目没有定位具体题 | 当前只传 bankId 和 random |
增加 questionId 参数和练习页定位逻辑 |
| 题库详情标签搜不到 | BankDetailProfile 未纳入搜索源 |
将 focusTags/sceneTags 作为可搜索字段 |
总结
句匠的 SearchPage.ets 没有把搜索页做成复杂系统,而是用很少的状态把入口、查询、结果和反馈分开:keyword 负责用户输入,categoryType 负责分类入口,searched 区分未搜索和无结果,bankResults 与 questionResults 分别承载题库和题目结果。BankDetailPage.ets 提供了题库语义资料,但当前并不参与搜索计算,这个边界同样需要明确。
对 HarmonyOS ArkTS 学习应用来说,这套实现的可复用点是状态边界,而不是某个样式。先把搜索范围讲清楚,再把未搜索、无结果、有结果分开渲染,最后让点击结果进入明确的练习入口。这样即使后续扩展全文索引、分类组合筛选或具体题定位,也能在现有结构上继续演进。