这一篇讲一个很实用的链路不接后端如何在本地内容库上完成搜索、类型切换、地区筛选、主题筛选、排序和无结果提示。探索页是内容 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主题筛选使用tagsprivate 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 也拥有真实的个人化体验。