鸿蒙业务需求实战:搜索 AI 总结卡片与搜索框提示词轮播

一、需求背景

这次需求围绕公司鸿蒙项目中的搜索能力展开,主要做了两个功能:

  1. 搜索结果页顶部新增 AI 总结卡片。
  2. 首页搜索框内新增推荐问题轮播提示词。

这两个功能表面上是 UI 改动,但实际涉及页面定位、组件抽离、ViewModel 状态管理、Biz mock 数据、Controller / Presenter 调用、路由传参、@Param@Event@TraceSwiper 等多个知识点。


二、根据页面文案定位入口

拿到需求后,第一步不是直接写代码,而是先找页面。

首页搜索框里有一段明显文案:

搜索服务、地图、帖子

通过全局搜索这段文案,可以定位到首页组件:

flushu/yuanzhou_home/src/main/ets/components/TabHomeComp.ets

搜索框在 bigSearchBuilder() 中渲染,点击后调用:

this.controller.onClickSearch()

继续追踪 onClickSearch(),可以定位到:

flushu/yuanzhou_home/src/main/ets/controller/TabHomeController.ets

这里通过项目封装路由跳转到全局搜索页:

HMUtil.push({
  pageUrl: LushuPageConstant.GlobalSearchPage,
  param: {
    searchContentState: SearchContentState.HISTORY,
    currentAreaData: areaHistoryUtil.getFirst(),
    areaSearchShowState: AreaSearchState.AREA,
    suggestNotRequest: false
  } as GlobalSearchPageParam
})

这说明首页搜索框只是入口,真正的搜索结果页在 GlobalSearchPage 中。


三、定位搜索结果页

继续根据 LushuPageConstant.GlobalSearchPage 查找,可以定位到:

flushu/lushu_historyandsearch/src/main/ets/pages/GlobalSearchPage.ets

页面通过 @HMRouter 注册:

@HMRouter({ pageUrl: LushuPageConstant.GlobalSearchPage })
@ComponentV2
export struct GlobalSearchPage

搜索页内部主要有三种状态:

SearchContentState.HISTORY        搜索历史
SearchContentState.SUGGEST        搜索联想
SearchContentState.SEARCH_RESULT  搜索结果

AI 总结卡片属于搜索结果页,所以继续追踪 SEARCH_RESULT,最终定位到:

flushu/lushu_historyandsearch/src/main/ets/components/GlobalSearchResultComp.ets

该组件负责渲染服务、路线、POI、攻略等搜索结果,因此 AI 总结卡片应该插在 List 顶部。


四、AI 总结卡片抽成独立组件

一开始可以直接在 GlobalSearchResultComp.ets 中写卡片,但这样会让搜索结果组件越来越重,不利于复用。因此将卡片抽成独立组件:

AISearchSummaryCard.ets

组件结构采用三段式:

Column
  ├── Row:AI 图标 + Ai
  ├── Text:AI 总结内容
  └── Text:查看详情,内容由AI生成 >

子组件通过 @Param 接收展示文案:

@Param summaryText: string = ''
@Param aiName: string = 'Ai'

通过 @Event 暴露点击事件:

@Event onClickDetail?: () => void

点击时只通知父组件:

.onClick(() => {
  this.onClickDetail?.()
})

这样组件只负责 UI,不关心跳转逻辑,复用性更好。


五、@Param 和 @Event 的理解

@Param 可以理解为父组件传给子组件的数据。

父组件:

AISearchSummaryCard({
  summaryText: this.getAiSummaryText()
})

子组件:

@Param summaryText: string = ''

@Event 本质是父组件传给子组件的回调函数。

父组件:

AISearchSummaryCard({
  summaryText: this.getAiSummaryText(),
  onClickDetail: () => {
    this.jumpAgentChatPage()
  }
})

子组件:

this.onClickDetail?.()

点击链路是:

用户点击卡片
  ↓
子组件触发 onClickDetail
  ↓
父组件执行 jumpAgentChatPage
  ↓
HMUtil.push 跳转 AI 聊天页

六、AI 总结 mock 数据分层

后端 / AI 接口暂时没有提供,所以前端只能先通过 mock 数据跑通链路。

初版将 mock 请求写在组件中,虽然能跑通,但组件逻辑过重。后续重构为:

GlobalSearchPage.ets
  触发搜索动作

GlobalSearchPresenter.ets
  调用 AI 总结逻辑,处理 loading 和异常

GlobalSearchBiz / MockBiz
  模拟后端返回 summaryText

GlobalSearchViewModel.ets
  保存 aiSummaryText、aiSummaryLoading、aiSummaryKeyword

