一、HarmonyOS与ArkTS声明式开发范式概述

鸿蒙操作系统(HarmonyOS)作为华为推出的分布式全场景智慧操作系统,自诞生以来就承载着打通多终端设备生态的重要使命。在HarmonyOS 6.1.1版本中,ArkTS作为应用开发的核心语言,已经发展出一套成熟且高效的声明式UI开发范式。ArkTS在TypeScript的基础上进行了深度定制与扩展,不仅保留了TypeScript的静态类型检查优势,还引入了专为鸿蒙生态设计的装饰器系统、状态管理机制以及高性能渲染管线。这种声明式的开发方式让开发者能够以更直观、更简洁的代码描述界面结构与交互逻辑,大幅提升了开发效率与代码可维护性。

在HarmonyOS ArkTS API 24中,声明式UI的核心思想是"数据驱动界面"。开发者只需声明界面的初始状态以及状态变化时界面应该如何响应,框架便会自动处理界面更新的细节。这一范式通过@State@Prop@Link@Observed@ObjectLink等一系列装饰器实现了精细化的状态管理。当被装饰的状态变量发生变化时,框架会自动触发与之绑定的UI组件重新渲染,从而保证界面与数据的实时同步。这种机制彻底改变了传统命令式UI开发中手动操作DOM节点的繁琐模式,让开发者能够将更多精力放在业务逻辑本身。

除了状态管理之外,ArkTS的组件化开发能力也是其一大亮点。通过@Component装饰器,开发者可以将复杂的界面拆分为多个独立、可复用的组件单元。每个组件拥有自己的内部状态、构建逻辑和生命周期回调,组件之间通过属性传递和事件回调进行通信。这种高内聚低耦合的架构设计,使得大型应用的代码组织更加清晰,团队协作更加高效。在本文即将深入剖析的烘焙社区应用中,我们将看到如何运用这些ArkTS核心特性,构建一个功能完整、交互丰富的移动端社区应用。

此外,HarmonyOS的渲染引擎针对声明式UI进行了深度优化。基于方舟编译器和高效的双层渲染管线,ArkTS应用能够实现接近原生的流畅度。无论是复杂的列表滚动、弹窗动画,还是实时的数据更新与界面刷新,都能在保证60帧每秒的基础上流畅运行。在API 24版本中,框架还进一步增强了ForEach列表渲染的性能,优化了@Observed对象的观察粒度,使得大型数据集合的变更通知更加精准高效。这些底层能力的提升,为开发者构建高质量的用户体验奠定了坚实基础。

二、静态配置体系:颜色、字号与特效的统一管理

2.1 颜色配置体系

在任何UI应用中,统一的色彩管理是保证视觉一致性的基石。本应用采用了烘焙暖色系作为整体视觉基调,通过接口定义和常量实例化的方式,构建了一套完整的颜色配置体系。

interface ColorPalette {
  primary: string       // 焦糖橙(主色)
  primaryDeep: string   // 深焦糖
  bg: string            // 奶油白背景
  card: string          // 卡片白
  text: string          // 主文本(巧克力棕)
  textSub: string       // 次要文本(浅棕)
  accent: string        // 莓果粉点缀
  warn: string          // 警告红
  ok: string            // 成功绿
  line: string          // 分割线(奶咖色)
  tagBg: string         // 标签背景(浅焦糖)
  dark: string          // 深巧克力
  cream: string         // 奶油黄
  mask: string          // 弹框遮罩
}

const COLORS: ColorPalette = {
  primary: '#E65100',
  primaryDeep: '#BF360C',
  bg: '#FFF8F0',
  card: '#FFFFFF',
  text: '#3E2723',
  textSub: '#8D6E63',
  accent: '#FF7043',
  warn: '#E53935',
  ok: '#43A047',
  line: '#F0E0D0',
  tagBg: '#FFF3E0',
  dark: '#4E342E',
  cream: '#FFE0B2',
  mask: 'rgba(62,39,35,0.55)'
}

这里首先定义了ColorPalette接口,将应用所需的所有颜色属性以类型安全的方式进行声明。接口中包含主色、深色、背景色、卡片色、文本色、次要文本色、点缀色、警告色、成功色、分割线色、标签背景色、深色、奶油色和遮罩色共计十四个颜色属性。每个属性都配有清晰的中文注释,说明其在界面中的用途。随后通过const COLORS实例化这个接口,为每个颜色赋予具体的十六进制色值。

这种设计模式的优势在于高度的集中管理能力。当需要调整应用的视觉主题时,开发者只需修改这一处配置,整个应用的所有组件都会自动应用新的颜色方案。同时,由于TypeScript的静态类型检查机制,如果在代码中错误地引用了不存在的颜色属性,编译阶段就会报错,有效避免了运行时的颜色缺失问题。颜色值的选取也经过了精心设计——焦糖橙#E65100作为主色调传递温暖烘焙的氛围感,奶油白#FFF8F0作为背景色营造柔和的视觉体验,巧克力棕#3E2723作为主文本色保证了良好的阅读对比度。

2.2 字号配置与特效配置

与颜色配置类似,应用同样通过接口定义和常量实例化的方式管理全局字号。

interface FontSet {
  xxl: number
  xl: number
  lg: number
  md: number
  sm: number
  xs: number
}

const FONTS: FontSet = {
  xxl: 26,
  xl: 20,
  lg: 17,
  md: 15,
  sm: 13,
  xs: 11
}

interface FxItem {
  e: string
  x: number
  speed: number
  size: number
}

const FX_EMOJIS: FxItem[] = [
  { e: '🧁', x: 22, speed: 2, size: 22 },
  { e: '🍞', x: 84, speed: 3, size: 26 },
  { e: '🥐', x: 146, speed: 2, size: 20 },
  { e: '🥧', x: 208, speed: 4, size: 24 },
  { e: '✨', x: 262, speed: 3, size: 18 },
  { e: '🧁', x: 318, speed: 2, size: 20 }
]

字号系统采用六级递进设计,从最大的xxl: 26到最小的xs: 11,覆盖了标题、正文、辅助文本等不同层级的排版需求。这种标准化的字号体系确保了界面文字的层次分明、节奏统一。在实际开发中,开发者只需引用FONTS.xlFONTS.sm等常量即可设置字号,无需在代码中反复硬编码数字值。

特效配置部分则定义了一个FxItem接口,包含emoji字符、水平位置、移动速度和字体大小四个属性。FX_EMOJIS数组中配置了六个烘焙主题的emoji表情,它们将在界面中以浮动动画的形式出现,为应用增添趣味性和沉浸感。每个emoji拥有不同的初始水平位置和移动速度,使得动画效果不会显得千篇一律。这种轻量级的特效层设计,在不影响交互性能的前提下,显著提升了应用的视觉表现力。

2.3 Tab栏与标签配置

应用采用了双层Tab导航结构,分别是内容Tab和主Tab,配合头部热门标签和各类筛选标签,构成了完整的导航体系。

interface TabItem {
  label: string
  icon: string
}

interface MainTabItem {
  label: string
  icon: string
  center: boolean
}

const CONTENT_TABS: TabItem[] = [
  { label: '笔记', icon: '📝' },
  { label: '配方', icon: '📖' },
  { label: '食材', icon: '🧺' },
  { label: '排行', icon: '🏆' },
  { label: '我的', icon: '👤' }
]

const MAIN_TABS: MainTabItem[] = [
  { label: '首页', icon: '🏠', center: false },
  { label: '配方', icon: '📖', center: false },
  { label: '发布', icon: '+', center: true },
  { label: '收藏', icon: '⭐', center: false },
  { label: '我的', icon: '👤', center: false }
]

const HOT_TAGS: TagItem[] = [
  { name: '可颂', icon: '🥐' },
  { name: '芝士蛋糕', icon: '🧀' },
  { name: '贝果', icon: '🥯' },
  { name: '免烤', icon: '❄️' },
  { name: '千层', icon: '🍰' },
  { name: '吐司', icon: '🍞' }
]

在这里插入图片描述
CONTENT_TABS定义了内容区域的五个标签页,分别对应笔记、配方、食材、排行和个人中心。MAIN_TABS定义了底部导航栏的五个入口,其中center: true的"发布"按钮被设计为突出的中心按钮,视觉上通过上浮和特殊背景色与其他导航项区分开来。HOT_TAGS则定义了头部区域展示的热门烘焙标签,用户点击这些标签可以快速筛选对应类型的笔记。

这种数据驱动的配置方式使得导航结构的调整变得异常灵活——增减Tab项或标签项只需修改数组内容,无需改动任何UI构建代码。MainTabItem接口中额外的center布尔字段巧妙地解决了中心按钮的特殊样式需求,体现了接口设计对业务场景的精准适配。

2.4 技能进度与厨房装备配置

个人中心页面展示了用户的烘焙技能进度和厨房装备清单,这些静态数据同样通过接口和常量进行管理。

interface StatItem {
  label: string
  percent: number
  color: string
}

const SKILLS: StatItem[] = [
  { label: '揉面出膜', percent: 92, color: '#E65100' },
  { label: '蛋白打发', percent: 85, color: '#FF7043' },
  { label: '裱花装饰', percent: 76, color: '#F4511E' },
  { label: '烘烤控温', percent: 88, color: '#BF360C' }
]

interface EquipItem {
  name: string
  icon: string
  note: string
}

const EQUIPS: EquipItem[] = [
  { name: '海氏C40平炉', icon: '🔥', note: '32L · 上下火独立控温' },
  { name: '柏翠厨师机', icon: '⚙️', note: 'PE4500 · 1500W直流电机' },
  { name: '卡士发酵箱', icon: '🌡', note: 'CF-100 · 精准控温控湿' },
  { name: '高精度电子秤', icon: '⚖️', note: '0.1g精度 · 称面粉神器' },
  { name: '三能金色烤盘', icon: '🍳', note: '40×30cm 不粘涂层' },
  { name: '硅胶揉面垫', icon: '🌀', note: '带刻度 · 防粘好清洗' }
]

在这里插入图片描述

StatItem接口定义了技能项的标签名、百分比和颜色,每项技能配有不同的焦糖色调,使得进度条在视觉上呈现出丰富的层次感。EquipItem接口则定义了装备名称、图标和规格说明,通过emoji图标和简洁的文字描述,将装备信息以卡片化的形式呈现。这些配置数据虽然属于静态内容,但通过接口类型的约束,保证了数据结构的规范性和可扩展性。

三、数据模型层:基于@Observed的响应式数据建模

3.1 烘焙笔记数据模型

数据模型是整个应用的核心基础。在ArkTS中,通过@Observed装饰器可以将普通的TypeScript类转化为可观察的响应式数据对象。当被观察对象的属性发生变化时,框架会自动通知所有引用了该对象的UI组件进行更新。

interface Note {
  id: number
  title: string
  author: string
  avatar: string
  recipe: string
  difficulty: number
  time: string
  temp: string
  result: string
  likes: number
  liked: boolean
  tags: string[]
  date: string
  steps: string[]
}

@Observed
class NoteItem implements Note {
  id: number = 0
  title: string = ''
  author: string = ''
  avatar: string = ''
  recipe: string = ''
  difficulty: number = 0
  time: string = ''
  temp: string = ''
  result: string = ''
  likes: number = 0
  liked: boolean = false
  tags: string[] = []
  date: string = ''
  steps: string[] = []

  constructor(o: Note) {
    this.id = o.id
    this.title = o.title
    this.author = o.author
    this.avatar = o.avatar
    this.recipe = o.recipe
    this.difficulty = o.difficulty
    this.time = o.time
    this.temp = o.temp
    this.result = o.result
    this.likes = o.likes
    this.liked = o.liked
    this.tags = o.tags
    this.date = o.date
    this.steps = o.steps
  }
}

在这里插入图片描述

这里首先定义了Note接口,声明了烘焙笔记所需的全部字段:唯一标识id、标题title、作者author、头像avatar、配方类型recipe、难度difficulty、烘烤时间time、烘烤温度temp、成果描述result、点赞数likes、是否已点赞liked、标签数组tags、日期date和步骤数组steps。随后定义了NoteItem类,使用@Observed装饰器标记为可观察对象。

值得注意的设计细节是,NoteItem类的所有属性都设置了默认初始值。这是ArkTS中@Observed类的常见实践——由于ArkTS的状态管理系统要求被观察对象在创建时就拥有完整的属性结构,为所有属性提供默认值可以避免因属性缺失而导致的运行时错误。构造函数接收一个Note接口类型的参数对象,将传入的属性值逐一赋值给类实例,这种模式既保证了类型安全,又提供了灵活的对象创建方式。

3.2 配方、食材与排行榜数据模型

除了笔记模型之外,应用还定义了配方、食材、排行榜和采购清单四个数据模型,每个都遵循相同的接口定义加@Observed类实现的模式。

interface Recipe {
  id: number
  name: string
  category: string
  difficulty: number
  servings: string
  time: string
  ingredients: string[]
  steps: string[]
  author: string
  likes: number
}

@Observed
class RecipeItem implements Recipe {
  id: number = 0
  name: string = ''
  category: string = ''
  difficulty: number = 0
  servings: string = ''
  time: string = ''
  ingredients: string[] = []
  steps: string[] = []
  author: string = ''
  likes: number = 0

  constructor(o: Recipe) {
    this.id = o.id
    this.name = o.name
    this.category = o.category
    this.difficulty = o.difficulty
    this.servings = o.servings
    this.time = o.time
    this.ingredients = o.ingredients
    this.steps = o.steps
    this.author = o.author
    this.likes = o.likes
  }
}

