一、技术概述与项目背景

HarmonyOS 6.1.1 作为华为鸿蒙生态的最新演进版本,其核心声明式开发范式 ArkTS 在 HarmonyOS ArkTS API 24 中得到了进一步的增强与完善。ArkTS 在 TypeScript 的基础上进行了深度定制,引入了状态驱动、组件化构建和静态类型约束等特性,使得开发者能够以更简洁、更高效的方式构建跨设备应用。在 HarmonyOS API 24 中,@Entry@Component@State@Observed@Builder 等装饰器构成了声明式 UI 的核心骨架,开发者只需描述界面在任何给定时刻的应有状态,框架便自动负责从状态到界面的渲染与更新,极大地降低了命令式编程中手动操作 DOM 的心智负担。

在这里插入图片描述

本项目「轮上食光 WHEEL KITCHEN」是一个面向移动餐车运营场景的综合性平台,融合了餐车整车运输调度、美食市集摊位预订和餐车社区评价三大核心业务线。从产品维度看,它涵盖了六辆不同菜系风格的餐车(汉堡、烧烤、甜品、轻食、墨西哥卷、咖啡)、六场不同主题的美食市集活动、八条完整的运输调度记录、十二道菜品菜单以及八条食客真实评价。整个应用以「美食复古橙黄风」为视觉基调,主色采用 #E8590C 的炽热橙红,辅以 #F59F00 的暖黄、#2F9E44 的鲜绿和 #FFF9EF 的奶油底色,营造出温暖、食欲感强烈的视觉氛围。

从技术架构角度审视,该应用采用了典型的单 @Entry 入口加多 @Component 子组件的分层架构。入口组件 WheelKitchenApp 负责全局状态管理、底部导航栏调度和全屏粒子动画层的驱动,六个子组件分别对应餐车、市集、运输、菜单、评价和个人中心六个功能 Tab。各子组件通过 @State 维护各自的局部状态,如市集页的报名状态数组、运输页的表单数据、菜单页的编辑弹窗等,实现了状态的合理隔离与组件的独立运作。同时,项目大量运用 @Observed 装饰器将数据模型类标记为可观察对象,配合 @ObjectLink@State 实现了细粒度的数据到视图的自动绑定与刷新。

在交互体验层面,该应用实现了多层级的动态反馈机制。入口组件通过 setInterval 定时器和 stepParticle 纯函数驱动了一个全屏食物 emoji 粒子漂浮动画层,九个粒子以不同速度和漂移方向在屏幕上持续移动,且通过 hitTestBehavior(HitTestMode.None) 确保不阻断下层交互。各功能页面则通过 Scroll 横滑轮播、Flex 换行网格、自定义柱状图、多类型弹窗(警示式取消确认、表单式报名详情、卡片式菜单编辑、头部式预约表单)等丰富的 UI 模式,为用户提供了沉浸式的操作体验。整个应用充分展示了基于 HarmonyOS API 24 的 ArkTS 在复杂业务场景下的组件化设计能力与状态管理实践。

二、整体架构流程图

辅助纯函数

数据层 @Observed

六大功能 Tab @Component

粒子动画层

全局状态

入口层 @Entry

条件渲染

条件渲染

条件渲染

条件渲染

条件渲染

条件渲染

WheelKitchenApp
全局状态管理

activeTab: FoodTab

searchKeyword: string

selectedCuisine: string

particles: ParticleItem[]

setInterval 300ms

stepParticle 纯函数

hitTestBehavior None

Tab1 餐车 TruckContent

Tab2 市集 MarketContent

Tab3 运输 ShipContent

Tab4 菜单 MenuContent

Tab5 评价 RatingContent

Tab6 我的 ProfileContent

TruckItem x6

MarketItem x6

TruckShipItem x8

DishFoodItem x12

ReviewItem x8

ParticleItem x9

truckStatusMeta

sortMarkets

filterDishItems

toggleInList

barHeightVp

formatPrice

三、逐段代码深度解析

3.1 颜色常量与设计系统

// ============ 颜色常量(美食复古橙黄风) ============
const COLOR_BG: string = '#FFF9EF'
const COLOR_CARD: string = '#FFFFFF'
const COLOR_PRIMARY: string = '#E8590C'
const COLOR_PRIMARY_LIGHT: string = '#FFB366'
const COLOR_GREEN: string = '#2F9E44'
const COLOR_GREEN_LIGHT: string = '#B2E5B8'
const COLOR_RED: string = '#C92A2A'
const COLOR_YELLOW: string = '#F59F00'
const COLOR_TEXT_MAIN: string = '#3A2A15'
const COLOR_TEXT_SUB: string = '#8C7A5E'
const COLOR_TEXT_HINT: string = '#C4B49A'
const COLOR_BORDER: string = '#F0E4CE'
const COLOR_SUCCESS: string = '#2F9E44'
const COLOR_WARN: string = '#F59F00'
const COLOR_DANGER: string = '#C92A2A'

在这里插入图片描述

这段代码定义了整个应用的视觉设计系统基础。在基于 HarmonyOS API 24 的 ArkTS 开发中,将颜色值提取为全局常量是一种工程化最佳实践,它确保了整个应用视觉风格的一致性和可维护性。可以看到,设计系统遵循了「美食复古橙黄风」的主题定位:COLOR_PRIMARY 取值为 #E8590C,这是一种高饱和度的炽热橙色,直接唤起食欲和温暖感;背景色 COLOR_BG 采用 #FFF9EF 的奶油暖白,为内容区提供了柔和的底色衬托。值得注意的是,文本颜色被细分为三个层级——COLOR_TEXT_MAIN#3A2A15 深褐主文字)、COLOR_TEXT_SUB#8C7A5E 中褐副文字)和 COLOR_TEXT_HINT#C4B49A 浅褐提示文字),这种三级文字层级体系使得信息层次清晰可辨。同时,语义化颜色如 COLOR_SUCCESS(绿色)、COLOR_WARN(黄色)、COLOR_DANGER(红色)直接对应业务状态,便于在状态标签、按钮、图标等场景中复用。

3.2 配置接口与映射表

interface TruckStatusMeta {
  label: string
  color: string
  bg: string
  icon: string
}

const TRUCK_STATUS_CONFIG: Record<string, TruckStatusMeta> = {
  '营业中': { label: '营业中', color: COLOR_SUCCESS, bg: '#EBF7ED', icon: '🔥' },
  '休息中': { label: '休息中', color: COLOR_TEXT_SUB, bg: '#F5EFE2', icon: '😴' },
  '运输中': { label: '运输中', color: COLOR_WARN, bg: '#FEF5E0', icon: '🚚' }
}

const SPICY_CONFIG: Record<string, SpicyMeta> = {
  '不辣': { label: '不辣', icon: '🥛', color: COLOR_GREEN },
  '微辣': { label: '微辣', icon: '🌶', color: COLOR_YELLOW },
  '中辣': { label: '中辣', icon: '🌶️', color: COLOR_PRIMARY },
  '重辣': { label: '重辣', icon: '🔥', color: COLOR_DANGER }
}

在这里插入图片描述

ArkTS 中的 interface 用于定义纯数据结构的类型契约,与 TypeScript 的接口类似但在 HarmonyOS API 24 中有更严格的静态类型检查。这里定义了 TruckStatusMeta 接口来描述餐车状态的元数据结构,包含标签、前景色、背景色和图标四个字段。随后通过 Record<string, TruckStatusMeta> 类型将状态字符串映射到对应的元数据对象,形成了配置驱动的 UI 渲染模式。这种设计模式的优势在于:当需要新增一个状态或修改某个状态的颜色时,只需修改配置表而无需改动渲染逻辑,极大地降低了维护成本。同理,SPICY_CONFIG 将辣度等级映射到不同的 emoji 和颜色——不辣对应牛奶和绿色,重辣对应火焰和红色,实现了直观的视觉语义化表达。项目中共定义了六组类似的配置映射表,分别管理餐车状态、市集状态、运输状态、辣度、菜单分类和菜系类型,构成了完整的配置中心。

3.3 数据模型接口与 @Observed 数据类

interface TruckModel {
  id: number
  name: string
  cuisine: string
  icon: string
  rating: number
  outingCount: number
  status: string
  color: string
}

@Observed
class TruckItem implements TruckModel {
  id: number = 0
  name: string = ''
  cuisine: string = ''
  icon: string = '🚚'
  rating: number = 5.0
  outingCount: number = 0
  status: string = '休息中'
  color: string = COLOR_PRIMARY

  constructor(id: number, name: string, cuisine: string, icon: string, rating: number,
    outingCount: number, status: string, color: string) {
    this.id = id
    this.name = name
    this.cuisine = cuisine
    this.icon = icon
    this.rating = rating
    this.outingCount = outingCount
    this.status = status
    this.color = color
  }
}

在这里插入图片描述

这段代码展示了 ArkTS 中数据模型的典型设计模式。首先通过 interface TruckModel 定义了纯接口契约,声明了餐车数据应包含的所有字段类型。然后通过 @Observed 装饰器修饰的 TruckItem 类来实现该接口。@Observed 是 HarmonyOS ArkTS API 24 中的关键装饰器,它将类标记为可观察对象,使得该类实例的属性变化能够被框架自动追踪并触发依赖该数据的 UI 组件重新渲染。注意类中每个属性都有默认值初始化,这是 ArkTS 的类型安全要求——在构造函数执行前,所有属性必须已有确定类型的初始值。构造函数接收所有参数并逐一赋值,这种显式构造方式虽然比对象字面量稍显冗长,但在 HarmonyOS API 24 的严格类型系统下能确保类型安全和编译期检查。项目中共定义了六个类似的 @Observed 数据类:TruckItemMarketItemTruckShipItemDishFoodItemReviewItemParticleItem,分别对应六种业务实体。

3.4 静态 Mock 数据初始化

const mockTrucks: TruckItem[] = [
  new TruckItem(1, '橘火汉堡车', '汉堡', '🍔', 4.9, 328, '营业中', COLOR_PRIMARY),
  new TruckItem(2, '碳焰烧烤车', '烧烤', '🍢', 4.8, 276, '营业中', COLOR_RED),
  new TruckItem(3, '云朵甜品车', '甜品', '🍩', 4.7, 189, '休息中', COLOR_YELLOW),
  new TruckItem(4, '绿洲轻食车', '亚洲菜', '🥗', 4.6, 214, '营业中', COLOR_GREEN),
  new TruckItem(5, '卷卷塔可车', '墨西哥卷', '🌮', 4.8, 301, '运输中', '#D6336C'),
  new TruckItem(6, '醒神咖啡车', '咖啡', '☕', 4.5, 152, '休息中', COLOR_TEXT_SUB)
]

在这里插入图片描述

这里定义了六辆餐车的静态数据集合,通过数组字面量配合构造函数实例化。每辆餐车都被赋予了鲜明的品牌性格:橘火汉堡车主打高评分高人气(4.9分、328次出摊),使用主橙色作为品牌色;碳焰烧烤车以红色呼应烧烤的炭火意象;云朵甜品车选用暖黄色营造甜品甜蜜感;绿洲轻食车用绿色传递健康理念;卷卷塔可车用玫红色突出异域风情;醒神咖啡车以低调褐色表现咖啡的沉稳。这些颜色值不仅用于餐车卡片的封面背景,还在各处衍生出半透明叠层(如 t.color + '22' 生成 0.13 透明度的色块)和图标背景,实现了色彩的系统性复用。数据中每个餐车的 status 字段值为 '营业中''休息中''运输中' 之一,直接映射到前文定义的 TRUCK_STATUS_CONFIG 配置表,驱动状态标签的动态渲染。同样模式的 mock 数据还有六场市集、八条运输记录、十二道菜品和八条评价,构成了应用的完整数据底座。

3.5 辅助纯函数体系

function truckStatusMeta(status: string): TruckStatusMeta {
  return TRUCK_STATUS_CONFIG[status] ?? { label: status, color: COLOR_TEXT_SUB, bg: COLOR_BG, icon: '🚚' }
}

function getStarText(rating: number): string {
  return '★★★★★'.substring(0, Math.round(rating)) + '☆☆☆☆☆'.substring(0, 5 - Math.round(rating))
}

function barHeightVp(value: number, max: number, totalVp: number): string {
  return Math.max(Math.round(value / max * totalVp), 4).toString() + 'vp'
}

function formatPrice(n: number): string {
  return '¥' + n.toFixed(0)
}

function getOpenTruckCount(): number {
  return mockTrucks.filter((t: TruckItem) => t.status === '营业中').length
}

在这里插入图片描述

ArkTS 中的纯函数是业务逻辑的核心载体。这些函数不依赖也不修改任何外部状态,给定相同输入必定返回相同输出,非常适合在 UI 渲染过程中被多次安全调用。truckStatusMeta 函数接收状态字符串,从配置表中查找对应的元数据,若未命中则通过空值合并运算符 ?? 返回一个默认值,保证了程序的健壮性。getStarText 函数巧妙地利用字符串的 substring 方法,从一个五星字符串中截取实心星,再从五空星字符串中截取剩余空心星,拼接出完整的星级文本——例如评分 4.8 会被四舍五入为 5,返回 '★★★★★☆' 去掉尾部零长度子串即为 '★★★★★'barHeightVp 是自定义柱状图的核心计算函数,它将数据值按比例映射为 vp 单位的高度字符串,并通过 Math.max 保证最小高度为 4vp,避免极小值柱体不可见。getOpenTruckCountgetTotalOutings 则通过 filterreduce 对静态数组进行聚合统计,在入口组件中直接用于大数字卡片的渲染。

3.6 粒子动画系统

const PARTICLE_EMOJIS: string[] = ['🍔', '🍕', '🌮', '🍩', '🍟', '🍕', '🍔', '🍩', '🌮']

function initParticles(): ParticleItem[] {
  return [
    new ParticleItem(6, 82, 24, 0.5, PARTICLE_EMOJIS[0], 1.2, 0.4),
    new ParticleItem(18, 64, 18, 0.35, PARTICLE_EMOJIS[1], 0.8, -0.3),
    new ParticleItem(32, 90, 28, 0.45, PARTICLE_EMOJIS[2], 1.5, 0.6),
    new ParticleItem(48, 55, 16, 0.3, PARTICLE_EMOJIS[3], 0.6, -0.5),
    new ParticleItem(62, 78, 22, 0.5, PARTICLE_EMOJIS[4], 1.1, 0.3),
    new ParticleItem(74, 40, 20, 0.4, PARTICLE_EMOJIS[5], 0.9, -0.4),
    new ParticleItem(86, 70, 26, 0.45, PARTICLE_EMOJIS[6], 1.3, 0.5),
    new ParticleItem(92, 92, 18, 0.3, PARTICLE_EMOJIS[7], 0.7, -0.2),
    new ParticleItem(12, 35, 22, 0.4, PARTICLE_EMOJIS[8], 1.0, 0.4)
  ]
}

function stepParticle(p: ParticleItem): ParticleItem {
  let ny: number = p.y - p.speed
  let nx: number = p.x + p.drift
  if (ny < -6) {
    ny = 104
  }
  if (nx < -6) {
    nx = 104
  }
  if (nx > 104) {
    nx = -4
  }
  return new ParticleItem(nx, ny, p.size, p.opacity, p.emoji, p.speed, p.drift)
}

在这里插入图片描述

粒子动画系统是本应用视觉表现的一大亮点。initParticles 函数创建了九个粒子对象,每个粒子拥有独立的初始位置(xy 以百分比表示)、大小(size 即 fontSize)、透明度(opacity)、emoji 图标、上升速度(speed)和水平漂移量(drift)。这些参数的差异化设置使得九个粒子在屏幕上呈现出各不相同的运动轨迹,避免了整齐划一的机械感。stepParticle 是核心的帧步进函数,它接收一个粒子并返回一个新位置的新粒子——注意这里采用了不可变数据模式,返回的是 new ParticleItem(...) 而非原地修改原对象。函数逻辑中,Y 坐标减去速度值实现上升效果,X 坐标加上漂移量实现水平偏移。当粒子飞出屏幕边界时(ny < -6nx 越界),通过重置坐标实现循环回绕,让粒子从屏幕另一侧重新进入。这种设计使得粒子动画可以无限循环而不会出现粒子消失的空洞。该函数被入口组件的 setInterval 定时器每 300 毫秒调用一次,通过 map 对所有粒子进行批量步进,驱动整个动画层的持续运动。

