这一篇讲一个很实用的链路:不接后端,如何在本地内容库上完成搜索、类型切换、地区筛选、主题筛选、排序和无结果提示。探索页是内容 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(); }这段代码完成了四件事:
- 去掉首尾空格。
- 空关键词不处理。
- 新关键词放到最前面。
- 旧列表里相同关键词先移除,避免重复。
- 最多保留 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))); }这里的链式过滤非常清楚:
- 关键词匹配
- 地区匹配
- 主题匹配
- 排序
页面展示时只关心结果:
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 也拥有真实的个人化体验。