interface Ingredient {
  id: number
  name: string
  category: string
  amount: string
  unit: string
  price: number
  stock: number
  expiry: string
}

@Observed
class IngredientItem implements Ingredient {
  id: number = 0
  name: string = ''
  category: string = ''
  amount: string = ''
  unit: string = ''
  price: number = 0
  stock: number = 0
  expiry: string = ''

  constructor(o: Ingredient) {
    this.id = o.id
    this.name = o.name
    this.category = o.category
    this.amount = o.amount
    this.unit = o.unit
    this.price = o.price
    this.stock = o.stock
    this.expiry = o.expiry
  }
}

interface Rank {
  id: number
  name: string
  avatar: string
  score: number
  posts: number
  badges: string[]
  level: string
}

@Observed
class RankItem implements Rank {
  id: number = 0
  name: string = ''
  avatar: string = ''
  score: number = 0
  posts: number = 0
  badges: string[] = []
  level: string = ''

  constructor(o: Rank) {
    this.id = o.id
    this.name = o.name
    this.avatar = o.avatar
    this.score = o.score
    this.posts = o.posts
    this.badges = o.badges
    this.level = o.level
  }
}

interface ShoppingItem {
  name: string
  amount: string
  price: string
}

在这里插入图片描述

RecipeItem模型包含了配方的完整信息,其中ingredientssteps两个字符串数组分别存储食材清单和制作步骤。IngredientItem模型用于食材库存管理,包含价格price、库存量stock和保质期expiry等关键字段,为后续的库存预警和库存总值计算提供数据支撑。RankItem模型记录排行榜信息,包含积分score、发布数posts、徽章数组badges和等级level

ShoppingItem是一个特殊的模型——它没有使用@Observed装饰器,仅定义了简单的接口。这是因为采购清单条目主要用于弹框内部的临时状态管理,不需要触发全局的UI响应式更新。这种按需使用@Observed的策略,避免了不必要的观察开销,体现了性能优化意识。

3.3 Mock数据初始化

应用预置了丰富的模拟数据,为开发和演示提供了完整的业务场景。以下是烘焙笔记和配方的Mock数据示例。

const NOTES: NoteItem[] = [
  new NoteItem({
    id: 1,
    title: '三层开酥可颂首挑战成功!',
    author: 'Momo小面包',
    avatar: '🧑‍🍳',
    recipe: '可颂',
    difficulty: 4,
    time: '18分钟',
    temp: '200℃',
    result: '金黄酥脆 · 层次分明 · 黄油香炸裂',
    likes: 328,
    liked: false,
    tags: ['开酥', '法式', '黄油'],
    date: '08-22',
    steps: ['三次三折冷藏过夜', '切9cm三角卷3.5圈', '32度发酵90分钟', '200度上下火烤18分钟']
  }),
  new NoteItem({
    id: 2,
    title: '免揉乡村欧包·零失败方子',
    author: '阿树的面包窑',
    avatar: '🌾',
    recipe: '面包',
    difficulty: 2,
    time: '35分钟',
    temp: '190℃',
    result: '外壳脆响 · 内里湿润 · 气孔自然',
    likes: 452,
    liked: true,
    tags: ['免揉', '欧包', '低糖'],
    date: '08-21',
    steps: ['粉水混合静置40分钟', '四角折叠共4次', '冷藏发酵12小时', '190度带蒸汽烤35分钟']
  }),
  // ... 更多笔记数据
]

const RECIPES: RecipeItem[] = [
  new RecipeItem({
    id: 101,
    name: '经典牛奶吐司',
    category: '面包',
    difficulty: 2,
    servings: '450g×1条',
    time: '3小时',
    ingredients: ['高筋面粉250g', '牛奶160ml', '细砂糖30g', '黄油25g', '耐高糖酵母3g'],
    steps: ['后油法揉至完全扩展', '一次发酵至2倍大', '分割松弛15分钟', '两次擀卷入模', '二发至8分满175度烤35分钟'],
    author: '阿树的面包窑',
    likes: 892
  }),
  // ... 更多配方数据
]

Mock数据通过直接调用new NoteItem(...)new RecipeItem(...)构造函数创建可观察对象实例。每条笔记数据都包含了真实烘焙场景中的完整信息——从标题、作者到烘烤温度、时间,从成果描述到详细的制作步骤,数据内容丰富且贴近实际使用场景。12条笔记覆盖了可颂、欧包、贝果、千层、芝士蛋糕、饼干、恰巴塔、熔岩蛋糕、肉桂卷、提拉米苏等多种烘焙品类,12个配方同样涵盖了面包、蛋糕、饼干、挞派、免烤等全品类。

这种高质量的Mock数据设计不仅方便了开发阶段的界面调试,更为后续接入真实后端API提供了清晰的接口契约。当真实数据接口就绪时,开发者只需将Mock数据替换为API返回的数据,按照相同的接口结构进行实例化即可完成对接,无需修改任何UI层代码。

3.4 食材库存与排行榜Mock数据

食材库存数据和排行榜数据同样以可观察对象数组的形式预置,为应用的食材管理页面和排行榜页面提供数据支撑。

const INGREDIENTS: IngredientItem[] = [
  new IngredientItem({ id: 201, name: '高筋面粉', category: '粉类', amount: '1kg/袋', unit: '袋', price: 18.5, stock: 3, expiry: '2026-12-01' }),
  new IngredientItem({ id: 202, name: '低筋面粉', category: '粉类', amount: '1kg/袋', unit: '袋', price: 16.8, stock: 5, expiry: '2026-11-20' }),
  new IngredientItem({ id: 203, name: '无盐黄油', category: '乳制品', amount: '200g/块', unit: '块', price: 32.0, stock: 2, expiry: '2026-09-10' }),
  new IngredientItem({ id: 204, name: '淡奶油', category: '乳制品', amount: '250ml/盒', unit: '盒', price: 22.5, stock: 1, expiry: '2026-08-30' }),
  new IngredientItem({ id: 205, name: '奶油芝士', category: '乳制品', amount: '250g/块', unit: '块', price: 28.0, stock: 4, expiry: '2026-09-05' }),
  new IngredientItem({ id: 206, name: '耐高糖酵母', category: '发酵', amount: '5g/包', unit: '包', price: 3.5, stock: 8, expiry: '2027-01-15' }),
  new IngredientItem({ id: 207, name: '抹茶粉', category: '调味', amount: '50g/罐', unit: '罐', price: 45.0, stock: 2, expiry: '2026-10-20' }),
  new IngredientItem({ id: 208, name: '可可粉', category: '调味', amount: '100g/罐', unit: '罐', price: 26.0, stock: 3, expiry: '2027-02-11' }),
  new IngredientItem({ id: 209, name: '细砂糖', category: '糖类', amount: '500g/袋', unit: '袋', price: 8.9, stock: 6, expiry: '2028-01-01' }),
  new IngredientItem({ id: 210, name: '鸡蛋', category: '蛋奶', amount: '60g/枚', unit: '枚', price: 1.2, stock: 12, expiry: '2026-08-28' }),
  new IngredientItem({ id: 211, name: '海盐', category: '调味', amount: '250g/罐', unit: '罐', price: 5.5, stock: 5, expiry: '2029-06-01' }),
  new IngredientItem({ id: 212, name: '香草荚', category: '调味', amount: '2根/包', unit: '包', price: 68.0, stock: 1, expiry: '2026-12-15' })
]

const RANKS: RankItem[] = [
  new RankItem({ id: 301, name: '花奶奶', avatar: '🍯', score: 968, posts: 86, badges: ['万人粉', '老手艺人', '配方之王'], level: 'Lv.9' }),
  new RankItem({ id: 302, name: '阿岚', avatar: '🧀', score: 921, posts: 74, badges: ['芝士女王', '月度最佳'], level: 'Lv.8' }),
  new RankItem({ id: 303, name: 'Momo小面包', avatar: '🧑‍🍳', score: 890, posts: 69, badges: ['开酥大神', '连更30天'], level: 'Lv.8' }),
  new RankItem({ id: 304, name: '云朵', avatar: '☁️', score: 856, posts: 63, badges: ['免烤达人', '颜值担当'], level: 'Lv.8' }),
  new RankItem({ id: 305, name: '茶茶', avatar: '🍵', score: 812, posts: 58, badges: ['千层控', '手绘封面'], level: 'Lv.7' }),
  new RankItem({ id: 306, name: '阿树的面包窑', avatar: '🌾', score: 789, posts: 55, badges: ['欧面包工头', '气孔狂魔'], level: 'Lv.7' }),
  new RankItem({ id: 307, name: '柚子酱', avatar: '🥯', score: 734, posts: 47, badges: ['贝果脑袋', '早餐档主'], level: 'Lv.6' }),
  new RankItem({ id: 308, name: '糖糖', avatar: '🍪', score: 690, posts: 42, badges: ['饼干专业户'], level: 'Lv.6' }),
  new RankItem({ id: 309, name: '小鹿', avatar: '🦌', score: 655, posts: 38, badges: ['节日限定王'], level: 'Lv.5' }),
  new RankItem({ id: 310, name: '大熊', avatar: '🍫', score: 618, posts: 35, badges: ['巧克力脑袋'], level: 'Lv.5' })
]

在这里插入图片描述

食材数据涵盖了粉类、乳制品、发酵、调味、糖类、蛋奶六大分类,12种食材各有不同的库存量、价格和保质期。其中淡奶油库存仅1盒、香草荚库存仅1包,这些低库存项将在界面上以红色预警形式突出显示,体现了库存预警功能的设计考量。排行榜数据则记录了10位烘焙达人的积分、发布数、徽章和等级信息,数据按积分降序排列,前三名将在界面上以金银铜奖牌图标进行特殊标识。

四、入口组件与状态管理体系

4.1 组件声明与状态变量定义

应用的核心入口组件通过@Entry@Component装饰器声明,内部定义了大量的@State状态变量来管理全局的UI状态和数据。

