HarmonyOS今天吃什么——管理页面的增删改查与收藏设计
管理页是美食转盘的数据后台——在这里新增食物、删除食物、切换收藏。界面分三个区域:顶部输入框用于新增,中间分类标签用于筛选,底部列表展示所有食物。每个食物卡片右侧有两个操作按钮——❤️ 切换收藏,🗑️ 删除。这个页面的代码量不大,但输入验证、分类联动、列表渲染都有值得拆解的细节。
完整效果
一、管理页的三个区块
从截图看,管理页从上到下分三个区块:
| 区块 | 内容 | 功能 |
|---|---|---|
| 顶部 | “美食管理” 标题 + 输入框 + 添加按钮 | 新增食物 |
| 中间 | 分类标签横滑(全部/中餐/日料/韩餐/甜品/饮品) | 筛选列表 |
| 底部 | 食物列表(emoji + 名称 + 收藏 + 删除) | 浏览和操作 |
三个区块各司其职——输入、筛选、展示完全分离。用户的操作流程是:选分类→输入名称→点添加→列表更新。
二、输入区的实现
输入框结构
Row(){TextInput({placeholder:'菜名'}).layoutWeight(2).height(40).fontSize(FontSize.sm).onChange((v:string)=>this.newName=v)TextInput({placeholder:'emoji'}).width(60).height(40).fontSize(FontSize.sm).onChange((v:string)=>this.newEmoji=v)Text('添加').fontSize(FontSize.sm).fontColor(this.newName?this.gc().primary:this.gc().textSecondary).margin({left:Spacing.sm}).onClick(()=>this.addFood())}.width('100%').padding({left:Spacing.md,right:Spacing.md,bottom:Spacing.sm})两个 TextInput 的分工
| 输入框 | 占比 | 用途 | 必填 |
|---|---|---|---|
| 菜名 | layoutWeight(2) | 输入食物名称 | 是 |
| emoji | width(60) | 输入表情符号 | 否(默认 🍽️) |
菜名框占 2/3 宽度,emoji 框固定 60px——因为菜名通常比 emoji 长。布局比例跟着内容长度走。
添加按钮的状态
.fontColor(this.newName?this.gc().primary:this.gc().textSecondary)有输入内容时按钮变主色(可点击),无内容时变灰色(不可用)。不需要禁用 onClick——在 addFood 方法开头做校验即可。颜色变化只是视觉提示。
addFood 方法
privateasyncaddFood():Promise<void>{if(!this.newName.trim()||!this.db)return;awaitthis.db.addFood(createFoodItem(this.newName.trim(),this.newCat,this.newEmoji||'🍽️'));this.newName='';this.newEmoji='';this.load();}三步操作:校验→写入→清空。写入后立即清空输入框——用户可以连续添加多个食物,不需要手动删除上一次的输入。
输入校验
if(!this.newName.trim()||!this.db)return;trim() 去除首尾空格——防止用户输入纯空格。this.db的空值检查防止数据库未初始化时崩溃。
三、分类筛选的设计
分类标签的结构
Scroll(){Row(){ForEach(CATEGORIES.slice(1),(c:FoodCategory)=>{Text(c.emoji+' '+c.name).fontSize(FontSize.xs).height(28).fontColor(this.newCat===c.key?'#FFFFFF':this.gc().text).backgroundColor(this.newCat===c.key?this.gc().primary:this.gc().surface).borderRadius(BorderRadius.full).border({width:1,color:this.newCat===c.key?this.gc().primary:this.gc().border}).padding({left:Spacing.sm,right:Spacing.sm}).onClick(()=>this.newCat=c.key).margin({right:Spacing.xs})})}}.scrollable(ScrollDirection.Horizontal)CATEGORIES.slice(1) 的含义
CATEGORIES.slice(1)// 跳过第一个"全部"分类管理页的分类标签不含"全部"——因为新增食物必须选择具体分类。"全部"只在转盘 Tab 用于筛选,在管理页没有意义——你不能给一个食物分配"全部"分类。
和转盘 Tab 的分类标签对比
| 特性 | 转盘 Tab | 管理 Tab |
|---|---|---|
| 数据源 | CATEGORIES.slice(0, 6) | CATEGORIES.slice(1) |
| 含"全部" | 是 | 否 |
| 选中态行为 | 筛选转盘范围 | 决定新增食物的分类 |
| 变量 | pickCat | newCat |
同一个 CATEGORIES 常量,两个 Tab 用不同的 slice 范围。转盘需要"全部"来不限分类,管理页不需要。
分类筛选的视觉设计
选中态:白色文字 + 主色背景 + 主色边框。未选中:深色文字 + surface 背景 + 边框色边框。这是贯穿整个 App 的 Chip 选中模式——和转盘 Tab 的筛选标签完全一致。
四、食物列表的渲染
列表结构
List(){ForEach(this.foods,(f:FoodItem)=>{ListItem(){Row(){Text(f.emoji).fontSize(24)Text(f.name).fontSize(FontSize.md).fontColor(this.gc().text).margin({left:Spacing.sm})if(f.isFavorite)Text('⭐').fontSize(14)Blank()Text('❤️').fontSize(18).fontColor(f.isFavorite?'#EF4444':this.gc().border).onClick(()=>this.fav(f.id)).margin({right:Spacing.sm})Text('🗑️').fontSize(16).onClick(()=>this.del(f.id))}.width('100%').padding(Spacing.md).backgroundColor(this.gc().surface).borderRadius(BorderRadius.md).margin({bottom:Spacing.sm,left:Spacing.md,right:Spacing.md})}})}每行的元素排列
[emoji] [名称] [⭐收藏标记] ── Blank ── [❤️收藏按钮] [🗑️删除按钮]左侧是信息(emoji + 名称),右侧是操作(收藏 + 删除)。中间用 Blank 撑开——信息左对齐,操作右对齐。
收藏标记的两种显示
| 状态 | ⭐ 标记 | ❤️ 按钮颜色 |
|---|---|---|
| 已收藏 | 显示 | 红色 (#EF4444) |
| 未收藏 | 不显示 | 边框色 (灰色) |
⭐ 是静态标记——只在已收藏时显示。❤️ 是交互按钮——点击切换收藏状态。两个元素配合:⭐ 告诉用户"这个已收藏",❤️ 让用户操作"取消/添加收藏"。
收藏按钮的颜色逻辑
.fontColor(f.isFavorite?'#EF4444':this.gc().border)已收藏时红色,未收藏时灰色。红色 = “激活状态”,灰色 = “未激活”。用户一眼就能区分哪些食物已收藏。
删除按钮的视觉处理
Text('🗑️').fontSize(16).onClick(()=>this.del(f.id))删除按钮没有特殊颜色——用默认的 🗑️ emoji。这是刻意的设计——如果删除按钮用红色,会和收藏按钮冲突,用户可能误操作。用 emoji 保持中性——不引导用户删除。
删除操作
privateasyncdel(id:string):Promise<void>{if(this.db){awaitthis.db.deleteFood(id);this.load();}}直接删除,没有确认弹窗。这是因为:1)删除操作可逆——用户可以重新添加;2)历史记录不受影响——删除食物不删除历史。风险低,不需要确认。
五、空状态的设计
if(this.foods.length===0){Column(){Text('🍽️').fontSize(48)Text('还没有美食').fontSize(FontSize.md).fontColor(this.gc().textSecondary)}.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)}48px 餐盘 emoji + “还没有美食” 文字。没有引导按钮——因为输入框就在上方,用户不需要跳转就能添加。
和转盘 Tab 空状态的对比
| 特性 | 转盘 Tab | 管理 Tab |
|---|---|---|
| emoji | 🎰 80px | 🍽️ 48px |
| 文字 | “点击下方按钮” | “还没有美食” |
| 操作引导 | 有按钮 | 无按钮 |
管理页不需要按钮——输入框本身就是操作入口。转盘 Tab 需要按钮是因为"开始"按钮在页面底部,空状态时用户可能看不到。
六、收藏功能的完整链路
用户操作流程
用户点击 ❤️ → fav(id) 被调用 → db.toggleFav(id) 更新内存 → save() 持久化到 Preferences → load() 刷新列表 → UI 重新渲染(⭐ 出现/消失,❤️ 颜色变化)toggleFav 的实现
asynctoggleFav(id:string):Promise<void>{for(leti=0;i<this.foods.length;i++){if(this.foods[i].id===id){this.foods[i].isFavorite=!this.foods[i].isFavoritebreak}}awaitthis.save()}直接修改 isFavorite 属性——布尔值取反。不需要创建新对象——只改一个字段。
收藏状态的持久化
收藏状态存在食物对象的 isFavorite 字段里——和其他字段一起序列化到 Preferences。不需要单独的收藏表——收藏是食物的一个属性,不是独立数据。
七、列表的布局参数
每行的间距
.padding(Spacing.md)// 内边距 16px.margin({bottom:Spacing.sm,left:Spacing.md,right:Spacing.md})上下间距不对称——padding 提供内边距,margin-bottom 提供行间距。实际行间距 = padding(16) + marginBottom(8) = 24px。左右 margin 让列表不贴边。
列表的滚动配置
.width('100%').layoutWeight(1).scrollBar(BarState.Off)scrollBar(BarState.Off) 隐藏滚动条——保持界面整洁。列表用 layoutWeight(1) 占据剩余空间——输入框和分类标签在上方固定,列表在下方滚动。
八、@State 变量的分工
| 变量 | 作用 | 触发 UI 刷新 |
|---|---|---|
| foods | 食物列表 | load() |
| pickCat | 分类筛选(转盘用) | load() |
| newCat | 新增食物的分类 | 分类标签高亮 |
| newName | 新增食物的名称 | 按钮颜色变化 |
| newEmoji | 新增食物的 emoji | 无(只在添加时读取) |
newName 的双重作用
newName 既控制按钮颜色,又是新增食物的参数。用户每输入一个字符,按钮颜色可能从灰色变为主色——这是实时响应的 UI 反馈。
newEmoji 的默认值处理
this.newEmoji||'🍽️'用户不输入 emoji 时用 🍽️ 兜底——保证每个食物都有表情。这个逻辑在 addFood 方法里,不在 onChange 里——不干扰用户的输入体验。
九、输入框的交互细节
TextInput 的 onChange 时机
TextInput({placeholder:'菜名'}).onChange((v:string)=>this.newName=v)每次输入变化都更新 @State——实时同步。不是"失去焦点时才更新"——用户边输入边看到按钮颜色变化。
输入框的样式
.height(40).fontSize(FontSize.sm)40px 高度 + 12px 字号——紧凑型输入框。管理页的输入框不需要太大——它不是主要内容,只是一个快速添加入口。
emoji 输入框的宽度
.width(60)60px 固定宽度——emoji 通常只有 1-2 个字符。不需要 layoutWeight——emoji 输入框的长度可预测。
十、整体嵌套结构
Column ManageTab 容器 ├→ Text ("美食管理") 标题 ├→ Row (输入区) TextInput×2 + 添加按钮 ├→ Scroll + Row (分类标签) 横向滚动的分类选择 └→ 条件渲染 ├→ 空状态 Column 无食物时 └→ List + ForEach 有食物时 └→ ListItem + Row 每行:emoji + 名称 + 收藏 + 删除嵌套深度 4 层——List → ListItem → Row → Text。这是 ArkTS 列表的标准嵌套层级——每一层都有明确职责。
