鸿蒙业务需求实战:搜索 AI 总结卡片与搜索框提示词轮播
一、需求背景
这次需求围绕公司鸿蒙项目中的搜索能力展开,主要做了两个功能:
- 搜索结果页顶部新增 AI 总结卡片。
- 首页搜索框内新增推荐问题轮播提示词。
这两个功能表面上是 UI 改动,但实际涉及页面定位、组件抽离、ViewModel 状态管理、Biz mock 数据、Controller / Presenter 调用、路由传参、@Param、@Event、@Trace、Swiper 等多个知识点。
二、根据页面文案定位入口
拿到需求后,第一步不是直接写代码,而是先找页面。
首页搜索框里有一段明显文案:
text
搜索服务、地图、帖子
通过全局搜索这段文案,可以定位到首页组件:
text
flushu/yuanzhou_home/src/main/ets/components/TabHomeComp.ets
搜索框在 bigSearchBuilder() 中渲染,点击后调用:
ts
this.controller.onClickSearch()
继续追踪 onClickSearch(),可以定位到:
text
flushu/yuanzhou_home/src/main/ets/controller/TabHomeController.ets
这里通过项目封装路由跳转到全局搜索页:
ts
HMUtil.push({
pageUrl: LushuPageConstant.GlobalSearchPage,
param: {
searchContentState: SearchContentState.HISTORY,
currentAreaData: areaHistoryUtil.getFirst(),
areaSearchShowState: AreaSearchState.AREA,
suggestNotRequest: false
} as GlobalSearchPageParam
})
这说明首页搜索框只是入口,真正的搜索结果页在 GlobalSearchPage 中。
三、定位搜索结果页
继续根据 LushuPageConstant.GlobalSearchPage 查找,可以定位到:
text
flushu/lushu_historyandsearch/src/main/ets/pages/GlobalSearchPage.ets
页面通过 @HMRouter 注册:
ts
@HMRouter({ pageUrl: LushuPageConstant.GlobalSearchPage })
@ComponentV2
export struct GlobalSearchPage
搜索页内部主要有三种状态:
text
SearchContentState.HISTORY 搜索历史
SearchContentState.SUGGEST 搜索联想
SearchContentState.SEARCH_RESULT 搜索结果
AI 总结卡片属于搜索结果页,所以继续追踪 SEARCH_RESULT,最终定位到:
text
flushu/lushu_historyandsearch/src/main/ets/components/GlobalSearchResultComp.ets
该组件负责渲染服务、路线、POI、攻略等搜索结果,因此 AI 总结卡片应该插在 List 顶部。
四、AI 总结卡片抽成独立组件
一开始可以直接在 GlobalSearchResultComp.ets 中写卡片,但这样会让搜索结果组件越来越重,不利于复用。因此将卡片抽成独立组件:
text
AISearchSummaryCard.ets
组件结构采用三段式:
text
Column
├── Row:AI 图标 + Ai
├── Text:AI 总结内容
└── Text:查看详情,内容由AI生成 >
子组件通过 @Param 接收展示文案:
ts
@Param summaryText: string = ''
@Param aiName: string = 'Ai'
通过 @Event 暴露点击事件:
ts
@Event onClickDetail?: () => void
点击时只通知父组件:
ts
.onClick(() => {
this.onClickDetail?.()
})
这样组件只负责 UI,不关心跳转逻辑,复用性更好。
五、@Param 和 @Event 的理解
@Param 可以理解为父组件传给子组件的数据。
父组件:
ts
AISearchSummaryCard({
summaryText: this.getAiSummaryText()
})
子组件:
ts
@Param summaryText: string = ''
@Event 本质是父组件传给子组件的回调函数。
父组件:
ts
AISearchSummaryCard({
summaryText: this.getAiSummaryText(),
onClickDetail: () => {
this.jumpAgentChatPage()
}
})
子组件:
ts
this.onClickDetail?.()
点击链路是:
text
用户点击卡片
↓
子组件触发 onClickDetail
↓
父组件执行 jumpAgentChatPage
↓
HMUtil.push 跳转 AI 聊天页
六、AI 总结 mock 数据分层
后端 / AI 接口暂时没有提供,所以前端只能先通过 mock 数据跑通链路。
初版将 mock 请求写在组件中,虽然能跑通,但组件逻辑过重。后续重构为:
text
GlobalSearchPage.ets
触发搜索动作
GlobalSearchPresenter.ets
调用 AI 总结逻辑,处理 loading 和异常
GlobalSearchBiz / MockBiz
模拟后端返回 summaryText
GlobalSearchViewModel.ets
保存 aiSummaryText、aiSummaryLoading、aiSummaryKeyword
GlobalSearchResultComp.ets
只接收数据并展示卡片
AISearchSummaryCard.ets
只负责 UI
这样后续真实接口提供后,只需要替换 Biz 层数据来源,UI 基本不用改。
七、搜索按钮和搜索历史都要触发 AI 总结
搜索页有两个入口会触发搜索:
- 用户手动输入关键词并提交。
- 用户点击搜索历史记录。
手动搜索时,在 onSubmit 中触发:
ts
this.presenter.searchResult(text, ...)
this.presenter.searchAiSummary(text)
点击搜索历史时,在 onHistoryClick 的搜索结果分支中触发:
ts
this.presenter.searchResult(item.searchKeyword!, ...)
this.presenter.searchAiSummary(item.searchKeyword!)
第一版先分开写,避免一次性抽公共方法导致改动太大。后续逻辑稳定后,可以再抽成统一的 doSearch()。
八、路由跳转和参数传递
AI 总结卡片点击后,需要跳转到 AI 聊天页,并携带当前搜索词。
搜索结果页跳转:
ts
HMUtil.push({
pageUrl: LushuPageConstant.AgentChatPage,
param: {
keyword: this.currentKeyword ?? '',
source: 'global_search_ai_summary'
}
})
AI 聊天页接收参数:
ts
const param = HMRouterMgr.getCurrentParam() as AgentChatPageParam | undefined
参数类型:
ts
interface AgentChatPageParam {
keyword?: string
source?: string
}
当前阶段只打印验证:
ts
console.info(`[AgentChatPage] keyword = ${param?.keyword ?? ''}`)
因为 AI 聊天页和后端协议还不明确,所以先只完成"搜索词传递闭环"。
九、首页搜索框推荐词轮播
第二个功能是首页搜索框中的提示词轮播。
原本设想是接口返回 10 条,前端分批轮播。后来根据需求调整为:
text
接口直接返回 5 条推荐问题
前端只保存这 5 条
UI 直接轮播展示
后续接口每两天更新一次数据源
因此 ViewModel 只需要:
ts
@Trace presetQuestionList: string[] = []
@Trace presetQuestionLoading: boolean = false
不需要保存全部数据,也不需要保存批次状态。
十、mock 推荐词放在 Biz 中
当前项目目录没有统一的 imp 文件夹,因此 mock 方法直接写在 Biz 中。
示例:
ts
static async getPresetQuestions(): Promise<string[]> {
return new Promise<string[]>((resolve) => {
setTimeout(() => {
resolve([
'香港必吃榜',
'亲子去哪玩',
'机场怎么去',
'一日游推荐',
'附近吃什么'
])
}, 300)
})
}
Controller 负责调用 Biz,并对返回数据做校验:
text
是否是数组
是否是字符串
是否为空
是否超过 10 个字符
最多取 5 条
校验完成后更新 ViewModel:
ts
this.viewModel.presetQuestionList = formatList
控制台验证成功:
text
[TabHomeController] presetQuestionList = ["香港必吃榜","亲子去哪玩","机场怎么去","一日游推荐","附近吃什么"]
[TabHomeController] first preset question = 香港必吃榜
十一、组件中不能直接写 console.log
在 ArkTS 组件结构体内部,不能直接写执行语句:
ts
console.log('xxx')
组件结构体内部只能声明属性、方法、生命周期、Builder 等。正确做法是放在生命周期或方法里:
ts
aboutToAppear(): void {
console.info('xxx')
}
但由于 mock 请求是异步的,aboutToAppear() 中立刻打印可能拿不到数据,因此更推荐在 Controller 的接口 then 中打印。
十二、arkts-no-any-unknown 规则
项目中遇到过这个报错:
text
Use explicit types instead of "any", "unknown" (arkts-no-any-unknown)
意思是 ArkTS 不推荐使用 any 和 unknown,需要写明确类型。
不要写:
ts
.catch((err: any) => {})
应该写:
ts
.catch((err: Error) => {})
不要写:
ts
private formatPresetQuestionList(list: any): string[] {}
应该写:
ts
private formatPresetQuestionList(list: string[]): string[] {}
这样更符合 ArkTS 的类型规范。
十三、使用 Swiper 实现提示词轮播
原来搜索框只展示第一条推荐词:
ts
Text(this.viewModel.presetQuestionList.length > 0
? this.viewModel.presetQuestionList[0]
: '搜索服务、地图、帖子')
现在改成 Swiper:
ts
if (this.viewModel.presetQuestionList.length > 0) {
Swiper() {
ForEach(this.viewModel.presetQuestionList, (item: string) => {
Text(item)
.fontSize(14)
.fontColor('#99000000')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.height(32)
}, (item: string) => item)
}
.layoutWeight(1)
.height(32)
.indicator(false)
.loop(true)
.autoPlay(true)
.interval(3000)
.duration(500)
.vertical(true)
} else {
Text('搜索服务、地图、帖子')
.fontSize(14)
.fontColor('#99000000')
.layoutWeight(1)
}
Swiper 自带自动播放、循环、垂直切换能力,因此不需要自己写 setInterval,也避免了忘记清理定时器的问题。
十四、本次需求涉及的知识点
这次需求虽然不大,但覆盖了很多业务开发中的常用能力:
text
1. 根据页面文案定位组件。
2. 顺着点击事件找到 Controller。
3. 通过路由常量定位页面。
4. 理解搜索页 HISTORY / SUGGEST / SEARCH_RESULT 状态。
5. 抽离可复用 UI 组件。
6. 使用 @Param 接收父组件数据。
7. 使用 @Event 暴露子组件事件。
8. 使用 @Trace 管理响应式状态。
9. 使用 Biz mock 后端数据。
10. Controller / Presenter 负责请求和数据校验。
11. UI 层只负责渲染。
12. 使用 HMUtil.push 进行路由跳转。
13. 使用 HMRouterMgr.getCurrentParam 接收路由参数。
14. 使用 Swiper 实现搜索框提示词轮播。
15. 遵守 ArkTS 显式类型规则,避免 any / unknown。
十五、总结
这次需求从首页搜索入口开始,逐步定位到全局搜索页、搜索结果组件、AI 聊天页和首页搜索框。整体开发没有把逻辑堆在 UI 中,而是按照公司项目分层方式拆分:UI 负责展示,ViewModel 保存状态,Biz 模拟接口,Controller / Presenter 处理业务逻辑和数据校验。
目前已经完成两个前端闭环:搜索结果页 AI 总结卡片可以展示 mock 返回内容,并能点击跳转到 AI 聊天页携带搜索词;首页搜索框可以从 Biz 获取 5 条 mock 推荐问题,并通过 Swiper 实现轮播展示。后续如果后端或 AI 接口提供,只需要替换 Biz 层 mock 数据来源,整体 UI 和业务链路可以基本保持不变。