3.7 入口组件与生命周期管理

@Entry
@Component
struct WheelKitchenApp {
  @State activeTab: FoodTab = FoodTab.TRUCK
  @State searchKeyword: string = ''
  @State selectedCuisine: string = '全部'
  @State particles: ParticleItem[] = initParticles()
  private particleTimer: number = -1

  aboutToAppear() {
    this.particleTimer = setInterval(() => {
      this.particles = this.particles.map((p: ParticleItem): ParticleItem => stepParticle(p))
    }, 300)
  }

  aboutToDisappear() {
    if (this.particleTimer >= 0) {
      clearInterval(this.particleTimer)
    }
  }

在这里插入图片描述

入口组件 WheelKitchenApp 通过 @Entry 装饰器标记为应用入口,通过 @Component 声明为自定义组件。@State 装饰的四个状态变量构成了全局状态:activeTab 控制当前激活的 Tab 页面(枚举值),searchKeyword 绑定搜索框文本,selectedCuisine 记录菜系筛选选择,particles 数组驱动全屏粒子动画。值得注意的是 particleTimer 未使用 @State 而是 private,因为定时器 ID 不需要触发 UI 更新,仅作为内部资源管理。aboutToAppearaboutToDisappear 是 HarmonyOS API 24 组件生命周期的关键回调:前者在组件创建后、渲染前被调用,用于初始化粒子定时器——每 300 毫秒通过 map 步进所有粒子并赋值给 this.particles,由于 particles@State 状态,赋值后自动触发粒子层的重新渲染;后者在组件销毁前被调用,负责清理定时器资源,防止内存泄漏。这种「生命周期回调加定时器驱动动画」的模式是 ArkTS 中实现轻量级动画的标准做法,相比 animateToTransition 更适合需要持续运行的装饰性效果。

3.8 @Builder 构建器与底部导航

@Builder contentArea() {
  Column() {
    if (this.activeTab === FoodTab.TRUCK) {
      TruckContent()
    } else if (this.activeTab === FoodTab.MARKET) {
      MarketContent()
    } else if (this.activeTab === FoodTab.SHIP) {
      ShipContent()
    } else if (this.activeTab === FoodTab.MENU) {
      MenuContent()
    } else if (this.activeTab === FoodTab.RATING) {
      RatingContent()
    } else {
      ProfileContent()
    }
  }
  .layoutWeight(1)
}

@Builder bottomTabItem(icon: string, label: string, tab: FoodTab) {
  Column() {
    Text(icon).fontSize(19).opacity(this.activeTab === tab ? 1.0 : 0.45)
    Text(label).fontSize(8)
      .fontColor(this.activeTab === tab ? COLOR_PRIMARY : COLOR_TEXT_HINT)
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 1 })
    if (this.activeTab === tab) {
      Column().width(16).height(3)
        .backgroundColor(COLOR_PRIMARY).borderRadius(2).margin({ top: 2 })
    }
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 5, bottom: 5 })
  .onClick(() => { this.activeTab = tab })
}

在这里插入图片描述

@Builder 是 ArkTS 中用于定义可复用 UI 片段的装饰器,类似于其他框架中的渲染函数。contentArea 构建器通过一系列 if-else 条件判断,根据 activeTab 的值渲染对应的子组件,外层 Column 配合 layoutWeight(1) 确保内容区占据头部和底部导航之间的所有剩余空间。这种条件渲染模式在 HarmonyOS API 24 中是高效的,框架会根据条件变化仅创建和销毁对应的组件实例,避免不必要的全量重建。bottomTabItem 构建器则封装了底部导航项的渲染逻辑,它接收图标、标签和目标 Tab 三个参数,通过三元运算符动态切换激活态与非激活态的样式——激活时图标不透明度为 1.0、文字为主橙色加粗、底部出现一个橙色指示条;非激活时图标半透明、文字为浅褐提示色。onClick 回调直接将 activeTab 设置为目标值,框架自动处理状态变化后的 UI 更新。整个底部导航栏由六个 bottomTabItem 调用组成,每个调用传入不同的图标 emoji 和标签文本,代码高度精炼。

3.9 粒子层与 Stack 布局

@Builder particleLayer() {
  Column() {
    ForEach(this.particles, (p: ParticleItem) => {
      Text(p.emoji)
        .fontSize(p.size)
        .opacity(p.opacity)
        .position({ x: p.x.toString() + '%', y: p.y.toString() + '%' })
    })
  }
  .width('100%').height('100%')
  .hitTestBehavior(HitTestMode.None)
}

build() {
  Stack() {
    Column() {
      // 头部区域...
      this.contentArea()
      // 底部Tab...
    }
    .width('100%').height('100%')

    // 全屏食物粒子层(不阻挡点击)
    this.particleLayer()
  }
  .width('100%').height('100%')
  .backgroundColor(COLOR_BG)
}

入口组件的 build 方法采用了 Stack 堆叠布局作为根容器,将主内容 Column 和粒子层 particleLayer 叠加在一起。Stack 的特性是子元素按声明顺序从底到顶堆叠,后声明的元素覆盖在前者之上。因此主内容区在底层,粒子层在顶层,实现了粒子漂浮在所有 UI 之上的视觉效果。粒子层的关键设计在于 hitTestBehavior(HitTestMode.None),这个属性使得粒子层完全不参与触摸命中测试,所有触摸事件都会穿透粒子层传递到下层的主内容区,保证了用户在欣赏粒子动画的同时不会影响任何交互操作。ForEach 遍历粒子数组,每个粒子通过 position 属性进行绝对定位,坐标值通过 p.x.toString() + '%' 拼接为百分比字符串,实现了响应式的屏幕适配。粒子层的 widthheight 均设为 '100%',确保覆盖整个屏幕区域。这种「Stack 叠加加 hitTestBehavior 穿透」的模式是 HarmonyOS ArkTS API 24 中实现装饰性动画层的经典手法。

3.10 餐车页面:大卡轮播与列表卡片

