news 2026/7/10 23:28:16

HarmonyOS实战《疆域纪行》第04篇|探索页检索链路:关键词、地区筛选和空状态联动

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS实战《疆域纪行》第04篇|探索页检索链路:关键词、地区筛选和空状态联动

这一篇讲一个很实用的链路:不接后端,如何在本地内容库上完成搜索、类型切换、地区筛选、主题筛选、排序和无结果提示。探索页是内容 App 的“找东西”能力,写顺了,用户才不会迷路。

项目落点:

  • 搜索状态:searchText
  • 类型状态:exploreType
  • 地区状态:exploreRegion
  • 主题状态:exploreTheme
  • 搜索历史:searchHistory
  • 结果计算:getExploreAtlasItems()getExploreRouteItems()getExploreCities()

读完这一篇,你可以复用一套本地检索链路:输入更新状态、提交写入历史、多条件过滤列表、无结果时给用户下一步提示。

探索页解决什么问题

首页负责推荐,探索页负责查找。

旅行攻略类 App 的内容一多,用户就会提出几个很自然的问题:

  • 我只想看北疆内容。
  • 我只想看自然风光。
  • 我想搜“喀什”。
  • 我想把景点、美食、路线分开看。
  • 没有结果时能不能给我一点提示。

《疆域纪行》的探索页就是围绕这些问题设计的。它不依赖后端搜索,而是直接在本地静态内容库上做过滤。

探索页状态设计

相关状态在Index.ets中:

@State searchText: string = ''; @State exploreType: string = 'spot'; @State exploreRegion: string = 'all'; @State exploreTheme: string = 'all'; @State exploreSort: string = 'recommend'; @State searchHistory: string[] = [];

这几个状态对应探索页的五个维度:

  • searchText:关键词
  • exploreType:景点 / 美食 / 路线 / 城市
  • exploreRegion:全部 / 北疆 / 南疆 / 连接地带
  • exploreTheme:自然风光 / 人文古城 / 美食小吃
  • exploreSort:推荐排序 / 短时优先

状态拆得足够细,页面逻辑就可以很清楚:任何筛选项变化,只要更新对应状态,列表就会重新计算。

探索页 UI 结构

ExplorePage()的结构如下:

@Builder ExplorePage() { Column({ space: 16 }) { this.PageHeader('探索', 'Explore') this.ExploreSearchBar() this.ExploreSearchHistory() Row({ space: 8 }) { this.ExploreTypeChip('spot', '景点') this.ExploreTypeChip('food', '美食') this.ExploreTypeChip('route', '路线') this.ExploreTypeChip('city', '城市') } Scroll() { Row({ space: 8 }) { this.ExploreFilterChip('all', '全部地区', 'region') this.ExploreFilterChip('north', '北疆', 'region') this.ExploreFilterChip('south', '南疆', 'region') this.ExploreFilterChip('connector', '连接地带', 'region') } } .scrollable(ScrollDirection.Horizontal) Scroll() { Row({ space: 8 }) { this.ExploreFilterChip('all', '全部主题', 'theme') this.ExploreFilterChip('nature', '自然风光', 'theme') this.ExploreFilterChip('culture', '人文古城', 'theme') this.ExploreFilterChip('food', '美食小吃', 'theme') } } .scrollable(ScrollDirection.Horizontal) } }

地区和主题都用了横向滚动,因为中文标签长度不固定。与其硬塞在一行导致压缩,不如让它自然横滑。

搜索框:输入和提交分离

搜索框的核心代码:

TextInput({ placeholder: '搜索景点 / 美食 / 城市 / 路线', text: this.searchText }) .layoutWeight(1) .height(44) .borderRadius(22) .backgroundColor('#FFFFFF') .fontSize(14) .onChange((value: string) => { this.searchText = value; }) Button('搜索') .width(64) .height(44) .onClick(() => { this.commitSearchTerm(this.searchText); })

这里采用了“输入即改变搜索词,点击搜索才写入历史”的策略。

这样设计的好处是:

  • 输入过程中列表可以即时过滤。
  • 搜索历史不会被每个半成品输入污染。
  • 用户点击热门词或历史词时,也可以复用同一套提交逻辑。

搜索历史去重

提交搜索词的逻辑:

private commitSearchTerm(term: string): void { const keyword = term.trim(); if (keyword.length === 0) { return; } this.searchText = keyword; this.searchHistory = [keyword, ...this.searchHistory.filter((item: string) => item !== keyword)].slice(0, 6); this.persistSearchHistory(); }

这段代码完成了四件事:

  1. 去掉首尾空格。
  2. 空关键词不处理。
  3. 新关键词放到最前面。
  4. 旧列表里相同关键词先移除,避免重复。
  5. 最多保留 6 条。

搜索历史功能不复杂,但如果不做去重和数量限制,体验会很快变乱。

本地搜索匹配

景点和美食的匹配逻辑:

private matchItem(item: AtlasItem): boolean { if (this.searchText.trim().length === 0) { return true; } const keyword = this.searchText.trim().toLowerCase(); return item.title.toLowerCase().indexOf(keyword) >= 0 || item.english.toLowerCase().indexOf(keyword) >= 0 || item.region.toLowerCase().indexOf(keyword) >= 0 || item.summary.toLowerCase().indexOf(keyword) >= 0; }

路线匹配类似:

private matchRoute(item: RouteItem): boolean { if (this.searchText.trim().length === 0) { return true; } const keyword = this.searchText.trim().toLowerCase(); return item.title.toLowerCase().indexOf(keyword) >= 0 || item.region.toLowerCase().indexOf(keyword) >= 0 || item.summary.toLowerCase().indexOf(keyword) >= 0; }

这里没有引入复杂搜索引擎,而是基于标题、英文名、地区、摘要做包含匹配。对当前内容量来说,这已经足够。

如果后续内容扩展到几百条,可以再做:

  • 搜索字段预处理。
  • 拼音字段。
  • 标签匹配。
  • 简单倒排索引。
  • 搜索结果排序权重。

MVP 阶段先把体验闭环做完整更重要。

地区筛选:用业务规则归类

项目没有为每条数据额外写regionType,而是通过地区文本判断:

private isNorthRegion(region: string): boolean { return region.indexOf('北疆') >= 0 || region.indexOf('阿勒泰') >= 0 || region.indexOf('乌鲁木齐') >= 0 || region.indexOf('伊犁') >= 0 || region.indexOf('博尔塔拉') >= 0 || region.indexOf('昌吉') >= 0; } private isSouthRegion(region: string): boolean { return region.indexOf('喀什') >= 0 || region.indexOf('和田') >= 0 || region.indexOf('库车') >= 0 || region.indexOf('阿克苏') >= 0 || region.indexOf('巴州') >= 0 || region.indexOf('轮台') >= 0 || region.indexOf('南疆') >= 0; }

这种写法对 MVP 很快,但也有升级空间。更严谨的方式是在数据模型里增加:

regionGroup: 'north' | 'south' | 'connector'

这样筛选就不依赖字符串。但当前项目内容量可控,字符串归类可以接受。

主题筛选:基于 tags

主题筛选使用tags

private matchTheme(tags: string[]): boolean { if (this.exploreTheme === 'all') { return true; } if (this.exploreTheme === 'nature') { return tags.some((tag: string) => ['湖泊', '雪山', '草原', '森林', '沙漠', '日落'].indexOf(tag) >= 0); } if (this.exploreTheme === 'culture') { return tags.some((tag: string) => ['古城', '人文', '市集', '文化'].indexOf(tag) >= 0); } return tags.some((tag: string) => ['美食', '主食', '小吃', '羊肉', '烧烤'].indexOf(tag) >= 0); }

这是一种很适合内容 App 的做法:数据里不需要为每个主题单独建字段,只要维护好标签,页面就可以根据标签组合出不同主题视图。

最终列表如何生成

景点和美食列表:

private getExploreAtlasItems(): AtlasItem[] { const source: AtlasItem[] = this.exploreType === 'food' ? this.foods : this.spots; return this.sortAtlasItems(source .filter((item: AtlasItem) => this.matchItem(item)) .filter((item: AtlasItem) => this.matchRegion(item.region)) .filter((item: AtlasItem) => this.matchTheme(item.tags))); }

路线列表:

private getExploreRouteItems(): RouteItem[] { return this.sortRouteItems(this.routes .filter((item: RouteItem) => this.matchRoute(item)) .filter((item: RouteItem) => this.matchRegion(item.region)) .filter((item: RouteItem) => this.matchTheme(item.tags))); }

这里的链式过滤非常清楚:

  1. 关键词匹配
  2. 地区匹配
  3. 主题匹配
  4. 排序