@Entry
@Component
struct BakingDiaryApp {
  // 页面状态
  @State curTab: number = 0
  @State mainTab: number = 0
  @State tick: number = 0
  // 弹框开关
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  // 列表数据
  @State notes: NoteItem[] = NOTES
  @State recipes: RecipeItem[] = RECIPES
  @State ingredients: IngredientItem[] = INGREDIENTS
  // 筛选状态
  @State hotTag: string = ''
  @State focusAuthor: string = ''
  @State showLiked: boolean = false
  @State searchKey: string = ''
  @State recipeCat: string = '全部'
  // 新增笔记表单
  @State addTitle: string = ''
  @State addType: string = '面包'
  @State addDiff: number = 2
  @State addTemp: string = '180'
  @State addTime: string = '25'
  @State addContent: string = ''
  // 编辑配方表单
  @State editIdx: number = -1
  @State editName: string = ''
  @State editCat: string = '面包'
  @State editDiff: number = 3
  @State editServings: number = 4
  @State editSteps: string = ''
  // 删除确认
  @State delKind: string = 'note'
  @State delIdx: number = -1
  @State delName: string = ''
  // 采购清单
  @State shopping: ShoppingItem[] = []
  @State bizName: string = ''
  @State bizAmount: string = '100'
  @State bizUnit: string = '克'
  @State bizPrice: string = ''
  @State bizNote: string = ''
  // 定时器
  private timer: number = -1

状态变量的组织方式遵循了清晰的分类原则。首先定义页面层级状态:curTabmainTab分别控制内容Tab和底部Tab的当前选中项,tick作为动画驱动计数器。其次是四个弹框开关布尔值,分别控制新增笔记、编辑配方、删除确认和采购清单弹框的显示与隐藏。然后是三个列表数据状态,直接引用前面定义的Mock数据数组。

筛选状态部分包含了热门标签hotTag、聚焦作者focusAuthor、只看收藏showLiked、搜索关键词searchKey和配方分类recipeCat五个变量,它们共同驱动笔记列表和配方列表的过滤逻辑。新增笔记表单和编辑配方表单的状态变量则用于弹框中的表单数据双向绑定。最后还有一个timer私有变量用于保存定时器ID。

@State装饰器的核心机制在于:当被装饰的变量值发生变化时,框架会自动重新执行引用了该变量的@Builder方法或build函数,从而更新对应的UI组件。这种自动化的响应式更新机制是声明式UI的核心优势,开发者无需手动调用setState或操作DOM,只需修改变量值即可触发界面刷新。

4.2 生命周期管理

ArkTS组件拥有完善的生命周期回调机制。本应用在aboutToAppearaboutToDisappear两个回调中实现了定时器的启动与清理。

aboutToAppear(): void {
  this.timer = setInterval(() => {
    this.tick = this.tick + 1
  }, 130)
}

aboutToDisappear(): void {
  if (this.timer >= 0) {
    clearInterval(this.timer)
    this.timer = -1
  }
}

aboutToAppear在组件创建后、UI渲染前被调用。这里通过setInterval设置了一个每130毫秒执行一次的定时器,每次执行时将tick值加1。由于tick@State装饰,每次值的变化都会触发特效层的重新渲染,从而驱动浮动emoji的动画效果。130毫秒的间隔在视觉上形成了流畅的动画节奏,既不会过快导致性能压力,也不会过慢影响动画的连贯感。

aboutToDisappear在组件销毁前被调用。这里通过clearInterval清理了之前创建的定时器,并将timer重置为-1以标记定时器已被清除。这种"创建-清理"的配对模式是资源管理的最佳实践,有效防止了组件销毁后定时器继续运行导致的内存泄漏问题。在HarmonyOS ArkTS API 24中,生命周期回调的可靠性得到了进一步保障,确保了回调的及时执行和资源的正确释放。

4.3 Tab切换与弹框管理逻辑

Tab切换逻辑需要处理内容Tab和底部主Tab之间的联动关系,同时管理各种筛选状态的清空与重置。

switchTab(i: number): void {
  this.curTab = i
  if (i === 0) {
    this.showLiked = false
    this.focusAuthor = ''
  }
  if (i === 1) {
    this.mainTab = 1
  } else if (i === 4) {
    this.mainTab = 4
  } else if (i === 3) {
    this.mainTab = 3
  } else {
    this.mainTab = 0
  }
}

switchMain(i: number): void {
  if (i === 2) {
    this.openAdd()
    return
  }
  this.mainTab = i
  this.curTab = i
  if (i === 3) {
    // 收藏:跳到笔记页并只看已点赞
    this.curTab = 0
    this.showLiked = true
    this.focusAuthor = ''
    this.hotTag = ''
  }
}

closeAll(): void {
  this.addOpen = false
  this.editOpen = false
  this.delOpen = false
  this.bizOpen = false
}

在这里插入图片描述

switchTab方法处理内容Tab的切换逻辑。当切换到笔记页(i=0)时,自动清除"只看收藏"和"聚焦作者"的筛选状态,恢复到完整的笔记列表视图。同时维护底部主Tab的同步状态——当切换到配方页时将mainTab设为1,切换到个人中心时设为4,切换到排行榜时设为3,其余情况重置为0。

switchMain方法处理底部主Tab的切换。特殊的处理在于索引2的"发布"按钮——点击它不会切换页面,而是调用openAdd打开新增笔记弹框。索引3的"收藏"按钮则执行跨页面跳转逻辑,将内容Tab切换到笔记页,同时启用showLiked筛选模式,使列表仅显示已点赞的笔记。closeAll方法统一关闭所有弹框,是弹框管理的基础方法。

4.4 弹框打开与查找工具方法

弹框的打开操作需要先初始化对应的表单状态,查找工具方法则用于在数组中定位特定ID的数据项。

openAdd(): void {
  this.closeAll()
  this.addTitle = ''
  this.addContent = ''
  this.addDiff = 2
  this.addOpen = true
}

openEdit(id: number): void {
  const idx: number = this.recipeIdx(id)
  if (idx < 0) {
    return
  }
  const r: RecipeItem = this.recipes[idx]
  this.editIdx = idx
  this.editName = r.name
  this.editCat = r.category
  this.editDiff = r.difficulty
  this.editServings = 4
  this.editSteps = this.joinSteps(r.steps)
  this.closeAll()
  this.editOpen = true
}

openNoteDel(id: number): void {
  const idx: number = this.noteIdx(id)
  if (idx < 0) {
    return
  }
  this.delKind = 'note'
  this.delIdx = idx
  this.delName = this.notes[idx].title
  this.closeAll()
  this.delOpen = true
}

noteIdx(id: number): number {
  for (let i = 0; i < this.notes.length; i++) {
    if (this.notes[i].id === id) {
      return i
    }
  }
  return -1
}

recipeIdx(id: number): number {
  for (let i = 0; i < this.recipes.length; i++) {
    if (this.recipes[i].id === id) {
      return i
    }
  }
  return -1
}

joinSteps(steps: string[]): string {
  return steps.join('\n')
}

splitSteps(s: string): string[] {
  const out: string[] = []
  const parts: string[] = s.split('\n')
  for (let i = 0; i < parts.length; i++) {
    const p: string = parts[i].trim()
    if (p !== '') {
      out.push(p)
    }
  }
  if (out.length === 0) {
    out.push('待补充')
  }
  return out
}

nextNoteId(): number {
  let maxId: number = 0
  for (let i = 0; i < this.notes.length; i++) {
    if (this.notes[i].id > maxId) {
      maxId = this.notes[i].id
    }
  }
  return maxId + 1
}

每个弹框打开方法都遵循相同的模式:首先调用closeAll关闭其他可能打开的弹框,然后初始化对应表单的状态变量,最后设置目标弹框的开关为trueopenEdit方法在打开编辑弹框前,会先通过recipeIdx查找目标配方的索引,然后将该配方的现有数据填充到编辑表单中,实现"编辑前预填"的交互体验。openNoteDelopenRecipeDel方法则在打开删除确认弹框前,记录待删除项的类型、索引和名称,以便在弹框中显示对应的提示信息。

查找工具方法提供了基于ID的数组索引查找能力。noteIdxrecipeIdx通过线性遍历在数组中查找匹配的ID,找到则返回索引,未找到则返回-1。joinStepssplitSteps互为逆操作——前者将步骤数组拼接为换行分隔的字符串用于编辑框显示,后者将编辑框的文本内容拆分为步骤数组。splitSteps还包含空行过滤和默认值处理逻辑,确保即使输入为空也不会产生空步骤。nextNoteId通过遍历查找当前最大ID并加1,生成新笔记的唯一标识。

五、业务确认逻辑:CRUD操作的实现

5.1 新增笔记与编辑配方

新增笔记和编辑配方是应用中最核心的写操作,它们都需要在确认后将表单数据转化为数据模型实例并更新到列表中。

confirmAdd(): void {
  if (this.addTitle === '') {
    return
  }
  const n: Note = {
    id: this.nextNoteId(),
    title: this.addTitle,
    author: '我',
    avatar: '🧑‍🍳',
    recipe: this.addType,
    difficulty: this.addDiff,
    time: this.addTime + '分钟',
    temp: this.addTemp + '℃',
    result: this.addContent === '' ? '新鲜出炉,记录第一次尝试' : this.addContent,
    likes: 0,
    liked: false,
    tags: [this.addType, '新作'],
    date: '08-22',
    steps: ['温度 ' + this.addTemp + '℃', '时长 ' + this.addTime + '分钟', '内容待补充']
  }
  const item: NoteItem = new NoteItem(n)
  const list: NoteItem[] = [item]
  for (let i = 0; i < this.notes.length; i++) {
    list.push(this.notes[i])
  }
  this.notes = list
  this.closeAll()
  this.hotTag = ''
  this.focusAuthor = ''
  this.showLiked = false
  this.curTab = 0
  this.mainTab = 0
}

confirmEdit(): void {
  if (this.editIdx < 0 || this.editIdx >= this.recipes.length) {
    return
  }
  const old: RecipeItem = this.recipes[this.editIdx]
  const r: Recipe = {
    id: old.id,
    name: this.editName === '' ? old.name : this.editName,
    category: this.editCat,
    difficulty: this.editDiff,
    servings: this.editServings.toString() + '人份',
    time: old.time,
    ingredients: old.ingredients,
    steps: this.editSteps === '' ? old.steps : this.splitSteps(this.editSteps),
    author: old.author,
    likes: old.likes
  }
  const list: RecipeItem[] = []
  for (let i = 0; i < this.recipes.length; i++) {
    if (i === this.editIdx) {
      list.push(new RecipeItem(r))
    } else {
      list.push(this.recipes[i])
    }
  }
  this.recipes = list
  this.editIdx = -1
  this.closeAll()
}

confirmAdd方法首先验证标题不能为空,然后构建一个Note接口对象,将表单中的各字段值组装为完整的数据结构。其中author固定为"我"、avatar使用默认emoji、likes初始为0、liked初始为falseresult字段采用了三元表达式进行空值处理——如果用户未填写笔记内容,则使用默认文案。

数据组装完成后,通过new NoteItem(n)将普通对象转化为可观察实例,然后构建一个新的数组:将新笔记放在数组首位(实现"最新发布排在最前"的效果),再将原有笔记依次追加。最后将新数组赋值给this.notes,触发UI更新。这种"创建新数组而非原地修改"的模式是ArkTS中确保状态变更被正确检测的关键做法。

confirmEdit方法同样遵循"构建新对象-创建新数组-整体赋值"的模式。它首先保存原始配方的引用old,然后构建更新后的Recipe对象,其中未在编辑表单中修改的字段(如ingredientsauthorlikes等)直接从old中继承。步骤字段通过splitSteps将编辑框中的多行文本拆分为字符串数组。更新后的配方通过new RecipeItem(r)重新实例化,替换原数组中对应位置的数据。

5.2 删除操作与采购清单管理

删除操作需要区分笔记和配方两种类型,采购清单则支持添加和移除条目。

confirmDel(): void {
  if (this.delKind === 'recipe') {
    if (this.delIdx >= 0 && this.delIdx < this.recipes.length) {
      const list: RecipeItem[] = []
      for (let i = 0; i < this.recipes.length; i++) {
        if (i !== this.delIdx) {
          list.push(this.recipes[i])
        }
      }
      this.recipes = list
    }
  } else {
    if (this.delIdx >= 0 && this.delIdx < this.notes.length) {
      const list: NoteItem[] = []
      for (let i = 0; i < this.notes.length; i++) {
        if (i !== this.delIdx) {
          list.push(this.notes[i])
        }
      }
      this.notes = list
    }
  }
  this.delIdx = -1
  this.delName = ''
  this.closeAll()
}

confirmBiz(): void {
  if (this.bizName === '') {
    return
  }
  const item: ShoppingItem = {
    name: this.bizName,
    amount: this.bizAmount + this.bizUnit,
    price: this.bizPrice
  }
  const list: ShoppingItem[] = []
  for (let i = 0; i < this.shopping.length; i++) {
    list.push(this.shopping[i])
  }
  list.push(item)
  this.shopping = list
  this.bizName = ''
  this.bizPrice = ''
  this.bizNote = ''
}

removeShop(i: number): void {
  const list: ShoppingItem[] = []
  for (let k = 0; k < this.shopping.length; k++) {
    if (k !== i) {
      list.push(this.shopping[k])
    }
  }
  this.shopping = list
}

confirmDel方法通过delKind字段区分删除类型。当删除配方时,遍历配方数组跳过待删除索引,构建新数组后整体赋值给this.recipes;删除笔记时同理操作this.notes。删除完成后清理delIdxdelName状态并关闭弹框。索引边界检查(delIdx >= 0 && delIdx < length)确保了在异常情况下不会越界访问数组。

confirmBiz方法将采购表单数据组装为ShoppingItem对象,其中amount字段通过拼接数量和单位(如"100克")形成完整的用量描述。新条目被追加到采购清单数组末尾,随后清空表单中名称、价格和备注字段,但保留数量和单位以便连续添加多个采购项。removeShop方法通过索引排除的方式移除指定条目,同样采用创建新数组的模式保证状态变更被正确检测。

5.3 点赞功能与不可变更新模式

点赞功能是社区应用中最频繁的交互操作之一,其实现方式充分体现了ArkTS中不可变数据更新的理念。

toggleLike(id: number): void {
  const idx: number = this.noteIdx(id)
  if (idx < 0) {
    return
  }
  const list: NoteItem[] = []
  for (let i = 0; i < this.notes.length; i++) {
    if (i === idx) {
      const src: NoteItem = this.notes[i]
      const c: NoteItem = new NoteItem(src)
      c.liked = !src.liked
      c.likes = src.liked ? src.likes - 1 : src.likes + 1
      list.push(c)
    } else {
      list.push(this.notes[i])
    }
  }
  this.notes = list
}

toggleLike方法的实现非常值得深入分析。它首先通过noteIdx查找目标笔记的索引,然后遍历整个笔记数组构建新数组。在遍历过程中,当遇到目标索引时,不直接修改原对象的likedlikes属性,而是先通过new NoteItem(src)创建一个原对象的深拷贝副本c,然后在副本上修改liked取反、likes加减1。最终将新数组整体赋值给this.notes

这种"创建副本-修改副本-整体替换"的不可变更新模式在ArkTS中至关重要。直接修改数组中对象的属性(如this.notes[idx].liked = !this.notes[idx].liked)可能无法被框架的状态观察系统正确捕获,导致UI不更新。而通过创建新对象和新数组,确保了引用地址的变化,框架能够可靠地检测到状态变更并触发重新渲染。虽然这种模式在每次操作时都需要遍历整个数组,但对于本应用的数据规模(12条笔记)来说,性能开销完全可以忽略。

六、筛选逻辑与统计工具

6.1 笔记筛选与配方过滤

筛选逻辑是列表页面的核心功能,它决定了用户最终看到的内容子集。

hasTag(n: NoteItem, tag: string): boolean {
  if (n.recipe === tag) {
    return true
  }
  for (let i = 0; i < n.tags.length; i++) {
    if (n.tags[i] === tag) {
      return true
    }
  }
  return false
}

shownNotes(): NoteItem[] {
  const list: NoteItem[] = []
  for (let i = 0; i < this.notes.length; i++) {
    const n: NoteItem = this.notes[i]
    const tagOk: boolean = this.hotTag === '' || this.hasTag(n, this.hotTag)
    const authorOk: boolean = this.focusAuthor === '' || n.author === this.focusAuthor
    const likedOk: boolean = !this.showLiked || n.liked
    if (tagOk && authorOk && likedOk) {
      list.push(n)
    }
  }
  return list
}

filteredRecipes(): RecipeItem[] {
  const list: RecipeItem[] = []
  for (let i = 0; i < this.recipes.length; i++) {
    const r: RecipeItem = this.recipes[i]
    const catOk: boolean = this.recipeCat === '全部' || r.category === this.recipeCat
    const keyOk: boolean = this.searchKey === '' || r.name.indexOf(this.searchKey) >= 0 ||
      r.category.indexOf(this.searchKey) >= 0 || r.author.indexOf(this.searchKey) >= 0
    if (catOk && keyOk) {
      list.push(r)
    }
  }
  return list
}

setHotTag(name: string): void {
  if (this.hotTag === name) {
    this.hotTag = ''
  } else {
    this.hotTag = name
    this.focusAuthor = ''
    this.showLiked = false
    this.curTab = 0
    this.mainTab = 0
  }
}

hasTag方法实现了标签匹配逻辑——它首先检查笔记的recipe字段是否与目标标签匹配,然后遍历tags数组查找。这种双重匹配机制确保了用户可以通过品类名称或具体标签两种方式筛选笔记。

shownNotes方法是笔记列表的核心筛选函数,它同时考虑三个筛选条件:标签筛选tagOk、作者筛选authorOk和收藏筛选likedOk。三个条件采用逻辑与(AND)关系,只有同时满足三个条件的笔记才会出现在结果列表中。当某个筛选条件的状态变量为空或false时,对应的条件自动满足(短路为true),实现了"无筛选则全显示"的效果。

filteredRecipes方法的筛选逻辑包含分类筛选和关键词搜索两个维度。关键词搜索通过indexOf方法在配方的名称、分类和作者三个字段中查找匹配,实现了多维度的模糊搜索能力。setHotTag方法实现了标签的切换选中效果——点击已选中的标签会取消选中,点击新标签则会选中并清除其他筛选条件,同时跳转到笔记页面。

6.2 统计计算工具

统计工具为个人中心页面和食材管理页面提供数据汇总能力。

totalLikes(): number {
  let sum: number = 0
  for (let i = 0; i < this.notes.length; i++) {
    sum = sum + this.notes[i].likes
  }
  return sum
}

lowStockCount(): number {
  let c: number = 0
  for (let i = 0; i < this.ingredients.length; i++) {
    if (this.ingredients[i].stock <= 2) {
      c = c + 1
    }
  }
  return c
}

totalValue(): number {
  let sum: number = 0
  for (let i = 0; i < this.ingredients.length; i++) {
    sum = sum + this.ingredients[i].price * this.ingredients[i].stock
  }
  return sum
}

totalLikes遍历所有笔记累加点赞数,用于个人中心展示用户的总获赞数。lowStockCount统计库存量不超过2的食材数量,用于食材页面的库存预警展示——这个阈值设计合理地反映了烘焙场景中食材补货的紧迫性。totalValue通过累加每种食材的单价乘以库存量,计算整个食材仓库的总价值,为用户提供直观的库存资产概览。

这三个统计方法虽然实现简单,但它们体现了前端数据计算的一个重要原则:在数据量可控的前提下,实时计算比缓存计算更加可靠和准确。每当底层数据发生变化时,这些方法会自动返回最新的计算结果,无需额外的缓存同步逻辑。

七、样式工具与动画特效

7.1 难度与库存的样式映射

应用通过一系列样式工具方法,将数据值映射为视觉属性(颜色、文本、背景色等),实现了数据驱动的动态样式。

diffStars(d: number): string {
  return '★★★★★'.substring(0, d) + '☆☆☆☆☆'.substring(0, 5 - d)
}

diffLabel(d: number): string {
  if (d >= 5) {
    return '地狱'
  }
  if (d === 4) {
    return '困难'
  }
  if (d === 3) {
    return '进阶'
  }
  if (d === 2) {
    return '简单'
  }
  return '新手'
}

diffColor(d: number): string {
  if (d >= 4) {
    return COLORS.warn
  }
  if (d === 3) {
    return COLORS.primary
  }
  return COLORS.ok
}

diffBg(d: number): string {
  if (d >= 4) {
    return '#FDECEA'
  }
  if (d === 3) {
    return COLORS.tagBg
  }
  return '#E8F5E9'
}

stockColor(ing: IngredientItem): string {
  if (ing.stock <= 2) {
    return COLORS.warn
  }
  if (ing.stock <= 4) {
    return COLORS.primary
  }
  return COLORS.ok
}

expiryColor(ing: IngredientItem): string {
  if (ing.expiry < '2026-09-10') {
    return COLORS.warn
  }
  return COLORS.textSub
}

难度相关的样式工具包含四个方法。diffStars利用字符串截取技巧生成星级显示——从五个实心星中截取前d个,再从五个空心星中截取前5-d个,拼接形成如"★★★★☆"的星级文本。diffLabel将1-5的数字难度映射为"新手"、“简单”、“进阶”、“困难”、"地狱"五个中文标签。diffColordiffBg则根据难度级别返回对应的文字颜色和背景颜色——高难度(4-5)使用警告红色系,中难度(3)使用主色焦糖橙,低难度(1-2)使用成功绿色系。

库存样式工具包含stockColorexpiryColor两个方法。stockColor将库存量分为三个级别:库存不超过2为红色警告、不超过4为橙色提醒、超过4为绿色安全。expiryColor通过字符串比较判断保质期是否临近——日期早于"2026-09-10"的食材以红色标注,提醒用户尽快使用。这种基于字符串的日期比较方式虽然简单,但在ISO 8601格式的日期字符串上可以正确工作。

7.2 排行榜与弹框辅助样式

rankMedal(i: number): string {
  if (i === 0) {
    return '🥇'
  }
  if (i === 1) {
    return '🥈'
  }
  if (i === 2) {
    return '🥉'
  }
  return '#' + (i + 1).toString()
}

barH(i: number): number {
  const score: number = RANKS[i].score
  return Math.round(score / 968 * 120)
}

barColor(i: number): string {
  const colors: string[] = ['#BF360C', '#E65100', '#F4511E', '#FF7043', '#FF8A65']
  return colors[i]
}

overlayAlign(): FlexAlign {
  if (this.addOpen || this.bizOpen) {
    return FlexAlign.End
  }
  return FlexAlign.Center
}

delTip(): string {
  if (this.delKind === 'recipe') {
    return '删除后配方「' + this.delName + '」将无法恢复'
  }
  return '删除后笔记「' + this.delName + '」将无法恢复'
}

rankMedal方法将排行榜名次映射为奖牌emoji——前三名分别显示金银铜牌图标,第四名及以后显示"#4"、"#5"等数字序号。barH方法计算柱状图中柱子的高度,以最高分968为基准按比例缩放到最大120像素的高度范围内。barColor为柱状图的前五名分别返回由深到浅的焦糖色渐变色值数组。

overlayAlign方法根据当前打开的弹框类型返回不同的对齐方式——新增笔记和采购清单弹框从底部弹出(FlexAlign.End),编辑配方和删除确认弹框居中显示(FlexAlign.Center)。delTip方法根据删除类型生成不同的提示文案,包含被删除项的名称,让用户明确知道即将删除的具体内容。

7.3 浮动emoji动画特效

动画特效是提升应用视觉吸引力的重要手段。本应用通过定时器驱动的数学计算,实现了emoji浮动上升、旋转和缩放的复合动画效果。

fxY(i: number): number {
  const f: FxItem = FX_EMOJIS[i]
  const p: number = (this.tick * f.speed * 8 + i * 137) % 660
  return 690 - p
}

fxOpacity(i: number): number {
  const f: FxItem = FX_EMOJIS[i]
  const p: number = (this.tick * f.speed * 8 + i * 137) % 660
  return 0.85 - (p / 660) * 0.85
}

fxRot(i: number): number {
  return ((this.tick * 6 + i * 47) % 72) - 36
}

fxScale(i: number): number {
  const f: FxItem = FX_EMOJIS[i]
  return 0.8 + ((this.tick * f.speed + i * 11) % 20) / 50
}

这四个方法分别计算每个emoji的垂直位置Y、透明度、旋转角度和缩放比例。核心计算公式(this.tick * f.speed * 8 + i * 137) % 660利用取模运算实现了循环动画效果。tick值随时间递增,乘以速度系数f.speed和固定倍数8控制上升速度。每个emoji的初始偏移量通过i * 137区分,使得六个emoji不会同步运动,呈现出错落有致的视觉效果。

Y位置计算690 - p使得emoji从屏幕底部(690)向上移动,当p达到660时重置回底部,形成无限循环的上升动画。透明度计算0.85 - (p / 660) * 0.85使emoji从底部0.85的透明度逐渐降低到顶部0,形成渐隐效果。旋转角度((this.tick * 6 + i * 47) % 72) - 36将结果映射到-36到36度之间,产生左右摇摆效果。缩放比例0.8 + ((this.tick * f.speed + i * 11) % 20) / 50在0.8到1.2之间变化,产生呼吸般的缩放效果。

@Builder
fxLayer() {
  Column() {
    ForEach(FX_EMOJIS, (f: FxItem, i: number) => {
      Text(f.e)
        .fontSize(f.size)
        .position({ x: f.x, y: this.fxY(i) })
        .rotate({ angle: this.fxRot(i) })
        .scale({ x: this.fxScale(i), y: this.fxScale(i) })
        .opacity(this.fxOpacity(i))
    }, (f: FxItem) => (f.e + f.x.toString()))
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

特效层的@Builder方法fxLayer通过ForEach渲染所有配置的emoji,每个Text组件使用.position设置绝对定位位置,.rotate设置旋转角度,.scale设置缩放比例,.opacity设置透明度。最关键的设置是.hitTestBehavior(HitTestMode.None)——它将整个特效层的点击测试行为设为透传,使得emoji动画不会拦截用户对下方界面元素的点击操作,保证了交互的完整性。

八、UI构建层:声明式界面的架构与实现

8.1 渐变头部与搜索栏

头部区域是用户进入应用后首先看到的界面,它集成了应用标题、搜索框和热门标签三个功能模块。

@Builder
header() {
  Column() {
    Row() {
      Column() {
        Text('🧁 手作烘焙日记')
          .fontSize(FONTS.xl)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('记录每一次出炉的心动瞬间')
          .fontSize(FONTS.xs)
          .fontColor('rgba(255,255,255,0.85)')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('🔔')
        .fontSize(18)
        .width(34)
        .height(34)
        .textAlign(TextAlign.Center)
        .borderRadius(17)
        .backgroundColor('rgba(255,255,255,0.25)')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14 })
    .alignItems(VerticalAlign.Center)

    TextInput({ text: this.searchKey, placeholder: '🔍 搜索配方 / 食材 / 烘焙达人…' })
      .backgroundColor('rgba(255,255,255,0.95)')
      .borderRadius(18)
      .height(36)
      .fontSize(FONTS.sm)
      .fontColor(COLORS.text)
      .placeholderColor('#BCAAA4')
      .placeholderFont({ size: FONTS.sm })
      .padding({ left: 14, right: 14 })
      .margin({ left: 16, right: 16, top: 12 })
      .onChange((v: string) => {
        this.searchKey = v
        if (v !== '') {
          this.curTab = 1
          this.mainTab = 1
        }
      })

    Scroll() {
      Row() {
        ForEach(HOT_TAGS, (t: TagItem) => {
          Text('#' + t.name)
            .fontSize(FONTS.xs)
            .fontColor(this.hotTag === t.name ? COLORS.primary : '#FFE0B2')
            .backgroundColor(this.hotTag === t.name ? COLORS.cream : 'rgba(255,255,255,0.18)')
            .borderRadius(12)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .margin({ left: 6, right: 6 })
            .onClick(() => {
              this.setHotTag(t.name)
            })
        }, (t: TagItem) => t.name)
      }
      .padding({ left: 10, right: 10 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ top: 10 })
  }
  .width('100%')
  .padding({ bottom: 14 })
  .linearGradient({
    angle: 150,
    colors: [[COLORS.primaryDeep, 0], [COLORS.primary, 0.55], [COLORS.accent, 1]]
  })
}

头部使用linearGradient设置了150度角的线性渐变背景,从深焦糖色到焦糖橙再到莓果粉,营造出温暖的烘焙氛围。标题区域采用Row水平布局,左侧是应用名称和副标题,右侧是通知铃铛图标。搜索框的onChange回调中实现了智能跳转——当用户输入搜索关键词时,自动切换到配方页面并同步底部Tab状态,使搜索结果立即可见。

热门标签区域使用Scroll组件实现横向滚动,scrollBar(BarState.Off)隐藏了滚动条以保持视觉简洁。每个标签的样式根据hotTag状态动态变化——选中时使用焦糖橙文字和奶油黄背景,未选中时使用浅色文字和半透明背景。标签的onClick调用setHotTag方法实现切换选中效果。

8.2 内容Tab栏

内容Tab栏是页面切换的核心导航控件,它需要清晰地标识当前选中的Tab并提供流畅的切换交互。

@Builder
tabBar() {
  Column() {
    Row() {
      ForEach(CONTENT_TABS, (t: TabItem, i: number) => {
        Column() {
          Text(t.icon + ' ' + t.label)
            .fontSize(FONTS.sm)
            .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.curTab === i ? COLORS.primary : COLORS.textSub)
          if (this.curTab === i) {
            Column()
              .width(20)
              .height(3)
              .borderRadius(2)
              .backgroundColor(COLORS.primary)
              .margin({ top: 4 })
          }
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)
        .padding({ top: 9, bottom: 7 })
        .onClick(() => {
          this.switchTab(i)
        })
      }, (t: TabItem) => t.label)
    }
    .width('100%')

    Divider()
      .color(COLORS.line)
      .strokeWidth(1)
  }
  .width('100%')
  .backgroundColor(COLORS.card)
}

Tab栏通过ForEach渲染五个内容Tab项,每个Tab包含图标和文字的组合。当前选中的Tab通过三个视觉变化进行标识:文字加粗(FontWeight.Bold)、文字颜色变为主色焦糖橙、以及下方显示一个3像素高的圆角指示条。指示条使用条件渲染if (this.curTab === i)实现——只有当前选中的Tab才会渲染这个指示条组件,其他Tab则不包含该元素。

每个Tab项使用layoutWeight(1)实现等宽分布,alignItems(HorizontalAlign.Center)justifyContent(FlexAlign.Center)确保内容居中显示。点击事件调用switchTab(i)切换页面。底部使用Divider组件绘制分割线,将Tab栏与下方内容区域在视觉上区分开来。

8.3 笔记时间线列表

笔记页面采用时间线垂直列表布局,左侧是时间轴标记,右侧是内容卡片,这种布局方式在社交类应用中非常流行。

@Builder
pageNote() {
  Column() {
    Row() {
      if (this.hotTag !== '') {
        Row() {
          Text('#' + this.hotTag)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.primary)
          Text('✕')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ left: 6 })
            .onClick(() => {
              this.hotTag = ''
            })
        }
        .backgroundColor(COLORS.tagBg)
        .borderRadius(12)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .margin({ right: 8 })
      }
      if (this.focusAuthor !== '') {
        Row() {
          Text('👤 ' + this.focusAuthor)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.primary)
          Text('✕')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ left: 6 })
            .onClick(() => {
              this.focusAuthor = ''
            })
        }
        .backgroundColor(COLORS.tagBg)
        .borderRadius(12)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .margin({ right: 8 })
      }
      if (this.showLiked) {
        Row() {
          Text('❤️ 只看收藏')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.warn)
          Text('✕')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ left: 6 })
            .onClick(() => {
              this.showLiked = false
            })
        }
        .backgroundColor('#FDECEA')
        .borderRadius(12)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .margin({ right: 8 })
      }
      Row()
        .layoutWeight(1)
      Text('共 ' + this.shownNotes().length.toString() + ' 篇')
        .fontSize(FONTS.xs)
        .fontColor(COLORS.textSub)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12 })
    .alignItems(VerticalAlign.Center)

    ForEach(this.shownNotes(), (n: NoteItem) => {
      Row() {
        // 左侧时间轴
        Column() {
          Text(n.date)
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Circle()
            .width(10)
            .height(10)
            .fill(COLORS.primary)
            .stroke(COLORS.cream)
            .strokeWidth(3)
            .margin({ top: 5 })
          Column()
            .width(2)
            .layoutWeight(1)
            .backgroundColor(COLORS.line)
            .margin({ top: 3 })
        }
        .width(46)
        .alignItems(HorizontalAlign.Center)

        // 右侧内容卡片
        Column() {
          Row() {
            Text(n.title)
              .fontSize(FONTS.md)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.text)
              .maxLines(2)
              .layoutWeight(1)
            Text(this.diffLabel(n.difficulty))
              .fontSize(FONTS.xs)
              .fontColor(this.diffColor(n.difficulty))
              .backgroundColor(this.diffBg(n.difficulty))
              .borderRadius(8)
              .padding({ left: 7, right: 7, top: 3, bottom: 3 })
              .margin({ left: 6 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)

          Row() {
            Text('🌡 ' + n.temp)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.textSub)
            Text('⏱ ' + n.time)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.textSub)
              .margin({ left: 12 })
            Row()
              .layoutWeight(1)
            Text(n.recipe)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.primary)
              .backgroundColor(COLORS.tagBg)
              .borderRadius(8)
              .padding({ left: 7, right: 7, top: 2, bottom: 2 })
          }
          .width('100%')
          .margin({ top: 9 })
          .alignItems(VerticalAlign.Center)

          Text(n.result)
            .fontSize(FONTS.sm)
            .fontColor(COLORS.text)
            .width('100%')
            .maxLines(1)
            .margin({ top: 9 })

          Column() {
            ForEach(n.steps, (s: string) => {
              Text('· ' + s)
                .fontSize(FONTS.xs)
                .fontColor(COLORS.textSub)
                .width('100%')
                .margin({ top: 4 })
            }, (s: string) => (n.id.toString() + s))
          }
          .width('100%')
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .padding(8)
          .margin({ top: 9 })

          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(n.tags, (t: string) => {
              Text('#' + t)
                .fontSize(FONTS.xs)
                .fontColor(COLORS.accent)
                .backgroundColor('#FFF0E8')
                .borderRadius(8)
                .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                .margin({ right: 6, top: 6 })
            }, (t: string) => (n.id.toString() + t))
          }
          .width('100%')
          .margin({ top: 8 })

          Row() {
            Text(n.avatar)
              .fontSize(16)
            Text(n.author)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.textSub)
              .margin({ left: 6 })
            Text('📅 2026.' + n.date)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.textSub)
              .margin({ left: 10 })
            Row()
              .layoutWeight(1)
            Row() {
              Text(n.liked ? '❤️' : '🤍')
                .fontSize(15)
              Text(n.likes.toString())
                .fontSize(FONTS.xs)
                .fontColor(n.liked ? COLORS.warn : COLORS.textSub)
                .margin({ left: 4 })
            }
            .alignItems(VerticalAlign.Center)
            .onClick(() => {
              this.toggleLike(n.id)
            })
            Text('🗑')
              .fontSize(14)
              .fontColor(COLORS.textSub)
              .padding({ left: 14 })
              .onClick(() => {
                this.openNoteDel(n.id)
              })
          }
          .width('100%')
          .margin({ top: 10 })
          .alignItems(VerticalAlign.Center)
        }
        .layoutWeight(1)
        .backgroundColor(COLORS.card)
        .borderRadius(14)
        .padding(12)
        .margin({ left: 6, right: 12, bottom: 12 })
        .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })
      }
      .width('100%')
      .padding({ left: 10 })
    }, (n: NoteItem) => n.id.toString())
  }
  .width('100%')
  .padding({ bottom: 12 })
}

