【句匠|08】HarmonyOS ArkTS 句库搜索实战:支持关键词、分类和无结果反馈

搜索页在学习类应用里经常被低估。用户真正需要的不是一个输入框,而是从"我想找某个题库""我想搜一道题里的关键词""我从分类页点进来想看某类题"这几种入口里,都能得到稳定反馈。若状态边界不清楚,搜索页很容易出现三个问题:还没搜索就显示无结果、分类筛选和关键词互相污染、结果为空时没有明确解释。

句匠项目的 SearchPage.ets 是一个轻量但完整的本地搜索页。它不做云端检索,不建全文索引,而是基于本地 BANKSREGIONSgetQuestions(),把题库名称、题目题干和分类类型收束到同一套状态:keywordcategoryTypebankResultsquestionResultssearched。本文基于真实源码 D:\huawei\one18-11\entry\src\main\ets\pages\SearchPage.ets,并结合 BankDetailPage.ets 的题库资料边界,复盘 HarmonyOS 5.0+ ArkTS 搜索页如何支持关键词、分类和无结果反馈。

正文唯一复核标记:com.jiaweikang.one18。本文只讨论源码可复核能力:SearchParamsaboutToAppear()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。它没有搜索 analysisoptionschapter.nameBankDetailProfile.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() 并没有搜索 focusTagssceneTagscultureNote。用户能通过题库名或题干关键词搜索,而不是通过题库详情文案全文搜索。

这个边界对后续迭代有指导意义。如果产品想让"地道表达""邮件用词""高频错词"等标签可搜索,就应该把 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
点击题目没有定位具体题 当前只传 bankIdrandom 增加 questionId 参数和练习页定位逻辑
题库详情标签搜不到 BankDetailProfile 未纳入搜索源 将 focusTags/sceneTags 作为可搜索字段

总结

句匠的 SearchPage.ets 没有把搜索页做成复杂系统,而是用很少的状态把入口、查询、结果和反馈分开:keyword 负责用户输入,categoryType 负责分类入口,searched 区分未搜索和无结果,bankResultsquestionResults 分别承载题库和题目结果。BankDetailPage.ets 提供了题库语义资料,但当前并不参与搜索计算,这个边界同样需要明确。

对 HarmonyOS ArkTS 学习应用来说,这套实现的可复用点是状态边界,而不是某个样式。先把搜索范围讲清楚,再把未搜索、无结果、有结果分开渲染,最后让点击结果进入明确的练习入口。这样即使后续扩展全文索引、分类组合筛选或具体题定位,也能在现有结构上继续演进。

相关推荐
Sunny_G2 小时前
从 DevEco Code 到 Claude Code:一次工具链切换的完整决策
harmonyos
Kevin Coding2 小时前
鸿蒙 emitter/EventHub 没有 Sticky 粘性事件?手写一个轻量级 EmitterManager 解决
前端·华为·前端框架·移动开发·harmonyos
ChinaDragonDreamer2 小时前
HarmonyOS:Web使用Dsbridge与JavaScript完成交互
harmonyos·鸿蒙
贾伟康2 小时前
【句匠|10】HarmonyOS ArkTS 分类句库实战:复用列表结构并保持导航参数类型安全
harmonyos·arkts·router·分类导航·题库列表
体毛旺盛的猿3 小时前
HarmonyOS开发面试题
前端·华为·harmonyos
贾伟康4 小时前
【口算王|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配
ChinaDragon13 小时前
HarmonyOS:6.0 新增和增强特性
harmonyos
贾伟康15 小时前
【句匠|01】HarmonyOS ArkTS 英语纠错页实战:把原句、修改建议和解释层级展示清楚
harmonyos·arkts·textarea·学习应用·英语纠错
淡写成灰17 小时前
「Flutter 文件保存太难了?」一个插件打通 7 大平台,我把方案开源了 🎉
flutter·harmonyos