@Builder truckBigCard(t: TruckItem) {
  Stack({ alignContent: Alignment.BottomStart }) {
    Column()
      .width('100%').height('100%')
      .backgroundColor(t.color)
    Column()
      .width('100%').height('100%')
      .backgroundColor('#33FFFFFF')
    Text(t.icon)
      .fontSize(52)
      .position({ x: '62%', y: '18%' })
    Column() {
      Row() {
        Text(t.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
      }
      Row() {
        Text('★ ' + t.rating.toFixed(1)).fontSize(12)
          .fontColor(COLOR_CARD).fontWeight(FontWeight.Bold)
        Text(' · ' + t.cuisine).fontSize(11).fontColor(COLOR_CARD)
        Text(' · 出摊' + t.outingCount + '次').fontSize(11).fontColor(COLOR_CARD)
      }
      .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .padding(12)
  }
  .width('78%')
  .height(148)
  .borderRadius(16)
  .margin({ left: 6, right: 6 })
  .clip(true)
}

餐车页面的核心视觉元素是横向滑动的大卡轮播。truckBigCard 构建器使用 Stack 堆叠四层内容:底层是餐车品牌色的满铺背景列,第二层是 #33FFFFFF(即 20% 透明度白色)的叠加层用于增加文字可读性,第三层是通过 position 定位在右上角的 52 号大 emoji 图标,最顶层是左下角对齐的餐车名称和评分信息文本列。这种「纯色背景加半透明蒙层加大图标加文字」的设计模式既保证了视觉冲击力,又确保了文字在不同品牌色背景上的可读性。clip(true) 确保圆角边界裁切内部元素,width('78%') 和固定 height(148) 定义了卡片的宽高比。在 build 方法中,三张大卡被放入一个横向 Scroll 中,配合 scrollable(ScrollDirection.Horizontal)scrollBar(BarState.Off) 实现了无滚动条的流畅横滑体验。下方则是通过 ForEach 遍历 mockTrucks 数组渲染的列表卡片,每张列表卡片包含图标圆角背景、餐车名称、状态标签、评分、出摊次数和关注按钮,信息密度高但层次分明。

3.11 市集页面:报名流程与取消确认弹窗

@State joinedMarketIds: number[] = [3, 6]
@State showCancelModal: boolean = false
@State selectedMarket: MarketItem | null = null

@Builder cancelSignupModal() {
  Column() {
    this.modalOverlay(() => { this.showCancelModal = false })
    Column() {
      Text('⚠️').fontSize(46).margin({ top: 22 })
      Text('确认取消此报名?').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
      Text('取消后摊位将释放给其他餐车').fontSize(12).fontColor(COLOR_DANGER).margin({ top: 4 })
      // 市集详情信息行...
      Row() {
        Text('再想想')
          .fontSize(13).fontColor(COLOR_TEXT_SUB)
          .backgroundColor(COLOR_BG)
          .borderRadius(18)
          .padding({ left: 24, right: 24, top: 9, bottom: 9 })
          .onClick(() => { this.showCancelModal = false })
        Text('确认取消')
          .fontSize(13).fontColor(COLOR_CARD)
          .backgroundColor(COLOR_DANGER)
          .borderRadius(18)
          .padding({ left: 24, right: 24, top: 9, bottom: 9 })
          .margin({ left: 12 })
          .onClick(() => {
            if (this.selectedMarket != null) {
              this.joinedMarketIds = this.joinedMarketIds.filter(
                (id: number) => id !== this.selectedMarket?.id)
            }
            this.showCancelModal = false
          })
      }
      .justifyContent(FlexAlign.Center)
    }
    .width('80%')
    .backgroundColor(COLOR_CARD)
    .borderRadius(18)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '10%', y: '24%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

市集页面的弹窗系统是该应用交互设计的精华所在。joinedMarketIds 数组维护已报名市集的 ID 列表,初始值为 [3, 6] 表示用户已报名第三场和第六场市集。selectedMarket 使用可空类型 MarketItem | null,在弹窗未打开时为 null,打开时持有当前操作的市集对象引用。cancelSignupModal 是一个警示式确认弹窗,结构上分为两层:外层是半透明遮罩 modalOverlay,通过 rgba(58,42,21,0.55) 的深褐色半透明背景覆盖全屏并拦截点击关闭弹窗;内层是居中的白色弹窗卡片,以警告图标开头,红色提示文字告知用户取消后果。弹窗底部是两个按钮:「再想想」使用背景色低调设计表示取消操作,「确认取消」使用红色背景表示危险操作。确认取消的回调通过 filterjoinedMarketIds 中移除当前市集的 ID,实现了数据的不可变更新。整个弹窗通过 zIndex(999) 确保浮于所有内容之上,position 绝对定位实现精准的屏幕居中。在 build 方法中,弹窗通过 if (this.showCancelModal) 条件渲染实现显示与隐藏。

3.12 市集报名详情弹窗与表单交互

@Builder signupDetailModal() {
  Column() {
    this.modalOverlay(() => { this.showSignupModal = false })
    Column() {
      Stack({ alignContent: Alignment.BottomStart }) {
        Column()
          .width('100%').height(92)
          .backgroundColor(this.selectedMarket?.color ?? COLOR_PRIMARY)
        Column()
          .width('100%').height(92)
          .backgroundColor('#26FFFFFF')
        Column() {
          Text(this.selectedMarket?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
          Text('📍 ' + (this.selectedMarket?.location ?? '')).fontSize(10).fontColor(COLOR_CARD).margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        Text('✕').fontSize(14).fontColor(COLOR_CARD)
          .width(28).height(28).textAlign(TextAlign.Center)
          .backgroundColor('#4D3A2A15')
          .borderRadius(14)
          .position({ x: '86%', y: 10 })
          .onClick(() => { this.showSignupModal = false })
      }
      .width('100%').height(92)
      .borderRadius({ topLeft: 16, topRight: 16 })
      .clip(true)

      Scroll() {
        Column() {
          // 摊位类型选择药丸...
          // 参与天数加减器...
          Row() {
            Text('−')
              .fontSize(18).fontColor(COLOR_PRIMARY)
              .width(34).height(34).textAlign(TextAlign.Center)
              .backgroundColor(COLOR_BG).borderRadius(17)
              .border({ width: 1, color: COLOR_BORDER, radius: 17 })
              .onClick(() => { if (this.signupDays > 1) { this.signupDays = this.signupDays - 1 } })
            Text(this.signupDays.toString() + ' 天')
              .fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width(80).textAlign(TextAlign.Center)
            Text('+')
              .onClick(() => { if (this.signupDays < 7) { this.signupDays = this.signupDays + 1 } })
          }
          // 费用实时计算
          Text('¥' + (((this.selectedMarket?.fee ?? 0) * this.signupDays *
            (this.boothType === '双倍摊位' ? 2 : 1)).toFixed(0)))
        }
      }
    }
  }
}

市集报名详情弹窗是一个复杂的表单式交互组件。弹窗头部采用与市集卡片相同的品牌色加半透明蒙层设计,展示了市集名称、地点、日期和摊位数量,右上角的关闭按钮使用 #4D3A2A15(约 30% 透明度的深褐色)圆形背景。弹窗主体是一个可滚动的 Scroll 区域,内含摊位类型选择(三种类型药丸式单选)、参与天数的加减器(通过 + 按钮调整,范围限制在 1 到 7 天)、电力需求复选框和报名须知说明。最精妙的设计是费用的实时计算:通过表达式 (this.selectedMarket?.fee ?? 0) * this.signupDays * (this.boothType === '双倍摊位' ? 2 : 1) 将基础费用乘以天数,并在选择双倍摊位时自动翻倍,结果通过 toFixed(0) 格式化为整数并加上 ¥ 前缀显示。由于所有参与计算的变量都是 @State,任何一项的变化都会自动触发费用文本的重新渲染。点击「立即报名」后,回调将当前市集 ID 通过 concat 追加到 joinedMarketIds 数组中,并关闭弹窗,整个报名流程完成。

3.13 运输页面:统计条与自定义柱状图

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          // 顶部统计条
          Row() {
            Column() {
              Text('16').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
              Text('本月运输').fontSize(9).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)
            // ... 其他三列统计
          }
          .width('100%').padding({ top: 12, bottom: 12 })
          .backgroundColor(COLOR_CARD).borderRadius(14)

          // 月度运输柱状图
          Column() {
            Text('📊 近6个月运输次数').fontSize(13).fontWeight(FontWeight.Bold)
            Row() {
              ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
                Column() {
                  Text(SHIP_MONTH_COUNTS[i].toString())
                    .fontSize(9).fontColor(COLOR_PRIMARY).margin({ bottom: 3 })
                  Column()
                    .width(24)
                    .height(barHeightVp(SHIP_MONTH_COUNTS[i], 16, 76))
                    .backgroundColor(i === 5 ? COLOR_PRIMARY : COLOR_PRIMARY_LIGHT)
                    .borderRadius({ topLeft: 4, topRight: 4 })
                  Text(SHIP_MONTHS[i]).fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
          }
        }
      }
    }
    if (this.showBookingModal) { this.bookingModal() }
  }
}

运输页面是信息密度最高的功能页面,集成了统计条、预约表单、柱状图和运输记录列表四大模块。顶部统计条使用四列等宽布局,通过 layoutWeight(1) 实现均分,每列展示一个数字和一个标签,分别使用不同颜色区分(主橙、暖黄、鲜绿、红色对应四种业务指标)。柱状图是纯 ArkTS 实现的自定义图表组件,无需引入任何第三方图表库:通过 ForEach 遍历六个月的数据,每个月份渲染为一个 Column,包含顶部的数值文本、中间的柱体 Column 和底部的月份标签。柱体高度通过 barHeightVp 函数计算,将数据值按最大值 16 映射到 76vp 的高度空间,最后一个月(当前月)使用主橙色 COLOR_PRIMARY 突出显示,其余月份使用浅橙 COLOR_PRIMARY_LIGHT,通过颜色对比引导用户关注最新数据。柱体顶部圆角通过 borderRadius({ topLeft: 4, topRight: 4 }) 实现,视觉上更加柔和。这种「纯组件柱状图」方案充分体现了 ArkTS 声明式 UI 的灵活性,开发者可以用基础组件拼装出任意复杂的数据可视化效果。

3.14 运输预约弹窗与多服务选择

@Builder bookingModal() {
  Column() {
    this.modalOverlay(() => { this.showBookingModal = false })
    Column() {
      Row() {
        Column() {
          Text('🚚 预约餐车运输').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
          Text('整车拖运 · 专人摆位 · 全程保险').fontSize(9).fontColor('#FFE3CC').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('✕').fontSize(16).fontColor(COLOR_CARD)
          .width(28).height(28).textAlign(TextAlign.Center)
          .backgroundColor('#33FFFFFF').borderRadius(14)
          .onClick(() => { this.showBookingModal = false })
      }
      .padding({ left: 18, right: 18, top: 16, bottom: 14 })
      .backgroundColor(COLOR_PRIMARY)
      .borderRadius({ topLeft: 16, topRight: 16 })

      Scroll() {
        Column() {
          // 餐车选择横向滚动药丸...
          // 出发地/目的地/日期输入框...
          Text('特殊服务').fontSize(12).fontWeight(FontWeight.Bold)
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(SHIP_SERVICES, (sv: string) => {
              Text((this.formServices.indexOf(sv) >= 0 ? '✓ ' : '○ ') + sv)
                .fontSize(11)
                .fontColor(this.formServices.indexOf(sv) >= 0 ? COLOR_PRIMARY : COLOR_TEXT_SUB)
                .backgroundColor(this.formServices.indexOf(sv) >= 0 ? '#FDEEE3' : COLOR_BG)
                .padding({ left: 10, right: 10, top: 6, bottom: 6 })
                .borderRadius(12)
                .margin({ right: 6, top: 4, bottom: 4 })
                .onClick(() => { this.formServices = toggleInList(this.formServices, sv) })
            })
          }
          // 备注 TextArea...
        }
      }
    }
  }
}

运输预约弹窗是一个头部式的复杂表单弹窗。弹窗头部采用品牌橙色背景加圆角顶部,标题旁有副标题描述服务亮点,关闭按钮使用半透明白色圆形背景。表单主体包含餐车选择(横向滚动药丸式单选)、出发地和目的地输入框(TextInput 组件)、运输日期输入、时段选择(上午/下午/夜间三选一药丸)和特殊服务多选。特殊服务的选择采用了 Flex({ wrap: FlexWrap.Wrap }) 容器实现自动换行的药丸组,每项服务通过 this.formServices.indexOf(sv) >= 0 判断是否已选,已选项前缀为 并使用橙色背景,未选项前缀为 并使用灰色背景。点击时调用 toggleInList 纯函数,该函数通过 indexOf 检查是否已包含,已包含则通过 filter 移除,未包含则通过 concat 添加,返回新数组实现不可变更新。SHIP_SERVICES 包含拖车、卸货、摆位、发电机租赁四种增值服务,用户可以自由组合。弹窗底部的「确认预约」和「取消」按钮采用水平居中布局,确认按钮使用主橙色突出。整个弹窗通过 positionzIndex 实现全屏覆盖,内部 Scroll 确保表单内容过多时可滚动查看。

3.15 菜单页面:分类过滤与菜品编辑

@Builder menuCardBuilder(m: DishFoodItem) {
  Column() {
    Stack({ alignContent: Alignment.TopEnd }) {
      Column() {
        Text(m.category === '饮品' ? '🥤' : (m.category === '甜点' ? '🍩' : '🍽'))
          .fontSize(34)
      }
      .width('100%').height(74)
      .backgroundColor(m.color)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      .borderRadius({ topLeft: 12, topRight: 12 })

      Row() {
        Text(spicyMeta(m.spicyLevel).icon + spicyMeta(m.spicyLevel).label)
          .fontSize(8)
          .fontColor(spicyMeta(m.spicyLevel).color)
          .backgroundColor(COLOR_CARD)
          .padding({ left: 5, right: 5, top: 2, bottom: 2 })
          .borderRadius(8)
      }
      .padding(6)
    }
    .width('100%').clip(true)

    Column() {
      Text(m.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
      Text('月售 ' + m.monthlySales).fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ top: 3 })
      Row() {
        Text(formatPrice(m.price)).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
        Column().layoutWeight(1)
        Text('加入订单')
          .fontSize(10).fontColor(COLOR_CARD).backgroundColor(COLOR_PRIMARY)
          .padding({ left: 9, right: 9, top: 5, bottom: 5 }).borderRadius(11)
          .onClick(() => { this.orderCount = this.orderCount + 1 })
        Text('✏️').fontSize(13).margin({ left: 6 })
          .onClick(() => {
            this.editingItem = m
            this.editName = m.name
            this.editPrice = m.price.toFixed(0)
            this.editCategory = m.category
            this.editSpicy = m.spicyLevel
            this.showEditModal = true
          })
      }
      .margin({ top: 8 })
    }
    .padding(10).width('100%')
  }
  .width('48%')
  .backgroundColor(COLOR_CARD).borderRadius(12)
  .border({ width: 1, color: COLOR_BORDER, radius: 12 })
  .margin({ top: 8 }).clip(true)
}

菜单页面的菜品卡片采用两列网格布局,通过 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 容器实现自动换行和两端对齐。每张卡片宽度为 '48%',恰好两列排列。卡片顶部是一个 Stack 叠层:底层是菜品分类背景色块和居中的大 emoji 图标(根据分类智能选择饮品、甜点或通用餐具图标),右上角通过 Alignment.TopEnd 对齐方式叠放了辣度标签。卡片内容区包含菜品名称(使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 实现单行省略号截断)、月销量、价格和操作按钮。「加入订单」按钮点击后递增 orderCount 状态,编辑图标点击后会将当前菜品的所有信息赋值到编辑状态变量并打开编辑弹窗。菜品的分类过滤通过 filterDishItems 纯函数实现,点击顶部分类药丸时修改 selectedCategoryForEach 自动重新渲染过滤后的菜品列表。页面底部还有一个五格分类统计区,通过 getMenuCategoryCount 函数统计各类菜品数量,用对应分类颜色的数字直观展示分布。

3.16 评价页面:评分分布图与点赞交互

@Builder reviewCardBuilder(r: ReviewItem) {
  Column() {
    Row() {
      Column() {
        Text(r.userAvatar).fontSize(24)
      }
      .width(42).height(42).backgroundColor(COLOR_BG).borderRadius(21)
      .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

      Column() {
        Text(r.userName).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
        Row() {
          Text(getStarText(r.rating)).fontSize(11).fontColor(COLOR_WARN)
          Text(' ' + r.rating.toFixed(1)).fontSize(10).fontColor(COLOR_TEXT_HINT)
        }
        .margin({ top: 2 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })

      Column() {
        Text(r.time).fontSize(9).fontColor(COLOR_TEXT_HINT)
        Row() {
          Text(this.likedReviewIds.indexOf(r.id) >= 0 ? '❤️' : '🤍').fontSize(13)
          Text((r.likeCount + (this.likedReviewIds.indexOf(r.id) >= 0 ? 1 : 0)).toString())
            .fontSize(10).fontColor(COLOR_TEXT_SUB).margin({ left: 3 })
        }
        .margin({ top: 4 })
        .onClick(() => { this.likedReviewIds = toggleInList(this.likedReviewIds, r.id) })
      }
      .alignItems(HorizontalAlign.End)
    }
    .alignItems(VerticalAlign.Top)

    Text(r.content).fontSize(12).fontColor(COLOR_TEXT_MAIN).lineHeight(18).margin({ top: 8 })

    Row() {
      Text('🍖 ' + r.foodTag)
        .fontSize(10).fontColor(COLOR_PRIMARY).backgroundColor('#FDEEE3')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(9)
      Text('已核销消费').fontSize(9).fontColor(COLOR_GREEN).backgroundColor('#EBF7ED')
        .padding({ left: 6, right: 6, top: 3, bottom: 3 }).borderRadius(9).margin({ left: 6 })
    }
    .margin({ top: 8 })
  }
  .padding(14).backgroundColor(COLOR_CARD).borderRadius(14)
  .border({ width: 1, color: COLOR_BORDER, radius: 14 }).margin({ left: 12, right: 12, top: 8 })
}

评价页面的评价卡片设计体现了社交化内容展示的精细考量。卡片顶部是用户头像(emoji 圆形背景)、用户名、星级评分和点赞按钮的横向布局。点赞交互是该卡片的核心亮点:通过 likedReviewIds 数组维护已点赞的评价 ID,点击点赞按钮时调用 toggleInList 切换状态。点赞图标在已点赞时显示为 ❤️(红心),未点赞时显示为 🤍(白心),同时点赞数通过 r.likeCount + (this.likedReviewIds.indexOf(r.id) >= 0 ? 1 : 0) 动态计算——当用户已点赞时在原始点赞数基础上加一,实现即时的数字反馈。评价正文使用 lineHeight(18) 增加行高提升可读性,底部标签区展示了菜品标签(橙色药丸)和「已核销消费」绿色标签,传达了评价的真实性保障。评价页面顶部还有综合评分大卡(4.8 分大字、好评率 96%、累计 100 条评价三列)和评分分布柱状图(五星到一星的分布数量),分布图中五星柱体使用 COLOR_WARN 黄色突出显示,因为五星评价占比最高(63 条),形成了视觉焦点。

3.17 个人中心页面:统计网格与设置列表

@Builder statCellBuilder(icon: string, value: string, label: string, color: string) {
  Column() {
    Text(icon).fontSize(22).margin({ top: 4 })
    Text(value).fontSize(22).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 4 })
    Text(label).fontSize(10).fontColor(COLOR_TEXT_SUB).margin({ top: 3, bottom: 4 })
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)
  .padding({ top: 12, bottom: 12 })
  .backgroundColor(COLOR_CARD).borderRadius(14)
  .border({ width: 1, color: COLOR_BORDER, radius: 14 })
}

@Builder settingsRowBuilder(icon: string, label: string, hint: string) {
  Row() {
    Column() {
      Text(icon).fontSize(17)
    }
    .width(36).height(36).backgroundColor(COLOR_BG).borderRadius(11)
    .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

    Text(label).fontSize(13).fontColor(COLOR_TEXT_MAIN).margin({ left: 12 }).layoutWeight(1)
    Text(hint).fontSize(10).fontColor(COLOR_TEXT_HINT)
    Text(' >').fontSize(12).fontColor(COLOR_TEXT_HINT).margin({ left: 4 })
  }
  .width('100%').padding({ top: 10, bottom: 10 })
  .alignItems(VerticalAlign.Center)
  .onClick(() => { this.settingsExpanded = !this.settingsExpanded })
}

个人中心页面通过两个高度参数化的 @Builder 构建器实现了统计网格和设置列表的复用。statCellBuilder 接收图标、数值、标签和颜色四个参数,渲染为一个居中的统计单元格——图标在顶部,数值使用 22 号粗体并着色,标签在底部。四个统计单元格通过两个 Row 组成 2x2 网格,分别展示总运输次数(86,橙色)、总订单数(12480,绿色)、总收入(¥486200,黄色)和好评率(98%,红色),每行两个单元格通过 layoutWeight(1) 均分宽度。settingsRowBuilder 接收图标、标签和提示文本三个参数,渲染为设置列表行——左侧是圆角方形图标背景,中间是设置项名称,右侧是提示值和箭头。设置列表通过 Divider 组件在每行之间添加分割线,并使用 margin({ left: 50 }) 使分割线左缩进与文字对齐而非从图标开始,形成了 iOS 风格的分组列表视觉效果。整个个人中心页面还包括顶部的资料卡(头像、用户名、身份标签和编辑按钮),信息层次从上到下依次递减,符合移动端「F 型」视觉浏览习惯。

四、弹窗交互流程图

菜单页面

运输页面

市集页面

弹窗通用流程

确认

取消

设置 selectedXxx

showXxxModal = true

Stack 条件渲染弹窗

半透明遮罩拦截

表单交互

确认或取消

更新数据状态

关闭弹窗

showXxxModal = false

点击立即报名

signupDetailModal

点击已报名查看

cancelSignupModal

点击预约整车运输

bookingModal

点击编辑图标

editMenuModal

五、技术方案对比分析

技术维度本项目方案替代方案优势分析
状态管理@State + @Observed 局部状态AppStorage 全局存储各 Tab 状态独立隔离,减少不必要的全局重渲染,组件内聚性更强
动画实现setInterval + 纯函数步进animateTo / Transition API适合持续运行的装饰性粒子动画,可控性高,逻辑清晰,不影响交互
弹窗实现Stack 条件渲染 + zIndexbindSheet / CustomDialog完全自定义弹窗样式和动画,支持复杂表单和多层嵌套,无平台限制
图表实现基础组件手动拼装柱状图第三方图表库零依赖、体积小、样式完全可控,与设计系统无缝融合
数据驱动配置映射表 Record枚举 + Switch 分支新增状态只需加配置项,无需修改渲染逻辑,开闭原则友好
列表渲染ForEach + 过滤纯函数LazyForEach 懒加载数据量适中时 ForEach 足够,纯函数过滤保证数据不可变,调试友好
布局策略layoutWeight 弹性均分百分比 / 固定宽度自适应不同屏幕尺寸,等分布局简洁直观
样式复用@Builder 参数化构建器全局样式 / 组件继承灵活传参、上下文闭包访问状态,比静态样式更动态

六、关键技术要点与设计模式

在配置驱动方面,本项目展示了 ArkTS 中 Record<string, T> 类型映射表的典型应用。六组配置映射表(TRUCK_STATUS_CONFIGMARKET_STATUS_CONFIGSHIP_STATUS_CONFIGSPICY_CONFIGMENU_CATEGORY_CONFIGCUISINE_CONFIG)将业务状态的字符串键映射到包含标签、颜色、背景色和图标的元数据对象,UI 渲染时只需调用对应的 xxxMeta 辅助函数即可获取全部视觉信息。这种模式将「数据到展示」的映射逻辑集中管理,当产品需求变更时(如调整某个状态的颜色或图标),只需修改配置表而无需遍历所有渲染代码,体现了开闭原则和单一职责原则的工程价值。同时,每个辅助函数都使用空值合并运算符 ?? 提供默认值兜底,即使传入了未知的状态字符串也不会导致渲染崩溃,增强了系统的健壮性。

在不可变数据方面,项目中的所有列表操作都遵循了不可变更新原则。toggleInList 函数通过 filter 移除或 concat 添加元素,始终返回新数组而非原地修改;stepParticle 函数返回新的 ParticleItem 实例而非修改原对象;sortMarkets 通过 slice() 创建数组副本后再 sort,避免修改原始数据。这种模式在 HarmonyOS ArkTS API 24 的状态管理中至关重要,因为 @State 的变化检测依赖于引用比较——只有赋值一个新数组或新对象才能触发 UI 更新,原地修改数组元素不会触发渲染。因此,this.joinedMarketIds = this.joinedMarketIds.filter(...)this.particles = this.particles.map(...) 这些看似冗余的赋值操作实际上是框架状态驱动机制的必要条件。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================================
// 轮上食光 WHEEL KITCHEN — 移动餐车运输 + 美食市集平台
// 场景:移动餐车/美食摊位的整车运输 + 市集摊位预订 + 餐车社区
// 主题:美食复古橙黄风
// ============================================================================

// ============ 颜色常量(美食复古橙黄风) ============
const COLOR_BG: string = '#FFF9EF'
const COLOR_CARD: string = '#FFFFFF'
const COLOR_PRIMARY: string = '#E8590C'
const COLOR_PRIMARY_LIGHT: string = '#FFB366'
const COLOR_GREEN: string = '#2F9E44'
const COLOR_GREEN_LIGHT: string = '#B2E5B8'
const COLOR_RED: string = '#C92A2A'
const COLOR_YELLOW: string = '#F59F00'
const COLOR_TEXT_MAIN: string = '#3A2A15'
const COLOR_TEXT_SUB: string = '#8C7A5E'
const COLOR_TEXT_HINT: string = '#C4B49A'
const COLOR_BORDER: string = '#F0E4CE'
const COLOR_SUCCESS: string = '#2F9E44'
const COLOR_WARN: string = '#F59F00'
const COLOR_DANGER: string = '#C92A2A'

// ============ 配置接口 ============
interface TruckStatusMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface MarketStatusMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface ShipStatusMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface SpicyMeta {
  label: string
  icon: string
  color: string
}

interface MenuCategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface CuisineMeta {
  label: string
  icon: string
}

// ============ 配置映射 ============
const TRUCK_STATUS_CONFIG: Record<string, TruckStatusMeta> = {
  '营业中': { label: '营业中', color: COLOR_SUCCESS, bg: '#EBF7ED', icon: '🔥' },
  '休息中': { label: '休息中', color: COLOR_TEXT_SUB, bg: '#F5EFE2', icon: '😴' },
  '运输中': { label: '运输中', color: COLOR_WARN, bg: '#FEF5E0', icon: '🚚' }
}

const MARKET_STATUS_CONFIG: Record<string, MarketStatusMeta> = {
  '报名中': { label: '报名中', color: COLOR_PRIMARY, bg: '#FDEEE3', icon: '📝' },
  '进行中': { label: '进行中', color: COLOR_SUCCESS, bg: '#EBF7ED', icon: '🎪' },
  '已结束': { label: '已结束', color: COLOR_TEXT_HINT, bg: '#F5EFE2', icon: '🏁' }
}

const SHIP_STATUS_CONFIG: Record<string, ShipStatusMeta> = {
  '待装车': { label: '待装车', color: COLOR_WARN, bg: '#FEF5E0', icon: '📦' },
  '运输中': { label: '运输中', color: COLOR_PRIMARY, bg: '#FDEEE3', icon: '🚛' },
  '已到位': { label: '已到位', color: COLOR_SUCCESS, bg: '#EBF7ED', icon: '✅' }
}

const SPICY_CONFIG: Record<string, SpicyMeta> = {
  '不辣': { label: '不辣', icon: '🥛', color: COLOR_GREEN },
  '微辣': { label: '微辣', icon: '🌶', color: COLOR_YELLOW },
  '中辣': { label: '中辣', icon: '🌶️', color: COLOR_PRIMARY },
  '重辣': { label: '重辣', icon: '🔥', color: COLOR_DANGER }
}

const MENU_CATEGORY_CONFIG: Record<string, MenuCategoryMeta> = {
  '全部': { label: '全部', icon: '🍽', color: COLOR_TEXT_MAIN, bg: '#F5EFE2' },
  '招牌': { label: '招牌', icon: '👑', color: COLOR_PRIMARY, bg: '#FDEEE3' },
  '小吃': { label: '小吃', icon: '🍟', color: COLOR_YELLOW, bg: '#FEF5E0' },
  '主食': { label: '主食', icon: '🍚', color: COLOR_RED, bg: '#FBE9E9' },
  '饮品': { label: '饮品', icon: '🥤', color: COLOR_GREEN, bg: '#EBF7ED' },
  '甜点': { label: '甜点', icon: '🍩', color: '#D6336C', bg: '#FDE8F1' }
}

const CUISINE_CONFIG: Record<string, CuisineMeta> = {
  '全部': { label: '全部', icon: '🍽' },
  '烧烤': { label: '烧烤', icon: '🍢' },
  '汉堡': { label: '汉堡', icon: '🍔' },
  '咖啡': { label: '咖啡', icon: '☕' },
  '甜品': { label: '甜品', icon: '🍰' },
  '亚洲菜': { label: '亚洲菜', icon: '🍜' },
  '墨西哥卷': { label: '墨西哥卷', icon: '🌮' }
}

const CUISINE_FILTER: string[] = ['全部', '烧烤', '汉堡', '咖啡', '甜品', '亚洲菜', '墨西哥卷']
const MARKET_SORTS: string[] = ['最新', '人气', '距离']
const SHIP_SERVICES: string[] = ['拖车', '卸货', '摆位', '发电机租赁']
const SHIP_TIME_SLOTS: string[] = ['上午', '下午', '夜间']
const SPICY_LEVELS: string[] = ['不辣', '微辣', '中辣', '重辣']
const STOCK_STATUS: string[] = ['有货', '限量', '售罄']
const BOOTH_TYPES: string[] = ['标准摊位', '角落摊位', '双倍摊位']

// ============ 数据模型接口 ============
interface TruckModel {
  id: number
  name: string
  cuisine: string
  icon: string
  rating: number
  outingCount: number
  status: string
  color: string
}

interface MarketModel {
  id: number
  name: string
  location: string
  date: string
  boothCount: number
  fee: number
  status: string
  color: string
}

interface TruckShipModel {
  id: number
  orderNo: string
  pickup: string
  destination: string
  truckName: string
  price: number
  status: string
  date: string
}

interface DishItemModel {
  id: number
  name: string
  price: number
  category: string
  monthlySales: number
  spicyLevel: string
  color: string
}

interface ReviewModel {
  id: number
  userName: string
  userAvatar: string
  rating: number
  content: string
  foodTag: string
  time: string
  likeCount: number
}

interface ParticleModel {
  x: number
  y: number
  size: number
  opacity: number
  emoji: string
  speed: number
  drift: number
}

// ============ @Observed 数据类 ============
@Observed
class TruckItem implements TruckModel {
  id: number = 0
  name: string = ''
  cuisine: string = ''
  icon: string = '🚚'
  rating: number = 5.0
  outingCount: number = 0
  status: string = '休息中'
  color: string = COLOR_PRIMARY

  constructor(id: number, name: string, cuisine: string, icon: string, rating: number,
    outingCount: number, status: string, color: string) {
    this.id = id
    this.name = name
    this.cuisine = cuisine
    this.icon = icon
    this.rating = rating
    this.outingCount = outingCount
    this.status = status
    this.color = color
  }
}

@Observed
class MarketItem implements MarketModel {
  id: number = 0
  name: string = ''
  location: string = ''
  date: string = ''
  boothCount: number = 0
  fee: number = 0
  status: string = '报名中'
  color: string = COLOR_PRIMARY

  constructor(id: number, name: string, location: string, date: string,
    boothCount: number, fee: number, status: string, color: string) {
    this.id = id
    this.name = name
    this.location = location
    this.date = date
    this.boothCount = boothCount
    this.fee = fee
    this.status = status
    this.color = color
  }
}

@Observed
class TruckShipItem implements TruckShipModel {
  id: number = 0
  orderNo: string = ''
  pickup: string = ''
  destination: string = ''
  truckName: string = ''
  price: number = 0
  status: string = '待装车'
  date: string = ''

  constructor(id: number, orderNo: string, pickup: string, destination: string,
    truckName: string, price: number, status: string, date: string) {
    this.id = id
    this.orderNo = orderNo
    this.pickup = pickup
    this.destination = destination
    this.truckName = truckName
    this.price = price
    this.status = status
    this.date = date
  }
}

@Observed
class DishFoodItem implements DishItemModel {
  id: number = 0
  name: string = ''
  price: number = 0
  category: string = '小吃'
  monthlySales: number = 0
  spicyLevel: string = '不辣'
  color: string = COLOR_PRIMARY_LIGHT

  constructor(id: number, name: string, price: number, category: string,
    monthlySales: number, spicyLevel: string, color: string) {
    this.id = id
    this.name = name
    this.price = price
    this.category = category
    this.monthlySales = monthlySales
    this.spicyLevel = spicyLevel
    this.color = color
  }
}

@Observed
class ReviewItem implements ReviewModel {
  id: number = 0
  userName: string = ''
  userAvatar: string = '😊'
  rating: number = 5.0
  content: string = ''
  foodTag: string = ''
  time: string = ''
  likeCount: number = 0

  constructor(id: number, userName: string, userAvatar: string, rating: number,
    content: string, foodTag: string, time: string, likeCount: number) {
    this.id = id
    this.userName = userName
    this.userAvatar = userAvatar
    this.rating = rating
    this.content = content
    this.foodTag = foodTag
    this.time = time
    this.likeCount = likeCount
  }
}

@Observed
class ParticleItem implements ParticleModel {
  x: number = 50
  y: number = 50
  size: number = 20
  opacity: number = 0.4
  emoji: string = '🍔'
  speed: number = 1
  drift: number = 0.5

  constructor(x: number, y: number, size: number, opacity: number,
    emoji: string, speed: number, drift: number) {
    this.x = x
    this.y = y
    this.size = size
    this.opacity = opacity
    this.emoji = emoji
    this.speed = speed
    this.drift = drift
  }
}

// ============ 静态数据:6辆餐车 ============
const mockTrucks: TruckItem[] = [
  new TruckItem(1, '橘火汉堡车', '汉堡', '🍔', 4.9, 328, '营业中', COLOR_PRIMARY),
  new TruckItem(2, '碳焰烧烤车', '烧烤', '🍢', 4.8, 276, '营业中', COLOR_RED),
  new TruckItem(3, '云朵甜品车', '甜品', '🍩', 4.7, 189, '休息中', COLOR_YELLOW),
  new TruckItem(4, '绿洲轻食车', '亚洲菜', '🥗', 4.6, 214, '营业中', COLOR_GREEN),
  new TruckItem(5, '卷卷塔可车', '墨西哥卷', '🌮', 4.8, 301, '运输中', '#D6336C'),
  new TruckItem(6, '醒神咖啡车', '咖啡', '☕', 4.5, 152, '休息中', COLOR_TEXT_SUB)
]

// ============ 静态数据:6场市集 ============
const mockMarkets: MarketItem[] = [
  new MarketItem(1, '滨江夜食集', '外滩源 · 滨江大道', '2026-09-05', 45, 380, '报名中', COLOR_PRIMARY),
  new MarketItem(2, '老城厢复古美食节', '豫园 · 老街广场', '2026-09-12', 60, 450, '报名中', COLOR_RED),
  new MarketItem(3, '大学城青春市集', '五角场 · 创智天地', '2026-08-28', 38, 260, '进行中', COLOR_GREEN),
  new MarketItem(4, '音乐节美食营地', '世博公园 · 草坪区', '2026-09-20', 80, 680, '报名中', '#D6336C'),
  new MarketItem(5, '创意园周末市集', 'M50 · 涂鸦街区', '2026-08-22', 30, 200, '已结束', COLOR_TEXT_SUB),
  new MarketItem(6, '海滨日落市集', '金山城市沙滩', '2026-09-01', 52, 320, '进行中', COLOR_YELLOW)
]

// ============ 静态数据:8条运输记录 ============
const mockShipments: TruckShipItem[] = [
  new TruckShipItem(1, 'WK20260801', '闵行餐车基地', '静安嘉里中心', '橘火汉堡车', 680, '已到位', '2026-08-01'),
  new TruckShipItem(2, 'WK20260806', '松江仓储园', '徐汇滨江', '碳焰烧烤车', 720, '运输中', '2026-08-06'),
  new TruckShipItem(3, 'WK20260810', '闵行餐车基地', '五角场大学城', '醒神咖啡车', 590, '待装车', '2026-08-10'),
  new TruckShipItem(4, 'WK20260813', '浦东集散中心', '世纪公园南门', '绿洲轻食车', 640, '已到位', '2026-08-13'),
  new TruckShipItem(5, 'WK20260816', '嘉定物流仓', '安亭汽车城', '卷卷塔可车', 760, '运输中', '2026-08-16'),
  new TruckShipItem(6, 'WK20260819', '闵行餐车基地', '虹桥天地', '云朵甜品车', 610, '待装车', '2026-08-19'),
  new TruckShipItem(7, 'WK20260821', '青浦冷链库', '朱家角古镇', '碳焰烧烤车', 690, '已到位', '2026-08-21'),
  new TruckShipItem(8, 'WK20260823', '闵行餐车基地', '前滩太古里', '橘火汉堡车', 730, '待装车', '2026-08-23')
]

// ============ 静态数据:12道菜品 ============
const mockDishItems: DishFoodItem[] = [
  new DishFoodItem(1, '芝士瀑布安格斯堡', 42, '招牌', 1284, '微辣', '#FDEEE3'),
  new DishFoodItem(2, '双层培根牛肉堡', 38, '招牌', 986, '中辣', '#FBE9E9'),
  new DishFoodItem(3, '现切波浪薯条', 18, '小吃', 2105, '不辣', '#FEF5E0'),
  new DishFoodItem(4, '炭烤蜂蜜鸡翅', 22, '小吃', 1560, '中辣', '#FDEEE3'),
  new DishFoodItem(5, '墨西哥牛肉卷', 26, '小吃', 890, '中辣', '#FDE8F1'),
  new DishFoodItem(6, '芝士玉米杯', 12, '小吃', 1320, '不辣', '#FEF5E0'),
  new DishFoodItem(7, '烟熏猪肋排饭', 36, '主食', 754, '微辣', '#FBE9E9'),
  new DishFoodItem(8, '韩式辣拌饭', 28, '主食', 668, '重辣', '#FDEEE3'),
  new DishFoodItem(9, '手打柠檬茶', 15, '饮品', 3210, '不辣', '#EBF7ED'),
  new DishFoodItem(10, '冰博克脏咖啡', 13, '饮品', 1780, '不辣', '#F5EFE2'),
  new DishFoodItem(11, '熔岩巧克力贝果', 20, '甜点', 940, '不辣', '#FDE8F1'),
  new DishFoodItem(12, '提拉米苏小杯', 22, '甜点', 610, '不辣', '#FEF5E0')
]

// ============ 静态数据:8条评价 ============
const mockReviews: ReviewItem[] = [
  new ReviewItem(1, '吃货小王', '🧑‍🍳', 5.0, '芝士瀑布名不虚传!拉丝能拉半米,牛肉饼炭火香气十足,排队20分钟也值!', '芝士瀑布安格斯堡', '2小时前', 328),
  new ReviewItem(2, '饕餮老李', '👨‍🦰', 4.5, '烧烤车的烤串火候到位,羊肉串外焦里嫩,就是出摊位置有点难找。', '炭烤蜂蜜鸡翅', '5小时前', 156),
  new ReviewItem(3, '甜品控Mia', '👩‍🎤', 5.0, '熔岩贝果切开瞬间治愈了整个加班夜,甜度刚好不齁,无限回购!', '熔岩巧克力贝果', '昨天 20:41', 274),
  new ReviewItem(4, '咖啡因选手', '🧑‍💻', 4.0, '冰博克 dirty 很醇,可惜下午三点就售罄了,建议老板多备货。', '冰博克脏咖啡', '昨天 15:22', 98),
  new ReviewItem(5, '夜宵战神', '👨‍🔧', 5.0, '韩式辣拌饭辣得超过瘾,深夜食堂天花板,配冰柠檬茶绝了。', '韩式辣拌饭', '2天前', 302),
  new ReviewItem(6, '轻食主义者', '👩‍🌾', 4.5, '绿洲轻食的沙拉很新鲜,酱汁低卡不腻,健身党的福音。', '鲜蔬轻食碗', '3天前', 121),
  new ReviewItem(7, '周末遛娃妈', '👩‍🍼', 5.0, '带孩子逛市集顺便解决了午饭,芝士玉米杯小朋友一个人干掉两杯。', '芝士玉米杯', '4天前', 187),
  new ReviewItem(8, '街拍美食家', '🧑‍🎨', 4.5, '餐车颜值和味道都在线,塔可的饼皮是现场压的,出片又出味。', '墨西哥牛肉卷', '5天前', 243)
]

// ============ 粒子初始数据 ============
const PARTICLE_EMOJIS: string[] = ['🍔', '🍕', '🌮', '🍩', '🍟', '🍕', '🍔', '🍩', '🌮']

function initParticles(): ParticleItem[] {
  return [
    new ParticleItem(6, 82, 24, 0.5, PARTICLE_EMOJIS[0], 1.2, 0.4),
    new ParticleItem(18, 64, 18, 0.35, PARTICLE_EMOJIS[1], 0.8, -0.3),
    new ParticleItem(32, 90, 28, 0.45, PARTICLE_EMOJIS[2], 1.5, 0.6),
    new ParticleItem(48, 55, 16, 0.3, PARTICLE_EMOJIS[3], 0.6, -0.5),
    new ParticleItem(62, 78, 22, 0.5, PARTICLE_EMOJIS[4], 1.1, 0.3),
    new ParticleItem(74, 40, 20, 0.4, PARTICLE_EMOJIS[5], 0.9, -0.4),
    new ParticleItem(86, 70, 26, 0.45, PARTICLE_EMOJIS[6], 1.3, 0.5),
    new ParticleItem(92, 92, 18, 0.3, PARTICLE_EMOJIS[7], 0.7, -0.2),
    new ParticleItem(12, 35, 22, 0.4, PARTICLE_EMOJIS[8], 1.0, 0.4)
  ]
}

// ============ 辅助纯函数 ============
function truckStatusMeta(status: string): TruckStatusMeta {
  return TRUCK_STATUS_CONFIG[status] ?? { label: status, color: COLOR_TEXT_SUB, bg: COLOR_BG, icon: '🚚' }
}

function marketStatusMeta(status: string): MarketStatusMeta {
  return MARKET_STATUS_CONFIG[status] ?? { label: status, color: COLOR_TEXT_SUB, bg: COLOR_BG, icon: '🎪' }
}

function shipStatusMeta(status: string): ShipStatusMeta {
  return SHIP_STATUS_CONFIG[status] ?? { label: status, color: COLOR_TEXT_SUB, bg: COLOR_BG, icon: '📦' }
}

function spicyMeta(level: string): SpicyMeta {
  return SPICY_CONFIG[level] ?? { label: level, icon: '🌶', color: COLOR_TEXT_SUB }
}

function getStarText(rating: number): string {
  return '★★★★★'.substring(0, Math.round(rating)) + '☆☆☆☆☆'.substring(0, 5 - Math.round(rating))
}

function barHeightVp(value: number, max: number, totalVp: number): string {
  return Math.max(Math.round(value / max * totalVp), 4).toString() + 'vp'
}

function formatPrice(n: number): string {
  return '¥' + n.toFixed(0)
}

function getOpenTruckCount(): number {
  return mockTrucks.filter((t: TruckItem) => t.status === '营业中').length
}

function getTotalOutings(): number {
  return mockTrucks.reduce((sum: number, t: TruckItem) => sum + t.outingCount, 0)
}

function sortMarkets(list: MarketItem[], sort: string): MarketItem[] {
  if (sort === '人气') {
    return list.slice().sort((a: MarketItem, b: MarketItem) => b.boothCount - a.boothCount)
  }
  if (sort === '距离') {
    return list.slice().reverse()
  }
  return list
}

function filterDishItems(list: DishFoodItem[], category: string): DishFoodItem[] {
  if (category === '全部') {
    return list
  }
  return list.filter((m: DishFoodItem) => m.category === category)
}

function toggleInList(list: string[], v: string): string[] {
  if (list.indexOf(v) >= 0) {
    return list.filter((x: string) => x !== v)
  }
  return list.concat([v])
}

function stepParticle(p: ParticleItem): ParticleItem {
  let ny: number = p.y - p.speed
  let nx: number = p.x + p.drift
  if (ny < -6) {
    ny = 104
  }
  if (nx < -6) {
    nx = 104
  }
  if (nx > 104) {
    nx = -4
  }
  return new ParticleItem(nx, ny, p.size, p.opacity, p.emoji, p.speed, p.drift)
}

function getMenuCategoryCount(category: string): number {
  return mockDishItems.filter((m: DishFoodItem) => m.category === category).length
}

// ============ 图表数据 ============
const SHIP_MONTHS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月']
const SHIP_MONTH_COUNTS: number[] = [8, 12, 9, 14, 11, 16]
const RATING_STARS: string[] = ['1星', '2星', '3星', '4星', '5星']
const RATING_DISTRIBUTION: number[] = [2, 3, 8, 24, 63]
const MENU_CATEGORY_KEYS: string[] = ['招牌', '小吃', '主食', '饮品', '甜点']

// ============ 底部 Tab 枚举 ============
enum FoodTab {
  TRUCK = 0,
  MARKET = 1,
  SHIP = 2,
  MENU = 3,
  RATING = 4,
  PROFILE = 5
}

// ============================================================================
// 入口主页面
// ============================================================================
@Entry
@Component
struct WheelKitchenApp {
  @State activeTab: FoodTab = FoodTab.TRUCK
  @State searchKeyword: string = ''
  @State selectedCuisine: string = '全部'
  @State particles: ParticleItem[] = initParticles()
  private particleTimer: number = -1

  aboutToAppear() {
    this.particleTimer = setInterval(() => {
      this.particles = this.particles.map((p: ParticleItem): ParticleItem => stepParticle(p))
    }, 300)
  }

  aboutToDisappear() {
    if (this.particleTimer >= 0) {
      clearInterval(this.particleTimer)
    }
  }

  @Builder contentArea() {
    Column() {
      if (this.activeTab === FoodTab.TRUCK) {
        TruckContent()
      } else if (this.activeTab === FoodTab.MARKET) {
        MarketContent()
      } else if (this.activeTab === FoodTab.SHIP) {
        ShipContent()
      } else if (this.activeTab === FoodTab.MENU) {
        MenuContent()
      } else if (this.activeTab === FoodTab.RATING) {
        RatingContent()
      } else {
        ProfileContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder bottomTabItem(icon: string, label: string, tab: FoodTab) {
    Column() {
      Text(icon).fontSize(19).opacity(this.activeTab === tab ? 1.0 : 0.45)
      Text(label).fontSize(8)
        .fontColor(this.activeTab === tab ? COLOR_PRIMARY : COLOR_TEXT_HINT)
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 1 })
      if (this.activeTab === tab) {
        Column().width(16).height(3)
          .backgroundColor(COLOR_PRIMARY).borderRadius(2).margin({ top: 2 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 5, bottom: 5 })
    .onClick(() => { this.activeTab = tab })
  }

  @Builder particleLayer() {
    Column() {
      ForEach(this.particles, (p: ParticleItem) => {
        Text(p.emoji)
          .fontSize(p.size)
          .opacity(p.opacity)
          .position({ x: p.x.toString() + '%', y: p.y.toString() + '%' })
      })
    }
    .width('100%').height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  build() {
    Stack() {
      Column() {
        // ===== 头部(无动画) =====
        Column() {
          Row() {
            Column() {
              Text('🚚 轮上食光').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
              Text('WHEEL KITCHEN').fontSize(7).fontColor(COLOR_TEXT_HINT).margin({ top: 1 })
            }
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 14 })

            Row() {
              Text('🔍').fontSize(13)
              TextInput({ placeholder: '搜索美食/餐车' })
                .placeholderColor(COLOR_TEXT_HINT)
                .fontSize(12)
                .layoutWeight(1)
                .height(32)
                .backgroundColor(COLOR_BG)
                .borderRadius(16)
                .margin({ left: 5, right: 5 })
                .onChange((v: string) => { this.searchKeyword = v })
              Text('🎙').fontSize(13)
            }
            .layoutWeight(1)
            .height(34)
            .backgroundColor(COLOR_BG)
            .borderRadius(17)
            .padding({ left: 10, right: 10 })
            .margin({ left: 10 })
            .alignItems(VerticalAlign.Center)

            Column() {
              Text('📍').fontSize(16)
              Text('静安').fontSize(8).fontColor(COLOR_TEXT_SUB)
            }
            .alignItems(HorizontalAlign.Center)
            .padding({ left: 10, right: 14 })
            .onClick(() => { this.activeTab = this.activeTab })
          }
          .width('100%')
          .height(52)
          .alignItems(VerticalAlign.Center)

          Scroll() {
            Row() {
              ForEach(CUISINE_FILTER, (c: string) => {
                Text((CUISINE_CONFIG[c]?.icon ?? '🍽') + ' ' + c)
                  .fontSize(11)
                  .fontColor(this.selectedCuisine === c ? COLOR_CARD : COLOR_TEXT_SUB)
                  .fontWeight(this.selectedCuisine === c ? FontWeight.Bold : FontWeight.Normal)
                  .backgroundColor(this.selectedCuisine === c ? COLOR_PRIMARY : COLOR_CARD)
                  .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                  .borderRadius(14)
                  .margin({ left: 4, right: 4 })
                  .onClick(() => { this.selectedCuisine = c })
              })
            }
            .padding({ left: 10, right: 10 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .height(34)
          .width('100%')
        }
        .width('100%')
        .backgroundColor(COLOR_CARD)
        .shadow({ radius: 4, color: '#143A2A15', offsetY: 2 })

        // ===== 内容区 =====
        this.contentArea()

        // ===== 底部Tab =====
        Row() {
          this.bottomTabItem('🚚', '餐车', FoodTab.TRUCK)
          this.bottomTabItem('🏪', '市集', FoodTab.MARKET)
          this.bottomTabItem('📦', '运输', FoodTab.SHIP)
          this.bottomTabItem('🍔', '菜单', FoodTab.MENU)
          this.bottomTabItem('⭐', '评价', FoodTab.RATING)
          this.bottomTabItem('👤', '我的', FoodTab.PROFILE)
        }
        .width('100%')
        .backgroundColor(COLOR_CARD)
        .padding({ top: 4, bottom: 6 })
        .shadow({ radius: 8, color: '#1A3A2A15', offsetY: -2 })
      }
      .width('100%').height('100%')

      // ===== 全屏食物粒子层(不阻挡点击) =====
      this.particleLayer()
    }
    .width('100%').height('100%')
    .backgroundColor(COLOR_BG)
  }
}

// ============================================================================
// Tab1 餐车页:大数字 + 大卡横滑轮播 + 餐车列表
// ============================================================================
@Component
struct TruckContent {
  @State selectedTruckId: number = 1

  @Builder truckBigCard(t: TruckItem) {
    Stack({ alignContent: Alignment.BottomStart }) {
      Column()
        .width('100%').height('100%')
        .backgroundColor(t.color)
      Column()
        .width('100%').height('100%')
        .backgroundColor('#33FFFFFF')
      Text(t.icon)
        .fontSize(52)
        .position({ x: '62%', y: '18%' })
      Column() {
        Row() {
          Text(t.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
        }
        Row() {
          Text('★ ' + t.rating.toFixed(1)).fontSize(12)
            .fontColor(COLOR_CARD).fontWeight(FontWeight.Bold)
          Text(' · ' + t.cuisine).fontSize(11).fontColor(COLOR_CARD)
          Text(' · 出摊' + t.outingCount + '次').fontSize(11).fontColor(COLOR_CARD)
        }
        .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .padding(12)
    }
    .width('78%')
    .height(148)
    .borderRadius(16)
    .margin({ left: 6, right: 6 })
    .clip(true)
  }

  @Builder truckListCard(t: TruckItem) {
    Row() {
      Column() {
        Text(t.icon).fontSize(26)
      }
      .width(52).height(52)
      .backgroundColor(t.color + '22')
      .borderRadius(14)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

      Column() {
        Row() {
          Text(t.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
          Text(truckStatusMeta(t.status).icon + ' ' + truckStatusMeta(t.status).label)
            .fontSize(9).fontColor(truckStatusMeta(t.status).color)
            .backgroundColor(truckStatusMeta(t.status).bg)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(8)
            .margin({ left: 6 })
        }
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('🍳 ' + t.cuisine).fontSize(11).fontColor(COLOR_TEXT_SUB)
          Text('★ ' + t.rating.toFixed(1)).fontSize(11)
            .fontColor(COLOR_WARN).fontWeight(FontWeight.Bold)
            .margin({ left: 10 })
        }
        .margin({ top: 4 })

        Row() {
          Text('出摊 ' + t.outingCount + ' 次 · ' + (t.status === '营业中' ? '现在去吃刚好吃' : '关注出摊提醒'))
            .fontSize(10).fontColor(COLOR_TEXT_HINT)
        }
        .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 12 })

      Column() {
        Text(this.selectedTruckId === t.id ? '已关注' : '+ 关注')
          .fontSize(11)
          .fontColor(this.selectedTruckId === t.id ? COLOR_TEXT_HINT : COLOR_PRIMARY)
          .backgroundColor(this.selectedTruckId === t.id ? COLOR_BG : '#FDEEE3')
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .borderRadius(12)
          .onClick(() => {
            if (this.selectedTruckId === t.id) {
              this.selectedTruckId = 0
            } else {
              this.selectedTruckId = t.id
            }
          })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLOR_CARD)
    .borderRadius(14)
    .border({ width: 1, color: COLOR_BORDER, radius: 14 })
    .margin({ left: 12, right: 12, top: 8 })
    .alignItems(VerticalAlign.Center)
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 营业中大数字卡
          Row() {
            Column() {
              Text(getOpenTruckCount().toString())
                .fontSize(44)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLOR_PRIMARY)
              Text('辆餐车营业中').fontSize(11).fontColor(COLOR_TEXT_SUB).margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column() {
              Text('🔥').fontSize(22)
              Text('累计出摊 ' + getTotalOutings() + ' 次')
                .fontSize(10).fontColor(COLOR_TEXT_HINT).margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 16, bottom: 16 })
          .backgroundColor(COLOR_CARD)
          .borderRadius(16)
          .border({ width: 1, color: COLOR_BORDER, radius: 16 })
          .margin({ left: 12, right: 12, top: 10 })

          // 大卡轮播区标题
          Row() {
            Text('🔥 本周热门餐车').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
            Column().layoutWeight(1)
            Text('横滑查看 >').fontSize(10).fontColor(COLOR_TEXT_HINT)
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 14 })

          // 大卡横滑轮播(3张)
          Scroll() {
            Row() {
              this.truckBigCard(mockTrucks[0])
              this.truckBigCard(mockTrucks[1])
              this.truckBigCard(mockTrucks[4])
            }
            .padding({ left: 6, right: 6 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 8 })

          // 餐车列表标题
          Row() {
            Text('🚚 全部餐车(' + mockTrucks.length + ')')
              .fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
            Column().layoutWeight(1)
            Text('按评分排序').fontSize(10).fontColor(COLOR_TEXT_HINT)
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 16, bottom: 4 })

          ForEach(mockTrucks, (t: TruckItem) => {
            this.truckListCard(t)
          })

          Text('— 轮上食光 · 让美味上路 —').fontSize(10)
            .fontColor(COLOR_TEXT_HINT)
            .margin({ top: 16, bottom: 20 })
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%').height('100%')
  }
}

// ============================================================================
// Tab2 市集页:排序药丸 + 市集活动卡片列表
// ============================================================================
@Component
struct MarketContent {
  @State marketSort: string = '最新'
  @State showCancelModal: boolean = false
  @State showSignupModal: boolean = false
  @State selectedMarket: MarketItem | null = null
  @State joinedMarketIds: number[] = [3, 6]
  @State boothType: string = '标准摊位'
  @State signupDays: number = 2
  @State needPower: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(58,42,21,0.55)')
      .onClick(onClose)
  }

  @Builder marketCardBuilder(m: MarketItem) {
    Column() {
      // 封面色块
      Stack({ alignContent: Alignment.BottomStart }) {
        Column()
          .width('100%').height(86)
          .backgroundColor(m.color)
          .borderRadius({ topLeft: 14, topRight: 14 })
        Column()
          .width('100%').height(86)
          .backgroundColor('#26FFFFFF')
          .borderRadius({ topLeft: 14, topRight: 14 })
        Text('🎪').fontSize(34).position({ x: '50%', y: '50%' })
        Text(marketStatusMeta(m.status).icon + ' ' + marketStatusMeta(m.status).label)
          .fontSize(10)
          .fontColor(marketStatusMeta(m.status).color)
          .backgroundColor(COLOR_CARD)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(10)
          .position({ x: '74%', y: 8 })
        Text(m.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLOR_CARD)
          .padding(10)
      }
      .width('100%')
      .height(86)
      .clip(true)

      Column() {
        Row() {
          Text('📍 ' + m.location).fontSize(11).fontColor(COLOR_TEXT_SUB)
        }
        Row() {
          Text('📅 ' + m.date).fontSize(11).fontColor(COLOR_TEXT_SUB)
          Text(' · ' + m.boothCount + '个摊位').fontSize(11).fontColor(COLOR_TEXT_SUB)
        }
        .margin({ top: 4 })

        Row() {
          Column() {
            Text('摊位参与费').fontSize(9).fontColor(COLOR_TEXT_HINT)
            Text(formatPrice(m.fee)).fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLOR_PRIMARY)
              .margin({ top: 1 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          if (this.joinedMarketIds.indexOf(m.id) >= 0) {
            Text('已报名 · 查看')
              .fontSize(11).fontColor(COLOR_GREEN)
              .backgroundColor('#EBF7ED')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(14)
              .onClick(() => {
                this.selectedMarket = m
                this.showCancelModal = true
              })
          } else {
            Text(m.status === '报名中' ? '立即报名' : '不可报名')
              .fontSize(11)
              .fontColor(m.status === '报名中' ? COLOR_CARD : COLOR_TEXT_HINT)
              .backgroundColor(m.status === '报名中' ? COLOR_PRIMARY : COLOR_BG)
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(14)
              .onClick(() => {
                if (m.status === '报名中') {
                  this.selectedMarket = m
                  this.signupDays = 1
                  this.boothType = '标准摊位'
                  this.needPower = false
                  this.showSignupModal = true
                }
              })
          }
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Bottom)
      }
      .padding(12)
      .width('100%')
    }
    .width('100%')
    .backgroundColor(COLOR_CARD)
    .borderRadius(14)
    .border({ width: 1, color: COLOR_BORDER, radius: 14 })
    .margin({ left: 12, right: 12, top: 10 })
    .clip(true)
  }

  // ========== 弹框3:取消市集报名(警示式) ==========
  @Builder cancelSignupModal() {
    Column() {
      this.modalOverlay(() => { this.showCancelModal = false })
      Column() {
        Text('⚠️').fontSize(46).margin({ top: 22 })
        Text('确认取消此报名?').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
        Text('取消后摊位将释放给其他餐车').fontSize(12).fontColor(COLOR_DANGER).margin({ top: 4 })

        Column() {
          Row() {
            Text('市集名称').fontSize(11).fontColor(COLOR_TEXT_SUB)
            Column().layoutWeight(1)
            Text(this.selectedMarket?.name ?? '').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
          }
          .width('100%').margin({ top: 4 })
          Row() {
            Text('活动日期').fontSize(11).fontColor(COLOR_TEXT_SUB)
            Column().layoutWeight(1)
            Text(this.selectedMarket?.date ?? '').fontSize(12).fontColor(COLOR_TEXT_MAIN)
          }
          .width('100%').margin({ top: 6 })
          Row() {
            Text('参与费用').fontSize(11).fontColor(COLOR_TEXT_SUB)
            Column().layoutWeight(1)
            Text(formatPrice(this.selectedMarket?.fee ?? 0)).fontSize(12)
              .fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
          }
          .width('100%').margin({ top: 6 })
        }
        .width('100%')
        .backgroundColor(COLOR_BG)
        .borderRadius(10)
        .border({ width: 1, color: COLOR_BORDER, radius: 10 })
        .padding(14)
        .margin({ top: 16, left: 20, right: 20 })

        Row() {
          Text('再想想')
            .fontSize(13).fontColor(COLOR_TEXT_SUB)
            .backgroundColor(COLOR_BG)
            .borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .onClick(() => { this.showCancelModal = false })
          Text('确认取消')
            .fontSize(13).fontColor(COLOR_CARD)
            .backgroundColor(COLOR_DANGER)
            .borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => {
              if (this.selectedMarket != null) {
                this.joinedMarketIds = this.joinedMarketIds.filter((id: number) => id !== this.selectedMarket?.id)
              }
              this.showCancelModal = false
            })
        }
        .justifyContent(FlexAlign.Center)
        .padding({ top: 20, bottom: 22, left: 20, right: 20 })
        .width('100%')
      }
      .width('80%')
      .backgroundColor(COLOR_CARD)
      .borderRadius(18)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '10%', y: '24%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // ========== 弹框4:市集报名详情(表单式) ==========
  @Builder signupDetailModal() {
    Column() {
      this.modalOverlay(() => { this.showSignupModal = false })
      Column() {
        // 头部封面
        Stack({ alignContent: Alignment.BottomStart }) {
          Column()
            .width('100%').height(92)
            .backgroundColor(this.selectedMarket?.color ?? COLOR_PRIMARY)
          Column()
            .width('100%').height(92)
            .backgroundColor('#26FFFFFF')
          Column() {
            Text(this.selectedMarket?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
            Text('📍 ' + (this.selectedMarket?.location ?? '')).fontSize(10).fontColor(COLOR_CARD).margin({ top: 2 })
            Text('📅 ' + (this.selectedMarket?.date ?? '') + ' · ' + (this.selectedMarket?.boothCount ?? 0) + '个摊位')
              .fontSize(10).fontColor(COLOR_CARD).margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .padding(12)

          Text('✕').fontSize(14).fontColor(COLOR_CARD)
            .width(28).height(28).textAlign(TextAlign.Center)
            .backgroundColor('#4D3A2A15')
            .borderRadius(14)
            .position({ x: '86%', y: 10 })
            .onClick(() => { this.showSignupModal = false })
        }
        .width('100%')
        .height(92)
        .borderRadius({ topLeft: 16, topRight: 16 })
        .clip(true)

        Scroll() {
          Column() {
            Text('摊位类型').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 8 })
            Row() {
              ForEach(BOOTH_TYPES, (b: string) => {
                Text(b + (b === '双倍摊位' ? ' ¥×2' : ''))
                  .fontSize(11)
                  .fontColor(this.boothType === b ? COLOR_CARD : COLOR_TEXT_SUB)
                  .backgroundColor(this.boothType === b ? COLOR_PRIMARY : COLOR_BG)
                  .padding({ left: 10, right: 10, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .margin({ left: 3, right: 3 })
                  .onClick(() => { this.boothType = b })
              })
            }
            .margin({ top: 6 })

            Text('参与天数').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 14 })
            Row() {
              Text('−')
                .fontSize(18).fontColor(COLOR_PRIMARY)
                .width(34).height(34).textAlign(TextAlign.Center)
                .backgroundColor(COLOR_BG)
                .borderRadius(17)
                .border({ width: 1, color: COLOR_BORDER, radius: 17 })
                .onClick(() => {
                  if (this.signupDays > 1) {
                    this.signupDays = this.signupDays - 1
                  }
                })
              Text(this.signupDays.toString() + ' 天')
                .fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
                .width(80).textAlign(TextAlign.Center)
              Text('+')
                .fontSize(18).fontColor(COLOR_PRIMARY)
                .width(34).height(34).textAlign(TextAlign.Center)
                .backgroundColor(COLOR_BG)
                .borderRadius(17)
                .border({ width: 1, color: COLOR_BORDER, radius: 17 })
                .onClick(() => {
                  if (this.signupDays < 7) {
                    this.signupDays = this.signupDays + 1
                  }
                })
              Column().layoutWeight(1)
              Text('¥' + (((this.selectedMarket?.fee ?? 0) * this.signupDays * (this.boothType === '双倍摊位' ? 2 : 1)).toFixed(0)))
                .fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
            }
            .width('100%')
            .margin({ top: 8 })
            .alignItems(VerticalAlign.Center)

            Row() {
              Text(this.needPower ? '☑' : '☐')
                .fontSize(17)
                .fontColor(this.needPower ? COLOR_PRIMARY : COLOR_TEXT_HINT)
              Text('⚡ 需要电力支持(餐车设备用电)')
                .fontSize(12).fontColor(COLOR_TEXT_SUB)
                .margin({ left: 8 })
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .onClick(() => { this.needPower = !this.needPower })

            Column() {
              Text('💡 报名须知').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_SUB)
              Text('· 摊位确认后48小时内可免费取消\n· 电力位数量有限,确认后将锁定\n· 到场摆位请提前1小时,运输可预约平台餐车拖运')
                .fontSize(10).fontColor(COLOR_TEXT_HINT)
                .lineHeight(16)
                .margin({ top: 4 })
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
            .backgroundColor(COLOR_BG)
            .borderRadius(10)
            .padding(12)
            .margin({ top: 6 })

            Text('立即报名')
              .fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
              .width('100%')
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLOR_PRIMARY)
              .borderRadius(22)
              .margin({ top: 16, bottom: 16 })
              .onClick(() => {
                if (this.selectedMarket != null) {
                  this.joinedMarketIds = this.joinedMarketIds.concat([this.selectedMarket.id])
                }
                this.showSignupModal = false
              })
          }
          .width('100%')
          .padding({ left: 18, right: 18 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .constraintSize({ maxHeight: '80%' })
        .width('100%')
      }
      .width('92%')
      .height('70%')
      .backgroundColor(COLOR_CARD)
      .borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '4%', y: '14%' })
      .clip(true)
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        // 排序药丸
        Row() {
          ForEach(MARKET_SORTS, (s: string) => {
            Text(s)
              .fontSize(11)
              .fontColor(this.marketSort === s ? COLOR_CARD : COLOR_TEXT_SUB)
              .backgroundColor(this.marketSort === s ? COLOR_PRIMARY : COLOR_CARD)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(14)
              .margin({ left: 4, right: 4 })
              .onClick(() => { this.marketSort = s })
          })
          Column().layoutWeight(1)
          Text('🎪 已报名 ' + this.joinedMarketIds.length + ' 场')
            .fontSize(10).fontColor(COLOR_GREEN)
            .backgroundColor('#EBF7ED')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .borderRadius(10)
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 6 })

        Scroll() {
          Column() {
            ForEach(sortMarkets(mockMarkets, this.marketSort), (m: MarketItem) => {
              this.marketCardBuilder(m)
            })
            Text('— 更多市集筹备中 —').fontSize(10)
              .fontColor(COLOR_TEXT_HINT)
              .margin({ top: 16, bottom: 20 })
          }
          .width('100%')
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')
      }
      .width('100%').height('100%')

      if (this.showCancelModal) { this.cancelSignupModal() }
      if (this.showSignupModal) { this.signupDetailModal() }
    }
    .width('100%').height('100%')
  }
}

// ============================================================================
// Tab3 运输页:统计条 + 预约表单 + 柱状图 + 运输记录
// ============================================================================
@Component
struct ShipContent {
  @State formTruck: string = '橘火汉堡车'
  @State formPickup: string = ''
  @State formDestination: string = ''
  @State formDate: string = ''
  @State formServices: string[] = []
  @State formSlot: string = '上午'
  @State showBookingModal: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(58,42,21,0.55)')
      .onClick(onClose)
  }

  @Builder shipRecordBuilder(s: TruckShipItem) {
    Row() {
      Column() {
        Text(shipStatusMeta(s.status).icon)
          .fontSize(20)
      }
      .width(40).height(40)
      .backgroundColor(shipStatusMeta(s.status).bg)
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

      Column() {
        Row() {
          Text(s.pickup + ' → ' + s.destination)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLOR_TEXT_MAIN)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .layoutWeight(1)
        }
        Row() {
          Text('🚚 ' + s.truckName + ' · ' + s.orderNo)
            .fontSize(10).fontColor(COLOR_TEXT_HINT)
        }
        .margin({ top: 3 })
        Row() {
          Text(shipStatusMeta(s.status).label)
            .fontSize(9)
            .fontColor(shipStatusMeta(s.status).color)
            .backgroundColor(shipStatusMeta(s.status).bg)
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
            .borderRadius(7)
          Text(s.date).fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ left: 6 })
        }
        .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 10 })

      Column() {
        Text(formatPrice(s.price)).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
        Text('跟踪 >').fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ top: 3 })
          .onClick(() => { this.formTruck = s.truckName })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLOR_CARD)
    .borderRadius(12)
    .border({ width: 1, color: COLOR_BORDER, radius: 12 })
    .margin({ left: 12, right: 12, top: 6 })
    .alignItems(VerticalAlign.Center)
  }

  // ========== 弹框1:预约餐车运输(橙黄表单式) ==========
  @Builder bookingModal() {
    Column() {
      this.modalOverlay(() => { this.showBookingModal = false })
      Column() {
        // 橙色头部
        Row() {
          Column() {
            Text('🚚 预约餐车运输').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
            Text('整车拖运 · 专人摆位 · 全程保险').fontSize(9).fontColor('#FFE3CC').margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('✕').fontSize(16).fontColor(COLOR_CARD)
            .width(28).height(28).textAlign(TextAlign.Center)
            .backgroundColor('#33FFFFFF')
            .borderRadius(14)
            .onClick(() => { this.showBookingModal = false })
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 16, bottom: 14 })
        .backgroundColor(COLOR_PRIMARY)
        .borderRadius({ topLeft: 16, topRight: 16 })

        Scroll() {
          Column() {
            Text('选择餐车').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 10 })
            Scroll() {
              Row() {
                ForEach(mockTrucks, (t: TruckItem) => {
                  Text(t.icon + ' ' + t.name)
                    .fontSize(10)
                    .fontColor(this.formTruck === t.name ? COLOR_CARD : COLOR_TEXT_SUB)
                    .backgroundColor(this.formTruck === t.name ? COLOR_PRIMARY : COLOR_BG)
                    .padding({ left: 9, right: 9, top: 5, bottom: 5 })
                    .borderRadius(12)
                    .margin({ left: 3, right: 3 })
                    .onClick(() => { this.formTruck = t.name })
                })
              }
              .padding({ top: 2, bottom: 2 })
            }
            .scrollable(ScrollDirection.Horizontal)
            .scrollBar(BarState.Off)
            .width('100%')
            .margin({ top: 6 })

            Text('出发地').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 12 })
            TextInput({ placeholder: this.formPickup === '' ? '如:闵行餐车基地' : this.formPickup })
              .placeholderColor(COLOR_TEXT_HINT)
              .fontSize(12)
              .height(38)
              .backgroundColor(COLOR_BG)
              .borderRadius(10)
              .margin({ top: 6 })
              .onChange((v: string) => { this.formPickup = v })

            Text('目的地').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 12 })
            TextInput({ placeholder: this.formDestination === '' ? '如:静安嘉里中心北门' : this.formDestination })
              .placeholderColor(COLOR_TEXT_HINT)
              .fontSize(12)
              .height(38)
              .backgroundColor(COLOR_BG)
              .borderRadius(10)
              .margin({ top: 6 })
              .onChange((v: string) => { this.formDestination = v })

            Text('运输日期').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 12 })
            TextInput({ placeholder: this.formDate === '' ? '如:2026-08-26' : this.formDate })
              .placeholderColor(COLOR_TEXT_HINT)
              .fontSize(12)
              .height(38)
              .backgroundColor(COLOR_BG)
              .borderRadius(10)
              .margin({ top: 6 })
              .onChange((v: string) => { this.formDate = v })

            Text('时段').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 12 })
            Row() {
              ForEach(SHIP_TIME_SLOTS, (slot: string) => {
                Text(slot)
                  .fontSize(11)
                  .fontColor(this.formSlot === slot ? COLOR_CARD : COLOR_TEXT_SUB)
                  .backgroundColor(this.formSlot === slot ? COLOR_GREEN : COLOR_BG)
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .margin({ left: 3, right: 3 })
                  .onClick(() => { this.formSlot = slot })
              })
            }
            .margin({ top: 6 })

            Text('特殊服务').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 12 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(SHIP_SERVICES, (sv: string) => {
                Text((this.formServices.indexOf(sv) >= 0 ? '✓ ' : '○ ') + sv)
                  .fontSize(11)
                  .fontColor(this.formServices.indexOf(sv) >= 0 ? COLOR_PRIMARY : COLOR_TEXT_SUB)
                  .backgroundColor(this.formServices.indexOf(sv) >= 0 ? '#FDEEE3' : COLOR_BG)
                  .padding({ left: 10, right: 10, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .margin({ right: 6, top: 4, bottom: 4 })
                  .onClick(() => { this.formServices = toggleInList(this.formServices, sv) })
              })
            }
            .width('100%')
            .margin({ top: 6 })

            Text('备注').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').margin({ top: 12 })
            TextArea({ placeholder: '如:餐车宽2.2米需低位拖板,到场后协助摆位…' })
              .placeholderColor(COLOR_TEXT_HINT)
              .fontSize(11)
              .height(64)
              .backgroundColor(COLOR_BG)
              .borderRadius(10)
              .margin({ top: 6 })
              .constraintSize({ maxHeight: '80%' })
          }
          .width('100%')
          .padding({ left: 18, right: 18, bottom: 12 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')

        Row() {
          Text('取消')
            .fontSize(13).fontColor(COLOR_TEXT_SUB)
            .backgroundColor(COLOR_BG)
            .borderRadius(20)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .onClick(() => { this.showBookingModal = false })
          Text('确认预约')
            .fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
            .backgroundColor(COLOR_PRIMARY)
            .borderRadius(20)
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showBookingModal = false })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .padding({ top: 12, bottom: 16 })
      }
      .width('90%')
      .height('75%')
      .backgroundColor(COLOR_CARD)
      .borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '5%', y: '12%' })
      .clip(true)
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            // 顶部统计条
            Row() {
              Column() {
                Text('16').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
                Text('本月运输').fontSize(9).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
              Column() {
                Text('2').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLOR_WARN)
                Text('进行中').fontSize(9).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
              Column() {
                Text('3,286').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLOR_GREEN)
                Text('总里程(km)').fontSize(9).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
              Column() {
                Text('¥10.4k').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLOR_RED)
                Text('总费用').fontSize(9).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .backgroundColor(COLOR_CARD)
            .borderRadius(14)
            .border({ width: 1, color: COLOR_BORDER, radius: 14 })
            .margin({ left: 12, right: 12, top: 10 })

            // 预约表单卡片
            Column() {
              Row() {
                Text('🚛 快速预约运输').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
                Column().layoutWeight(1)
                Text('整车拖运').fontSize(9).fontColor(COLOR_PRIMARY)
                  .backgroundColor('#FDEEE3')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(9)
              }
              .width('100%')

              Text('餐车').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 10 })
              Scroll() {
                Row() {
                  ForEach(mockTrucks, (t: TruckItem) => {
                    Text(t.icon + ' ' + t.name)
                      .fontSize(10)
                      .fontColor(this.formTruck === t.name ? COLOR_CARD : COLOR_TEXT_SUB)
                      .backgroundColor(this.formTruck === t.name ? COLOR_PRIMARY : COLOR_BG)
                      .padding({ left: 9, right: 9, top: 5, bottom: 5 })
                      .borderRadius(12)
                      .margin({ left: 3, right: 3 })
                      .onClick(() => { this.formTruck = t.name })
                  })
                }
              }
              .scrollable(ScrollDirection.Horizontal)
              .scrollBar(BarState.Off)
              .width('100%')
              .margin({ top: 4 })

              Row() {
                Column() {
                  Text('出发地').fontSize(11).fontColor(COLOR_TEXT_SUB)
                  TextInput({ placeholder: '仓储/基地' })
                    .placeholderColor(COLOR_TEXT_HINT)
                    .fontSize(11)
                    .height(36)
                    .backgroundColor(COLOR_BG)
                    .borderRadius(10)
                    .margin({ top: 4 })
                    .onChange((v: string) => { this.formPickup = v })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)

                Column() {
                  Text('目的地').fontSize(11).fontColor(COLOR_TEXT_SUB)
                  TextInput({ placeholder: '市集/商圈' })
                    .placeholderColor(COLOR_TEXT_HINT)
                    .fontSize(11)
                    .height(36)
                    .backgroundColor(COLOR_BG)
                    .borderRadius(10)
                    .margin({ top: 4 })
                    .onChange((v: string) => { this.formDestination = v })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding({ left: 8 })
              }
              .width('100%')
              .margin({ top: 10 })

              Text('日期').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 10 })
              TextInput({ placeholder: '如:2026-08-26' })
                .placeholderColor(COLOR_TEXT_HINT)
                .fontSize(11)
                .height(36)
                .backgroundColor(COLOR_BG)
                .borderRadius(10)
                .margin({ top: 4 })
                .onChange((v: string) => { this.formDate = v })

              Text('特殊服务').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 10 })
              Flex({ wrap: FlexWrap.Wrap }) {
                ForEach(SHIP_SERVICES, (sv: string) => {
                  Text((this.formServices.indexOf(sv) >= 0 ? '✓ ' : '○ ') + sv)
                    .fontSize(10)
                    .fontColor(this.formServices.indexOf(sv) >= 0 ? COLOR_PRIMARY : COLOR_TEXT_SUB)
                    .backgroundColor(this.formServices.indexOf(sv) >= 0 ? '#FDEEE3' : COLOR_BG)
                    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                    .borderRadius(11)
                    .margin({ right: 6, top: 4 })
                    .onClick(() => { this.formServices = toggleInList(this.formServices, sv) })
                })
              }
              .width('100%')
              .margin({ top: 4 })

              Text('预约整车运输')
                .fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
                .width('100%')
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLOR_PRIMARY)
                .borderRadius(21)
                .margin({ top: 14 })
                .onClick(() => { this.showBookingModal = true })
            }
            .width('100%')
            .padding(14)
            .backgroundColor(COLOR_CARD)
            .borderRadius(14)
            .border({ width: 1, color: COLOR_BORDER, radius: 14 })
            .margin({ left: 12, right: 12, top: 10 })

            // 月度运输柱状图
            Column() {
              Text('📊 近6个月运输次数').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
                .width('100%').padding({ left: 14, top: 12, bottom: 6 })
              Row() {
                ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
                  Column() {
                    Text(SHIP_MONTH_COUNTS[i].toString())
                      .fontSize(9).fontColor(COLOR_PRIMARY).margin({ bottom: 3 })
                    Column()
                      .width(24)
                      .height(barHeightVp(SHIP_MONTH_COUNTS[i], 16, 76))
                      .backgroundColor(i === 5 ? COLOR_PRIMARY : COLOR_PRIMARY_LIGHT)
                      .borderRadius({ topLeft: 4, topRight: 4 })
                    Text(SHIP_MONTHS[i]).fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ top: 3 })
                  }
                  .layoutWeight(1)
                  .alignItems(HorizontalAlign.Center)
                })
              }
              .width('100%')
              .padding({ left: 10, right: 10, bottom: 12 })
            }
            .width('100%')
            .backgroundColor(COLOR_CARD)
            .borderRadius(14)
            .border({ width: 1, color: COLOR_BORDER, radius: 14 })
            .margin({ left: 12, right: 12, top: 10 })

            // 运输记录
            Row() {
              Text('📦 运输记录(' + mockShipments.length + ')')
                .fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              Column().layoutWeight(1)
              Text('全部 >').fontSize(10).fontColor(COLOR_TEXT_HINT)
                .onClick(() => { this.formServices = this.formServices })
            }
            .width('100%')
            .padding({ left: 18, right: 18, top: 14, bottom: 4 })

            ForEach(mockShipments, (s: TruckShipItem) => {
              this.shipRecordBuilder(s)
            })

            Text('— 运输就找轮上食光 —').fontSize(10)
              .fontColor(COLOR_TEXT_HINT)
              .margin({ top: 16, bottom: 20 })
          }
          .width('100%')
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')
      }
      .width('100%').height('100%')

      if (this.showBookingModal) { this.bookingModal() }
    }
    .width('100%').height('100%')
  }
}