笔记页面的顶部是筛选状态条,通过条件渲染动态显示当前激活的筛选条件——热门标签、聚焦作者和只看收藏三个筛选chip,每个chip都配有取消按钮。右侧显示当前筛选结果的数量统计。

主体部分通过ForEach(this.shownNotes(), ...)渲染筛选后的笔记列表。每条笔记采用Row布局分为左右两部分。左侧时间轴是一个宽度46像素的Column,包含日期文本、圆形节点和垂直连接线。圆形节点使用Circle组件绘制,填充焦糖橙色,描边奶油色,视觉上形成醒目的时间轴标记。垂直连接线使用layoutWeight(1)填充剩余高度,颜色为浅奶咖色。

右侧内容卡片是信息密度最高的区域。卡片顶部是标题和难度标签的水平排列,标题最多显示两行(maxLines(2)),难度标签使用动态颜色和背景色。下方是温度和时间信息行,以及成果描述文本。制作步骤区域使用嵌套的ForEach渲染步骤列表,每个步骤以"· "前缀显示。标签区域使用Flex({ wrap: FlexWrap.Wrap })实现自动换行的标签流式布局。卡片底部是作者信息、日期、点赞按钮和删除按钮的操作行,点赞按钮的emoji和颜色根据liked状态动态变化。