GlobalSearchResultComp.ets
  只接收数据并展示卡片

AISearchSummaryCard.ets
  只负责 UI

这样后续真实接口提供后,只需要替换 Biz 层数据来源,UI 基本不用改。


七、搜索按钮和搜索历史都要触发 AI 总结

搜索页有两个入口会触发搜索:

  1. 用户手动输入关键词并提交。
  2. 用户点击搜索历史记录。

手动搜索时,在 onSubmit 中触发:

this.presenter.searchResult(text, ...)
this.presenter.searchAiSummary(text)

点击搜索历史时,在 onHistoryClick 的搜索结果分支中触发:

this.presenter.searchResult(item.searchKeyword!, ...)
this.presenter.searchAiSummary(item.searchKeyword!)

第一版先分开写,避免一次性抽公共方法导致改动太大。后续逻辑稳定后,可以再抽成统一的 doSearch()


八、路由跳转和参数传递

AI 总结卡片点击后,需要跳转到 AI 聊天页,并携带当前搜索词。

搜索结果页跳转:

HMUtil.push({
  pageUrl: LushuPageConstant.AgentChatPage,
  param: {
    keyword: this.currentKeyword ?? '',
    source: 'global_search_ai_summary'
  }
})

AI 聊天页接收参数:

const param = HMRouterMgr.getCurrentParam() as AgentChatPageParam | undefined

参数类型:

interface AgentChatPageParam {
  keyword?: string
  source?: string
}

当前阶段只打印验证:

console.info(`[AgentChatPage] keyword = ${param?.keyword ?? ''}`)

因为 AI 聊天页和后端协议还不明确,所以先只完成“搜索词传递闭环”。


九、首页搜索框推荐词轮播

第二个功能是首页搜索框中的提示词轮播。

原本设想是接口返回 10 条,前端分批轮播。后来根据需求调整为:

接口直接返回 5 条推荐问题
前端只保存这 5 条
UI 直接轮播展示
后续接口每两天更新一次数据源

因此 ViewModel 只需要:

@Trace presetQuestionList: string[] = []
@Trace presetQuestionLoading: boolean = false

不需要保存全部数据,也不需要保存批次状态。


十、mock 推荐词放在 Biz 中

当前项目目录没有统一的 imp 文件夹,因此 mock 方法直接写在 Biz 中。

示例:

static async getPresetQuestions(): Promise<string[]> {
  return new Promise<string[]>((resolve) => {
    setTimeout(() => {
      resolve([
        '香港必吃榜',
        '亲子去哪玩',
        '机场怎么去',
        '一日游推荐',
        '附近吃什么'
      ])
    }, 300)
  })
}

Controller 负责调用 Biz,并对返回数据做校验:

是否是数组
是否是字符串
是否为空
是否超过 10 个字符
最多取 5 条

校验完成后更新 ViewModel:

this.viewModel.presetQuestionList = formatList

控制台验证成功:

[TabHomeController] presetQuestionList = ["香港必吃榜","亲子去哪玩","机场怎么去","一日游推荐","附近吃什么"]
[TabHomeController] first preset question = 香港必吃榜

十一、组件中不能直接写 console.log

在 ArkTS 组件结构体内部,不能直接写执行语句:

console.log('xxx')

组件结构体内部只能声明属性、方法、生命周期、Builder 等。正确做法是放在生命周期或方法里:

aboutToAppear(): void {
  console.info('xxx')
}

但由于 mock 请求是异步的,aboutToAppear() 中立刻打印可能拿不到数据,因此更推荐在 Controller 的接口 then 中打印。


十二、arkts-no-any-unknown 规则

项目中遇到过这个报错:

Use explicit types instead of "any", "unknown" (arkts-no-any-unknown)

意思是 ArkTS 不推荐使用 anyunknown,需要写明确类型。

不要写:

.catch((err: any) => {})

应该写:

.catch((err: Error) => {})

不要写:

private formatPresetQuestionList(list: any): string[] {}

应该写:

private formatPresetQuestionList(list: string[]): string[] {}

这样更符合 ArkTS 的类型规范。


十三、使用 Swiper 实现提示词轮播

原来搜索框只展示第一条推荐词:

Text(this.viewModel.presetQuestionList.length > 0
  ? this.viewModel.presetQuestionList[0]
  : '搜索服务、地图、帖子')

现在改成 Swiper

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,也避免了忘记清理定时器的问题。


十四、本次需求涉及的知识点

这次需求虽然不大,但覆盖了很多业务开发中的常用能力:

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 和业务链路可以基本保持不变。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