知识库 App 的 NoteList 是一个"万能列表页"——从不同入口进来时显示不同数据:从笔记本卡片进来显示该笔记本的笔记,从搜索 tab 进来显示全部笔记,从标签进来显示该标签的笔记。同一个页面承载三种数据源,靠的是路由参数的灵活判断。

完整效果
在这里插入图片描述

一、三种数据来源的路由参数设计

入口 参数 数据源 标题
首页笔记本卡片 notebookId + notebookName getNotesByNotebook(id) 笔记本名称(如"工作")
底部导航"搜索" notebookId=-1, notebookName=‘全部笔记’ getNotes() “全部笔记”
标签页 TagFilterPage 无(直接调 searchNotes) searchNotes(keyword) “搜索结果”

三种来源走不同的查询函数:

aboutToAppear(): void {
  const p = router.getParams() as Record<string, Object>
  if (p) {
    if (p['notebookId']) {
      const id: number = p['notebookId'] as number
      this.notes = id < 0 ? getNotes() : getNotesByNotebook(id)
      this.title = p['notebookName'] as string
    }
  }
}

在这里插入图片描述

id < 0 ? getNotes() : getNotesByNotebook(id) 的特殊值判断。 notebookId=-1 是一个约定值——表示"不按笔记本筛选,显示全部笔记"。NoteList 收到 -1 后走 getNotes(),收到其他值走 getNotesByNotebook(id)

为什么不传 notebookId 来表示"全部笔记"? 因为 router.getParams() 返回的对象可能没有 notebookId 字段,需要额外判断 if (p['notebookId'])。用 -1 这个约定值,判断逻辑更简单——只需要比较数字。

踩坑记录: 最初传了 notebookId: 0,但 NOTEBOOKS 里没有 id=0 的笔记本,getNotesByNotebook(0) 返回空数组,页面显示空白。改成 -1 后在 NoteList 里加了 id < 0 判断解决。

二、搜索功能的实时过滤

@State searchText: string = ''

private doSearch(): void {
  this.notes = this.searchText.trim().length > 0
    ? searchNotes(this.searchText)
    : getNotes()
}

// 搜索框
TextInput({ placeholder: '搜索笔记...', text: this.searchText })
  .layoutWeight(1).height(36).fontSize(14)
  .backgroundColor('#F5F5FA').borderRadius(18).padding({ left: 14 })
  .onChange((v: string) => { this.searchText = v; this.doSearch() })

在这里插入图片描述

搜索框的 onChange 同时做两件事: 同步搜索词到 @State,然后调用 doSearch 重新过滤。用户每打一个字都会触发过滤——实时响应。

doSearch 的二分支逻辑: 搜索词非空时调用 searchNotes(keyword),为空时调用 getNotes() 恢复全部笔记。这个"恢复"逻辑很重要——用户清空搜索框后应该看到全部笔记,而不是空列表。

搜索词为空时为什么调 getNotes() 而不是 getNotesByNotebook()? 因为搜索是全局搜索——不限定笔记本。清空搜索词后恢复全部笔记更符合用户预期。

踩坑记录: 最初搜索词为空时没有恢复逻辑,用户搜完后清空搜索框,列表还是空的。加了 getNotes() 恢复后解决。

三、getNotesByNotebook 的双重遍历

export function getNotesByNotebook(nbId: number): Note[] {
  const r: Note[] = []
  for (let i: number = 0; i < _notes.length; i++) {
    if (_notes[i].notebookId === nbId) r.push(_notes[i])
  }
  return r
}

单层遍历——比 getFavs 的双重遍历简单。 因为 Notebook 的关联是单向的——笔记直接存了 notebookId,不需要额外匹配。菜谱 App 的收藏需要遍历 RECIPES 和 _favs 两个数组做交叉匹配,复杂度更高。

返回新数组——r.push(_notes[i]) 创建了独立数组。 调用方修改返回值不会影响 _notes。

四、搜索结果的两种空状态

NoteList 有两种空状态场景:

场景 原因 当前处理
笔记本内没有笔记 该笔记本是空的 显示空白
搜索无结果 关键词没有匹配 显示空白

当前两种空状态都没有提示——页面只有导航栏和空白区域。优化方案:

if (this.notes.length === 0) {
  Column() {
    Text(this.searchText.length > 0 ? '🔍' : '📝').fontSize(48).opacity(0.2).margin({ bottom: 12 })
    Text(this.searchText.length > 0 ? '没有找到相关笔记' : '还没有笔记')
      .fontSize(14).fontColor(T2)
    Text(this.searchText.length > 0 ? '试试其他关键词' : '点击 + 新建一篇')
      .fontSize(12).fontColor(T3).margin({ top: 4 })
  }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
}

根据搜索状态显示不同的空状态文案。 有搜索词时提示"没有找到相关笔记",没有搜索词时提示"还没有笔记"。两种场景的解决方案不同——前者建议换关键词,后者建议新建笔记。