// ============================================================================
// Tab4 菜单页:分类药丸 + 2列菜品网格 + 分类统计
// ============================================================================
@Component
struct MenuContent {
  @State selectedCategory: string = '全部'
  @State orderCount: number = 0
  @State showEditModal: boolean = false
  @State editingItem: DishFoodItem | null = null
  @State editName: string = ''
  @State editPrice: string = ''
  @State editCategory: string = '招牌'
  @State editSpicy: string = '不辣'
  @State editStock: string = '有货'
  @State editDesc: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(58,42,21,0.55)')
      .onClick(onClose)
  }

  @Builder menuCardBuilder(m: DishFoodItem) {
    Column() {
      Stack({ alignContent: Alignment.TopEnd }) {
        Column() {
          Text(m.category === '饮品' ? '🥤' : (m.category === '甜点' ? '🍩' : '🍽'))
            .fontSize(34)
        }
        .width('100%')
        .height(74)
        .backgroundColor(m.color)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)
        .borderRadius({ topLeft: 12, topRight: 12 })

        Row() {
          Text(spicyMeta(m.spicyLevel).icon + spicyMeta(m.spicyLevel).label)
            .fontSize(8)
            .fontColor(spicyMeta(m.spicyLevel).color)
            .backgroundColor(COLOR_CARD)
            .padding({ left: 5, right: 5, top: 2, bottom: 2 })
            .borderRadius(8)
        }
        .padding(6)
      }
      .width('100%')
      .clip(true)

      Column() {
        Text(m.name)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLOR_TEXT_MAIN)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')

        Text('月售 ' + m.monthlySales)
          .fontSize(9).fontColor(COLOR_TEXT_HINT)
          .margin({ top: 3 })
          .width('100%')

        Row() {
          Text(formatPrice(m.price))
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLOR_PRIMARY)
          Column().layoutWeight(1)
          Text('加入订单')
            .fontSize(10)
            .fontColor(COLOR_CARD)
            .backgroundColor(COLOR_PRIMARY)
            .padding({ left: 9, right: 9, top: 5, bottom: 5 })
            .borderRadius(11)
            .onClick(() => { this.orderCount = this.orderCount + 1 })
          Text('✏️')
            .fontSize(13)
            .margin({ left: 6 })
            .onClick(() => {
              this.editingItem = m
              this.editName = m.name
              this.editPrice = m.price.toFixed(0)
              this.editCategory = m.category
              this.editSpicy = m.spicyLevel
              this.editStock = '有货'
              this.showEditModal = true
            })
        }
        .width('100%')
        .margin({ top: 8 })
        .alignItems(VerticalAlign.Center)
      }
      .padding(10)
      .width('100%')
    }
    .width('48%')
    .backgroundColor(COLOR_CARD)
    .borderRadius(12)
    .border({ width: 1, color: COLOR_BORDER, radius: 12 })
    .margin({ top: 8 })
    .clip(true)
  }

  // ========== 弹框2:编辑菜单项(卡片式) ==========
  @Builder editMenuModal() {
    Column() {
      this.modalOverlay(() => { this.showEditModal = false })
      Column() {
        Row() {
          Column() {
            Text('✏️ 编辑菜单项').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
            Text('调整价格与辣度,实时同步到餐车菜单屏').fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('✕').fontSize(15).fontColor(COLOR_TEXT_SUB)
            .width(28).height(28).textAlign(TextAlign.Center)
            .backgroundColor(COLOR_BG)
            .borderRadius(14)
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 10 })

        Divider().color(COLOR_BORDER)

        Scroll() {
          Column() {
            Row() {
              Text('菜品名').fontSize(11).fontColor(COLOR_TEXT_SUB).width(64)
              TextInput({ placeholder: this.editName === '' ? '菜品名称' : this.editName })
                .placeholderColor(COLOR_TEXT_HINT)
                .fontSize(12)
                .height(36)
                .backgroundColor(COLOR_BG)
                .borderRadius(10)
                .layoutWeight(1)
                .onChange((v: string) => { this.editName = v })
            }
            .width('100%')
            .margin({ top: 8 })
            .alignItems(VerticalAlign.Center)

            Row() {
              Text('价格(¥)').fontSize(11).fontColor(COLOR_TEXT_SUB).width(64)
              TextInput({ placeholder: this.editPrice === '' ? '0' : this.editPrice })
                .placeholderColor(COLOR_TEXT_HINT)
                .fontSize(12)
                .height(36)
                .backgroundColor(COLOR_BG)
                .borderRadius(10)
                .layoutWeight(1)
                .onChange((v: string) => { this.editPrice = v })
            }
            .width('100%')
            .margin({ top: 10 })
            .alignItems(VerticalAlign.Center)

            Text('分类').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 12 })
            Row() {
              ForEach(MENU_CATEGORY_KEYS, (c: string) => {
                Text((MENU_CATEGORY_CONFIG[c]?.icon ?? '🍽') + c)
                  .fontSize(10)
                  .fontColor(this.editCategory === c ? COLOR_CARD : COLOR_TEXT_SUB)
                  .backgroundColor(this.editCategory === c ? COLOR_PRIMARY : COLOR_BG)
                  .padding({ left: 9, right: 9, top: 5, bottom: 5 })
                  .borderRadius(11)
                  .margin({ right: 5 })
                  .onClick(() => { this.editCategory = c })
              })
            }
            .margin({ top: 6 })

            Text('辣度').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 12 })
            Row() {
              ForEach(SPICY_LEVELS, (lv: string) => {
                Text(spicyMeta(lv).icon + lv)
                  .fontSize(10)
                  .fontColor(this.editSpicy === lv ? COLOR_CARD : spicyMeta(lv).color)
                  .backgroundColor(this.editSpicy === lv ? spicyMeta(lv).color : COLOR_BG)
                  .padding({ left: 9, right: 9, top: 5, bottom: 5 })
                  .borderRadius(11)
                  .margin({ right: 5 })
                  .onClick(() => { this.editSpicy = lv })
              })
            }
            .margin({ top: 6 })

            Text('库存状态').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 12 })
            Row() {
              ForEach(STOCK_STATUS, (st: string) => {
                Text(st)
                  .fontSize(10)
                  .fontColor(this.editStock === st ? COLOR_CARD : COLOR_TEXT_SUB)
                  .backgroundColor(this.editStock === st ? COLOR_GREEN : COLOR_BG)
                  .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                  .borderRadius(11)
                  .margin({ right: 5 })
                  .onClick(() => { this.editStock = st })
              })
            }
            .margin({ top: 6 })

            Text('描述').fontSize(11).fontColor(COLOR_TEXT_SUB).width('100%').margin({ top: 12 })
            TextArea({ placeholder: '一句话卖点,显示在菜品卡片下方…' })
              .placeholderColor(COLOR_TEXT_HINT)
              .fontSize(11)
              .height(56)
              .backgroundColor(COLOR_BG)
              .borderRadius(10)
              .margin({ top: 6 })
              .constraintSize({ maxHeight: '80%' })
              .onChange((v: string) => { this.editDesc = v })
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 10 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')

        Row() {
          Text('取消')
            .fontSize(13).fontColor(COLOR_TEXT_SUB)
            .backgroundColor(COLOR_BG)
            .borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .onClick(() => { this.showEditModal = false })
          Text('保存')
            .fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_CARD)
            .backgroundColor(COLOR_PRIMARY)
            .borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .padding({ top: 10, bottom: 14 })
      }
      .width('85%')
      .height('60%')
      .backgroundColor(COLOR_CARD)
      .borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '7.5%', y: '18%' })
      .clip(true)
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        // 分类药丸
        Scroll() {
          Row() {
            ForEach(MENU_CATEGORY_KEYS, (c: string) => {
              Text((MENU_CATEGORY_CONFIG[c]?.icon ?? '🍽') + ' ' + c)
                .fontSize(11)
                .fontColor(this.selectedCategory === c ? COLOR_CARD : COLOR_TEXT_SUB)
                .backgroundColor(this.selectedCategory === c ? COLOR_PRIMARY : COLOR_CARD)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(13)
                .margin({ left: 4, right: 4 })
                .onClick(() => { this.selectedCategory = c })
            })
          }
          .padding({ left: 10, right: 10 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(34)

        // 订单数量提示条
        Row() {
          Text('🧺 订单中 ' + this.orderCount + ' 件菜品')
            .fontSize(11).fontColor(COLOR_PRIMARY)
          Column().layoutWeight(1)
          Text('去结算 >').fontSize(11).fontColor(COLOR_CARD)
            .backgroundColor(COLOR_PRIMARY)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .borderRadius(12)
            .onClick(() => { this.orderCount = 0 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 6, bottom: 4 })

        Scroll() {
          Column() {
            // 菜品网格(2列)
            Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
              ForEach(filterDishItems(mockDishItems, this.selectedCategory), (m: DishFoodItem) => {
                this.menuCardBuilder(m)
              })
            }
            .width('100%')
            .padding({ left: 12, right: 12 })

            // 菜品分类统计(5格)
            Column() {
              Text('📋 菜品分类统计').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
                .width('100%').padding({ left: 14, top: 12, bottom: 8 })
              Row() {
                ForEach(MENU_CATEGORY_KEYS, (c: string) => {
                  Column() {
                    Text(MENU_CATEGORY_CONFIG[c]?.icon ?? '🍽').fontSize(16)
                    Text(getMenuCategoryCount(c).toString())
                      .fontSize(16).fontWeight(FontWeight.Bold)
                      .fontColor(MENU_CATEGORY_CONFIG[c]?.color ?? COLOR_TEXT_MAIN)
                      .margin({ top: 2 })
                    Text(MENU_CATEGORY_CONFIG[c]?.label ?? c)
                      .fontSize(9).fontColor(COLOR_TEXT_SUB)
                      .margin({ top: 1 })
                  }
                  .layoutWeight(1)
                  .alignItems(HorizontalAlign.Center)
                  .padding({ top: 8, bottom: 8 })
                })
              }
              .width('100%')
              .padding({ left: 6, right: 6, bottom: 8 })
            }
            .width('100%')
            .backgroundColor(COLOR_CARD)
            .borderRadius(14)
            .border({ width: 1, color: COLOR_BORDER, radius: 14 })
            .margin({ left: 12, right: 12, top: 14 })

            Text('— 今日现做,售完即止 —').fontSize(10)
              .fontColor(COLOR_TEXT_HINT)
              .margin({ top: 16, bottom: 20 })
          }
          .width('100%')
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')
      }
      .width('100%').height('100%')

      if (this.showEditModal) { this.editMenuModal() }
    }
    .width('100%').height('100%')
  }
}