8.4 配方列表页

配方页面展示了完整的配方信息,包含分类筛选和可编辑的配方卡片。

@Builder
pageRecipe() {
  Column() {
    Scroll() {
      Row() {
        ForEach(RECIPE_CATS, (c: TagItem) => {
          Text(c.icon + ' ' + c.name)
            .fontSize(FONTS.sm)
            .fontColor(this.recipeCat === c.name ? '#FFFFFF' : COLORS.primaryDeep)
            .backgroundColor(this.recipeCat === c.name ? COLORS.primary : COLORS.tagBg)
            .borderRadius(14)
            .padding({ left: 13, right: 13, top: 6, bottom: 6 })
            .margin({ left: 6, right: 6 })
            .onClick(() => {
              this.recipeCat = c.name
            })
        }, (c: TagItem) => c.name)
      }
      .padding({ left: 8, right: 8 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .margin({ top: 10 })

    ForEach(this.filteredRecipes(), (r: RecipeItem) => {
      Column() {
        Row() {
          Text(r.name)
            .fontSize(FONTS.lg)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text)
            .layoutWeight(1)
          Text(r.category)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.primary)
            .backgroundColor(COLORS.tagBg)
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('难度 ' + this.diffStars(r.difficulty))
            .fontSize(FONTS.xs)
            .fontColor(COLORS.primaryDeep)
          Text('👥 ' + r.servings)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ left: 10 })
          Text('⏱ ' + r.time)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ left: 10 })
          Row()
            .layoutWeight(1)
          Text('❤ ' + r.likes.toString())
            .fontSize(FONTS.xs)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .margin({ top: 9 })
        .alignItems(VerticalAlign.Center)

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(r.ingredients, (ing: string) => {
            Text(ing)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.textSub)
              .backgroundColor(COLORS.bg)
              .borderRadius(6)
              .padding({ left: 7, right: 7, top: 3, bottom: 3 })
              .margin({ right: 6, top: 6 })
          }, (ing: string) => (r.id.toString() + ing))
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Text('📋 共 ' + r.steps.length.toString() + ' 个步骤 · by ' + r.author)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .layoutWeight(1)
          Text('✏️ 编辑')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.primary)
            .backgroundColor(COLORS.tagBg)
            .borderRadius(12)
            .padding({ left: 11, right: 11, top: 5, bottom: 5 })
            .onClick(() => {
              this.openEdit(r.id)
            })
          Text('🗑')
            .fontSize(14)
            .fontColor(COLORS.textSub)
            .padding({ left: 10 })
            .onClick(() => {
              this.openRecipeDel(r.id)
            })
        }
        .width('100%')
        .margin({ top: 8 })
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .backgroundColor(COLORS.card)
      .borderRadius(14)
      .padding(12)
      .margin({ left: 12, right: 12, top: 10 })
      .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })
    }, (r: RecipeItem) => ('r' + r.id.toString()))
  }
  .width('100%')
  .padding({ bottom: 12 })
}

配方页面的顶部是横向滚动的分类筛选条,包含"全部"、“面包”、“蛋糕”、“饼干”、“挞派”、"免烤"六个分类选项。选中的分类使用白色文字和焦糖橙背景,未选中使用深焦糖文字和浅焦糖背景。点击分类选项直接修改recipeCat状态变量,触发配方列表的重新筛选和渲染。

配方卡片的信息结构层次分明。顶部是配方名称和分类标签,第二行是难度星级、份量、时间和点赞数四个信息项的水平排列。食材清单使用Flex自动换行布局,每个食材以独立的chip形式展示。底部操作行包含步骤数量和作者信息、编辑按钮和删除按钮。编辑按钮点击调用openEdit方法打开编辑弹框,删除按钮点击调用openRecipeDel方法打开删除确认弹框。

8.5 食材库存表格

食材页面以表格形式展示库存信息,配合汇总统计和采购入口,构成了完整的食材管理功能。

@Builder
pageIngredient() {
  Column() {
    Text('🧺 我的食材仓库')
      .fontSize(FONTS.lg)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text)
      .width('100%')
      .margin({ left: 16, top: 12 })

    Column() {
      Row() {
        Text('食材')
          .fontSize(FONTS.xs)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textSub)
          .layoutWeight(1.3)
        Text('分类')
          .fontSize(FONTS.xs)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textSub)
          .layoutWeight(0.8)
        Text('库存量')
          .fontSize(FONTS.xs)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textSub)
          .layoutWeight(1)
        Text('单价')
          .fontSize(FONTS.xs)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textSub)
          .layoutWeight(0.8)
        Text('保质期')
          .fontSize(FONTS.xs)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textSub)
          .layoutWeight(1.1)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 9 })

      ForEach(this.ingredients, (ing: IngredientItem) => {
        Column() {
          Row() {
            Column() {
              Text(ing.name)
                .fontSize(FONTS.sm)
                .fontColor(COLORS.text)
              Text(ing.amount)
                .fontSize(9)
                .fontColor(COLORS.textSub)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1.3)

            Text(ing.category)
              .fontSize(FONTS.xs)
              .fontColor(COLORS.textSub)
              .layoutWeight(0.8)

            Text(ing.stock.toString() + ing.unit)
              .fontSize(FONTS.xs)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.stockColor(ing))
              .layoutWeight(1)

            Text('¥' + ing.price.toFixed(1))
              .fontSize(FONTS.xs)
              .fontColor(COLORS.text)
              .layoutWeight(0.8)

            Text(ing.expiry)
              .fontSize(FONTS.xs)
              .fontColor(this.expiryColor(ing))
              .layoutWeight(1.1)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 9, bottom: 9 })
          .alignItems(VerticalAlign.Center)
          .onClick(() => {
            this.openBiz(ing.name)
          })

          Divider()
            .color(COLORS.line)
            .strokeWidth(1)
            .margin({ left: 6, right: 6 })
        }
        .width('100%')
      }, (ing: IngredientItem) => ing.id.toString())
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .borderRadius(14)
    .margin({ left: 12, right: 12, top: 10 })
    .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })

    Text('💡 点击任意食材行可快速加入采购清单')
      .fontSize(FONTS.xs)
      .fontColor(COLORS.textSub)
      .width('100%')
      .margin({ left: 16, top: 8 })

    // 汇总统计
    Row() {
      Column() {
        Text(this.ingredients.length.toString())
          .fontSize(FONTS.xl)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
        Text('食材种类')
          .fontSize(FONTS.xs)
          .fontColor(COLORS.textSub)
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text(this.lowStockCount().toString())
          .fontSize(FONTS.xl)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.warn)
        Text('库存预警')
          .fontSize(FONTS.xs)
          .fontColor(COLORS.textSub)
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text('¥' + this.totalValue().toFixed(1))
          .fontSize(FONTS.xl)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ok)
        Text('库存总值')
          .fontSize(FONTS.xs)
          .fontColor(COLORS.textSub)
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)

      Text('🛒 去采购')
        .fontSize(FONTS.sm)
        .fontColor('#FFFFFF')
        .backgroundColor(COLORS.primary)
        .borderRadius(18)
        .padding({ left: 15, right: 15, top: 9, bottom: 9 })
        .margin({ right: 4 })
        .onClick(() => {
          this.openBiz('')
        })
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .borderRadius(14)
    .padding({ top: 12, bottom: 12 })
    .margin({ left: 12, right: 12, top: 10 })
    .alignItems(VerticalAlign.Center)
    .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })
  }
  .width('100%')
  .padding({ bottom: 12 })
}