五、笔记卡片的信息精简

Row() {
  Text(this.nbIcon(n.notebookId)).fontSize(20).width(44).height(44)
    .borderRadius(12).backgroundColor(this.nbColor(n.notebookId) + '12')
    .textAlign(TextAlign.Center)
  Column() {
    Row() {
      Text(n.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)
        .maxLines(1).layoutWeight(1)
      if (n.favorited) Text('❤️').fontSize(12)
    }
    Text(this.nbName(n.notebookId) + ' · ' + n.createdAt)
      .fontSize(10).fontColor(T3).margin({ top: 2 })
  }.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
  SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
}

在这里插入图片描述

和首页笔记卡片的对比:

部分 首页卡片 NoteList 卡片
第一行 标题 + 收藏图标 标题 + 收藏图标
第二行 内容预览(前 50 字) 笔记本名称 + 日期
第三行 笔记本标签 + 日期 + 标签
右侧箭头

NoteList 卡片比首页卡片更精简——去掉了内容预览和标签。 原因是:NoteList 可能显示很多笔记(全部 12 篇或搜索结果),精简的卡片让用户一屏能看到更多条目。首页是仪表盘,需要展示更多信息密度,所以加了内容预览和标签。

信息密度的选择取决于使用场景:

  • 仪表盘(首页):高密度,一屏展示尽量多信息
  • 列表页(NoteList):中密度,平衡信息量和浏览效率
  • 空状态:零密度,留白引导用户操作

六、nbName/nbIcon/nbColor 的重复出现

NoteList、FavoritesPage、TagFilterPage 三个页面都有这三个辅助函数:

private nbName(id: number): string { ... }
private nbIcon(id: number): string { ... }
private nbColor(id: number): string { ... }

三个函数结构完全一样——只是返回的字段不同。 这是抽取公共函数的典型场景。

两种抽取方案

方案一:抽取到 NoteService 数据层

// NoteService.ets 新增
export function getNotebookName(id: number): string { ... }
export function getNotebookIcon(id: number): string { ... }
export function getNotebookColor(id: number): string { ... }

三个页面直接导入调用,不需要各自定义。优点是代码零重复,缺点是数据层承担了 UI 相关的职责(图标和颜色是展示逻辑)。

方案二:保持各自定义

三个页面各自写一份。代码有重复,但每个页面自包含——不依赖其他页面或额外的函数。在 6 个页面的规模下,重复代码的维护成本可以接受。

当前选了方案二。 原因是:知识库 App 只有 3 个页面用到这三个函数,重复量不大。如果以后增加到 10+ 页面都用到,再抽取也不迟。

七、整体嵌套结构

Column                                    页面容器
  ├→ Row (导航栏)                          返回 + 标题 + 数量
  ├→ Row (搜索框)                          搜索图标 + TextInput
  └→ Scroll                                可滚动内容
       └→ Column({ space: 8 })             垂直排列笔记卡片
            ├→ ForEach(this.notes, ...)    笔记卡片列表
            └→ Blank(20)                   底部留白

嵌套深度只有 3 层——比首页少一层。 因为没有底部导航栏,不需要 Stack 层叠。这是列表页的标准结构:导航栏 + 搜索框 + 列表,三层嵌套足够。

搜索框在导航栏和列表之间。 这是移动端搜索列表的标准位置——用户先看到标题,再看到搜索框,最后看到列表。搜索框靠近列表让视觉焦点集中在"搜索 → 结果"的流程上。

八、数据流的完整链路

入口一:首页笔记本卡片
  → router.pushUrl({ notebookId: 1, notebookName: '工作' })
  → NoteList.aboutToAppear → getNotesByNotebook(1)
  → this.notes = 3 篇工作笔记
  → ForEach 渲染列表

入口二:底部导航"搜索"
  → router.pushUrl({ notebookId: -1, notebookName: '全部笔记' })
  → NoteList.aboutToAppear → getNotes() (id < 0)
  → this.notes = 12 篇全部笔记
  → ForEach 渲染列表

用户在搜索框输入"HarmonyOS"
  → onChange → doSearch → searchNotes('HarmonyOS')
  → this.notes = 1 篇匹配笔记
  → ForEach 重新渲染

三种入口最终都走到同一个 ForEach 渲染逻辑。 数据来源不同,但 UI 结构完全一样。这就是"一个页面多种数据源"的架构优势——只需要一套卡片组件,不同数据源只需切换查询函数。

九、和其他列表页的对比

维度 NoteList FavoritesPage TagFilterPage
数据来源 三种(全部/按笔记本/搜索) 一种(收藏) 两种(标签列表/按标签筛选)
搜索功能 有(实时过滤)
空状态
标签展示 有(标签网格)
代码量 最多 最少 中等

NoteList 是三个列表页里功能最全的——有搜索、有多种数据源。FavoritesPage 最简单——只显示收藏笔记。TagFilterPage 介于两者之间——有两种状态(标签列表/筛选结果)。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