管理页是美食转盘的数据后台——在这里新增食物、删除食物、切换收藏。界面分三个区域:顶部输入框用于新增,中间分类标签用于筛选,底部列表展示所有食物。每个食物卡片右侧有两个操作按钮——❤️ 切换收藏,🗑️ 删除。这个页面的代码量不大,但输入验证、分类联动、列表渲染都有值得拆解的细节。

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

一、管理页的三个区块

从截图看,管理页从上到下分三个区块:

区块内容功能
顶部“美食管理” 标题 + 输入框 + 添加按钮新增食物
中间分类标签横滑(全部/中餐/日料/韩餐/甜品/饮品)筛选列表
底部食物列表(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)输入食物名称
emojiwidth(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)
含"全部"
选中态行为筛选转盘范围决定新增食物的分类
变量pickCatnewCat

同一个 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 列表的标准嵌套层级——每一层都有明确职责。

Logo

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

更多推荐