// ============================================================================
// Tab5 评价页:综合评分 + 评分分布图 + 评价Feed
// ============================================================================
@Component
struct RatingContent {
  @State likedReviewIds: number[] = []

  @Builder reviewCardBuilder(r: ReviewItem) {
    Column() {
      Row() {
        Column() {
          Text(r.userAvatar).fontSize(24)
        }
        .width(42).height(42)
        .backgroundColor(COLOR_BG)
        .borderRadius(21)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)

        Column() {
          Text(r.userName).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
          Row() {
            Text(getStarText(r.rating)).fontSize(11).fontColor(COLOR_WARN)
            Text(' ' + r.rating.toFixed(1)).fontSize(10).fontColor(COLOR_TEXT_HINT)
          }
          .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 10 })

        Column() {
          Text(r.time).fontSize(9).fontColor(COLOR_TEXT_HINT)
          Row() {
            Text(this.likedReviewIds.indexOf(r.id) >= 0 ? '❤️' : '🤍').fontSize(13)
            Text((r.likeCount + (this.likedReviewIds.indexOf(r.id) >= 0 ? 1 : 0)).toString())
              .fontSize(10).fontColor(COLOR_TEXT_SUB)
              .margin({ left: 3 })
          }
          .margin({ top: 4 })
          .onClick(() => { this.likedReviewIds = toggleInList(this.likedReviewIds, r.id) })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)

      Text(r.content)
        .fontSize(12)
        .fontColor(COLOR_TEXT_MAIN)
        .lineHeight(18)
        .margin({ top: 8 })
        .width('100%')

      Row() {
        Text('🍖 ' + r.foodTag)
          .fontSize(10).fontColor(COLOR_PRIMARY)
          .backgroundColor('#FDEEE3')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(9)
        Text('已核销消费').fontSize(9).fontColor(COLOR_GREEN)
          .backgroundColor('#EBF7ED')
          .padding({ left: 6, right: 6, top: 3, bottom: 3 })
          .borderRadius(9)
          .margin({ left: 6 })
      }
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLOR_CARD)
    .borderRadius(14)
    .border({ width: 1, color: COLOR_BORDER, radius: 14 })
    .margin({ left: 12, right: 12, top: 8 })
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 综合评分卡
          Row() {
            Column() {
              Text('4.8').fontSize(46).fontWeight(FontWeight.Bold).fontColor(COLOR_WARN)
              Text(getStarText(4.8)).fontSize(13).fontColor(COLOR_WARN).margin({ top: 2 })
              Text('综合评分').fontSize(10).fontColor(COLOR_TEXT_SUB).margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text('96%').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLOR_SUCCESS)
              Text('好评率').fontSize(10).fontColor(COLOR_TEXT_SUB).margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text('100').fontSize(30).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
              Text('累计评价').fontSize(10).fontColor(COLOR_TEXT_SUB).margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ top: 16, bottom: 16 })
          .backgroundColor(COLOR_CARD)
          .borderRadius(16)
          .border({ width: 1, color: COLOR_BORDER, radius: 16 })
          .margin({ left: 12, right: 12, top: 10 })

          // 评分分布柱状图
          Column() {
            Text('⭐ 评分分布').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').padding({ left: 14, top: 12, bottom: 6 })
            Row() {
              ForEach([0, 1, 2, 3, 4], (i: number) => {
                Column() {
                  Text(RATING_DISTRIBUTION[i].toString())
                    .fontSize(9).fontColor(COLOR_TEXT_SUB).margin({ bottom: 3 })
                  Column()
                    .width(30)
                    .height(barHeightVp(RATING_DISTRIBUTION[i], 63, 72))
                    .backgroundColor(i === 4 ? COLOR_WARN : COLOR_PRIMARY_LIGHT)
                    .borderRadius({ topLeft: 4, topRight: 4 })
                  Text(RATING_STARS[i]).fontSize(9).fontColor(COLOR_TEXT_HINT).margin({ top: 3 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
              })
            }
            .width('100%')
            .padding({ left: 10, right: 10, bottom: 12 })
          }
          .width('100%')
          .backgroundColor(COLOR_CARD)
          .borderRadius(14)
          .border({ width: 1, color: COLOR_BORDER, radius: 14 })
          .margin({ left: 12, right: 12, top: 10 })

          // 评价Feed标题
          Row() {
            Text('💬 食客怎么说(' + mockReviews.length + ')')
              .fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
            Column().layoutWeight(1)
            Text('最新优先').fontSize(10).fontColor(COLOR_TEXT_HINT)
              .onClick(() => { this.likedReviewIds = this.likedReviewIds })
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 14, bottom: 4 })

          ForEach(mockReviews, (r: ReviewItem) => {
            this.reviewCardBuilder(r)
          })

          Text('— 吃完记得评价哦 —').fontSize(10)
            .fontColor(COLOR_TEXT_HINT)
            .margin({ top: 16, bottom: 20 })
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%').height('100%')
  }
}

