知识库 App 的 NoteEditor 承担了新建和编辑两种完全不同的职责——新建时所有字段为空,编辑时需要预填已有数据。这种"一个页面两种模式"的设计在工具类 App 里很常见,但要做到两种模式切换流畅、数据不串,需要在状态初始化和保存逻辑上下功夫。

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

一、八个 @State 的职责划分

@State note: Note | undefined = undefined   // 原始笔记数据(编辑模式)
@State isNew: boolean = true                 // 新建/编辑模式标记
@State title: string = ''                    // 标题输入
@State content: string = ''                  // 内容输入
@State selectedNb: number = 1                // 选中的笔记本 id
@State fav: boolean = false                  // 收藏状态
@State tagInput: string = ''                 // 标签输入框内容
@State tags: string[] = []                   // 已添加的标签列表

在这里插入图片描述

八个 @State 可以分为三组:

分组 变量 作用
模式控制 note, isNew 区分新建和编辑
内容输入 title, content, selectedNb, tags 笔记的四个核心字段
交互状态 fav, tagInput 收藏切换和标签输入

note 存原始数据、title/content 存编辑副本——为什么要分开? 因为编辑过程中用户可能修改了但没保存。如果直接修改 note.title,用户取消编辑后原始数据也被改了。用独立的 title/content 存编辑副本,只有点"保存"才写回 note

踩坑记录: 最初直接修改 this.note.title = v,用户改了标题但点返回没保存,首页列表里标题却变了——因为 note 是对象引用,直接修改会影响原始数据。改成独立的 title/content 变量后解决。

二、aboutToAppear 的模式判断

aboutToAppear(): void {
  const p = router.getParams() as Record<string, Object>
  if (p && p['noteId']) {
    this.note = getNoteById(p['noteId'] as number)
    if (this.note) {
      this.isNew = false
      this.title = this.note.title
      this.content = this.note.content
      this.selectedNb = this.note.notebookId
      this.fav = this.note.favorited
      const t: string[] = []
      for (let i: number = 0; i < this.note.tags.length; i++) { t.push(this.note.tags[i]) }
      this.tags = t
    }
  }
}

在这里插入图片描述

有没有 noteId 参数决定了是新建还是编辑。 从首页点"+"新建时不传 noteId,从笔记列表点某篇笔记进来时传 noteId。

编辑模式需要复制 tags 数组。 this.note.tags 是原始数据的引用,如果直接赋值 this.tags = this.note.tags,用户在编辑器里添加/删除标签会直接影响原始数据。用 for 循环创建副本保证原始数据安全。

为什么 tags 要复制而 title/content 不需要? 因为 tags 是数组——数组的引用复制会让两个变量指向同一内存,修改任何一个都会影响另一个。title/content 是基本类型(string),赋值时直接复制值,不存在引用问题。

两种模式的初始化对比

字段 新建模式 编辑模式
isNew true false
title ‘’(空) note.title(预填)
content ‘’(空) note.content(预填)
selectedNb 1(默认第一个笔记本) note.notebookId(原笔记本)
fav false note.favorited(原状态)
tags [](空数组) note.tags 的副本
导航栏标题 “新建笔记” “编辑笔记”
收藏/删除按钮 不显示 显示

三、笔记本选择器:Chip 样式的实现

Row() {
  ForEach(NOTEBOOKS, (nb: Notebook) => {
    Text(nb.icon + ' ' + nb.name).fontSize(12)
      .fontWeight(this.selectedNb === nb.id ? FontWeight.Bold : FontWeight.Regular)
      .fontColor(this.selectedNb === nb.id ? '#FFFFFF' : T2)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14)
      .backgroundColor(this.selectedNb === nb.id ? nb.color : '#F5F5FA')
      .margin({ right: 6 })
      .onClick(() => { this.selectedNb = nb.id })
  })
}

在这里插入图片描述

选中和未选中的双重样式切换:

状态 背景色 文字颜色 字重
选中 笔记本对应颜色(如 #FF6B6B) 白色 Bold
未选中 #F5F5FA(浅灰) T2(灰色) Regular

选中状态用笔记本自己的颜色——让用户一眼知道"这篇笔记属于哪个笔记本"。 4 个笔记本各有不同颜色(红/紫/绿/橙),选中时背景变成对应颜色,视觉上非常直观。

margin({ right: 6 }) 保证最后一个笔记本右侧也有间距。 如果用 Row({ space: 6 }),最后一个元素右侧没有间距。手动加 margin 更可控。

踩坑记录: 最初笔记本选择器没有 fontWeight 区分,选中和未选中只有颜色差异。在小屏手机上颜色差异不明显,用户反馈"不知道选了哪个"。加了 Bold 字重后视觉区分更清晰。

四、标题和内容的输入设计

标题:单行 TextInput

TextInput({ placeholder: '笔记标题', text: this.title })
  .fontSize(20).fontWeight(FontWeight.Bold)
  .backgroundColor('transparent').width('100%')
  .padding({ left: 16, right: 16 })
  .onChange((v: string) => { this.title = v })

标题用 TextInput(单行输入)——因为标题通常只有一行。 fontSize 20 + Bold 让标题在视觉上最突出。backgroundColor('transparent') 让输入框融入白色背景,用户感知不到"这是一个输入框",只看到文字。

内容:全高 TextArea

TextArea({ placeholder: '开始记录你的想法...', text: this.content })
  .fontSize(15).backgroundColor('transparent')
  .width('100%').padding({ left: 16, right: 16 }).layoutWeight(1)
  .onChange((v: string) => { this.content = v })

内容用 TextArea(多行输入)——因为笔记内容可能很长。 layoutWeight(1) 让 TextArea 占据标题和标签之间的所有剩余空间,用户有最大的输入区域。

TextArea 和 TextInput 的选择标准:

组件 行数 适用场景
TextInput 单行 标题、搜索框、标签输入
TextArea 多行 笔记内容、长文本描述

分隔线用 Divider 而不是 Border。 Divider().strokeWidth(0.5).color('#F0EEF4') 在标题和内容之间画一条细线。比用 border-bottom 更简洁——不需要计算 padding 和宽度。

五、标签管理:添加与删除

标签展示区

Row({ space: 6 }) {
  ForEach(this.tags, (t: string, idx: number) => {
    Row() {
      Text('#' + t).fontSize(11).fontColor(A)
      Text(' ×').fontSize(11).fontColor(T3)
    }.padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .backgroundColor(A + '12').borderRadius(10)
    .onClick(() => { this.removeTag(idx) })
  })
}

标签用 # 前缀——像社交媒体的 hashtag。 紫色文字 + 浅紫背景,和主色保持一致。点击整个标签行触发删除——不需要单独的删除按钮。

idx 是 ForEach 的第二个参数——当前项的索引。 removeTag(idx) 按索引删除,不需要查找标签内容。

标签输入区

Row() {
  TextInput({ placeholder: '添加标签...', text: this.tagInput })
    .layoutWeight(1).height(32).fontSize(13)
    .backgroundColor('#F5F5FA').borderRadius(16).padding({ left: 12 })
    .onChange((v: string) => { this.tagInput = v })
    .onSubmit(() => { this.addTag() })
  Text('添加').fontSize(12).fontWeight(FontWeight.Bold).fontColor(A)
    .margin({ left: 8 }).onClick(() => { this.addTag() })
}

两种添加方式: 点"添加"按钮,或者按键盘确认键。onSubmitonClick 都调 addTag(),用户习惯哪种操作都能添加。

踩坑记录: 最初只写了 onClick 没写 onSubmit,用户在输入标签后习惯按回车确认,但按回车没反应。加了 onSubmit 后体验好很多。

addTag 的防御性检查

private addTag(): void {
  if (this.tagInput.trim().length === 0) return
  const t: string[] = []
  for (let i: number = 0; i < this.tags.length; i++) { t.push(this.tags[i]) }
  t.push(this.tagInput.trim())
  this.tags = t
  this.tagInput = ''
}

三个关键步骤:

  1. 空值检查trim().length === 0 防止添加空白标签
  2. 复制数组 — 创建新数组 t,避免直接修改 this.tags 引用
  3. 清空输入this.tagInput = '' 让输入框恢复空白

为什么 addTag 和 removeTag 都要复制数组? 因为 @State 要求替换引用才能触发刷新。直接 this.tags.push(...) 修改原数组不触发刷新。

removeTag 的实现

private removeTag(idx: number): void {
  const t: string[] = []
  for (let i: number = 0; i < this.tags.length; i++) {
    if (i !== idx) t.push(this.tags[i])
  }
  this.tags = t
}

按索引过滤——遍历 tags,跳过指定索引,创建新数组。和菜谱 App 的 removeFromShop 逻辑完全一致。

六、保存逻辑:新建与编辑的分支处理

private save(): void {
  if (this.title.trim().length === 0) return
  if (this.isNew) {
    addNote(this.title.trim(), this.content, this.selectedNb, this.tags)
  } else if (this.note) {
    this.note.title = this.title.trim()
    this.note.content = this.content
    this.note.notebookId = this.selectedNb
    this.note.tags = this.tags
    this.note.updatedAt = '2026-07-15'
  }
  router.back()
}

新建和编辑走不同的保存路径:

模式 保存方式 数据变化
新建 addNote() 创建新对象插入 _notes 数组 新增一条笔记,noteCount+1
编辑 直接修改 this.note 的属性 更新已有笔记,updatedAt 改为当天

新建用 addNote 函数(数据层操作),编辑直接修改对象(引用操作)。 为什么编辑不用函数?因为编辑是修改已有对象的属性——this.note 是 _notes 数组里某个元素的引用,修改它就等于修改了 _notes 里的数据。

this.title.trim().length === 0 检查——标题不能为空。 如果用户没输入标题就点保存,save 直接 return,不执行任何操作。内容可以为空(用户可能只想记一个标题),但标题不能为空。

踩坑记录: 最初没检查标题为空,用户保存了一篇标题为空的笔记,首页列表里显示一个空标题卡片。加了检查后,空标题保存时静默失败——没有错误提示,但数据不会保存。更好的做法是弹一个 Toast 提示"标题不能为空"。

七、删除与收藏的交互

// 导航栏:编辑模式显示收藏和删除按钮
if (!this.isNew) {
  Text(this.fav ? '❤️' : '🤍').fontSize(20)
    .onClick(() => { this.toggleFav() }).margin({ right: 8 })
  Text('🗑').fontSize(18)
    .onClick(() => { if (this.note) { deleteNote(this.note.id); router.back() } })
    .margin({ right: 8 })
}

// 收藏切换
private toggleFav(): void {
  if (this.note) { this.fav = toggleFav(this.note.id) }
}

新建模式不显示收藏和删除按钮——因为还没保存,没有笔记可以收藏或删除。 if (!this.isNew) 控制按钮的显隐。

删除后直接 router.back() 删除完不需要等用户确认——因为这是一个轻量级的知识库 App,不是重要数据管理工具。真实 App 应该加确认弹窗。

收藏切换的返回值设计。 toggleFav 返回 boolean,直接赋值 this.fav = toggleFav(...)。和菜谱 App 的收藏切换逻辑一致。

八、整体嵌套结构

Column                                     页面容器
  ├→ Row (导航栏)                           返回 + 标题 + 操作按钮
  └→ Column (白色内容区)                    白色背景,包裹所有输入区
       ├→ Row (笔记本选择器)                横向排列 4 个 Chip
       ├→ TextInput (标题)                  单行输入,20px 粗体
       ├→ Divider                           分隔线
       ├→ TextArea (内容)                   多行输入,layoutWeight(1) 全高
       └→ Column (标签区)                   白色背景 + 顶部边框
            ├→ Row (已有标签)                标签 Chip,点击删除
            └→ Row (标签输入)                TextInput + 添加按钮

标签区为什么在底部而不是标题旁边? 因为标签数量不确定——0 个标签时不占空间,5 个标签时占较多空间。放在底部避免了标签区的高度变化影响标题和内容的布局稳定性。

标签区的白色背景 + 顶部边框。 backgroundColor('#FFFFFF').border({ width: { top: 0.5 }, color: '#F0EEF4' }) 让标签区和上方的 TextArea 视觉上分开——TextArea 在白色背景上,标签区也在白色背景上,但标签区多了一条顶部细线作为分隔。

九、数据流向图

用户输入 title/content/tags
         ↓
    @State 变量接收
         ↓
    点击"保存"
         ↓
  ┌──────┴──────┐
  新建模式      编辑模式
  addNote()    this.note.xxx = xxx
  (插入新对象)  (修改引用属性)
       ↓            ↓
    _notes 数组变化
       ↓
    router.back()
       ↓
  首页 onPageShow → refresh()
       ↓
  列表更新显示新笔记

新建和编辑的数据流差异: 新建走 addNote → 修改 _notes → 返回首页 → refresh → 列表更新。编辑直接修改 note 引用 → 返回首页 → 列表显示修改后的内容。两种方式最终都能让首页看到最新数据。

Logo

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

更多推荐