食材表格采用了五列布局,通过不同的layoutWeight值分配各列宽度:食材列1.3、分类列0.8、库存量列1.0、单价列0.8、保质期列1.1,使得名称较长的食材列获得更多空间。表头行使用加粗字体和次要文本色,与数据行形成视觉区分。每个数据行之间使用Divider分割线分隔。

库存量的文字颜色通过stockColor方法动态设置——低库存显示红色、中库存显示橙色、充足库存显示绿色。保质期的颜色同样通过expiryColor方法动态计算。点击任意食材行会调用openBiz(ing.name)打开采购清单弹框,并自动填入该食材的名称,实现了"一键加入采购"的快捷操作。

底部的汇总统计区域使用三列等宽布局,分别展示食材种类数、库存预警数和库存总值,三个数字使用不同的颜色(焦糖橙、警告红、成功绿)进行视觉区分。最右侧的"去采购"按钮提供全局采购入口。

8.6 排行榜与柱状图

排行榜页面通过自绘柱状图和排行榜列表的组合,直观展示烘焙达人的热度排名。

@Builder
pageRank() {
  Column() {
    // 柱状图卡片
    Column() {
      Text('🔥 本周烘焙热度 TOP5')
        .fontSize(FONTS.lg)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')

      Row() {
        ForEach(RANKS, (r: RankItem, i: number) => {
          if (i < 5) {
            Column() {
              Text(r.score.toString())
                .fontSize(FONTS.xs)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primaryDeep)
              Column()
                .width(24)
                .height(this.barH(i))
                .borderRadius({ topLeft: 5, topRight: 5 })
                .backgroundColor(this.barColor(i))
                .margin({ top: 5 })
              Text(r.name)
                .fontSize(9)
                .fontColor(COLORS.textSub)
                .margin({ top: 5 })
                .maxLines(1)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
        }, (r: RankItem) => ('bar' + r.id.toString()))
      }
      .width('100%')
      .alignItems(VerticalAlign.Bottom)
      .margin({ top: 14 })

      Divider()
        .color(COLORS.line)
        .strokeWidth(1)
        .margin({ top: 4 })
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .borderRadius(14)
    .padding(14)
    .margin({ left: 12, right: 12, top: 12 })
    .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })

    Text('👑 烘焙达人总榜')
      .fontSize(FONTS.lg)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text)
      .width('100%')
      .margin({ left: 16, top: 14 })

    ForEach(RANKS, (r: RankItem, i: number) => {
      Row() {
        Text(this.rankMedal(i))
          .fontSize(FONTS.md)
          .width(38)
          .textAlign(TextAlign.Center)

        Text(r.avatar)
          .fontSize(22)
          .width(40)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(20)

        Column() {
          Row() {
            Text(r.name)
              .fontSize(FONTS.md)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.text)
            Text(r.level)
              .fontSize(9)
              .fontColor(COLORS.primary)
              .backgroundColor(COLORS.tagBg)
              .borderRadius(6)
              .padding({ left: 6, right: 6, top: 1, bottom: 1 })
              .margin({ left: 8 })
          }
          .alignItems(VerticalAlign.Center)

          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(r.badges, (b: string) => {
              Text('🏅' + b)
                .fontSize(9)
                .fontColor(COLORS.accent)
                .margin({ right: 8, top: 4 })
            }, (b: string) => (r.id.toString() + b))
          }
          .width('100%')
          .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Column() {
          Text(r.score.toString())
            .fontSize(FONTS.lg)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text(r.posts.toString() + ' 篇笔记')
            .fontSize(9)
            .fontColor(COLORS.textSub)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ left: 12, right: 12, top: 8 })
      .alignItems(VerticalAlign.Center)
      .shadow({ radius: 4, color: 'rgba(120,72,30,0.06)', offsetY: 2 })
      .onClick(() => {
        this.focusAuthor = r.name
        this.hotTag = ''
        this.showLiked = false
        this.curTab = 0
        this.mainTab = 0
      })
    }, (r: RankItem) => ('rank' + r.id.toString()))
  }
  .width('100%')
  .padding({ bottom: 12 })
}

柱状图区域通过ForEach渲染前五名达人的柱状条。每个柱子由三部分组成:顶部的分数文本、中间的柱体Column和底部的达人名称。柱体高度通过barH(i)方法按比例计算,颜色通过barColor(i)从预定义的焦糖色渐变数组中获取。Row容器使用alignItems(VerticalAlign.Bottom)使所有柱子底部对齐,形成标准的柱状图效果。

排行榜列表展示全部10位达人信息。每行包含奖牌标识(前三名使用emoji奖牌,其余使用数字序号)、头像、名称、等级标签、徽章列表和积分信息。点击排行榜条目会设置focusAuthor并跳转到笔记页面,实现"查看该达人所有笔记"的功能。徽章列表使用Flex自动换行布局,以小字号和点缀色展示。

8.7 个人中心页面

个人中心页面展示了用户的完整信息,包含用户信息卡、技能进度条、厨房装备清单和快捷操作入口。

@Builder
pageProfile() {
  Column() {
    // 用户信息卡
    Column() {
      Text('🧑‍🍳')
        .fontSize(38)
        .width(72)
        .height(72)
        .textAlign(TextAlign.Center)
        .backgroundColor(COLORS.cream)
        .borderRadius(36)
      Text('大卫的烤箱')
        .fontSize(FONTS.xl)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .margin({ top: 10 })
      Row() {
        Text('Lv.7 烘焙达人')
          .fontSize(FONTS.xs)
          .fontColor(COLORS.primary)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(8)
          .padding({ left: 9, right: 9, top: 3, bottom: 3 })
        Text('🏠 广州 · 小小家庭厨房')
          .fontSize(FONTS.xs)
          .fontColor(COLORS.textSub)
          .margin({ left: 8 })
      }
      .margin({ top: 7 })
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          Text(this.notes.length.toString())
            .fontSize(FONTS.xl)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Text('笔记')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text(this.recipes.length.toString())
            .fontSize(FONTS.xl)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Text('配方')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text('12860')
            .fontSize(FONTS.xl)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Text('粉丝')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text(this.totalLikes().toString())
            .fontSize(FONTS.xl)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Text('获赞')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
    .backgroundColor(COLORS.card)
    .borderRadius(16)
    .padding(16)
    .margin({ left: 12, right: 12, top: 12 })
    .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })

    // 技能进度条
    Column() {
      Text('🎨 烘焙技能')
        .fontSize(FONTS.md)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')

      ForEach(SKILLS, (s: StatItem) => {
        Row() {
          Text(s.label)
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .width(64)
          Column() {
            Column()
              .height(6)
              .width(s.percent + '%')
              .borderRadius(3)
              .backgroundColor(s.color)
          }
          .layoutWeight(1)
          .height(6)
          .backgroundColor(COLORS.bg)
          .borderRadius(3)
          .margin({ left: 8, right: 8 })

          Text(s.percent.toString() + '%')
            .fontSize(FONTS.xs)
            .fontColor(COLORS.textSub)
            .width(34)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .margin({ top: 9 })
        .alignItems(VerticalAlign.Center)
      }, (s: StatItem) => s.label)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .borderRadius(14)
    .padding(14)
    .margin({ left: 12, right: 12, top: 10 })
    .shadow({ radius: 6, color: 'rgba(120,72,30,0.08)', offsetY: 2 })
  }
  .width('100%')
  .padding({ bottom: 12 })
}

用户信息卡顶部是72x72的圆形头像区域,使用奶油黄背景和36的圆角形成完美的圆形。头像下方是用户名称、等级标签和地域信息。信息卡的底部是四列等宽的统计数据行,分别展示笔记数、配方数、粉丝数和获赞数。其中笔记数和配方数通过this.notes.lengththis.recipes.length实时计算,获赞数通过this.totalLikes()方法动态统计。

技能进度条区域通过ForEach渲染四项烘焙技能的进度。每行包含技能名称(固定宽度64)、进度条和百分比文本。进度条采用双层Column结构——外层是背景色为奶油白的容器(高度6、圆角3),内层是实际进度色块(宽度为百分比、背景色为技能对应的颜色)。这种双层结构实现了一个简洁但视觉效果良好的进度条组件。

厨房装备和快捷操作部分同样遵循卡片化的设计模式,每个功能模块独立成卡,使用统一的圆角、阴影和间距规范,保证了整个个人中心页面的视觉一致性。

九、弹窗系统:多类型弹框的统一管理

9.1 弹框遮罩层与定位策略

应用实现了四种不同类型的弹框,通过统一的遮罩层进行管理,并根据弹框类型采用不同的定位策略。

@Builder
modalOverlay() {
  Column() {
    if (this.addOpen) {
      this.modalBodyAdd()
    }
    if (this.editOpen) {
      this.modalBodyEdit()
    }
    if (this.delOpen) {
      this.modalBodyDel()
    }
    if (this.bizOpen) {
      this.modalBodyBiz()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.mask)
  .justifyContent(this.overlayAlign())
  .onClick(() => {
    this.closeAll()
  })
}

遮罩层是一个全屏的Column容器,背景色为半透明的巧克力棕rgba(62,39,35,0.55)。它通过四个独立的if条件判断决定渲染哪个弹框内容。justifyContent(this.overlayAlign())根据当前打开的弹框类型动态设置对齐方式——新增笔记和采购清单弹框使用FlexAlign.End从底部弹出,编辑配方和删除确认弹框使用FlexAlign.Center居中显示。

遮罩层的onClick调用closeAll方法关闭所有弹框,实现了"点击遮罩区域关闭弹框"的常见交互模式。而每个弹框内容组件内部都包含一个空的onClick(() => {})处理器,用于阻止点击事件冒泡到遮罩层,防止误关闭。

9.2 新增笔记弹框

新增笔记弹框从底部弹出,包含完整的笔记创建表单。

@Builder
modalBodyAdd() {
  Column() {
    Column()
      .width(40)
      .height(4)
      .borderRadius(2)
      .backgroundColor(COLORS.line)
      .margin({ top: 10 })

    Row() {
      Text('🧁 发布新笔记')
        .fontSize(FONTS.lg)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
      Row()
        .layoutWeight(1)
      Text('✕')
        .fontSize(FONTS.lg)
        .fontColor(COLORS.textSub)
        .padding(6)
        .onClick(() => {
          this.closeAll()
        })
    }
    .width('100%')
    .padding({ left: 16, right: 12, top: 10 })
    .alignItems(VerticalAlign.Center)

    Scroll() {
      Column() {
        Text('笔记标题')
          .fontSize(FONTS.sm)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ top: 8 })
        TextInput({ text: this.addTitle, placeholder: '给这次烘焙起个名字吧' })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .height(40)
          .fontSize(FONTS.sm)
          .fontColor(COLORS.text)
          .placeholderColor('#BCAAA4')
          .placeholderFont({ size: FONTS.sm })
          .width('100%')
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addTitle = v
          })

        Text('烘焙类型')
          .fontSize(FONTS.sm)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ top: 12 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(BAKE_TYPES, (t: TagItem) => {
            Text(t.icon + ' ' + t.name)
              .fontSize(FONTS.sm)
              .fontColor(this.addType === t.name ? '#FFFFFF' : COLORS.text)
              .backgroundColor(this.addType === t.name ? COLORS.primary : COLORS.bg)
              .borderRadius(12)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .margin({ right: 8, top: 6 })
              .onClick(() => {
                this.addType = t.name
              })
          }, (t: TagItem) => t.name)
        }
        .width('100%')

        Text('难度星级')
          .fontSize(FONTS.sm)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ top: 12 })
        Row() {
          ForEach([1, 2, 3, 4, 5], (d: number) => {
            Text(this.addDiff >= d ? '★' : '☆')
              .fontSize(24)
              .fontColor(this.addDiff >= d ? COLORS.primary : COLORS.line)
              .padding(4)
              .onClick(() => {
                this.addDiff = d
              })
          }, (d: number) => d.toString())
        }
        .margin({ top: 6 })

        Row() {
          Column() {
            Text('温度(℃)')
              .fontSize(FONTS.sm)
              .fontColor(COLORS.textSub)
            TextInput({ text: this.addTemp, placeholder: '180' })
              .type(InputType.Number)
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              .height(40)
              .fontSize(FONTS.sm)
              .fontColor(COLORS.text)
              .placeholderColor('#BCAAA4')
              .placeholderFont({ size: FONTS.sm })
              .width('100%')
              .margin({ top: 6 })
              .onChange((v: string) => {
                this.addTemp = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Text('时长(分钟)')
              .fontSize(FONTS.sm)
              .fontColor(COLORS.textSub)
            TextInput({ text: this.addTime, placeholder: '25' })
              .type(InputType.Number)
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              .height(40)
              .fontSize(FONTS.sm)
              .fontColor(COLORS.text)
              .placeholderColor('#BCAAA4')
              .placeholderFont({ size: FONTS.sm })
              .width('100%')
              .margin({ top: 6 })
              .onChange((v: string) => {
                this.addTime = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
        }
        .width('100%')
        .margin({ top: 12 })
        .alignItems(VerticalAlign.Top)

        Text('笔记内容')
          .fontSize(FONTS.sm)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ top: 12 })
        TextArea({ text: this.addContent, placeholder: '记录配方要点、踩坑心得、出炉感受…' })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .fontSize(FONTS.sm)
          .fontColor(COLORS.text)
          .placeholderColor('#BCAAA4')
          .constraintSize({ maxHeight: 100 })
          .width('100%')
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addContent = v
          })

        Text(this.addTitle === '' ? '✨ 标题还没填哦' : '✨ 发布')
          .fontSize(FONTS.md)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('100%')
          .height(44)
          .textAlign(TextAlign.Center)
          .backgroundColor(this.addTitle === '' ? '#FFCC80' : COLORS.primary)
          .borderRadius(22)
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            this.confirmAdd()
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
  .width('90%')
  .constraintSize({ maxHeight: '75%' })
  .backgroundColor(COLORS.card)
  .borderRadius({ topLeft: 20, topRight: 20 })
  .onClick(() => {
  })
}