// ============================================================================
// Tab6 我的页:资料卡 + 2x2统计网格 + 设置列表
// ============================================================================
@Component
struct ProfileContent {
  @State settingsExpanded: boolean = false

  @Builder statCellBuilder(icon: string, value: string, label: string, color: string) {
    Column() {
      Text(icon).fontSize(22).margin({ top: 4 })
      Text(value).fontSize(22).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 4 })
      Text(label).fontSize(10).fontColor(COLOR_TEXT_SUB).margin({ top: 3, bottom: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 12, bottom: 12 })
    .backgroundColor(COLOR_CARD)
    .borderRadius(14)
    .border({ width: 1, color: COLOR_BORDER, radius: 14 })
  }

  @Builder settingsRowBuilder(icon: string, label: string, hint: string) {
    Row() {
      Column() {
        Text(icon).fontSize(17)
      }
      .width(36).height(36)
      .backgroundColor(COLOR_BG)
      .borderRadius(11)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

      Text(label).fontSize(13).fontColor(COLOR_TEXT_MAIN)
        .margin({ left: 12 })
        .layoutWeight(1)

      Text(hint).fontSize(10).fontColor(COLOR_TEXT_HINT)
      Text(' >').fontSize(12).fontColor(COLOR_TEXT_HINT).margin({ left: 4 })
    }
    .width('100%')
    .padding({ top: 10, bottom: 10 })
    .alignItems(VerticalAlign.Center)
    .onClick(() => { this.settingsExpanded = !this.settingsExpanded })
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 资料卡
          Column() {
            Row() {
              Column() {
                Text('🧑‍🍳').fontSize(38)
              }
              .width(64).height(64)
              .backgroundColor('#FDEEE3')
              .borderRadius(32)
              .alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.Center)
              .border({ width: 2, color: COLOR_PRIMARY_LIGHT, radius: 32 })

              Column() {
                Text('大橘师傅').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
                Text('餐车老板 · 橘火汉堡车主理人').fontSize(11).fontColor(COLOR_TEXT_SUB).margin({ top: 3 })
                Row() {
                  Text('🚚 3辆餐车').fontSize(10).fontColor(COLOR_PRIMARY)
                    .backgroundColor('#FDEEE3')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(9)
                  Text('🔥 出摊328次').fontSize(10).fontColor(COLOR_WARN)
                    .backgroundColor('#FEF5E0')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(9)
                    .margin({ left: 6 })
                }
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 14 })

              Text('编辑').fontSize(11).fontColor(COLOR_PRIMARY)
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .borderRadius(11)
                .border({ width: 1, color: COLOR_PRIMARY, radius: 11 })
                .onClick(() => { this.settingsExpanded = false })
            }
            .width('100%')
            .padding(16)
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .backgroundColor(COLOR_CARD)
          .borderRadius(16)
          .border({ width: 1, color: COLOR_BORDER, radius: 16 })
          .margin({ left: 12, right: 12, top: 10 })

          // 2x2 统计网格
          Row() {
            this.statCellBuilder('🚛', '86', '总运输(次)', COLOR_PRIMARY)
            this.statCellBuilder('🧾', '12,480', '总订单(单)', COLOR_GREEN)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 10 })
          .alignItems(VerticalAlign.Center)

          Row() {
            this.statCellBuilder('💰', '¥486,200', '总收入', COLOR_WARN)
            this.statCellBuilder('⭐', '98%', '好评率', COLOR_RED)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 8 })
          .alignItems(VerticalAlign.Center)

          // 设置列表
          Column() {
            Text('⚙️ 账号管理').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLOR_TEXT_MAIN)
              .width('100%').padding({ left: 14, top: 12, bottom: 4 })
            this.settingsRowBuilder('🚚', '我的餐车', '3辆')
            Divider().color(COLOR_BORDER).margin({ left: 50 })
            this.settingsRowBuilder('📍', '常用地址', '5个')
            Divider().color(COLOR_BORDER).margin({ left: 50 })
            this.settingsRowBuilder('📊', '收入统计', '本月+8.6%')
            Divider().color(COLOR_BORDER).margin({ left: 50 })
            this.settingsRowBuilder('🎧', '联系客服', '7×24h')
            Divider().color(COLOR_BORDER).margin({ left: 50 })
            this.settingsRowBuilder('🔧', '设置', 'v2.4.0')
          }
          .width('100%')
          .backgroundColor(COLOR_CARD)
          .borderRadius(14)
          .border({ width: 1, color: COLOR_BORDER, radius: 14 })
          .margin({ left: 12, right: 12, top: 12 })
          .padding({ left: 14, right: 14, bottom: 8 })

          Text('轮上食光 WHEEL KITCHEN · v2.4.0')
            .fontSize(10).fontColor(COLOR_TEXT_HINT)
            .margin({ top: 18, bottom: 20 })
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%').height('100%')
  }
}


