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

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

一、管理页的三个区块

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

区块 内容 功能
顶部 “美食管理” 标题 + 输入框 + 添加按钮 新增食物
中间 分类标签横滑(全部/中餐/日料/韩餐/甜品/饮品) 筛选列表
底部 食物列表(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 列表的标准嵌套层级——每一层都有明确职责。

Logo

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

更多推荐