鸿蒙原生应用实战(三):笔记详情与编辑页面的路由与CRUD
鸿蒙原生应用实战(三):笔记详情与编辑页面的路由与CRUD
系列目录:
- 第一篇:项目搭建与页面架构设计
- 第二篇:首页开发与全局数据流设计
- 第三篇:笔记详情与编辑页面的路由与CRUD ← 当前
- 第四篇:分类浏览与个人中心的多维数据展示
- 第五篇:构建调试、异常处理与HAP发布
一、前言
上一篇我们完成了首页开发,实现了笔记列表展示、搜索筛选和数据流设计。本篇将开发两个核心交互页面——笔记详情页(NotePage)和编辑页(EditPage),涵盖:
- 页面间路由参数传递
- 笔记 CRUD(增删改查)
- 编辑态/新建态的双模式切换
- 删除确认弹窗(bindContentCover)
- API 23 下 router 的正确使用方法
二、鸿蒙路由机制详解
2.1 router 的正确导入
在 API 23 中,路由模块必须从@ohos.router导入:
importrouterfrom'@ohos.router';⚠️不要从@kit.AbilityKit导入——API 23 版本中该路径不导出 router。
2.2 页面跳转与传参
// 跳转并传参router.pushUrl({url:'pages/NotePage',params:{noteId:note.id}});// 无参数跳转router.pushUrl({url:'pages/EditPage'});2.3 接收参数(含空值保护)
接收参数时使用router.getParams(),必须处理 null 情况:
aboutToAppear():void{// 关键:类型声明为 | null,加 if 保护letparams:Record<string,Object>|null=router.getParams()asRecord<string,Object>|null;if(params){letnoteId:number|undefined=params['noteId']asnumber|undefined;// ... 处理逻辑}}这是最容易出错的点!如果不加| null判断,当从无参数跳转进入页面时,router.getParams()返回 null,访问params['noteId']会直接崩溃。
2.4 页面返回
router.back();// 返回上一页关于弃用警告:在 API 23 SDK 中,pushUrl、getParams、back都会显示 deprecation warning,但功能正常可用。这些 API 要到更高版本才移除,目前无需处理。
三、笔记详情页 (NotePage)
3.1 页面结构
Column ├── 顶部导航栏 (Row) │ ├── 返回按钮 (带点击) │ ├── 标题 "笔记详情" │ └── "编辑" 文字按钮 ├── Scroll │ └── Column (内容区) │ ├── 分类标签 (带颜色边框) │ ├── 标题 (大字体粗体) │ ├── 日期 │ ├── Divider 分隔线 │ └── 正文 (lineHeight 26) └── 底部删除按钮 (Row → Button) └── bindContentCover 删除确认弹窗3.2 加载笔记数据
通过路由参数noteId从全局 AppStorage 中查找对应笔记:
@Statenote:Note={id:0,title:'',content:'',category:'',date:''};aboutToAppear():void{letparams:Record<string,Object>=router.getParams()asRecord<string,Object>;letnoteId:number=params['noteId']asnumber;this.loadNote(noteId);}loadNote(noteId:number):void{letstored:string|undefined=AppStorage.get<string>('notes');if(stored){letallNotes:Note[]=JSON.parse(stored)asNote[];letfound:Note|undefined=allNotes.find((n:Note)=>n.id===noteId);if(found){this.note=found;}}}这里find方法返回Note | undefined,如果数据被删除或不存在,页面会显示空内容。实际生产环境可以加上错误提示。
3.3 分类颜色标签
每个分类有不同的颜色标识:
getCategoryColor(category:string):ResourceColor{letcolorMap:Record<string,ResourceColor>={'工作':'#007AFF',// 蓝色'学习':'#34C759',// 绿色'生活':'#FF9500',// 橙色'灵感':'#AF52DE'// 紫色};returncolorMap[category]||'#999999';}应用在 UI 上:
Text(this.note.category).fontColor(this.getCategoryColor(this.note.category)).border({width:1,color:this.getCategoryColor(this.note.category)}).borderRadius(6).alignSelf(ItemAlign.Start)3.4 删除确认弹窗
使用bindContentCover实现底部弹出确认对话框:
@StateshowDeleteDialog:boolean=false;// 在 Column 上绑定.bindContentCover($$this.showDeleteDialog,this.DeleteDialogBuilder())// Builder 定义弹窗内容@BuilderDeleteDialogBuilder(){Column(){Text('确认删除').fontSize($r('app.float.subtitle_font_size')).fontWeight(FontWeight.Bold).margin({bottom:12})Text('确定要删除这条笔记吗?').fontColor($r('app.color.text_secondary')).margin({bottom:24})Row(){Button('取消').onClick(()=>{this.showDeleteDialog=false;})Blank().width(12)Button('确定').backgroundColor($r('app.color.delete_red')).onClick(()=>{this.showDeleteDialog=false;this.deleteNote();})}.width('100%')}.padding(24).backgroundColor($r('app.color.card_bg')).borderRadius(16).width('80%')}⚠️ 注意$$this.showDeleteDialog的双向绑定语法——$$前缀实现状态变量和弹窗显示状态的同步。
四、编辑页面 (EditPage)
4.1 双模式设计
编辑页面同时处理新建笔记和编辑已有笔记两种场景:
| 场景 | 路由参数 | 页面标题 | 保存行为 |
|---|---|---|---|
| 新建 | 无(或 noteId=0) | “新建笔记” | 生成新 id,插入列表头部 |
| 编辑 | noteId=目标ID | “编辑笔记” | 覆盖原数据 |
@StateisEditing:boolean=false;@StateeditNoteId:number=0;aboutToAppear():void{letparams:Record<string,Object>|null=router.getParams()asRecord<string,Object>|null;if(params){letnoteId:number|undefined=params['noteId']asnumber|undefined;if(noteId!==undefined&¬eId>0){this.isEditing=true;this.editNoteId=noteId;// 从 AppStorage 加载已有数据letstored:string|undefined=AppStorage.get<string>('notes');if(stored){letallNotes:Note[]=JSON.parse(stored)asNote[];letfound:Note|undefined=allNotes.find((n:Note)=>n.id===noteId);if(found){this.title=found.title;this.content=found.content;this.selectedCategory=found.category;}}}}}4.2 页面结构
Column ├── 顶部导航栏 │ ├── "取消" 文字按钮 → router.back() │ ├── "新建笔记" 或 "编辑笔记" 标题 │ └── "保存" 文字按钮 → saveNote() ├── 标题输入框 (TextInput) ├── 分类选择器 (Row) │ ├── "分类" 标签 │ └── [工作] [学习] [生活] [灵感] 按钮组 ├── Divider └── 正文输入 (TextArea) ← layoutWeight(1) 撑满剩余空间4.3 分类选择器实现
分类采用按钮组样式,单选的交互模式:
privatecategoryOptions:CategoryOption[]=[{label:'工作',value:'工作'},{label:'学习',value:'学习'},{label:'生活',value:'生活'},{label:'灵感',value:'灵感'}];Row(){Text('分类').fontColor($r('app.color.text_secondary'))Blank()ForEach(this.categoryOptions,(option:CategoryOption)=>{Text(option.label).fontColor(this.selectedCategory===option.value?Color.White:$r('app.color.text_secondary')).backgroundColor(this.selectedCategory===option.value?$r('app.color.primary'):$r('app.color.card_bg')).borderRadius(14).onClick(()=>{this.selectedCategory=option.value;})},(option:CategoryOption)=>option.value)}4.4 保存逻辑 (CRUD)
saveNote():void{// 标题为空时不保存if(this.title.trim().length===0){return;}letstored:string|undefined=AppStorage.get<string>('notes');letallNotes:Note[]=stored?JSON.parse(stored)asNote[]:[];// 生成当前日期字符串letnow:Date=newDate();letdateStr:string=now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0');if(this.isEditing){// UPDATE: 查找并替换letindex:number=allNotes.findIndex((n:Note)=>n.id===this.editNoteId);if(index!==-1){allNotes[index]={id:this.editNoteId,title:this.title.trim(),content:this.content.trim(),category:this.selectedCategory,date:allNotes[index].date// 保留原日期};}}else{// CREATE: 生成新ID,插入列表头部letmaxId:number=0;for(letnoteofallNotes){if(note.id>maxId){maxId=note.id;}}letnewNote:Note={id:maxId+1,title:this.title.trim(),content:this.content.trim(),category:this.selectedCategory,date:dateStr};allNotes=[newNote,...allNotes];// 新笔记在顶部}// 持久化到 AppStorageAppStorage.setOrCreate<string>('notes',JSON.stringify(allNotes));router.back();// 返回上一页}4.5 删除逻辑
deleteNote():void{letstored:string|undefined=AppStorage.get<string>('notes');if(stored){letallNotes:Note[]=JSON.parse(stored)asNote[];// DELETE: 过滤掉目标IDallNotes=allNotes.filter((n:Note)=>n.id!==this.note.id);AppStorage.setOrCreate<string>('notes',JSON.stringify(allNotes));}router.back();// 返回上一页}五、ArkTS 对象字面量陷阱
这是本项目遇到的一个典型编译错误:
Object literal must correspond to some explicitly declared class or interface (arkts-no-untyped-obj-literals)以下写法不允许:
// ❌ 编译错误:@Builder 参数类型不能是对象字面量@BuilderStatBadge(params:{label:string;value:string;color:string;}){}// ❌ 编译错误:调用 @Builder 时不能直接传对象字面量this.StatBadge({label:'工作',value:'3',color:'#007AFF'});正确写法:
// ✅ 方案1:定义接口interfaceStatBadgeParams{label:string;value:string;color:string;}@BuilderStatBadge(params:StatBadgeParams){}// ✅ 方案2:使用独立参数@BuilderStatBadge(label:string,value:string,color:string){}this.StatBadge('工作','3','#007AFF');// 直接传值六、本篇总结
本篇我们完成了:
- ✅ 鸿蒙路由机制:pushUrl传参、getParams接收(含空值保护)、back返回
- ✅ 笔记详情页:数据加载、分类颜色标签、删除确认弹窗
- ✅ 编辑页:新建/编辑双模式、分类选择器、标题+正文输入
- ✅ 完整 CRUD:创建(id递增+头部插入)、读取、更新、删除
- ✅ @Builder 参数类型的 ArkTS 严格模式避坑
下一篇将开发分类浏览页和个人中心页,展示更丰富的数据可视化内容。