七、总结

本项目「轮上食光 WHEEL KITCHEN」基于 HarmonyOS 6.1.1 和 HarmonyOS ArkTS API 24 构建,完整展示了一个面向移动餐车运营场景的综合性应用的技术实现。从架构层面看,单入口加六子组件的分层设计实现了关注点分离,每个 Tab 独立管理自己的局部状态和交互逻辑,入口组件仅负责全局导航和装饰性动画,这种架构在中小型应用中平衡了开发效率和代码可维护性。六大功能模块——餐车展示、市集预订、运输调度、菜单管理、评价互动和个人中心——覆盖了餐车运营的核心业务链路,每个模块都包含了数据展示、状态管理和复杂交互三个层面的实现,为理解 ArkTS 声明式 UI 的实际应用提供了丰富的参考样本。

在这里插入图片描述

从状态管理角度,项目系统性地运用了 @State@Observed@Builder 三大核心装饰器。@State 在入口组件和各子组件中维护了从全局 Tab 切换到局部表单输入的多层级状态,@Observed 标记的六个数据类实现了数据到视图的自动绑定,@Builder 构建器则将可复用的 UI 片段参数化封装。特别是弹窗系统的实现——通过 showXxxModal 布尔状态驱动 Stack 条件渲染,配合 zIndexposition 实现全屏覆盖——展示了在不依赖 CustomDialogbindSheet 等 API 的前提下,如何用纯组件方式构建高度自定义的弹窗交互,这种方案在样式定制和交互灵活性上具有显著优势。