弹框顶部有一个40x4的圆角拖拽指示条,这是底部弹框的标准设计语言。标题行包含"发布新笔记"文字和关闭按钮。表单内容区域使用Scroll包裹,constraintSize({ maxHeight: '75%' })限制了弹框最大高度不超过屏幕的75%,当内容超出时可滚动查看。

表单包含五个输入项:标题文本输入框、烘焙类型选择器(使用Flex自动换行的标签选择器)、难度星级选择器(使用五个可点击的星形字符)、温度和时间的数字输入框(并排排列),以及笔记内容的多行文本域。温度和时间的TextInput使用.type(InputType.Number)限制只能输入数字。

发布按钮的文案和颜色根据标题是否为空动态变化——标题为空时显示"标题还没填哦"并使用浅色背景,标题不为空时显示"发布"并使用焦糖橙背景。这种即时的状态反馈让用户清楚地知道何时可以提交。

9.3 编辑配方弹框与删除确认弹框

编辑配方弹框居中显示,包含配方名称、分类、难度、份量和步骤的编辑表单。

@Builder
modalBodyEdit() {
  Column() {
    Row() {
      Text('✏️ 编辑配方')
        .fontSize(FONTS.lg)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
      Row()
        .layoutWeight(1)
      Text('✕')
        .fontSize(FONTS.lg)
        .fontColor(COLORS.textSub)
        .padding(6)
        .onClick(() => {
          this.closeAll()
        })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Text('配方名称')
      .fontSize(FONTS.sm)
      .fontColor(COLORS.textSub)
      .width('100%')
      .margin({ top: 8 })
    TextInput({ text: this.editName, placeholder: '配方名称' })
      .backgroundColor(COLORS.bg)
      .borderRadius(10)
      .height(40)
      .fontSize(FONTS.sm)
      .fontColor(COLORS.text)
      .placeholderColor('#BCAAA4')
      .placeholderFont({ size: FONTS.sm })
      .width('100%')
      .margin({ top: 6 })
      .onChange((v: string) => {
        this.editName = v
      })

    Text('份量(人份)')
      .fontSize(FONTS.sm)
      .fontColor(COLORS.textSub)
      .width('100%')
      .margin({ top: 12 })
    Row() {
      Text('−')
        .fontSize(FONTS.lg)
        .fontColor(COLORS.primary)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(18)
        .onClick(() => {
          if (this.editServings > 1) {
            this.editServings = this.editServings - 1
          }
        })
      Text(this.editServings.toString() + ' 人份')
        .fontSize(FONTS.md)
        .fontColor(COLORS.text)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      Text('+')
        .fontSize(FONTS.lg)
        .fontColor(COLORS.primary)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(18)
        .onClick(() => {
          if (this.editServings < 20) {
            this.editServings = this.editServings + 1
          }
        })
    }
    .width('100%')
    .margin({ top: 8 })
    .alignItems(VerticalAlign.Center)

    Row() {
      Text('取消')
        .fontSize(FONTS.md)
        .fontColor(COLORS.textSub)
        .backgroundColor(COLORS.bg)
        .height(42)
        .textAlign(TextAlign.Center)
        .borderRadius(21)
        .layoutWeight(1)
        .onClick(() => {
          this.closeAll()
        })
      Text('保存修改')
        .fontSize(FONTS.md)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .backgroundColor(COLORS.primary)
        .height(42)
        .textAlign(TextAlign.Center)
        .borderRadius(21)
        .layoutWeight(1)
        .margin({ left: 12 })
        .onClick(() => {
          this.confirmEdit()
        })
    }
    .width('100%')
    .margin({ top: 16 })
    .alignItems(VerticalAlign.Center)
  }
  .width('85%')
  .constraintSize({ maxHeight: '80%' })
  .backgroundColor(COLORS.card)
  .borderRadius(18)
  .padding(16)
  .onClick(() => {
  })
}

@Builder
modalBodyDel() {
  Column() {
    Text('⚠️')
      .fontSize(40)
    Text('确认删除?')
      .fontSize(FONTS.lg)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text)
      .margin({ top: 8 })
    Text(this.delTip())
      .fontSize(FONTS.sm)
      .fontColor(COLORS.textSub)
      .textAlign(TextAlign.Center)
      .margin({ top: 6 })

    Row() {
      Text('再想想')
        .fontSize(FONTS.sm)
        .fontColor(COLORS.textSub)
        .backgroundColor(COLORS.bg)
        .height(40)
        .textAlign(TextAlign.Center)
        .borderRadius(20)
        .layoutWeight(1)
        .onClick(() => {
          this.closeAll()
        })
      Text('确认删除')
        .fontSize(FONTS.sm)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .backgroundColor(COLORS.warn)
        .height(40)
        .textAlign(TextAlign.Center)
        .borderRadius(20)
        .layoutWeight(1)
        .margin({ left: 10 })
        .onClick(() => {
          this.confirmDel()
        })
    }
    .width('100%')
    .margin({ top: 18 })
    .alignItems(VerticalAlign.Center)
  }
  .width('70%')
  .backgroundColor(COLORS.card)
  .borderRadius(18)
  .padding({ left: 20, right: 20, top: 22, bottom: 20 })
  .alignItems(HorizontalAlign.Center)
  .onClick(() => {
  })
}

编辑弹框的份量选择器是一个典型的步进器组件——左右两侧各有一个圆形按钮(“−"和"+”),中间显示当前数值。点击减号按钮在editServings > 1时递减,点击加号按钮在editServings < 20时递增,边界检查防止了数值越界。底部使用"取消"和"保存修改"双按钮布局,取消按钮使用浅色背景,保存按钮使用焦糖橙背景,视觉上引导用户点击保存。

删除确认弹框是一个简洁的居中小弹框,宽度仅为屏幕的70%。顶部是警告图标,中间是确认标题和包含被删除项名称的提示文案,底部是"再想想"(取消)和"确认删除"两个按钮。确认删除按钮使用警告红色背景,与取消按钮形成强烈的视觉对比,提醒用户操作的不可逆性。

9.4 采购清单弹框

采购清单弹框从底部弹出,支持添加采购条目并管理已添加的采购列表。

@Builder
modalBodyBiz() {
  Column() {
    Column()
      .width(40)
      .height(4)
      .borderRadius(2)
      .backgroundColor(COLORS.line)
      .margin({ top: 10 })

    Row() {
      Text('🛒 加入采购清单')
        .fontSize(FONTS.lg)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
      Row()
        .layoutWeight(1)
      Text('✕')
        .fontSize(FONTS.lg)
        .fontColor(COLORS.textSub)
        .padding(6)
        .onClick(() => {
          this.closeAll()
        })
    }
    .width('100%')
    .padding({ left: 16, right: 12, top: 10 })
    .alignItems(VerticalAlign.Center)

    Scroll() {
      Column() {
        if (this.shopping.length > 0) {
          Column() {
            Text('已加入 ' + this.shopping.length.toString() + ' 项')
              .fontSize(FONTS.xs)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
              .width('100%')
            ForEach(this.shopping, (s: ShoppingItem, i: number) => {
              Row() {
                Text('· ' + s.name + ' ' + s.amount)
                  .fontSize(FONTS.xs)
                  .fontColor(COLORS.textSub)
                  .layoutWeight(1)
                Text(s.price === '' ? '待估价' : '¥' + s.price)
                  .fontSize(FONTS.xs)
                  .fontColor(COLORS.accent)
                Text('✕')
                  .fontSize(FONTS.xs)
                  .fontColor(COLORS.warn)
                  .padding({ left: 10 })
                  .onClick(() => {
                    this.removeShop(i)
                  })
              }
              .width('100%')
              .margin({ top: 6 })
              .alignItems(VerticalAlign.Center)
            }, (s: ShoppingItem) => (s.name + s.amount + s.price))
          }
          .width('100%')
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .padding(10)
          .margin({ top: 12 })
        }

        Text('食材名称')
          .fontSize(FONTS.sm)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ top: 12 })
        TextInput({ text: this.bizName, placeholder: '要采购的食材,如:法国发酵黄油' })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .height(40)
          .fontSize(FONTS.sm)
          .fontColor(COLORS.text)
          .placeholderColor('#BCAAA4')
          .placeholderFont({ size: FONTS.sm })
          .width('100%')
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.bizName = v
          })

        Row() {
          Column() {
            Text('数量')
              .fontSize(FONTS.sm)
              .fontColor(COLORS.textSub)
            TextInput({ text: this.bizAmount, placeholder: '100' })
              .type(InputType.Number)
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              .height(40)
              .fontSize(FONTS.sm)
              .fontColor(COLORS.text)
              .placeholderColor('#BCAAA4')
              .placeholderFont({ size: FONTS.sm })
              .width('100%')
              .margin({ top: 6 })
              .onChange((v: string) => {
                this.bizAmount = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Text('单位')
              .fontSize(FONTS.sm)
              .fontColor(COLORS.textSub)
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(UNIT_CHIPS, (u: TagItem) => {
                Text(u.name)
                  .fontSize(FONTS.xs)
                  .fontColor(this.bizUnit === u.name ? '#FFFFFF' : COLORS.text)
                  .backgroundColor(this.bizUnit === u.name ? COLORS.primary : COLORS.bg)
                  .borderRadius(10)
                  .padding({ left: 9, right: 9, top: 5, bottom: 5 })
                  .margin({ right: 6, top: 6 })
                  .onClick(() => {
                    this.bizUnit = u.name
                  })
              }, (u: TagItem) => u.name)
            }
            .width('100%')
            .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1.5)
          .margin({ left: 12 })
        }
        .width('100%')
        .margin({ top: 12 })
        .alignItems(VerticalAlign.Top)

        Text(this.bizName === '' ? '🧺 食材名还没填哦' : '🧺 加入清单')
          .fontSize(FONTS.md)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('100%')
          .height(44)
          .textAlign(TextAlign.Center)
          .backgroundColor(this.bizName === '' ? '#FFCC80' : COLORS.primary)
          .borderRadius(22)
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            this.confirmBiz()
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
  .width('95%')
  .constraintSize({ maxHeight: '80%' })
  .backgroundColor(COLORS.card)
  .borderRadius({ topLeft: 20, topRight: 20 })
  .onClick(() => {
  })
}

在这里插入图片描述

采购清单弹框的独特之处在于其"已加入列表"区域——当shopping数组不为空时,在表单上方显示已添加的采购条目列表。每条记录显示食材名称、用量和估价,并配有删除按钮。这种"边添加边显示"的交互模式让用户可以直观地管理整个采购清单。

表单部分包含食材名称输入框、数量和单位的并排输入区域。单位选择器使用Flex自动换行的标签选择器,提供"克"、“毫升”、“个”、“盒”、"罐"五种常用单位。添加按钮的文案和颜色同样根据食材名称是否为空动态变化,提供了即时的表单验证反馈。

十、底部导航栏与整体构建

10.1 底部主Tab栏

底部导航栏是应用的顶层导航入口,其中"发布"按钮被设计为突出的中心按钮。