页面展示时只关心结果:

if (this.exploreType === 'spot') { ForEach(this.getExploreAtlasItems(), (item: AtlasItem) => { this.FeatureCard(item) }, (item: AtlasItem) => item.id) } else if (this.exploreType === 'food') { ForEach(this.getExploreAtlasItems(), (item: AtlasItem) => { this.ListCard(item) }, (item: AtlasItem) => item.id) }

这就是状态驱动 UI 的优势。筛选逻辑集中在方法里,UI 只负责根据类型选择卡片。

空状态不能省

探索页还有空状态:

if ((this.exploreType === 'city' && this.getExploreCities().length === 0) || (this.exploreType === 'route' && this.getExploreRouteItems().length === 0) || ((this.exploreType === 'spot' || this.exploreType === 'food') && this.getExploreAtlasItems().length === 0)) { this.EmptyCard('没有匹配内容', '可以切换地区、主题或清空搜索关键词后再试。') }

空状态的价值是告诉用户“不是 App 坏了,而是当前条件没有结果”。尤其是多条件筛选时,用户很容易把搜索词、地区、主题组合成无结果状态。

一个成熟的 App,不能只设计有数据时的页面。

本篇小结

探索页的核心经验可以总结为:

  • 搜索词、类型、地区、主题、排序分别建状态。
  • 输入和提交搜索历史分离。
  • 本地搜索先覆盖标题、英文名、地区、摘要。
  • 地区筛选和主题筛选都集中在纯函数里。
  • 列表渲染只消费最终过滤结果。
  • 无结果时必须给出明确空状态。

下一篇我们看本地持久化:如何用 Preferences 保存收藏、搜索历史和行程清单,让单机 App 也拥有真实的个人化体验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/10 23:24:56

Discode.Ai 智能编码助手实战应用指南

在大型软件项目的开发周期中,我们常常会遇到这样的困境:业务需求变更频繁,导致核心逻辑的代码结构日益臃肿;或是面对多年前的遗留系统,文档缺失、逻辑晦涩,让重构工作举步维艰。许多开发者花费了大量时间在…

作者头像 李华
网站建设 2026/7/10 23:24:15

【C++】 STL容器

vector数组 定义&#xff1a;vector 是C STL的一个容器类&#xff0c; 用于存储和管理的动态数组。操作 定义 vector<数据类型、结构体> 名称 vector<int> a;添加元素 (1)尾插 a.push_back(x)(2)删除 a.pop_back() // 无参数(3)访问元素 int x a[0] // 通过下标访…

作者头像 李华
网站建设 2026/7/10 23:23:52

制造业如何提升产品质量:从理念到落地的实战指南

一、引言&#xff1a;质量是制造业的生命线在竞争日益激烈的全球市场中&#xff0c;产品质量已成为制造业企业生存与发展的核心命脉。对于广大制造业从业者而言&#xff0c;提升产品质量不仅是满足客户需求、赢得市场信任的基础&#xff0c;更是降低成本、提高效率、实现可持续…

作者头像 李华
网站建设 2026/7/10 23:22:10

近期量化工具怎么选,先看规则流程能否承接

对已有量化经验的人来说&#xff0c;AI 带来的吸引力很直接&#xff1a;它似乎可以让策略表达、代码生成和问题排查都更快。但在选择量化工具之前&#xff0c;更关键的问题不是哪个工具看起来更全面&#xff0c;而是自己要完成什么、能控制到什么程度。工具要跟着当前任务走如果…

作者头像 李华
网站建设 2026/7/10 23:18:03

AI 驱动的智能合约运行时监控:链上异常模式识别与自动化告警策略

AI 驱动的智能合约运行时监控&#xff1a;链上异常模式识别与自动化告警策略 一、智能合约监控的困境与 AI 破局思路 智能合约部署到链上之后&#xff0c;开发者的控制力骤然下降。链上状态不可篡改的特性虽然是去中心化的基石&#xff0c;却也意味着任何逻辑漏洞一旦被利用&am…

作者头像 李华
网站建设 2026/7/10 23:17:22

深入理解文件系统(二)

个人主页:小则又沐风 个人专栏:<数据结构> <竞赛专栏> <C语言> <C> <Linux> 座右铭 悟已往之不谏&#xff0c;知来者之可追 目录 前言 "块"的概念 …

作者头像 李华