从视觉与交互设计角度,项目的「美食复古橙黄风」设计系统通过 15 个颜色常量构建了完整的色彩体系,配合配置映射表实现了状态到颜色的自动化映射。全屏粒子动画层通过 setInterval 定时器驱动纯函数步进,配合 hitTestBehavior(HitTestMode.None) 实现了不影响交互的装饰性动效。自定义柱状图通过 barHeightVp 辅助函数和基础 Column 组件拼装,无需任何第三方图表库即可实现数据可视化。四个不同风格的弹窗(警示式、表单式、卡片式、头部式)展示了 ArkTS 在复杂交互场景下的表现力,每个弹窗都包含表单输入、选择器、实时计算和状态更新等完整功能链路。

展望未来,该应用可以在多个维度进行深化扩展。在数据层面,可以将静态 mock 数据替换为通过网络请求获取的真实后端数据,引入 @Observed 配合 @ObjectLink 实现更细粒度的数据绑定,并加入本地持久化存储。在动画层面,可以引入 animateTo 实现弹窗的进出场过渡动画和卡片点击的缩放反馈,增强交互的物理质感。在性能层面,当数据量增大时可以将 ForEach 替换为 LazyForEach 实现懒加载,避免一次性渲染大量列表项导致的性能问题。在功能层面,可以引入地图组件展示餐车实时位置和运输轨迹,接入推送通知实现出摊提醒和运输状态更新,以及增加社交分享和支付能力,将应用从展示型原型演进为完整的商业级产品。基于 HarmonyOS API 24 的 ArkTS 声明式范式为这些扩展提供了坚实的技术基础,其组件化、状态驱动和类型安全的设计理念能够有效支撑应用的持续迭代与规模增长。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