@Builder
bottomBar() {
  Column() {
    Divider()
      .color(COLORS.line)
      .strokeWidth(1)

    Row() {
      ForEach(MAIN_TABS, (t: MainTabItem, i: number) => {
        Column() {
          if (t.center) {
            Text(t.icon)
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .width(46)
              .height(46)
              .textAlign(TextAlign.Center)
              .borderRadius(23)
              .backgroundColor(COLORS.primary)
              .margin({ top: -20 })
              .shadow({ radius: 10, color: 'rgba(230,81,0,0.4)', offsetY: 3 })
          } else {
            Text(t.icon)
              .fontSize(20)
              .fontColor(this.mainTab === i ? COLORS.primary : COLORS.textSub)
            Text(t.label)
              .fontSize(FONTS.xs)
              .fontColor(this.mainTab === i ? COLORS.primary : COLORS.textSub)
              .margin({ top: 2 })
          }
        }
        .layoutWeight(1)
        .height(54)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.switchMain(i)
        })
      }, (t: MainTabItem) => t.label)
    }
    .width('100%')
    .height(56)
  }
  .width('100%')
  .backgroundColor(COLORS.card)
}

底部导航栏通过ForEach渲染五个导航项,根据MainTabItemcenter字段区分普通导航项和中心发布按钮。中心按钮使用margin({ top: -20 })实现上浮效果,使其视觉上突出于导航栏主体。按钮采用46x46的圆形设计(borderRadius(23)),焦糖橙背景色,并配有10像素半径的橙色阴影,形成立体的悬浮视觉效果。

普通导航项由图标和文字两行组成,选中状态使用焦糖橙色,未选中状态使用次要文本色。每个导航项的高度为54像素,整个导航栏(含分割线)高度为56像素。点击事件调用switchMain(i)方法处理导航逻辑。

10.2 build方法:整体页面组装

build方法是组件的根构建函数,它将所有子组件组装为完整的应用界面。

build() {
  Stack() {
    Column() {
      this.header()
      this.tabBar()
      Scroll() {
        Column() {
          if (this.curTab === 0) {
            this.pageNote()
          }
          if (this.curTab === 1) {
            this.pageRecipe()
          }
          if (this.curTab === 2) {
            this.pageIngredient()
          }
          if (this.curTab === 3) {
            this.pageRank()
          }
          if (this.curTab === 4) {
            this.pageProfile()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)

      this.bottomBar()
    }
    .width('100%')
    .height('100%')

    this.fxLayer()

    if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
      this.modalOverlay()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build方法使用Stack作为根容器,实现图层叠加效果。第一层是主界面Column,从上到下依次排列头部、内容Tab栏、可滚动的内容区域和底部导航栏。内容区域使用Scroll包裹,layoutWeight(1)使其填充头部和底部之间的所有可用空间。edgeEffect(EdgeEffect.Spring)为滚动添加了弹性回弹效果,提升了滚动的手感体验。内容区域内部通过五个if条件判断决定渲染哪个页面,根据curTab的值切换显示。

第二层是特效层fxLayer,覆盖在整个界面上方,但由于设置了hitTestBehavior(HitTestMode.None),不会影响用户交互。第三层是弹框遮罩层,仅当任一弹框开关为true时才渲染,显示在最顶层。

这种三层Stack架构清晰地将基础界面、装饰特效和交互弹框分离为独立的图层,各层各司其职,互不干扰。当弹框打开时,它自然覆盖在基础界面和特效层之上,用户只能与弹框内容交互;弹框关闭后,底层界面的交互自动恢复。

十一、核心流程图解

11.1 应用初始化与页面渲染流程

0

1

2

3

4

addOpen

editOpen

delOpen

bizOpen

应用启动

aboutToAppear 生命周期

启动定时器 setInterval 130ms

tick 值开始递增

build 方法执行

Stack 根容器渲染

第一层: 主界面 Column

第二层: 特效层 fxLayer

是否有弹框打开?

第三层: 弹框遮罩层

仅渲染前两层

header 渐变头部

tabBar 内容Tab栏

Scroll 可滚动内容区

bottomBar 底部导航栏

curTab 值判断

pageNote 笔记时间线

pageRecipe 配方列表

pageIngredient 食材表格

pageRank 排行榜

pageProfile 个人中心

弹框类型判断

modalBodyAdd 新增笔记

modalBodyEdit 编辑配方

modalBodyDel 删除确认

modalBodyBiz 采购清单

上图展示了应用从启动到完整渲染的完整流程。aboutToAppear生命周期回调是整个流程的起点,它启动定时器为动画特效提供驱动力。随后build方法执行,Stack容器按层依次渲染三个图层。主界面层的渲染顺序严格遵循从上到下的布局规则,内容区域根据curTab值条件渲染对应的页面构建器。弹框遮罩层作为最顶层,根据四个弹框开关的状态决定是否渲染以及渲染哪个弹框内容。

11.2 笔记筛选与状态联动流程

点击热门标签

点击排行榜达人

点击底部收藏Tab

输入搜索关键词

是且

是且

是且

用户操作触发筛选

筛选类型判断

setHotTag 方法

设置 focusAuthor

设置 showLiked = true

设置 searchKey

hotTag 是否已选中?

取消选中 hotTag = ''

选中标签并清除其他筛选

跳转到笔记页 curTab = 0

清除标签和收藏筛选

跳转到笔记页

跳转到笔记页

跳转到配方页 curTab = 1

shownNotes 重新计算

filteredRecipes 重新计算

遍历笔记列表

tagOk 标签匹配?

authorOk 作者匹配?

likedOk 收藏匹配?

加入结果列表

跳过该笔记

ForEach 重新渲染列表

遍历配方列表

catOk 分类匹配?

keyOk 关键词匹配?

加入结果列表

跳过该配方

ForEach 重新渲染列表

这张流程图详细展示了笔记和配方两个维度的筛选联动机制。当用户通过不同入口触发筛选时,对应的筛选状态变量被更新,随后触发筛选方法的重新执行。shownNotes方法同时考虑标签、作者和收藏三个筛选条件,采用逻辑与关系过滤笔记列表。filteredRecipes方法则考虑分类和关键词两个条件进行过滤。筛选结果通过ForEach的自动响应式机制重新渲染到界面上,整个过程无需开发者手动调用刷新方法。

11.3 弹框交互与CRUD操作流程

点击发布按钮

点击编辑按钮

点击删除按钮

点击食材行/去采购

确认发布

点击遮罩/关闭

确认

再想想

确认

关闭

recipe

note

用户触发弹框

弹框类型

openAdd 新增笔记

openEdit 编辑配方

openNoteDel/openRecipeDel

openBiz 采购清单

closeAll 关闭其他弹框

初始化表单状态

addOpen = true

recipeIdx 查找配方索引

找到?

返回不操作

填充编辑表单数据

editOpen = true

查找目标索引

记录删除类型和名称

delOpen = true

closeAll 关闭其他弹框

预填食材名称

bizOpen = true

用户填写表单

用户确认

用户填写采购信息

确认操作

confirmAdd

closeAll

确认删除

confirmDel

加入清单

confirmBiz

构建Note对象

new NoteItem 实例化

新笔记插入数组首位

notes = 新数组 触发UI更新

重置筛选并跳转笔记页

delKind 类型

过滤配方数组

过滤笔记数组

recipes = 新数组

notes = 新数组

清理状态并关闭弹框

构建 ShoppingItem

追加到采购清单数组

清空表单保留数量单位

这张流程图完整描绘了从弹框打开到业务确认的全链路操作流程。每个弹框的打开都遵循"关闭其他弹框-初始化状态-打开目标弹框"的三步模式。确认操作后,核心逻辑统一采用"构建数据对象-创建新数组-整体赋值触发响应式更新"的不可变更新模式。删除操作通过delKind字段区分笔记和配方两种删除路径,采购清单操作则支持连续添加多个条目。

十二、技术总结与对比分析

12.1 声明式UI范式的优势与实践

回顾整个应用的实现,我们可以清晰地看到HarmonyOS ArkTS声明式UI范式带来的开发效率提升。在传统的命令式UI开发中,开发者需要手动获取DOM节点、设置属性值、绑定事件监听器,代码量庞大且容易出错。而在ArkTS中,通过@State装饰器和@Builder方法的组合,开发者只需声明界面的初始状态和数据变化时的渲染逻辑,框架便会自动处理所有界面更新的细节。这种范式不仅大幅减少了样板代码,更重要的是从根本上消除了"数据与界面不同步"的常见问题。

在本应用的具体实践中,状态管理的优势体现得尤为明显。例如点赞功能——当用户点击点赞按钮时,toggleLike方法只需更新notes数组中对应项的状态,界面上的心形图标、点赞数字和颜色样式便会自动更新。笔记筛选功能同样如此——当hotTagfocusAuthorshowLiked任一筛选条件发生变化时,shownNotes方法自动重新执行,返回新的筛选结果列表,ForEach自动重新渲染列表内容。整个过程中开发者无需编写任何手动刷新界面的代码。

不可变数据更新模式是本应用的另一个重要实践。在所有CRUD操作中,无论是新增笔记、编辑配方还是删除条目,都采用了"创建新数组而非原地修改"的策略。这种模式虽然看起来增加了内存开销,但它保证了状态变更的可靠检测和UI的正确更新。在HarmonyOS ArkTS API 24的渲染管线中,引用比较是检测状态变化的第一道关卡——只有当状态变量的引用地址发生变化时,框架才会深入比较内容差异。因此,原地修改数组内容(如this.notes.push(item)this.notes[i].liked = true)可能无法触发期望的UI更新。

12.2 组件化架构与代码组织

应用的代码组织遵循了清晰的分层架构原则。最底层是静态配置层,包含颜色、字号、Tab项、标签、技能和装备等配置数据,这些数据通过接口定义和常量实例化的方式集中管理。其上是数据模型层,通过@Observed装饰器将普通类转化为响应式数据对象,每个模型都包含完整的属性定义和构造函数。再往上是Mock数据层,为应用提供丰富的初始数据。最顶层是组件逻辑层和UI构建层,负责状态管理、业务处理和界面渲染。

在UI构建层中,@Builder方法承担了组件拆分的核心职责。应用将整个界面拆分为头部header、Tab栏tabBar、五个页面构建器(pageNotepageRecipepageIngredientpageRankpageProfile)、四个弹框构建器(modalBodyAddmodalBodyEditmodalBodyDelmodalBodyBiz)以及特效层fxLayer和遮罩层modalOverlay等十余个独立的构建方法。每个方法负责一个独立的UI区域,方法之间通过this引用和状态变量进行数据传递和通信。这种拆分方式使得每个构建方法的代码量保持在可控范围内,便于阅读和维护。

12.3 动画特效与性能考量

应用的动画特效实现方式值得深入分析。通过setInterval每130毫秒递增tick值,再由fxYfxOpacityfxRotfxScale四个方法基于tick值进行数学计算,驱动六个emoji的浮动、渐隐、旋转和缩放动画。这种"定时器驱动-状态递增-数学计算-属性绑定"的动画实现方式简单直观,但在性能方面需要注意几个要点。

首先,tick作为@State变量,每次递增都会触发fxLayer构建方法的重新执行,进而重新渲染六个Text组件。在HarmonyOS ArkTS API 24中,ForEach的diff算法会通过key函数(f.e + f.x.toString())判断子项是否需要重新创建。由于emoji的配置数据不变,框架会复用已有的组件实例,仅更新位置、旋转、缩放和透明度等动态属性,这种精准的局部更新机制有效控制了重绘开销。其次,特效层设置了hitTestBehavior(HitTestMode.None),使其完全不参与触摸事件分发,避免了动画层对用户交互的任何干扰。

12.4 技术维度对比分析

技术维度 本应用实践方式 传统命令式UI方式 优势对比分析
状态管理 @State装饰器自动驱动UI更新 手动调用setState/invalidate触发重绘 声明式范式消除了数据与界面不同步的风险,代码量减少约60%
数据更新 创建新数组整体赋值,引用变更触发检测 原地修改后手动调用刷新方法 不可变更新确保状态变更被可靠检测,但需注意避免直接修改对象属性
组件拆分 @Builder方法按功能区域拆分界面 通过Fragment或View拆分 @Builder方法更轻量,无需额外类定义,但复用性低于独立组件
列表渲染 ForEach配合key函数自动diff RecyclerView/ListView+Adapter ForEach的声明式语法更简洁,API 24的diff算法性能已接近原生Adapter
动画实现 定时器驱动@State+数学计算绑定属性 ObjectAnimator/ValueAnimator ArkTS方式代码更直接,但需开发者自行控制帧率,框架动画API更省心
弹框管理 条件渲染+遮罩Stack叠加 Dialog/BottomSheet管理器 Stack叠加方式灵活度高,但需自行处理事件冒泡和遮罩点击逻辑
数据建模 @Observed类+接口双层定义 data class/POJO+观察者模式 @Observed自动触发响应式更新,无需手动注册观察者,但需提供完整默认值
样式管理 配置常量+样式工具方法动态映射 XML样式/Style资源文件 ArkTS方式在代码内闭环,类型安全有保障,但缺乏样式与逻辑的物理分离
表单交互 @State双向绑定+条件样式 TextWatcher/OnEditorActionListener 声明式绑定更简洁,表单验证通过条件表达式即时反馈
资源清理 aboutToDisappear回调清理定时器 onDestroy/onStop生命周期 ArkTS生命周期回调与组件创建销毁严格配对,资源管理更可靠

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述



在这里插入图片描述

12.5 总结

通过对这个烘焙社区应用的完整代码剖析,我们深入了解了HarmonyOS ArkTS声明式UI开发的核心技术实践。从静态配置体系的集中管理,到基于@Observed的响应式数据建模;从@State驱动的状态管理机制,到@Builder方法的组件化界面构建;从不可变数据更新的CRUD操作,到定时器驱动的动画特效实现——每一个技术环节都体现了ArkTS范式在提升开发效率和代码质量方面的显著优势。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