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 方法
private async addFood(): Promise<void> {
if (!this.newName.trim() || !this.db) return;
await this.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 保持中性——不引导用户删除。
删除操作
private async del(id: string): Promise<void> {
if (this.db) {
await this.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 的实现
async toggleFav(id: string): Promise<void> {
for (let i = 0; i < this.foods.length; i++) {
if (this.foods[i].id === id) {
this.foods[i].isFavorite = !this.foods[i].isFavorite
break
}
}
await this.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 列表的标准嵌套层级——每一层都有明确职责。
更多推荐


所有评论(0)