鸿蒙操作系统(HarmonyOS)作为华为推出的面向万物互联时代的分布式操作系统,其应用开发框架 ArkUI 声明式范式以 ArkTS 语言为核心载体,提供了一套从状态驱动到声明式 UI 渲染的完整开发体系。ArkTS 在 TypeScript 基础上进行了静态类型强化与运行时优化,摒弃了动态类型带来的性能不确定性,同时保留了类型推断与接口定义等现代语言特性。在组件化开发思想层面,ArkUI 采用 struct 结构体作为组件的基本承载单元,通过 @Component 装饰器将其标记为可复用的 UI 单元,通过 @Entry 装饰器指定应用入口组件,通过 @State 装饰器实现响应式状态管理,通过 @Builder 装饰器封装可复用的 UI 构建逻辑片段。这种"状态即视图"的编程范式使得开发者能够以数据为中心,让 UI 自动响应状态变化,极大简化了复杂界面的构建过程。在布局系统方面,ArkUI 提供了 Column(纵向线性布局)、Row(横向线性布局)、Stack(层叠布局)、Flex(弹性布局)等多种容器组件,每种布局都有其特定的排列规则与适用场景。在列表渲染方面,ForEach 组件提供了高效的数组遍历渲染能力,支持键值生成与条件渲染。在动画方面,animateTo 接口封装了属性动画的创建逻辑,支持自定义时长、曲线、迭代次数与播放模式。本文将以一个完整的同城跑腿代办平台应用为载体,逐段剖析其从配色常量定义、数据模型建模、全局纯函数封装到七大组件构建的全部代码细节,力求为读者呈现一份可复用的工程实践参考。

一、配色体系与全局常量定义

1.1 配色接口与常量对象

interface FGColor {
  bg: string
  card: string
  card2: string
  chip: string
  primary: string
  accent: string
  ink: string
  sub: string
  hint: string
  line: string
  danger: string
  green: string
  blue: string
  white: string
  dark: string
}

在这里插入图片描述

这段代码定义了一个名为 FGColor 的接口(interface),它声明了十五个字符串类型的属性,每一个属性对应应用中的一种语义化颜色角色。在 ArkTS 中,接口是一种纯类型声明工具,编译后不会产生运行时代码,但它为开发者提供了编译期类型检查能力。FGColor 接口的设计理念是"语义优先"——属性名称不使用具体的颜色值或色调名称,而是采用角色名称,如 bg 代表全局背景色、card 代表卡片背景色、primary 代表主色调、accent 代表辅助强调色等。

这种语义化命名的核心优势在于主题切换的便利性。当需要从深色主题切换到浅色主题时,只需修改常量对象中的颜色值,而不需要改动任何组件代码。接口中定义了 card2 作为卡片次级背景色,用于区分主卡片与嵌套卡片的视觉层次。chip 属性专门用于标签、胶囊按钮等小型交互元素的背景色,与主卡片形成微妙的色差对比。ink 属性代表正文主文字颜色,sub 属性代表次要文字颜色,hint 属性代表提示性文字颜色,这三个属性构成了文字层级的三级灰度体系。

const C: FGColor = {
  bg: '#111318',
  card: '#1C2028',
  card2: '#232936',
  chip: '#2A303B',
  primary: '#FFD21F',
  accent: '#FF7A2F',
  ink: '#E8EAED',
  sub: '#9AA0A8',
  hint: '#636A75',
  line: '#2A303B',
  danger: '#FF5252',
  green: '#3DDC84',
  blue: '#6C8CFF',
  white: '#FFFFFF',
  dark: '#1A1400'
}

这里声明了一个名为 C 的常量对象,类型标注为 FGColor,意味着该对象必须严格包含接口声明的全部十五个属性。const 关键字确保该引用在运行时不可被重新赋值,配合对象字面量的初始化方式,构成了一种不可变配置对象的惯用模式。配色方案采用了"炭黑 x 荧光黄 x 骑手橙"的深色潮酷骑手风格:背景色 #111318 是一种接近纯黑但略带蓝灰色调的炭黑色,避免了纯黑带来的生硬感;卡片色 #1C2028 比背景稍亮,形成微妙的层次感;主色 #FFD21F 是一种高饱和度的荧光黄,在深色背景上具有极强的视觉冲击力,非常适合骑手品牌的活力感表达。

辅助色 #FF7A2F 是一种骑手橙,与荧光黄同属暖色系但色相不同,两者搭配使用时能够形成既有统一性又有辨识度的配色关系。文字色 #E8EAED 是一种接近白色的暖灰,在深色背景上具有极佳的对比度且不刺眼。danger 色值 #FF5252 是标准的红色信号色,用于错误状态与取消操作的视觉警示。green 色值 #3DDC84 和 blue 色值 #6C8CFF 分别用于成功状态与信息提示状态。dark 色值 #1A1400 是一种极深的暖棕色,专门用于荧光黄背景上的文字颜色——当文字位于荧光黄或骑手橙背景上时,使用深色文字而非白色文字,能形成更清晰的视觉对比。

在 ArkUI 的深色主题设计中,背景色并非简单的纯黑值(#000000),而是带有微妙色相偏移的暗色调。这种设计借鉴了 Material Design 的"海拔"(Elevation)概念——通过微小的亮度差异来暗示元素的层级关系,而非依赖阴影。在本应用中,bg、card、card2、chip 四个色值从暗到亮依次递增,构建了一个四层视觉海拔体系。

1.2 Tab 定义与分类宫格数据

interface TabDef {
  icon: string
  label: string
}
const FG_TABS: TabDef[] = [
  { icon: '⚡', label: '接单大厅' },
  { icon: '📦', label: '订单' },
  { icon: '🏆', label: '骑手榜' },
  { icon: '🧮', label: '计价' },
  { icon: '💰', label: '钱包' },
  { icon: '🏍️', label: '我的' }
]

TabDef 接口定义了底部导航标签的基本结构,包含 icon 和 label 两个字符串属性。icon 使用 Emoji 字符作为图标,这是一种轻量级的图标方案,无需引入图片资源或字体图标库即可实现跨平台一致显示。const 常量 FG_TABS 是一个包含六个 TabDef 对象的数组,分别对应接单大厅、订单、骑手榜、计价、钱包、我的六个主要功能页面。

这种数据驱动的设计模式使得底部导航的配置完全解耦于 UI 渲染逻辑。如果需要增加或减少 Tab 数量,只需修改数组内容,而无需改动渲染代码。在 ArkUI 中,底部 TabBar 通常使用 Tabs 组件实现,但本应用选择了自绘方式——通过 Row 布局容器配合自定义 Builder 函数逐个渲染 Tab 单元。自绘方式的优势在于可以完全控制每个 Tab 的视觉表现,包括图标大小、文字颜色、选中态样式等,而不受 Tabs 组件默认样式的限制。

interface CatT {
  id: number
  name: string
  icon: string
  badge: string
}
const CAT_LIST: CatT[] = [
  { id: 1, name: '帮我买', icon: '🛒', badge: '超时赔' },
  { id: 2, name: '帮我送', icon: '📦', badge: '30分达' },
  { id: 3, name: '代排队', icon: '🎫', badge: '按时计' },
  { id: 4, name: '代取件', icon: '📭', badge: '取件码' },
  { id: 5, name: '代买药', icon: '💊', badge: '24h' },
  { id: 6, name: '代送花', icon: '💐', badge: '稳送' },
  { id: 7, name: '全城飞', icon: '🛵', badge: 'HOT' },
  { id: 8, name: '更多服务', icon: '⋯', badge: ' ' }
]

在这里插入图片描述

CatT 接口定义了头部分类宫格入口的数据结构。每个分类项包含 id(唯一标识)、name(分类名称)、icon(Emoji 图标)、badge(角标文案)四个字段。badge 字段是该数据模型的一大特色——它将营销文案直接绑定到分类数据上,如"超时赔"“30分达”"24h"等,在渲染时以小型角标的形式展示在图标右上角。这种设计使得分类入口不仅是导航工具,更是运营信息的载体。

CAT_LIST 数组包含八个分类项,前六个对应跑腿平台的核心服务类型,第七个"全城飞"是一个促销入口,第八个"更多服务"使用空格作为 badge 值——这是一种特殊的约定,在渲染逻辑中会检查 badge 是否为空格字符串,如果是则不渲染角标。这种"空格即无角标"的约定比使用空字符串或 null 更加语义化,避免了空值检查的歧义。

二、数据模型与 Mock 数据体系

2.1 核心业务数据接口

interface BannerT {
  id: number
  title: string
  sub: string
  icon: string
  tag: string
  bg: string
}

BannerT 接口定义了顶部横幅卡片的数据模型。每个 Banner 项包含 id(编号)、title(主标题)、sub(副标题)、icon(Emoji 图标)、tag(标签文案)、bg(背景色十六进制值)六个字段。bg 字段的设计值得注意——它将每个 Banner 的背景色直接编码在数据中,而非使用统一色值,这使得每张 Banner 卡片可以拥有独特的色调氛围,在横向滚动时形成丰富的视觉节奏感。

在 ArkUI 的 Scroll 组件中实现横向 Banner 轮播时,一种常见做法是使用 Swiper 组件。但本应用选择 Scroll 配合 Row 布局手动实现,这种方案虽然需要自行控制滚动行为,但提供了更高的自定义自由度——例如可以精确控制卡片间距、宽度、圆角等视觉属性,而不受 Swiper 组件默认动画曲线的限制。

interface HallT {
  id: number
  kind: string
  icon: string
  from: string
  to: string
  dist: number
  fee: number
  tip: number
  weight: string
  time: string
  urgent: boolean
  tag: string
  note: string
}

在这里插入图片描述

HallT 接口定义了接单大厅中订单卡片的数据模型,是整个应用中字段最多的接口之一。kind 字段表示服务品类(帮我买、帮我送、代排队、代取件),from 和 to 分别表示取件地址和送达地址。dist 字段是配送距离(公里数,number 类型),fee 是基础运费,tip 是小费金额。weight 字段使用字符串枚举值(“<5kg”、“5-15kg”、“15-30kg”)而非数字,这是因为在业务场景中,重量档位是离散的计价区间,而非连续的数值——使用字符串可以避免浮点比较的精度问题,同时提高代码可读性。

urgent 字段是 boolean 类型,标记该订单是否为加急订单,在渲染时通过条件判断显示"加急"红色标签。tag 字段是一个自由文本标签,用于显示订单的特色属性(如"顺路单"“文件”“蛋糕"等)。note 字段是用户备注文本,在卡片底部以较小字号展示。这种将所有展示信息打包在数据模型中的做法,使得 UI 渲染逻辑只需关心"如何展示”,而无需关心"展示什么"的数据组装逻辑。

2.2 订单与骑手数据接口

interface OrderT {
  id: number
  no: string
  kind: string
  icon: string
  from: string
  to: string
  fee: number
  status: string
  step: number
  date: string
  rider: string
}

在这里插入图片描述

OrderT 接口定义了"我的订单"页面的数据模型。no 字段是订单编号字符串(如"FG20260827001"),采用"前缀+日期+流水号"的格式,这种格式在展示时既保持了可读性又保证了唯一性。status 字段使用字符串枚举值(“进行中”“已完成”“已取消”),而非数字编码,这直接将业务状态映射为用户可读文本。step 字段是 number 类型的进度步骤值(0-4),表示订单当前处于哪个流转节点。

rider 字段是骑手名称字符串,对于已取消的订单,该值为"待分配",表示骑手尚未匹配。这种在数据层面用文本值表示"空状态"的做法,简化了 UI 渲染时的空值判断逻辑——不需要检查 null 或空字符串,直接显示即可。

interface RiderT {
  id: number
  name: string
  avatar: string
  city: string
  orders: number
  score: number
  hours: number
  level: string
}

在这里插入图片描述

RiderT 接口定义了骑手排行榜中每位骑手的数据模型。avatar 字段使用 Emoji 字符(如"🦅"“🦌”)作为头像替代品,这是一种极简的头像方案,无需加载图片资源。score 字段是 number 类型的评分值(如4.9),hours 字段是在线时长(小时数)。level 字段使用字符串枚举值(“钻石”“黄金”“白银”),对应不同的骑手等级体系。

2.3 统计与配置数据接口

interface WeekT {
  id: number
  day: string
  orders: number
  income: number
}

interface TypeT {
  id: number
  name: string
  count: number
  pct: number
  color: string
  icon: string
}

interface PriceRuleT {
  id: number
  range: string
  start: string
  over: string
  weight: string
  note: string
}

在这里插入图片描述

WeekT 接口定义了本周接单量柱状图的每日数据,day 字段是中文星期名称,orders 是当日订单数,income 是当日收入金额。TypeT 接口定义了订单类型分布的占比数据,pct 是百分比数值,color 直接携带了该类型在图表中的展示颜色。PriceRuleT 接口定义了计价规则表的数据行,range 是里程区间文本,start 是起步价文本,over 是超程单价文本,这些价格信息全部以字符串形式存储,因为它们需要直接展示给用户阅读,而非参与数值计算。

interface BillT {
  id: number
  title: string
  icon: string
  amount: number
  kind: string
  date: string
  status: string
}

interface AddrT {
  id: number
  name: string
  phone: string
  addr: string
  tag: string
  def: boolean
}

interface MenuT {
  id: number
  name: string
  icon: string
}

BillT 接口定义了钱包账单流水的数据模型,amount 字段使用带正负号的 number 类型——正数表示收入,负数表示支出,在渲染时通过条件判断显示不同的文字颜色。AddrT 接口定义了地址簿条目,def 是 boolean 类型的默认地址标记。MenuT 接口定义了"我的"页面功能宫格的菜单项。

2.4 Mock 数据数组

const BANNER_LIST: BannerT[] = [
  { id: 1, title: '新人首单立减 8 元', sub: '全城 30 分钟极速达', icon: '⚡', tag: '新客专享', bg: '#B35A1F' },
  { id: 2, title: '帮我买 · 商超代购', sub: '山姆 / 盒马 / 大润发一键代买', icon: '🛒', tag: '低至 8 元', bg: '#3A4252' },
  // ...更多 Banner 数据
]

在这里插入图片描述

BANNER_LIST 是一个包含六条 Banner 数据的常量数组。每条数据的 bg 字段使用了不同的色值——从暖棕色到冷蓝色,涵盖了多种色相,使得横向滚动 Banner 时能够产生明显的色彩变化节奏。这种"一卡一色"的设计在电商类应用中非常常见,能够有效吸引用户注意力。

在 ArkUI 中,const 声明的数组虽然是引用不可变的(不能重新赋值为另一个数组),但数组内容在技术上是可以修改的。然而在本应用的工程约定中,所有以全大写命名(如 BANNER_LIST、HALL_LIST 等)的常量数组都被视为只读数据源,不会被任何代码修改。这种命名约定在 TypeScript 社区中广泛使用,能够帮助开发者快速区分"配置数据"与"运行时状态"。

const HALL_LIST: HallT[] = [
  { id: 1, kind: '帮我买', icon: '🛒', from: '山姆会员店前门', to: '科技园中一区 5 栋', dist: 4.2, fee: 15, tip: 3, weight: '<5kg', time: '立即', urgent: true, tag: '顺路单', note: '两箱矿泉水加一提抽纸,放前台即可' },
  // ...更多订单数据
]

HALL_LIST 数组包含了十四条接单大厅订单数据,每条数据都精心设计了真实的业务场景——从商超代购到文件急送,从医院排队到蛋糕配送,涵盖了同城跑腿的各种典型场景。dist 字段使用了浮点数(如4.2),fee 和 tip 使用了整数,weight 使用了档位字符串。这些数据在设计时考虑了筛选函数的测试覆盖——包含了所有品类、所有重量档位、加急与非加急、不同时效要求的组合。

在实际生产环境中,这些 Mock 数据会被替换为从后端 API 获取的动态数据。但 Mock 数据的设计质量直接影响开发阶段的 UI 调试效果——如果 Mock 数据过于简单或缺少边界情况,会导致 UI 在真实数据下出现意料之外的布局问题。本应用的数据设计充分考虑了这一点,每条数据都包含了完整的字段填充。

三、全局纯函数体系

3.1 数据筛选函数

function hallFiltered(cat: string): HallT[] {
  let r: HallT[] = []
  for (let i = 0; i < HALL_LIST.length; i++) {
    if (cat === '全部' || HALL_LIST[i].kind === cat) {
      r.push(HALL_LIST[i])
    }
  }
  return r
}

在这里插入图片描述

hallFiltered 函数实现了接单大厅的按品类筛选逻辑。它接收一个品类字符串参数,返回筛选后的 HallT 数组。当传入"全部"时,函数直接返回所有订单数据。这里使用的是传统的 for 循环加条件判断加 push 的命令式编程风格,而非 JavaScript 中常见的 filter 高阶函数——这是因为 ArkTS 在编译期会进行更严格的类型检查,命令式循环在类型推断上比高阶函数更加明确。

函数内部声明了局部变量 r 并初始化为空数组,类型标注为 HallT[]。for 循环遍历 HALL_LIST 的每一个元素,通过索引访问 HALL_LIST[i],检查其 kind 字段是否匹配传入的品类参数。如果匹配,则将该元素 push 到结果数组中。最终返回 r 数组。这种实现方式虽然代码量比 filter + lambda 更长,但执行效率在 ArkTS 的运行环境中更为稳定可预期。

function orderFiltered(st: string): OrderT[] {
  let r: OrderT[] = []
  for (let i = 0; i < ORDER_LIST.length; i++) {
    if (st === '全部' || ORDER_LIST[i].status === st) {
      r.push(ORDER_LIST[i])
    }
  }
  return r
}

function orderIdxById(id: number): number {
  let r = 0
  for (let i = 0; i < ORDER_LIST.length; i++) {
    if (ORDER_LIST[i].id === id) {
      r = i
    }
  }
  return r
}

orderFiltered 函数与 hallFiltered 函数的结构完全对称,区别仅在于操作的数据源(ORDER_LIST)和筛选字段(status)。这种函数式封装的筛选逻辑使得 UI 组件只需调用一个函数即可获得筛选后的数据,无需在组件内部编写循环逻辑。

orderIdxById 函数实现了根据订单 id 查找其在 ORDER_LIST 数组中下标的功能。该函数在弹框需要显示当前选中订单的详细信息时被调用——当用户点击某个订单的"取消订单"或"评价骑手"按钮时,组件会将该订单的 id 赋值给 selIdx 状态变量(通过 orderIdxById 转换为下标),然后弹框通过 ORDER_LIST[this.selIdx] 访问完整的订单数据。

3.2 计价引擎函数

function calcPrice(dist: number, weight: string, kind: string): number {
  let p = 8
  if (dist > 3) {
    p = p + (dist - 3) * 1.5
  }
  if (dist > 10) {
    p = p + (dist - 10) * 1
  }
  if (weight === '5-15kg') {
    p = p + 3
  }
  if (weight === '15-30kg') {
    p = p + 8
  }
  if (weight === '>30kg') {
    p = p + 15
  }
  if (kind === '代排队') {
    p = p + 20
  }
  if (kind === '帮我买') {
    p = p + 5
  }
  return Math.round(p)
}

calcPrice 函数是整个应用的计价核心引擎,它接收配送距离(dist)、重量档位(weight)、服务品类(kind)三个参数,返回计算后的整数值运费。函数内部使用局部变量 p 初始化为 8(起步价),然后通过一系列条件判断累加费用。

距离计费采用了分段累进策略:3公里以内只收起步价8元;3-10公里部分每公里加收1.5元;10公里以上部分每公里加收1元。这种分段计费逻辑在代码中体现为两个独立的 if 判断——注意它们不是 else if 关系,而是顺序执行的关系,即距离15公里的订单会先执行第一个 if(加收121.5=18元),再执行第二个 if(加收51=5元),总计31元。这种"累加而非替换"的设计使得计费规则可以灵活组合。

重量计费采用档位加价策略,不同重量区间加收不同金额。品类计费中,"代排队"加收20元排队费,"帮我买"加收5元代购费。最终通过 Math.round 函数将结果四舍五入为整数。这个函数在计价器页面和发布订单弹框中都被调用,实现了"一次编写、多处复用"的函数式设计目标。

3.3 聚合与映射函数

function weekMax(): number {
  let m = 0
  for (let i = 0; i < WEEK_LIST.length; i++) {
    if (WEEK_LIST[i].orders > m) {
      m = WEEK_LIST[i].orders
    }
  }
  return m
}

function cityMax(): number {
  let m = 0
  for (let i = 0; i < CITY_LIST.length; i++) {
    if (CITY_LIST[i].orders > m) {
      m = CITY_LIST[i].orders
    }
  }
  return m
}

function riderMax(): number {
  let m = 0
  for (let i = 0; i < RIDER_LIST.length; i++) {
    if (RIDER_LIST[i].orders > m) {
      m = RIDER_LIST[i].orders
    }
  }
  return m
}

这三个函数分别计算本周接单量峰值、城市订单峰值和骑手单量峰值。它们的结构完全相同——遍历数组、比较元素值、保留最大值。这些峰值在柱状图和占比条的渲染中扮演"归一化基准"的角色:柱状图的实际高度等于该数据值除以峰值乘以最大可用高度。这种"相对值渲染"的方式使得不同量级的数据都能够在同一图表中合理展示。

function statusColor(s: string): string {
  if (s === '进行中') {
    return C.primary
  }
  if (s === '已完成') {
    return C.green
  }
  return C.danger
}

function statusBg(s: string): string {
  if (s === '进行中') {
    return 'rgba(255,210,31,0.16)'
  }
  if (s === '已完成') {
    return 'rgba(61,220,132,0.16)'
  }
  return 'rgba(255,82,82,0.16)'
}

statusColor 和 statusBg 是一对状态配色映射函数,分别返回状态文字颜色和状态背景色。statusColor 返回的是不透明色值,用于文字着色;statusBg 返回的是带 alpha 通道的半透明色值(使用 rgba 格式),用于背景填充。半透明背景配合不透明文字的设计能够在深色主题下保持良好的可读性——文字直接使用主色调(如荧光黄、绿色、红色),而背景使用同色系的极低透明度值(0.16即16%不透明度),形成"淡色背景+深色文字"的标签效果。

ArkUI 的颜色系统同时支持十六进制色值(如 #FFD21F)和 rgba 函数式色值(如 rgba(255,210,31,0.16))。rgba 格式的优势在于可以精确控制透明度,这在深色主题中尤为重要——深色背景上直接使用饱和色块会过于刺眼,而半透明色块则能营造柔和的"染色"效果。

3.4 文本与键盘辅助函数

function stepText(step: number): string {
  if (step === 1) {
    return '进行中 · 骑手已接单正在赶往取件地'
  }
  if (step === 2) {
    return '进行中 · 骑手已到店正在取件'
  }
  if (step === 3) {
    return '进行中 · 骑手配送中请保持电话畅通'
  }
  return '进行中 · 等待骑手接单'
}

function starStr(n: number): string {
  let s = ''
  for (let i = 0; i < n; i++) {
    s = s + '⭐'
  }
  return s
}

function keyAppend(src: string, k: string): string {
  let r = src
  if (k === '⌫') {
    r = r.substring(0, r.length - 1)
  } else {
    if (r.length < 7) {
      r = r + k
    }
  }
  return r
}

function keyNum(s: string): number {
  let v = 0
  if (s.length > 0) {
    v = parseFloat(s)
  }
  if (isNaN(v)) {
    v = 0
  }
  return v
}

stepText 函数将订单步骤数字映射为用户可读的进度文案,这种"数字到文本"的映射在业务应用中非常常见。starStr 函数通过字符串拼接生成指定数量的星星字符串,用于显示星级评分。

keyAppend 函数实现了自定义数字键盘的输入逻辑。当按下退格键(⌫)时,使用 substring 方法截掉最后一个字符;当按下数字或小数点时,在长度不超过7的限制下追加到字符串末尾。keyNum 函数将字符串安全地转换为数字——先检查字符串长度,再使用 parseFloat 解析,最后通过 isNaN 检查解析结果。这种防御性编程模式确保了即使输入了非法字符串(如连续小数点),也不会导致运行时错误。

全局纯函数体系

数据筛选函数

计价引擎函数

聚合统计函数

状态映射函数

文本辅助函数

hallFiltered - 大厅按品类筛选

orderFiltered - 订单按状态筛选

orderIdxById - 根据ID查下标

calcPrice - 距离+重量+品类三段计价

weekMax - 本周接单峰值

cityMax - 城市订单峰值

riderMax - 骑手单量峰值

typeTotal - 订单类型总量

statusColor - 状态文字色

statusBg - 状态背景色

kindColor - 品类配色

levelColor - 等级配色

rankColor - 排名配色

stepText - 进度文案

starStr - 星级字符串

keyAppend - 键盘输入

keyNum - 字符串转金额

四、主入口组件架构

4.1 组件声明与状态定义

@Entry
@Component
struct FlashGoApp {
  @State activeTab: number = 0

这是应用的入口组件声明。@Entry 装饰器标记该 struct 为页面入口组件,ArkUI 框架在应用启动时会自动实例化并渲染该组件。@Component 装饰器将其声明为一个自定义组件,使其可以在其他组件中被引用(虽然在当前应用中 FlashGoApp 作为入口组件不会被其他组件引用,但 @Component 是 @Entry 的前置要求)。

struct 是 ArkTS 中的结构体关键字,用于定义组件的基本容器。与 TypeScript 中的 class 不同,struct 是值类型(在某些语义场景下),且其属性和方法有特定的装饰器约束规则。FlashGoApp 结构体内部声明了一个 @State 装饰的状态变量 activeTab,初始值为0。

@State 是 ArkUI 响应式状态管理的核心装饰器。被 @State 标注的变量在其值发生变化时,会自动触发所在组件的 build 方法的重新执行,从而更新 UI。在本应用中,activeTab 变量记录当前激活的 Tab 索引——当用户点击底部 TabBar 或头部分类宫格时,activeTab 的值会被修改,从而触发条件渲染逻辑切换显示不同的内容组件。

@State 装饰器的响应式机制基于 ArkUI 的渲染管线。当状态变量被赋新值时,框架会比较新旧值(对于基本类型直接比较值,对于对象类型比较引用),如果检测到变化,则将该组件标记为"脏"(dirty),并在下一帧的渲染周期中重新执行其 build 方法。这种机制确保了 UI 与状态的始终同步,开发者无需手动调用 setState 或 invalidate 等命令式 API。

4.2 分类宫格单元 Builder

@Builder catCell(c: CatT, tab: number) {
  Column() {
    Stack() {
      Text(c.icon).fontSize(24)
      if (c.badge !== ' ') {
        Text(c.badge).fontSize(6).fontColor(C.dark).backgroundColor(C.accent)
          .padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(6)
          .position({ x: 16, y: -2 })
      }
    }.width(40).height(30)
    Text(c.name).fontSize(9).fontColor(C.sub).margin({ top: 5 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  .onClick(() => { this.activeTab = tab })
}

@Builder 装饰器是 ArkUI 中用于封装可复用 UI 片段的关键工具。与 @Component 不同,@Builder 不会创建独立的组件实例,而是在调用处内联展开——这意味着 @Builder 内部可以通过 this 访问所属组件的状态变量和方法,且不会产生额外的组件实例化开销。catCell Builder 接收两个参数:c 是分类数据对象,tab 是点击后要切换到的 Tab 索引。

Builder 内部的 UI 结构采用了 Column 包裹 Stack 的嵌套布局。Column 是 ArkUI 中的纵向线性布局容器,其子元素按从上到下的顺序垂直排列。在本例中,Column 包含两个子元素:上方的 Stack(图标区域)和下方的 Text(分类名称)。

Stack 是 ArkUI 中的层叠布局容器,其子元素按声明顺序从底层到顶层堆叠,后声明的元素覆盖先声明的元素。在 catCell 中,Stack 包含两个子元素:基础图标 Text 和条件渲染的角标 Text。角标通过 position 方法设置绝对定位坐标(x:16, y:-2),使其偏移到图标右上角外侧,形成"角标悬挂"的视觉效果。

if 条件判断检查 c.badge 是否不等于空格字符串——这正是前文提到的"空格即无角标"约定的具体体现。layoutWeight(1) 方法为 Column 分配弹性权重1,使得在一行中排列的多个 catCell 能够等分父容器的宽度。onClick 事件回调将传入的 tab 参数赋值给 activeTab 状态变量,触发组件重新渲染。

4.3 Banner 卡片与头部构建

@Builder bannerCard(b: BannerT) {
  Column() {
    Row() {
      Column() {
        Text(b.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.white)
        Text(b.sub).fontSize(9).fontColor('#F5E9C8').margin({ top: 5 }).maxLines(1)
        Text(b.tag).fontSize(8).fontColor(C.dark).backgroundColor(C.primary)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).margin({ top: 9 })
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      Text(b.icon).fontSize(34)
    }.width('100%').alignItems(VerticalAlign.Center)
  }
  .width(250).height(96).padding({ left: 14, right: 12 }).borderRadius(14).backgroundColor(b.bg)
  .margin({ right: 10 })
  .onClick(() => { this.activeTab = 0 })
}

bannerCard Builder 构建了横幅卡片的完整 UI。外层 Column 作为容器,设置固定宽度250、固定高度96、圆角14,背景色取自数据对象的 bg 字段。Column 内部嵌套一个 Row,Row 是 ArkUI 的横向线性布局容器,子元素从左到右排列。

Row 内部包含两个子元素:左侧的 Column(文本信息区)和右侧的 Text(大图标)。左侧 Column 的 alignItems 设置为 HorizontalAlign.Start,使内部文本左对齐;layoutWeight(1) 使其占据 Row 中的剩余空间,将右侧图标"推"到最右端。文本区包含三行文本:主标题(14号加粗白色)、副标题(9号暖白色)、标签(8号深色文字配荧光黄背景的胶囊)。

@Builder headerTop() {
  Column() {
    Row() {
      Text('⚡').fontSize(20)
      Column() {
        Text('闪送帮').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.ink)
        Text('FLASH GO · 同城跑腿代办').fontSize(7).fontColor(C.sub).margin({ top: 1 })
      }.alignItems(HorizontalAlign.Start).margin({ left: 6 })
      Column().layoutWeight(1)
      Text('☀️ 26°').fontSize(10).fontColor(C.sub)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 }).backgroundColor(C.card).borderRadius(12)
      Text('🔔').fontSize(15).margin({ left: 8 })
        .padding({ left: 6, right: 6, top: 4, bottom: 4 }).backgroundColor(C.card).borderRadius(12)
    }.width('100%').alignItems(VerticalAlign.Center)

headerTop Builder 构建了应用的头部的第一行——品牌标识行。Row 作为横向容器,从左到右依次排列:闪电图标、品牌名称列(包含中文名称和英文副标题)、弹性占位 Column(layoutWeight(1) 用于填充中间空间)、天气胶囊、通知图标胶囊。弹性占位 Column 是 ArkUI 中实现"两端对齐"布局的常用技巧——通过在需要推开的元素之间插入一个 layoutWeight(1) 的空 Column,可以将后续元素推到容器的右端。

搜索栏内部

头部结构

品牌标识行 Row

搜索栏 Row

分类宫格第一行 Row - 4个catCell

分类宫格第二行 Row - 4个catCell

Banner横向滚动 Scroll

搜索图标 Text

输入框 TextInput

发布按钮 Text

4.4 搜索栏与分类宫格

      Row() {
        Text('🔍').fontSize(13)
        TextInput({ placeholder: '搜索服务:帮我买 / 代排队 / 代取件' })
          .placeholderColor(C.hint).fontSize(11).layoutWeight(1)
          .backgroundColor(Color.Transparent).padding({ left: 6, right: 6 })
        Text('发布').fontSize(10).fontWeight(FontWeight.Bold).fontColor(C.dark).backgroundColor(C.primary)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
          .onClick(() => { this.activeTab = 0 })
      }
      .width('100%').height(36).backgroundColor(C.card).borderRadius(18)
      .padding({ left: 10, right: 6 }).margin({ top: 10 })

搜索栏 Row 的整体高度固定为36,背景色为卡片色,圆角18(高度的一半),形成完全圆角的"搜索胶囊"外观。Row 内部包含三个元素:搜索图标、TextInput 输入框、"发布"按钮。

TextInput 是 ArkUI 提供的文本输入组件,通过 placeholder 参数设置占位提示文本。placeholderColor 方法设置占位文本颜色,layoutWeight(1) 使输入框占据中间的剩余空间。backgroundColor 设置为 Color.Transparent(透明),使输入框融入搜索栏的背景,形成统一的视觉容器。"发布"按钮是一个带荧光黄背景的 Text,通过 onClick 事件切换到接单大厅 Tab。

      Row() {
        this.catCell(CAT_LIST[0], 0)
        this.catCell(CAT_LIST[1], 0)
        this.catCell(CAT_LIST[2], 0)
        this.catCell(CAT_LIST[3], 0)
      }.width('100%').margin({ top: 14 })

      Row() {
        this.catCell(CAT_LIST[4], 0)
        this.catCell(CAT_LIST[5], 0)
        this.catCell(CAT_LIST[6], 2)
        this.catCell(CAT_LIST[7], 5)
      }.width('100%').margin({ top: 10 })

分类宫格通过两个 Row 实现,每行四个 catCell 单元。注意第二个 Row 中的 catCell 调用传入了不同的 tab 参数——CAT_LIST[6](全城飞)对应 tab=2(骑手榜),CAT_LIST[7](更多服务)对应 tab=5(我的)。这种设计使得分类宫格不仅是功能入口,也是 Tab 导航的一部分——点击"全城飞"会跳转到骑手榜页面,点击"更多服务"会跳转到我的页面。

这里使用 this.catCell() 的方式调用 Builder 函数,this 指向 FlashGoApp 组件实例。Builder 函数的调用在编译期会被内联展开为对应的 UI 声明代码,不会产生函数调用的运行时开销。

4.5 Banner 横向滚动与底部 TabBar

      Scroll() {
        Row() {
          ForEach(BANNER_LIST, (b: BannerT) => {
            this.bannerCard(b)
          })
        }
      }
      .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
      .width('100%').margin({ top: 12 })

Banner 区域使用 Scroll 组件配合 Row 实现横向滚动。Scroll 组件的 scrollable 方法设置为 ScrollDirection.Horizontal,使滚动方向为水平方向。scrollBar 方法设置为 BarState.Off,隐藏滚动条以保持视觉简洁。

ForEach 是 ArkUI 中的列表渲染组件,它接收三个参数:数据源数组、子项渲染函数(item generator)、键值生成函数(key generator,可选)。在这里,ForEach 遍历 BANNER_LIST 数组,对每个 BannerT 元素调用 bannerCard Builder 函数生成对应的 UI。ForEach 在 ArkUI 中的核心价值在于其高效的 diff 算法——当数据源发生变化时,ForEach 会通过键值比较最小化 DOM 操作,只更新变化的子项。

  @Builder tabCell(t: TabDef, idx: number) {
    Column() {
      Text(t.icon).fontSize(19)
      Text(t.label).fontSize(8).margin({ top: 2 })
        .fontColor(this.activeTab === idx ? C.primary : C.sub)
    }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 6 })
    .onClick(() => { this.activeTab = idx })
  }

  @Builder tabBar() {
    Column() {
      Divider().color(C.line)
      Row() {
        this.tabCell(FG_TABS[0], 0)
        this.tabCell(FG_TABS[1], 1)
        this.tabCell(FG_TABS[2], 2)
        this.tabCell(FG_TABS[3], 3)
        this.tabCell(FG_TABS[4], 4)
        this.tabCell(FG_TABS[5], 5)
      }.width('100%').height(54).backgroundColor(C.card)
    }.width('100%')
  }

tabCell Builder 构建底部 Tab 的单个单元。它使用条件表达式(三元运算符)动态设置文字颜色——当 this.activeTab === idx 时(当前 Tab 处于激活状态),文字颜色为荧光黄主色(C.primary),否则为次要灰色(C.sub)。这种条件着色是 @State 响应式机制的直接体现:当 activeTab 变化时,所有 tabCell 的 fontColor 都会自动重新计算。

tabBar Builder 组合了六个 tabCell,外层包裹 Divider(分割线)和 Row(54高度,卡片背景色)。Divider 是 ArkUI 的分割线组件,通过 color 方法设置颜色。这里 Divider 位于 TabBar 顶部,与上方内容区形成视觉分隔。

4.6 build 方法与条件路由

  build() {
    Column() {
      if (this.activeTab === 0) {
        this.headerTop()
      }
      Column() {
        if (this.activeTab === 0) {
          HallContent()
        } else if (this.activeTab === 1) {
          OrderContent()
        } else if (this.activeTab === 2) {
          RankContent()
        } else if (this.activeTab === 3) {
          PriceContent()
        } else if (this.activeTab === 4) {
          WalletContent()
        } else {
          MineContent()
        }
      }.layoutWeight(1).width('100%')

      this.tabBar()
    }
    .width('100%').height('100%').backgroundColor(C.bg)
  }

build 方法是每个 ArkUI 组件的核心方法,它返回组件的 UI 声明式描述。FlashGoApp 的 build 方法构建了三层结构:头部(条件渲染)、内容区(条件路由)、底部 TabBar。

头部仅在 activeTab === 0(接单大厅 Tab)时渲染,这是为了在其他 Tab 页面中为内容区腾出更多垂直空间。内容区使用 if-else if-else 条件链,根据 activeTab 的值渲染对应的子组件(HallContent、OrderContent、RankContent、PriceContent、WalletContent、MineContent)。内容区 Column 的 layoutWeight(1) 使其占据除头部和 TabBar 之外的所有剩余垂直空间。

这里值得注意的一个设计细节是:子组件(如 HallContent)通过无参构造函数的方式被引用——HallContent() 的写法类似于函数调用,但实际上是组件实例化的声明式语法。ArkUI 框架在编译期会将这种声明转换为组件的创建和挂载操作。这种"组件即标签"的语法设计使得 UI 声明具有类似 HTML 的可读性。

在 ArkUI 中,build 方法的执行是声明式而非命令式的——开发者只需描述"UI 应该长什么样",框架负责将其转换为实际的渲染操作。当状态变量变化时,build 方法会被重新执行,ArkUI 的 diff 算法会比较新旧 UI 描述树,只对差异部分进行实际 DOM 操作。这种机制使得开发者能够以极低的认知负担构建复杂的状态驱动 UI。

FlashGoApp 主入口组件

0

1

2

3

4

5

onClick

onClick

onClick

onClick

onClick

onClick

headerTop 条件渲染 - 仅Tab0

内容区 Column layoutWeight=1

tabBar 底部导航

activeTab

接单大厅

订单管理

骑手榜

计价器

钱包

我的

接单大厅

订单

骑手榜

计价

钱包

我的

五、接单大厅组件深度解析

5.1 状态定义与生命周期动画

@Component
struct HallContent {
  @State hallFilter: string = '全部'
  @State showPublish: boolean = false
  @State showCategory: boolean = false
  @State showRange: boolean = false
  @State fabScale: number = 1
  @State liveOp: number = 0.3
  @State rangeKm: number = 5
  @State pubCat: string = '帮我买'
  @State pubFrom: string = ''
  @State pubTo: string = ''
  @State pubDist: number = 5
  @State pubWeight: string = '<5kg'
  @State pubTip: number = 0
  @State pubNote: string = ''
  @State pubTime: string = '立即送达'

HallContent 组件声明了十四个 @State 状态变量,是所有子组件中状态最丰富的。这些状态可以分为三类:弹框显隐控制(showPublish、showCategory、showRange)、动画驱动值(fabScale、liveOp)、表单数据(pubCat、pubFrom、pubTo、pubDist、pubWeight、pubTip、pubNote、pubTime)、筛选状态(hallFilter)、滑条值(rangeKm)。

在 ArkUI 中,@State 变量是组件私有的响应式状态。与全局变量不同,每个 HallContent 组件实例都有自己独立的状态副本——当状态变化时,只有该实例的 UI 会被更新。这种"状态局部化"的设计确保了组件的独立性和可复用性。showPublish 等布尔状态变量控制弹框的条件渲染——当值为 true 时,对应的弹框 Builder 会被执行并渲染到 UI 树中;当值为 false 时,弹框从 UI 树中移除。

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 900,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.fabScale = 1.12
    })
    this.getUIContext().animateTo({
      duration: 1200,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.liveOp = 1
    })
  }

aboutToAppear 是 ArkUI 组件的生命周期回调方法,在组件实例创建后、build 方法首次执行前被调用。本应用在该回调中启动了两个无限循环动画。

animateTo 是 ArkUI 的属性动画接口,通过 this.getUIContext() 获取 UI 上下文后调用。它接收两个参数:动画配置对象和状态修改闭包。动画配置对象包含 duration(动画时长,毫秒)、iterations(迭代次数,-1表示无限循环)、playMode(播放模式)、curve(动画曲线)。PlayMode.Alternate 表示交替播放——从起始值动画到目标值后,再从目标值反向动画回起始值,形成往复运动效果。Curve.EaseInOut 是缓入缓出曲线,使动画在开始和结束时减速,中间阶段加速,产生自然的运动感。

第一个动画驱动 fabScale 从1到1.12的往复变化,用于悬浮发布按钮的"呼吸缩放"效果。第二个动画驱动 liveOp 从0.3到1的往复变化,用于在线骑手红点的"呼吸闪烁"效果。这种"呼吸动画"在移动应用中广泛用于引导用户注意力和营造"实时在线"的氛围感。

5.2 遮罩基底与订单卡片

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.62)')
      .onClick(onClose)
  }

modalOverlay Builder 是所有弹框的遮罩层基底。它创建一个铺满全屏的 Column,背景色为半透明黑色(rgba(0,0,0,0.62)即62%不透明度的纯黑)。该 Builder 接收一个 onClose 回调函数作为参数,绑定到 Column 的 onClick 事件上——当用户点击遮罩区域(弹框外部)时,回调被触发关闭弹框。

这种将遮罩逻辑提取为独立 Builder 的做法是 ArkUI 中典型的"组合式设计"——所有需要遮罩的弹框都可以复用这个 Builder,只需传入不同的关闭回调即可。闭包参数(onClose: () => void)的类型标注确保了传入的回调函数签名正确。

  @Builder hallCard(h: HallT) {
    Column() {
      Row() {
        Text(h.icon).fontSize(17)
        Text(h.kind).fontSize(9).fontColor(C.dark).backgroundColor(kindColor(h.kind))
          .padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(8).margin({ left: 6 })
        Column().layoutWeight(1)
        if (h.urgent) {
          Text('加急').fontSize(8).fontColor(C.white).backgroundColor(C.danger)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
        }
      }.width('100%').alignItems(VerticalAlign.Center)

hallCard Builder 构建接单大厅的订单卡片,是整个应用中最复杂的 UI 单元之一。卡片顶部 Row 从左到右排列:品类图标、品类标签胶囊(使用 kindColor 函数动态着色)、弹性占位 Column、条件渲染的"加急"标签。kindColor 函数根据品类名称返回对应的主题色——"帮我买"返回荧光黄、"帮我送"返回骑手橙、"代排队"返回蓝色、其他返回绿色。这种通过函数动态计算颜色的做法使得品类标签的视觉识别高度一致。

if 条件判断检查 h.urgent 的布尔值——true 时渲染红色"加急"标签,false 时不渲染任何内容。在 ArkUI 的声明式语法中,if 语句可以直接出现在组件声明的位置,控制子元素的条件渲染。

5.3 瀑布流布局实现

        Scroll() {
          Column() {
            Row() {
              Column() {
                ForEach(hallFiltered(this.hallFilter), (h: HallT, idx: number) => {
                  if (idx % 2 === 0) {
                    this.hallCard(h)
                  }
                })
              }.layoutWeight(1)
              Column().width(8)
              Column() {
                ForEach(hallFiltered(this.hallFilter), (h: HallT, idx: number) => {
                  if (idx % 2 === 1) {
                    this.hallCard(h)
                  }
                })
              }.layoutWeight(1)
            }.width('100%').alignItems(VerticalAlign.Top).padding({ left: 12, right: 12 })

瀑布流布局是本应用的一大设计亮点。它通过两个等宽的 Column(各占 layoutWeight(1))配合一个8px宽的间距 Column 实现双列瀑布流效果。左列通过 ForEach 遍历 hallFiltered 返回的数组,使用 if (idx % 2 === 0) 条件判断只渲染偶数索引的订单卡片;右列使用 if (idx % 2 === 1) 只渲染奇数索引的卡片。

这种"两次遍历、条件过滤"的实现方式虽然看起来不如 CSS 的 column-count 属性简洁,但在 ArkUI 中却是最直接的瀑布流方案——因为 ArkUI 的布局系统不支持 CSS 的多列布局,开发者需要通过手动分列来模拟。alignItems(VerticalAlign.Top) 确保两列在顶部对齐,使得卡片高度不同时不会居中对齐导致上方出现空白。

hallFiltered(this.hallFilter) 的调用将筛选逻辑与 UI 渲染解耦——当用户点击品类筛选胶囊修改 hallFilter 状态时,ForEach 会自动重新执行并渲染筛选后的数据。如果筛选结果为空数组,ForEach 不渲染任何内容,瀑布流区域为空白。

5.4 柱状图 Stack 实现

            Column() {
              Row() {
                Text('📊 本周接单量').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.ink)
                Column().layoutWeight(1)
                Text('峰值 ' + weekMax() + ' 单').fontSize(9).fontColor(C.hint)
              }.width('100%')
              Row() {
                ForEach(WEEK_LIST, (w: WeekT) => {
                  Column() {
                    Text(w.orders + '').fontSize(8).fontColor(C.sub).margin({ bottom: 4 })
                    Stack({ alignContent: Alignment.Bottom }) {
                      Column().width('100%').height(96).backgroundColor(C.chip).borderRadius(4)
                      Column().width('100%').height(96 * w.orders / weekMax())
                        .backgroundColor(w.day === '周六' ? C.accent : C.primary).borderRadius(4)
                    }.width('100%').height(96)
                    Text(w.day).fontSize(9).fontColor(C.hint).margin({ top: 5 })
                  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
                })
              }.width('100%').margin({ top: 12 })

柱状图的实现巧妙地利用了 Stack 的层叠特性。每根柱子由两个 Column 组成:底层是一个满高度(96)的灰色背景柱,上层是一个根据数据值按比例缩短的彩色前景柱。Stack 的 alignContent 设置为 Alignment.Bottom,使两层柱子从底部对齐——灰色背景柱全高,彩色前景柱从底部向上延伸至数据值对应的高度。

前景柱的高度计算公式为 96 * w.orders / weekMax()——数据值除以峰值再乘以最大高度,得到该柱子的实际像素高度。周六的柱子使用骑手橙色(C.accent),其他天使用荧光黄(C.primary),形成色彩对比以突出周末数据。每根柱子上方显示数值文本,下方显示星期名称,三者在 Column 中从上到下排列。

Stack 层叠布局在数据可视化中有着广泛的应用。除了柱状图,它还可以用于实现进度条(底层灰色全宽 + 上层彩色按比例宽度)、环形图(多个同心圆叠加)、评分星级(底层灰色星星 + 上层金色星星按比例宽度裁剪)等常见图表组件。其核心思想是"通过层叠两个不同尺寸的同形元素来表达比例关系"。

5.5 发布订单弹框

  @Builder publishOverlay() {
    Stack({ alignContent: Alignment.Bottom }) {
      this.modalOverlay(() => { this.showPublish = false })
      Column() {
        Column().width(40).height(4).borderRadius(2).backgroundColor(C.hint).margin({ top: 10 })
        Row() {
          Text('🚀 发布跑腿订单').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.ink)
          Column().layoutWeight(1)
          Text('✕').fontSize(16).fontColor(C.hint)
            .onClick(() => { this.showPublish = false })
        }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })

publishOverlay Builder 构建了发布跑腿订单的底部抽屉弹框。外层 Stack 的 alignContent 设置为 Alignment.Bottom,使内容 Column 从底部对齐——这就是"底部抽屉"效果的实现原理:遮罩层铺满全屏,内容区从底部弹出。

内容区顶部有一个40x4的小型圆角矩形,这是移动端底部抽屉的标准"拖拽指示条"(grab handle),虽然本应用中它仅作为视觉提示(没有实现实际拖拽关闭功能),但其视觉语言已经足够清晰地传达了"可向下滑动关闭"的交互意图。

        Scroll() {
          Column() {
            Text('① 服务品类').fontSize(10).fontColor(C.sub).fontWeight(FontWeight.Bold)
              .width('100%').margin({ top: 6 })
            Row() {
              Text(this.pubCat).fontSize(11).fontColor(C.primary)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(12)
                .border({ width: 1, color: C.primary })
              Text('更换品类').fontSize(9).fontColor(C.sub).backgroundColor(C.chip)
                .padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).margin({ left: 8 })
                .onClick(() => { this.showCategory = true })
              Column().layoutWeight(1)
            }.width('100%').margin({ top: 8 })

弹框内容区使用 Scroll 包裹 Column,高度限制为430,使得内容超出时可垂直滚动。表单按步骤编号组织(① 到 ⑦),每个步骤有标题行和对应的输入控件。品类选择步骤中,当前品类以边框样式显示,旁边有"更换品类"按钮——点击后设置 showCategory 为 true,触发品类选择宫格弹框的渲染。这是一种"弹框中套弹框"的设计模式,通过多个布尔状态变量控制多个弹框的显隐。

            Row() {
              Column() {
                Text('预估运费').fontSize(9).fontColor(C.hint)
                Text('¥' + (calcPrice(this.pubDist, this.pubWeight, this.pubCat) + this.pubTip))
                  .fontSize(22).fontWeight(FontWeight.Bold).fontColor(C.primary).margin({ top: 2 })
              }.alignItems(HorizontalAlign.Start)
              Column().layoutWeight(1)
              Text('立即发布').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.dark).backgroundColor(C.primary)
                .padding({ left: 30, right: 30, top: 10, bottom: 10 }).borderRadius(20)
                .onClick(() => { this.showPublish = false })
            }.width('100%').margin({ top: 16, bottom: 18 }).alignItems(VerticalAlign.Center)

表单底部的运费预估区域调用了 calcPrice 全局函数,将当前表单中填写的距离、重量、品类三个参数传入计算引擎,再加上小费金额,得到预估总价。由于 pubDist、pubWeight、pubCat 都是 @State 变量,当用户修改任一参数时,运费文本会自动重新计算并更新——这就是响应式状态驱动 UI 的典型场景。整个表单的数据流是单向的:用户输入 -> @State 变更 -> UI 重新计算渲染,无需手动调用刷新方法。

弹框控制

true

true

true

点击更换品类

点击关闭

点击确定

点击保存

showPublish

publishOverlay渲染

showCategory

categoryOverlay渲染

showRange

rangeOverlay渲染

发布订单弹框数据流

用户选择品类 pubCat

calcPrice

用户拖动距离 pubDist

用户选择重量 pubWeight

用户选择小费 pubTip

总价 = calcPrice + pubTip

UI自动更新预估运费

六、订单管理组件深度解析

6.1 订单时间线卡片

  @Builder orderCard(o: OrderT) {
    Column() {
      Row() {
        Text(o.icon).fontSize(16)
        Text(o.kind).fontSize(10).fontColor(C.ink).fontWeight(FontWeight.Medium).margin({ left: 5 })
        Text(o.no).fontSize(8).fontColor(C.hint).margin({ left: 6 })
        Column().layoutWeight(1)
        if (o.status === '进行中') {
          Row() {
            Column().width(5).height(5).borderRadius(3).backgroundColor(C.accent).opacity(this.glowOp)
            Text(o.status).fontSize(8).fontColor(statusColor(o.status))
              .backgroundColor(statusBg(o.status)).padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius(8).margin({ left: 4 })
          }.alignItems(VerticalAlign.Center)
        } else {
          Text(o.status).fontSize(8).fontColor(statusColor(o.status))
            .backgroundColor(statusBg(o.status)).padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(8)
        }
      }.width('100%').alignItems(VerticalAlign.Center)

orderCard Builder 构建了订单时间线卡片,其设计精妙之处在于根据订单状态呈现不同的操作按钮和视觉样式。卡片顶部的状态标签使用了条件渲染——当状态为"进行中"时,在标签前方添加一个5x5的圆点,其 opacity 绑定到 glowOp 状态变量,实现呼吸闪烁效果。这种"进行中订单有闪烁指示灯、已完成/已取消订单无指示灯"的视觉差异,使用户能够在列表中快速识别需要关注的订单。

statusColor 和 statusBg 函数将状态文本映射为对应的颜色——"进行中"映射为荧光黄、"已完成"映射为绿色、"已取消"映射为红色。这种状态-颜色映射在整个应用中保持一致,形成了稳定的色彩语义系统。

      Row() {
        Column() {
          Text('●').fontSize(8).fontColor(C.accent)
          Column().width(2).layoutWeight(1).backgroundColor(C.line).margin({ top: 2, bottom: 2 })
          Text('●').fontSize(8).fontColor(C.primary)
        }.width(12).alignItems(HorizontalAlign.Center).margin({ top: 5, bottom: 5 })

        Column() {
          Row() {
            Text(o.from).fontSize(10).fontColor(C.ink).maxLines(1).layoutWeight(1)
            Text('取件').fontSize(7).fontColor(C.hint)
          }.width('100%')
          Column().height(16)
          Row() {
            Text(o.to).fontSize(10).fontColor(C.ink).maxLines(1).layoutWeight(1)
            Text('送达').fontSize(7).fontColor(C.hint)
          }.width('100%')
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 6 })
      }.width('100%').padding({ top: 10 }).alignItems(VerticalAlign.Top)

订单卡片的中部是一个时间线节点设计——左侧是一个纵向 Column,包含顶部圆点(取件节点,橙色)、中间的连线(2px宽的灰色 Column,layoutWeight(1) 撑满中间空间)、底部圆点(送达节点,黄色)。这个设计的精髓在于 Column().width(2).layoutWeight(1) 这一行——它创建了一个极窄的 Column 作为连线,通过 layoutWeight(1) 使其在两个圆点之间自动撑满剩余空间。无论圆点之间的距离如何变化(取决于右侧地址文本的高度),连线都能自适应地连接两个圆点。

右侧是取件地址和送达地址的文本区域,两个 Row 之间通过 Column().height(16) 设置固定间距。maxLines(1) 确保地址文本只显示一行,超出部分被截断,防止过长的地址文本撑破卡片布局。

6.2 条件操作按钮

      if (o.status === '进行中') {
        Text(stepText(o.step)).fontSize(8).fontColor(C.accent).width('100%').margin({ top: 6 }).maxLines(1)
        Row() {
          Text('修改地址').fontSize(9).fontColor(C.sub).backgroundColor(C.chip)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
            .onClick(() => {
              this.selIdx = orderIdxById(o.id)
              this.addrName = ADDR_LIST[0].name
              this.addrPhone = ADDR_LIST[0].phone
              this.addrDetail = o.to
              this.addrTag = '家'
              this.addrDef = false
              this.showAddr = true
            })
          Text('联系骑手').fontSize(9).fontColor(C.sub).backgroundColor(C.chip)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).margin({ left: 8 })
            .onClick(() => { this.selIdx = orderIdxById(o.id) })
          Text('取消订单').fontSize(9).fontColor(C.white).backgroundColor(C.danger)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).margin({ left: 8 })
            .onClick(() => {
              this.selIdx = orderIdxById(o.id)
              this.showCancel = true
            })
        }.width('100%').justifyContent(FlexAlign.End).margin({ top: 9 })
      } else if (o.status === '已完成') {

订单卡片底部的操作按钮区域采用了三层条件渲染:进行中订单显示"修改地址"“联系骑手”“取消订单"三个按钮;已完成订单显示"再来一单”“评价骑手"两个按钮;已取消订单显示"重新下单”"删除记录"两个按钮。每种状态下的按钮组合直接映射了该状态下用户可能需要的操作,这是一种以"状态-操作矩阵"驱动的 UI 设计模式。

"修改地址"按钮的 onClick 回调展示了弹框预填数据的完整流程:首先通过 orderIdxById 将订单 id 转换为数组下标并赋值给 selIdx,然后将地址簿数据预填到弹框的状态变量中(addrName、addrPhone、addrDetail、addrTag、addrDef),最后将 showAddr 设置为 true 触发弹框渲染。这种"先填数据再开弹框"的顺序确保了弹框出现时已经显示了正确的预填内容。

Row 的 justifyContent 设置为 FlexAlign.End,使操作按钮靠右排列。FlexAlign 是 ArkUI 弹性布局的对齐枚举,End 表示主轴方向上的末端对齐。

6.3 订单类型分布占比条

        Column() {
          Row() {
            Text('🧾 订单类型分布').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.ink)
            Column().layoutWeight(1)
            Text('共 ' + typeTotal() + ' 单').fontSize(9).fontColor(C.hint)
          }.width('100%')

          Row() {
            ForEach(TYPE_LIST, (t: TypeT) => {
              Column().layoutWeight(t.pct).height(14).backgroundColor(t.color)
            })
          }
          .width('100%').borderRadius(7).clip(true).margin({ top: 12 })

订单类型分布的占比条是另一个巧妙利用 layoutWeight 实现比例可视化的例子。ForEach 遍历 TYPE_LIST 数组,为每个类型创建一个 Column,其 layoutWeight 设置为该类型的百分比(t.pct)。由于 layoutWeight 的值是相对权重,多个 Column 的权重之和为100,它们会自动按比例分配 Row 的宽度。例如,如果某类型占比42%,其 layoutWeight(42) 会使该 Column 占据 Row 宽度的42%。

每个 Column 的背景色取自数据对象的 color 字段,不同类型使用不同颜色。Row 的 borderRadius(7) 和 clip(true) 使占比条两端圆角,clip 确保子 Column 的直角不会溢出圆角区域。

下方还有一个明细列表,每行显示类型图标、名称、数量、以及一个按比例缩小的迷你进度条(Column().width(t.pct * 1.6))。这种"宏观占比条 + 微观明细列表"的双层可视化设计,使得数据既能一览全局又能了解细节。

6.4 取消订单与评分弹框

  @Builder cancelOverlay() {
    Column() {
      this.modalOverlay(() => { this.showCancel = false })
      Column() {
        Column() {
          Text('🚫').fontSize(34)
          Text('确认取消该订单?').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.white).margin({ top: 8 })
          Text('取消后支付金额将在 2 小时内原路退回').fontSize(9).fontColor('#FFD5D5').margin({ top: 5 })
        }.width('100%').padding({ top: 20, bottom: 16 })
        .linearGradient({ direction: GradientDirection.Right, colors: [['#E5484D', 0], ['#FF5252', 1]] })
        .borderRadius({ topLeft: 18, topRight: 18 })
        .alignItems(HorizontalAlign.Center)

cancelOverlay Builder 构建了取消订单的确认弹框。弹框顶部区域使用了 linearGradient 线性渐变背景——direction 设置为 GradientDirection.Right(从左到右),colors 数组定义了渐变断点:从 #E5484D(0%位置)到 #FF5252(100%位置)。这种红色渐变背景配合禁止图标,形成了强烈的"危险操作"视觉警示。

linearGradient 是 ArkUI 的渐变背景方法,支持线性渐变(GradientDirection)、锥形渐变和径向渐变。colors 参数是一个二维数组,每个元素是 [色值, 位置] 的元组。位置值范围0-1,表示该色值在渐变方向上的相对位置。

        Row() {
          ForEach(STAR_ARR, (s: string, idx: number) => {
            if (this.rateScore >= idx + 1) {
              Text(s).fontSize(26).margin({ left: 4, right: 4 })
                .onClick(() => { this.rateScore = idx + 1 })
            } else {
              Text(s).fontSize(26).opacity(0.2).margin({ left: 4, right: 4 })
                .onClick(() => { this.rateScore = idx + 1 })
            }
          })
        }.width('100%').justifyContent(FlexAlign.Center).margin({ top: 12 })

评分弹框中的星级评分组件使用 ForEach 遍历 STAR_ARR 数组(五个星星元素),通过索引 idx 与 rateScore 的比较控制每颗星的显示样式——已选中的星星(idx + 1 <= rateScore)正常显示,未选中的星星使用 opacity(0.2) 变暗。点击任意星星会将 rateScore 设置为对应的星级,触发重新渲染更新所有星星的显示状态。这种交互式的星级评分组件在电商和配送类应用中非常常见。

Toggle 组件在评分弹框和地址弹框中都有使用——Toggle({ type: ToggleType.Switch, isOn: this.rateAnon }) 创建一个开关样式的切换器。selectedColor 设置开关开启时的颜色,switchPointColor 设置滑块颜色。onChange 回调在开关状态变化时将新值同步到状态变量。

七、骑手榜组件深度解析

7.1 领奖台设计

          Column() {
            Text('🥇 本周三甲').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.ink).width('100%')
            Row() {
              // 亚军
              Column() {
                Text('🥈').fontSize(22)
                Text(RIDER_LIST[1].avatar).fontSize(30).margin({ top: 4 })
                Text(RIDER_LIST[1].name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(C.ink).margin({ top: 5 })
                Text(RIDER_LIST[1].orders + ' 单').fontSize(9).fontColor(C.sub).margin({ top: 3 })
              }
              .layoutWeight(1).height(120).backgroundColor(C.card2).borderRadius({ topLeft: 14, bottomLeft: 14 })
              .justifyContent(FlexAlign.End).padding({ bottom: 12 }).alignItems(HorizontalAlign.Center)

骑手榜的领奖台设计是整个应用中视觉效果最为丰富的部分之一。前三名骑手分别站在三个不同高度的"奖台"上——亚军高度120、冠军高度150、季军高度104。这种通过不同高度的 Column 来模拟实体领奖台的手法,在纯声明式 UI 中是一种优雅的解决方案。

冠军奖台使用了 layoutWeight(1.15),比两侧的 layoutWeight(1) 稍大,使其在水平方向上也略宽,进一步强化了冠军的视觉中心地位。冠军奖台还添加了 shadow 阴影效果——shadow 方法接收一个包含 radius、color、offsetX、offsetY 的对象,创建带有金色光晕的投影效果。

justifyContent(FlexAlign.End) 使奖台内部的子元素(图标、名称、单量)靠底部排列,模拟"站在奖台上"的视觉效果。冠军顶部的皇冠使用了 scale 缩放属性,绑定到 medalScale 状态变量,在 aboutToAppear 中启动的动画驱动下产生脉动效果。

7.2 榜单进度条

  @Builder riderRow(r: RiderT, idx: number) {
    Row() {
      Text((idx + 1).toString()).fontSize(12).fontWeight(FontWeight.Bold).fontColor(rankColor(idx)).width(24)
      Text(r.avatar).fontSize(20)
      Column() {
        Row() {
          Text(r.name).fontSize(11).fontColor(C.ink).fontWeight(FontWeight.Medium)
          Text(r.level).fontSize(7).fontColor(levelColor(r.level))
            .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6).margin({ left: 5 })
            .border({ width: 1, color: levelColor(r.level) })
          Column().layoutWeight(1)
          Text(r.score + ' 分').fontSize(8).fontColor(C.accent)
        }.width('100%')
        Row() {
          Text(r.city + ' · ' + r.orders + ' 单 · 在线 ' + r.hours + 'h')
            .fontSize(8).fontColor(C.hint).maxLines(1).layoutWeight(1)
          Text(r.orders + '').fontSize(8).fontColor(C.sub)
        }.width('100%').margin({ top: 4 })
        Row() {
          Column().layoutWeight(r.orders).height(5).backgroundColor(idx < 3 ? C.primary : C.accent).borderRadius(3)
          Column().layoutWeight(riderMax() - r.orders).height(5)
        }.width('100%').backgroundColor(C.chip).borderRadius(3).margin({ top: 5 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 8 })
    }

riderRow Builder 构建了榜单中的每一行。排名数字使用 rankColor 函数着色——第1名(idx=0)返回荧光黄、第2名返回银色(#C9D1D9)、第3名返回铜色(#CD7F32)、其他返回灰色。这种"金银铜"三色排名系统是竞技排名的通用视觉语言。

进度条的实现使用了与占比条类似的 layoutWeight 技术——彩色部分的 layoutWeight 设为骑手的单量(r.orders),灰色部分的 layoutWeight 设为最大单量与该骑手单量的差值(riderMax() - r.orders)。两部分共享同一个 Row 的宽度,彩色部分占据的比例即为该骑手单量占最大单量的比例。前3名(idx < 3)的进度条使用荧光黄,其他使用骑手橙,形成排名的视觉区分。

八、计价器组件深度解析

8.1 计算器卡片

          Column() {
            Text('① 选择服务品类').fontSize(10).fontColor(C.sub).fontWeight(FontWeight.Bold).width('100%')
            Row() {
              ForEach(PRICE_KINDS, (k: string) => {
                this.kindChip(k)
              })
            }.width('100%').margin({ top: 8 })

            Text('② 配送距离:' + this.calcDist + ' km').fontSize(10).fontColor(C.sub)
              .fontWeight(FontWeight.Bold).width('100%').margin({ top: 14 })
            Slider({ value: this.calcDist, min: 1, max: 20, step: 1 })
              .blockColor(C.primary).trackColor(C.chip).selectedColor(C.primary)
              .width('100%').margin({ top: 4 })
              .onChange((v: number) => { this.calcDist = v })

计价器卡片展示了与发布弹框类似的表单结构,但数据绑定到不同的状态变量(calcKind、calcDist、calcWeight)。Slider 组件是 ArkUI 的滑动选择器,通过 value、min、max、step 四个参数配置滑动范围。blockColor 设置滑块颜色,trackColor 设置轨道颜色,selectedColor 设置已选区域的颜色。onChange 回调在用户拖动滑块时将新值同步到 calcDist 状态变量,触发预估运费的自动重新计算。

kindChip Builder 使用条件渲染控制选中态样式——选中时使用 kindColor 函数返回的品类色作为背景,未选中时使用 chip 灰色背景。这种"选中高亮、未选中灰底"的交互模式在多选/单选场景中广泛应用。

8.2 费用明细与会员卡

          Column() {
            Text('💡 费用构成说明').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.ink).width('100%')
            Row() {
              Text('起步价').fontSize(10).fontColor(C.sub).layoutWeight(1)
              Text('¥8.0(含 3km)').fontSize(10).fontColor(C.ink)
            }.width('100%').margin({ top: 10 })
            Row() {
              Text('超程费').fontSize(10).fontColor(C.sub).layoutWeight(1)
              Text(this.calcDist > 3 ? '¥' + ((this.calcDist - 3) * 1.5).toFixed(1) : '¥0.0')
                .fontSize(10).fontColor(C.ink)
            }.width('100%').margin({ top: 8 })

费用明细区域逐项展示运费的构成——起步价、超程费、重量加价、品类附加。每项的费用都通过条件表达式动态计算,例如超程费在距离大于3时显示 (dist-3)*1.5 的结果,否则显示"¥0.0"。toFixed(1) 方法将数字格式化为一位小数的字符串,确保金额显示的一致性。

这种"计算器 + 明细"的双层展示设计,使得用户不仅看到最终价格,还能理解价格的构成方式,提升了计价系统的透明度——这正呼应了卡片标题旁"透明计价 · 无隐藏费"的文案承诺。

          Column() {
            Row() {
              Column() {
                Text('⚡ 闪送帮会员').fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.dark)
                Text('每月 4 张免跑腿券 · 运费 88 折').fontSize(9).fontColor('#5B4A16').margin({ top: 5 })
                Row() {
                  Text('¥39').fontSize(10).fontColor('#8A7A3A')
                    .decoration({ type: TextDecorationType.LineThrough })
                  Text('¥29/月').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.dark).margin({ left: 8 })
                }.margin({ top: 7 }).alignItems(VerticalAlign.Center)
              }.alignItems(HorizontalAlign.Start).layoutWeight(1)

会员卡区域使用了荧光黄背景(C.primary),所有文字使用深色(C.dark 或暖棕色),形成与深色卡片截然不同的"亮色卡片"视觉对比。原价"¥39"使用了 decoration 方法添加删除线——TextDecorationType.LineThrough 在文字中间画一条横线,表示该价格为"原价/划线价"。这种"原价删除线 + 现价加粗"的价格展示模式是电商应用的标准设计语言。

在 ArkUI 中,Text 组件的 decoration 方法支持三种装饰类型:LineThrough(删除线)、Underline(下划线)、None(无装饰)。这些装饰效果在视觉上等同于 HTML 的 text-decoration CSS 属性,但通过声明式 API 调用。

九、钱包组件深度解析

9.1 余额卡与渐变光晕

            Stack() {
              Column()
                .width('100%').height('100%').borderRadius(18).opacity(this.glowOp)
                .linearGradient({ direction: GradientDirection.Right, colors: [['#FFD21F', 0], ['#FF7A2F', 1]] })
              Row() {
                Column() {
                  Text('账户余额(元)').fontSize(9).fontColor('#5B4A16')
                  Text('286.50').fontSize(34).fontWeight(FontWeight.Bold).fontColor(C.dark).margin({ top: 4 })
                  Text('本周跑腿收益 +¥128.00').fontSize(9).fontColor('#5B4A16').margin({ top: 5 })
                }.alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text('💰').fontSize(40).scale({ x: this.coinScale, y: this.coinScale })
              }.width('100%').padding({ left: 18, right: 18 }).alignItems(VerticalAlign.Center)
            }
            .width('100%').height(120).borderRadius(18)

余额卡的设计是 Stack 层叠布局的又一经典应用。底层 Column 使用 linearGradient 设置从荧光黄到骑手橙的横向渐变,并通过 opacity 绑定到 glowOp 状态变量,实现"光晕呼吸"效果——光晕透明度在0.35到1之间往复变化,使卡片背景的亮度产生微妙的脉动感。上层 Row 包含实际的文本内容(余额数字、金币图标),金币图标通过 scale 绑定到 coinScale 状态变量实现缩放浮动动画。

这种"渐变底层 + 内容上层"的双层 Stack 结构,使得动画效果只作用于背景层而不影响内容层的清晰度——即使背景在呼吸闪烁,文字内容始终保持稳定的显示状态。

9.2 自定义数字键盘

  @Builder keyCell(k: string) {
    Text(k).fontSize(20).fontColor(C.ink)
      .width('100%').padding({ top: 13, bottom: 13 }).textAlign(TextAlign.Center)
      .backgroundColor(C.card2).borderRadius(12).margin(4)
      .onClick(() => { this.amountStr = keyAppend(this.amountStr, k) })
  }

keyCell Builder 构建了自定义数字键盘的单个按键。每个按键是一个 Text 组件,宽度100%(配合外层 Row 的等分布局),上下padding各13,居中对齐,圆角背景。onClick 回调调用 keyAppend 全局函数,将按键字符追加到 amountStr 状态字符串中——keyAppend 函数处理退格键(删除最后一个字符)和普通字符(在长度限制内追加)两种逻辑。

        Column() {
          Row() {
            this.keyCell(KEYS[0])
            this.keyCell(KEYS[1])
            this.keyCell(KEYS[2])
          }.width('100%')
          Row() {
            this.keyCell(KEYS[3])
            this.keyCell(KEYS[4])
            this.keyCell(KEYS[5])
          }.width('100%')
          Row() {
            this.keyCell(KEYS[6])
            this.keyCell(KEYS[7])
            this.keyCell(KEYS[8])
          }.width('100%')
          Row() {
            this.keyCell(KEYS[9])
            this.keyCell(KEYS[10])
            this.keyCell(KEYS[11])
          }.width('100%')
        }.width('100%').padding({ left: 8, right: 8 }).margin({ top: 6 })

键盘布局通过四个 Row 实现,每行三个按键。KEYS 数组定义了12个按键内容:数字1-9、小数点、0、退格符号⌫。每个 Row 的宽度100%,内部的三个 keyCell 各自宽度100%配合margin(4)实现等分间距布局。这种"4行3列"的数字键盘布局在金融类应用的金额输入场景中是标准设计。

自定义数字键盘在移动应用中有两个核心优势:一是避免了系统键盘弹出时的布局挤压问题(系统键盘通常占据屏幕40%以上的高度);二是可以完全控制按键的视觉样式和交互逻辑(如限制输入长度、自动格式化等)。在 ArkUI 中,由于 TextInput 组件获得焦点时会自动弹出系统软键盘,自定义键盘通常配合 TextInput 的 focus 控制 API 使用——或者在输入区域使用 Text 而非 TextInput 来避免系统键盘弹出。本应用采用了后一种方案。

9.3 提现弹框

  @Builder withdrawOverlay() {
    Column() {
      this.modalOverlay(() => { this.showWithdraw = false })
      Column() {
        Row() {
          Text('💸 余额提现').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.ink)
          Column().layoutWeight(1)
          Text('✕').fontSize(16).fontColor(C.hint)
            .onClick(() => { this.showWithdraw = false })
        }.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 6 })

        Text('可提余额 ¥286.50').fontSize(10).fontColor(C.hint).margin({ top: 8 })
        Text('¥' + this.withdrawAmt).fontSize(34).fontWeight(FontWeight.Bold).fontColor(C.primary)
          .margin({ top: 6 })

提现弹框与取消弹框、评分弹框等采用了相同的架构模式——modalOverlay 遮罩 + Column 内容卡 + zIndex(999) 层级提升。withdrawAmt 状态变量控制当前选择的提现金额,初始值为100。弹框中显示了四个快捷金额选项(50、100、200、286)——其中286标注为"全部提现",这是一个特殊的语义化金额值,代表用户可提的全部余额。

wdChip Builder 使用条件渲染区分选中与未选中状态——选中时使用荧光黄背景,未选中时使用灰色背景。modeChip Builder 控制提现方式的选择(微信零钱、银行卡),选中时使用骑手橙色背景。这种多组单选胶囊的设计在需要从有限选项中选择一个的场景中非常高效。

十、个人中心组件深度解析

10.1 个人卡与今日数据

            Row() {
              Text('🛵').fontSize(38)
                .padding(14).backgroundColor(C.card2).borderRadius(28)
                .scale({ x: this.avatarScale, y: this.avatarScale })
              Column() {
                Row() {
                  Text('阿飞').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.ink)
                  Text('黄金骑手').fontSize(7).fontColor(C.dark).backgroundColor(C.primary)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).margin({ left: 6 })
                }.alignItems(VerticalAlign.Center)
                Text('ID: FG886655 · 已实名认证 ✓').fontSize(8).fontColor(C.hint).margin({ top: 5 })
                Row() {
                  Text('⭐ 4.9').fontSize(9).fontColor(C.primary)
                  Text('接单 288 · 好评率 99%').fontSize(8).fontColor(C.sub).margin({ left: 10 })
                }.margin({ top: 5 }).alignItems(VerticalAlign.Center)
              }.alignItems(HorizontalAlign.Start).margin({ left: 12 })
              Column().layoutWeight(1)
              Text('›').fontSize(18).fontColor(C.hint)

个人卡使用 Row 布局,左侧是头像区域——一个 Emoji 摩托车图标放置在28x28的圆角方形背景中,通过 scale 绑定到 avatarScale 状态变量实现缩放动画。中间是用户信息列(昵称+等级标签、ID+认证状态、评分+单量),右侧是进入箭头"›"。

今日数据区域使用 Row 等分布局展示四个数据项(今日接单、今日里程、今日在线、今日收益),每个数据项是一个 Column(上方大数字+下方小标签),layoutWeight(1) 使四项等分宽度。四个数字分别使用不同颜色——主色黄、辅助橙、绿色、白色,形成数据维度的色彩编码。

10.2 等级进度条

            Column() {
              Row() {
                Text('🎖️ 黄金骑手').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.ink)
                Column().layoutWeight(1)
                Text('还差 320 单升级铂金').fontSize(9).fontColor(C.hint)
              }.width('100%')
              Row() {
                Column().layoutWeight(68).height(10).backgroundColor(C.primary).borderRadius(5)
                Column().layoutWeight(32).height(10)
              }.width('100%').backgroundColor(C.chip).borderRadius(5).clip(true).margin({ top: 10 })
              Row() {
                Text('Lv.5 黄金').fontSize(8).fontColor(C.primary)
                Column().layoutWeight(1)
                Text('Lv.6 铂金 · 68%').fontSize(8).fontColor(C.hint)
              }.width('100%').margin({ top: 6 })

等级进度条使用 layoutWeight(68) 和 layoutWeight(32) 的硬编码比例来表示68%的进度。与前面使用动态计算 layoutWeight 的进度条不同,这里使用了静态的固定比例——因为等级进度在数据层面就是固定的68%,不需要根据数组数据动态计算。

外层 Row 的背景色设为 chip 灰色,圆角5,clip(true) 确保内部两个 Column 的边缘不会超出圆角区域。前段 Column 背景为荧光黄(已完成进度),后段 Column 无背景色(显示外层灰色,即未完成进度)。这种"灰底彩条"的进度条设计在等级系统、任务完成度等场景中非常常见。

10.3 功能宫格与设置列表

  @Builder mineCell(m: MenuT) {
    Column() {
      Text(m.icon).fontSize(22)
      Text(m.name).fontSize(9).fontColor(C.sub).margin({ top: 6 })
    }.layoutWeight(1).padding({ top: 12, bottom: 12 }).alignItems(HorizontalAlign.Center)
    .onClick(() => { this.mineHint = '已进入:' + m.name })
  }

  @Builder settingRow(icon: string, title: string, sub: string, hasToggle: boolean, key: number) {
    Row() {
      Text(icon).fontSize(16)
      Column() {
        Text(title).fontSize(11).fontColor(C.ink).fontWeight(FontWeight.Medium)
        Text(sub).fontSize(8).fontColor(C.hint).margin({ top: 3 })
      }.alignItems(HorizontalAlign.Start).margin({ left: 10 })
      Column().layoutWeight(1)
      if (hasToggle) {
        if (key === 0) {
          Toggle({ type: ToggleType.Switch, isOn: this.orderNotify })
            .selectedColor(C.primary).switchPointColor(C.dark)
            .onChange((isOn: boolean) => { this.orderNotify = isOn })
        } else {
          Toggle({ type: ToggleType.Switch, isOn: this.soundOn })
            .selectedColor(C.accent).switchPointColor(C.dark)
            .onChange((isOn: boolean) => { this.soundOn = isOn })
        }
      } else {
        Text('›').fontSize(14).fontColor(C.hint)
      }
    }

mineCell Builder 构建功能宫格的单个单元,与头部分类宫格的 catCell 结构类似但图标更大、文字更小。onClick 回调将功能名称写入 mineHint 状态变量,该变量在页面底部以提示文字的形式显示,为用户提供操作反馈。

settingRow Builder 是一个高度参数化的设置行组件——它接收图标、标题、副标题、是否包含开关、开关键值五个参数。通过 hasToggle 参数控制右侧是显示 Toggle 开关还是显示进入箭头"›"。key 参数用于区分控制哪个状态变量——key=0 绑定 orderNotify,key=1 绑定 soundOn。这种通过参数化 Builder 实现的设置行组件,使得设置列表的构建完全数据驱动——只需调用 settingRow 并传入不同的参数即可生成不同的设置行。

个人中心组件架构

个人卡 Row - 头像+信息+箭头

今日数据 Row - 4项等分

等级进度 Column - 进度条

功能宫格 4x3

设置列表

提示与版本

我的地址

收货地址簿

接单设置

车辆信息

实名认证

骑手装备

保险保障

邀请好友

客服中心

意见反馈

规则中心

设置

新订单提醒 Toggle

接单提示音 Toggle

联系客服 Arrow

规则中心 Arrow

十一、组件关系与状态管理全景

11.1 组件层级关系

本应用由七个 struct 组件构成:一个 @Entry 入口组件 FlashGoApp 和六个 @Component 子内容组件(HallContent、OrderContent、RankContent、PriceContent、WalletContent、MineContent)。入口组件 FlashGoApp 通过 activeTab 状态变量控制六个子组件的条件渲染——每次只有一个子组件处于渲染状态。

每个子内容组件都有自己独立的 @State 状态体系,组件之间不共享状态。这种"组件状态隔离"的设计确保了组件的独立性——例如 HallContent 的弹框状态不会影响 OrderContent 的渲染。如果需要跨组件通信,ArkUI 提供了 @Prop(单向父到子传递)、@Link(双向同步)、@Provide/@Consume(跨层级传递)等装饰器,但本应用由于组件间不需要通信,这些装饰器均未使用。

11.2 弹框管理模式

八个全屏弹框分布在三个子组件中:HallContent 拥有 publishOverlay(发布订单)、categoryOverlay(品类选择)、rangeOverlay(接单范围);OrderContent 拥有 cancelOverlay(取消订单)、rateOverlay(骑手评分)、addrOverlay(修改地址);WalletContent 拥有 amountOverlay(充值键盘)、withdrawOverlay(提现确认)。

每个弹框都使用相同的架构模式:modalOverlay 遮罩 + 内容卡片 + zIndex(999) 层级提升 + 布尔状态变量控制显隐。弹框的触发方式统一为:在操作按钮的 onClick 回调中设置对应的状态变量为 true。弹框的关闭方式有两种:点击遮罩区域触发 onClose 回调,或点击弹框内的取消/关闭按钮。

弹框统一架构

弹框分布

WalletContent

OrderContent

HallContent

publishOverlay 发布订单

categoryOverlay 品类选择

rangeOverlay 接单范围

cancelOverlay 取消订单

rateOverlay 骑手评分

addrOverlay 修改地址

amountOverlay 充值键盘

withdrawOverlay 提现确认

modalOverlay 遮罩基底

内容卡片

zIndex 999

布尔状态变量控制

11.3 动画系统总览

应用中使用了九组动画效果,分布在六个子组件的 aboutToAppear 生命周期回调中:

编号组件动画效果驱动变量时长迭代
1HallContent悬浮发布按钮呼吸缩放fabScale900ms无限
2HallContent在线骑手红点呼吸闪烁liveOp1200ms无限
3OrderContent进行中订单呼吸光点glowOp1000ms无限
4RankContent冠军皇冠脉动缩放medalScale1100ms无限
5RankContent榜单光效呼吸barOp1400ms无限
6PriceContent预估价数字脉动pricePulse1500ms无限
7WalletContent金币浮动缩放coinScale1200ms无限
8WalletContent余额卡光晕呼吸glowOp1600ms无限
9MineContent头像浮动缩放avatarScale1700ms无限

所有动画都使用 PlayMode.Alternate 交替模式和 Curve.EaseInOut 缓入缓出曲线,时长从900ms到1700ms不等——不同的时长使得各动画不会同步,避免了"所有元素同时缩放"的机械感。这种"错峰动画"的设计使得界面始终保持微妙的动感而不显得呆板。

十二、核心技术特性对比

特性/组件ColumnRowStackScrollFlexForEachSliderToggleTextInputDivider
布局方向纵向排列横向排列层叠堆叠可滚动弹性排列列表渲染滑动选择开关切换文本输入分割线
主轴方向垂直(上到下)水平(左到右)Z轴(底到顶)可配置可配置N/A水平水平水平水平
子元素对齐HorizontalAlignVerticalAlignAlignmentN/AFlexAlignN/AN/AN/AN/AN/A
layoutWeight支持(垂直方向)支持(水平方向)不支持N/A支持N/AN/AN/AN/AN/A
常用场景卡片容器按钮行图标角标长列表按钮组数据列表距离/范围设置开关搜索/表单区域分隔
响应状态需配合@State需配合@State需配合@StateN/A需配合@State自动响应数据变化需配合@State需配合@State需配合@StateN/A
onClick支持支持支持支持支持支持子元素支持支持onChangeonChange不支持
动画属性scale/opacityscale/opacityposition/opacityscrollByN/AN/AvalueisOntextcolor
本应用使用次数极多(~200+)极多(~150+)多(~30+)多(~8)未直接使用多(~12)3次4次多(~8)多(~10)
圆角/裁剪borderRadiusborderRadiusposition+sizescrollBarN/AN/AblockColorselectedColorborderRadiuscolor
装饰器/概念@Entry@Component@State@BuilderinterfaceconststructaboutToAppearbuildanimateTo
作用标记入口组件标记自定义组件响应式状态可复用UI片段类型声明不可变引用组件容器生命周期回调UI声明方法属性动画
使用范围仅主组件所有组件组件内部组件内部全局全局所有组件组件内部所有组件生命周期内
参数初始值函数参数属性定义配置+闭包
响应式触发N/AN/A值变化触发重渲染跟随组件重渲染N/AN/AN/AN/A被重渲染调用修改状态触发
本应用使用次数1次7次50+次30+次12次20+次7次6次7次9次
核心价值应用入口组件化基础数据驱动UIUI复用类型安全配置不可变ArkUI基本单元初始化逻辑UI声明动画驱动
数据模型BannerTHallTOrderTRiderTCityTWeekTTypeTPriceRuleTBillTAddrTMenuTCatTTabDef
字段数613118546666342
Mock数据量6条14条16条12位8城7天5类8条16条5条12项8项6项
用于组件主入口大厅订单骑手榜骑手榜大厅订单计价钱包订单弹框我的主入口主入口
含颜色字段bgcolor
含布尔字段urgentdef
用于图表城市对比柱状图占比条规则表
用于弹框取消/评分地址弹框品类弹框

十三、全局纯函数特性对比

函数名参数返回类型功能描述被调用组件
hallFilteredcat: stringHallT[]按品类筛选大厅订单HallContent
nearbyCountkm: numbernumber统计范围内的可抢订单数HallContent
orderFilteredst: stringOrderT[]按状态筛选订单OrderContent
orderIdxByIdid: numbernumber根据订单ID查找数组下标OrderContent
calcPricedist, weight, kindnumber计算预估运费HallContent, PriceContent
weekMaxnumber本周接单量峰值HallContent
cityMaxnumber城市订单峰值RankContent
riderMaxnumber骑手单量峰值RankContent
typeTotalnumber订单类型总量OrderContent
statusColors: stringstring状态文字颜色映射OrderContent
statusBgs: stringstring状态背景颜色映射OrderContent
kindColork: stringstring品类颜色映射HallContent, PriceContent
levelColorl: stringstring等级颜色映射RankContent
rankColori: numberstring排名颜色映射RankContent
stepTextstep: numberstring订单进度文案OrderContent
starStrn: numberstring星级字符串生成OrderContent
keyAppendsrc, k: stringstring键盘输入处理WalletContent
keyNums: stringnumber字符串转金额WalletContent

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 闪送帮 FLASH GO · 同城跑腿代办平台 —— 仿 58 同城类同城服务场景
// 深色潮酷骑手风:炭黑底 #111318 / 深灰卡 #1C2028
// 荧光黄 #FFD21F 主色 + 骑手橙 #FF7A2F 辅助
// 服务:帮我买 / 帮我送 / 代排队 / 代取件 / 代买药 / 代送花
// 外卖电商风头部(搜索 + 分类宫格 + banner,头部无动画)
// 底部 6 Tab 自绘可点击切换,每个 Tab 布局差异化
// ArkTS 单文件 · 7 个 struct(1 个 @Entry + 6 个 Tab 内容组件)
// 8 个自定义全屏遮罩弹框(新增 / 删除 / 编辑 / 宫格 / 滑条 / 评分 / 键盘 / 提现)
// ============================================================

// ============ 配色(炭黑 x 荧光黄 x 骑手橙) ============
interface FGColor {
  bg: string
  card: string
  card2: string
  chip: string
  primary: string
  accent: string
  ink: string
  sub: string
  hint: string
  line: string
  danger: string
  green: string
  blue: string
  white: string
  dark: string
}
const C: FGColor = {
  bg: '#111318',
  card: '#1C2028',
  card2: '#232936',
  chip: '#2A303B',
  primary: '#FFD21F',
  accent: '#FF7A2F',
  ink: '#E8EAED',
  sub: '#9AA0A8',
  hint: '#636A75',
  line: '#2A303B',
  danger: '#FF5252',
  green: '#3DDC84',
  blue: '#6C8CFF',
  white: '#FFFFFF',
  dark: '#1A1400'
}

// ============ Tab 定义(6 个) ============
interface TabDef {
  icon: string
  label: string
}
const FG_TABS: TabDef[] = [
  { icon: '⚡', label: '接单大厅' },
  { icon: '📦', label: '订单' },
  { icon: '🏆', label: '骑手榜' },
  { icon: '🧮', label: '计价' },
  { icon: '💰', label: '钱包' },
  { icon: '🏍️', label: '我的' }
]

// ============ 头部分类宫格(8 个入口) ============
interface CatT {
  id: number
  name: string
  icon: string
  badge: string
}
const CAT_LIST: CatT[] = [
  { id: 1, name: '帮我买', icon: '🛒', badge: '超时赔' },
  { id: 2, name: '帮我送', icon: '📦', badge: '30分达' },
  { id: 3, name: '代排队', icon: '🎫', badge: '按时计' },
  { id: 4, name: '代取件', icon: '📭', badge: '取件码' },
  { id: 5, name: '代买药', icon: '💊', badge: '24h' },
  { id: 6, name: '代送花', icon: '💐', badge: '稳送' },
  { id: 7, name: '全城飞', icon: '🛵', badge: 'HOT' },
  { id: 8, name: '更多服务', icon: '⋯', badge: ' ' }
]

// ============ 数据模型:banner ============
interface BannerT {
  id: number
  title: string
  sub: string
  icon: string
  tag: string
  bg: string
}

// ============ 数据模型:接单大厅订单 ============
interface HallT {
  id: number
  kind: string
  icon: string
  from: string
  to: string
  dist: number
  fee: number
  tip: number
  weight: string
  time: string
  urgent: boolean
  tag: string
  note: string
}

// ============ 数据模型:我的跑腿订单 ============
interface OrderT {
  id: number
  no: string
  kind: string
  icon: string
  from: string
  to: string
  fee: number
  status: string
  step: number
  date: string
  rider: string
}

// ============ 数据模型:骑手 ============
interface RiderT {
  id: number
  name: string
  avatar: string
  city: string
  orders: number
  score: number
  hours: number
  level: string
}

// ============ 数据模型:城市热度 ============
interface CityT {
  id: number
  city: string
  orders: number
  riders: number
  hot: string
}

// ============ 数据模型:本周接单量 ============
interface WeekT {
  id: number
  day: string
  orders: number
  income: number
}

// ============ 数据模型:订单类型分布 ============
interface TypeT {
  id: number
  name: string
  count: number
  pct: number
  color: string
  icon: string
}

// ============ 数据模型:计价规则 ============
interface PriceRuleT {
  id: number
  range: string
  start: string
  over: string
  weight: string
  note: string
}

// ============ 数据模型:账单 ============
interface BillT {
  id: number
  title: string
  icon: string
  amount: number
  kind: string
  date: string
  status: string
}

// ============ 数据模型:地址簿 ============
interface AddrT {
  id: number
  name: string
  phone: string
  addr: string
  tag: string
  def: boolean
}

// ============ 数据模型:我的宫格 ============
interface MenuT {
  id: number
  name: string
  icon: string
}

// ============ banner(6 条) ============
const BANNER_LIST: BannerT[] = [
  { id: 1, title: '新人首单立减 8 元', sub: '全城 30 分钟极速达', icon: '⚡', tag: '新客专享', bg: '#B35A1F' },
  { id: 2, title: '帮我买 · 商超代购', sub: '山姆 / 盒马 / 大润发一键代买', icon: '🛒', tag: '低至 8 元', bg: '#3A4252' },
  { id: 3, title: '代排队服务上线', sub: '网红店 / 挂号 / 演唱会都排', icon: '🎫', tag: '按时计费', bg: '#2E6E4E' },
  { id: 4, title: '骑手招募进行中', sub: '黄金骑手月入过万不是梦', icon: '🏍️', tag: '即刻加入', bg: '#5B4A16' },
  { id: 5, title: '帮我送 · 文件急送', sub: '同城互寄 2 小时必达', icon: '📦', tag: '企业认证', bg: '#6B3FA0' },
  { id: 6, title: '夜间单补贴 50%', sub: '22 点后下单运费半价', icon: '🌙', tag: '限时', bg: '#1F5C8C' }
]

// ============ 接单大厅(14 条) ============
const HALL_LIST: HallT[] = [
  { id: 1, kind: '帮我买', icon: '🛒', from: '山姆会员店前门', to: '科技园中一区 5 栋', dist: 4.2, fee: 15, tip: 3, weight: '<5kg', time: '立即', urgent: true, tag: '顺路单', note: '两箱矿泉水加一提抽纸,放前台即可' },
  { id: 2, kind: '帮我送', icon: '📦', from: '科兴科学园 B 座', to: '湾厦地铁站 A 口', dist: 7.8, fee: 22, tip: 0, weight: '5-15kg', time: '30 分钟', urgent: false, tag: '文件', note: '合同文件需当面签收并拍照回传' },
  { id: 3, kind: '代排队', icon: '🎫', from: '市一医院门诊部', to: '原地交付取号单', dist: 9.6, fee: 68, tip: 10, weight: '<5kg', time: '2 小时', urgent: true, tag: '挂号', note: '替家人排队取号,拿到号拍照确认' },
  { id: 4, kind: '代取件', icon: '📭', from: '顺丰网点科技北', to: '阳光花园 12 栋 2 单元', dist: 3.5, fee: 12, tip: 0, weight: '<5kg', time: '立即', urgent: false, tag: '加急', note: '到付件已付款,取件码 8-2-3006' },
  { id: 5, kind: '帮我买', icon: '🍰', from: '幸福西饼海岸城店', to: '后海写字楼 A 座 12F', dist: 2.6, fee: 10, tip: 2, weight: '<5kg', time: '立即', urgent: false, tag: '蛋糕', note: '生日蛋糕轻拿轻放,尽快送达' },
  { id: 6, kind: '帮我送', icon: '💐', from: '花田花艺南山店', to: '万科云城 3 期 18 栋', dist: 6.2, fee: 18, tip: 5, weight: '<5kg', time: '1 小时', urgent: true, tag: '鲜花', note: '求婚花束,务必稳驾慢行不能倾斜' },
  { id: 7, kind: '代取件', icon: '📱', from: '京东派蛇口站', to: '海上世界 C 座 1208', dist: 8.4, fee: 20, tip: 0, weight: '5-15kg', time: '今天内', urgent: false, tag: '数码', note: '全新手机一台,注意保价轻放' },
  { id: 8, kind: '帮我买', icon: '💊', from: '海王星辰创业路', to: '阳光带海滨城 6 栋', dist: 3.1, fee: 12, tip: 3, weight: '<5kg', time: '立即', urgent: true, tag: '药品', note: '感冒药加体温计,买完小票拍照' },
  { id: 9, kind: '帮我送', icon: '🧋', from: '喜茶万象天地', to: '滨海大厦 38F 前台', dist: 5.0, fee: 16, tip: 0, weight: '<5kg', time: '立即', urgent: false, tag: '饮品', note: '20 杯团建奶茶,请配保温箱配送' },
  { id: 10, kind: '代排队', icon: '🍜', from: '文和友海雅店', to: '海雅缤纷城正门', dist: 4.8, fee: 55, tip: 8, weight: '<5kg', time: '1 小时', urgent: false, tag: '餐饮', note: '热门店取号,过号作废请提前到场' },
  { id: 11, kind: '帮我买', icon: '🐟', from: '蛇口渔港码头', to: '前海嘉里中心 T2', dist: 11.2, fee: 32, tip: 6, weight: '15-30kg', time: '2 小时', urgent: false, tag: '生鲜', note: '鲜活海产加冰袋,全程冷链保温箱' },
  { id: 12, kind: '帮我送', icon: '👜', from: '万象城奢侈品护理', to: '深铁懿府 9 栋 1 单元', dist: 5.6, fee: 25, tip: 0, weight: '<5kg', time: '1 小时', urgent: true, tag: '贵重', note: '名牌包护理取回,请当面验收再签收' },
  { id: 13, kind: '代取件', icon: '📚', from: '图书馆南门服务台', to: '桂庙新村 44 号', dist: 2.2, fee: 8, tip: 0, weight: '<5kg', time: '立即', urgent: false, tag: '图书', note: '三本归还图书,凭读者证代还' },
  { id: 14, kind: '帮我送', icon: '🎂', from: '好利来来福士店', to: '太子湾湾玺 12 栋', dist: 9.0, fee: 28, tip: 4, weight: '5-15kg', time: '30 分钟', urgent: true, tag: '易碎', note: '三层蛋糕需平稳骑行,禁急刹' }
]

// ============ 我的订单(16 条) ============
const ORDER_LIST: OrderT[] = [
  { id: 1, no: 'FG20260827001', kind: '帮我买', icon: '🛒', from: '山姆会员店前门', to: '科技园中一区 5 栋 802', fee: 18, status: '进行中', step: 3, date: '08-27 14:32', rider: '风哥' },
  { id: 2, no: 'FG20260827002', kind: '帮我送', icon: '📦', from: '科兴科学园 B 座', to: '湾厦地铁站 A 口', fee: 22, status: '进行中', step: 2, date: '08-27 13:10', rider: '阿飞' },
  { id: 3, no: 'FG20260827003', kind: '代取件', icon: '📭', from: '顺丰网点科技北', to: '阳光花园 12 栋 2 单元', fee: 12, status: '进行中', step: 1, date: '08-27 11:46', rider: '夜枭' },
  { id: 4, no: 'FG20260826004', kind: '代排队', icon: '🎫', from: '市一医院门诊部', to: '原地交付取号单', fee: 78, status: '已完成', step: 4, date: '08-26 16:40', rider: '大力' },
  { id: 5, no: 'FG20260826005', kind: '帮我买', icon: '🍰', from: '幸福西饼海岸城店', to: '后海写字楼 A 座 12F', fee: 12, status: '已完成', step: 4, date: '08-26 15:02', rider: '小鹿' },
  { id: 6, no: 'FG20260826006', kind: '帮我送', icon: '💐', from: '花田花艺南山店', to: '万科云城 3 期 18 栋', fee: 23, status: '已完成', step: 4, date: '08-26 10:21', rider: '阿俊' },
  { id: 7, no: 'FG20260825007', kind: '帮我买', icon: '💊', from: '海王星辰创业路', to: '阳光带海滨城 6 栋', fee: 15, status: '已完成', step: 4, date: '08-25 20:44', rider: '闪电' },
  { id: 8, no: 'FG20260825008', kind: '代取件', icon: '📱', from: '京东派蛇口站', to: '海上世界 C 座 1208', fee: 20, status: '已完成', step: 4, date: '08-25 17:58', rider: '追风' },
  { id: 9, no: 'FG20260824009', kind: '帮我送', icon: '🧋', from: '喜茶万象天地', to: '滨海大厦 38F 前台', fee: 16, status: '已完成', step: 4, date: '08-24 14:26', rider: '阿飞' },
  { id: 10, no: 'FG20260824010', kind: '帮我买', icon: '🐟', from: '蛇口渔港码头', to: '前海嘉里中心 T2', fee: 38, status: '已完成', step: 4, date: '08-24 09:12', rider: '铁骑' },
  { id: 11, no: 'FG20260823011', kind: '代排队', icon: '🍜', from: '文和友海雅店', to: '海雅缤纷城正门', fee: 63, status: '已完成', step: 4, date: '08-23 18:36', rider: '山猫' },
  { id: 12, no: 'FG20260823012', kind: '帮我送', icon: '🎂', from: '好利来来福士店', to: '太子湾湾玺 12 栋', fee: 32, status: '已取消', step: 0, date: '08-23 12:04', rider: '待分配' },
  { id: 13, no: 'FG20260822013', kind: '帮我买', icon: '🛒', from: '盒马鲜生太古城', to: '南光路 7 号大院', fee: 14, status: '已完成', step: 4, date: '08-22 16:20', rider: '海燕' },
  { id: 14, no: 'FG20260822014', kind: '代取件', icon: '📚', from: '图书馆南门服务台', to: '桂庙新村 44 号', fee: 8, status: '已完成', step: 4, date: '08-22 10:48', rider: '老周' },
  { id: 15, no: 'FG20260821015', kind: '帮我送', icon: '👜', from: '万象城奢侈品护理', to: '深铁懿府 9 栋 1 单元', fee: 25, status: '已完成', step: 4, date: '08-21 15:33', rider: '风哥' },
  { id: 16, no: 'FG20260820016', kind: '帮我买', icon: '🍰', from: '幸福西饼来福士店', to: '南海大道 1029 号', fee: 10, status: '已取消', step: 0, date: '08-20 19:02', rider: '待分配' }
]

// ============ 骑手榜(12 位) ============
const RIDER_LIST: RiderT[] = [
  { id: 1, name: '风哥', avatar: '🦅', city: '深圳', orders: 486, score: 4.9, hours: 10.5, level: '钻石' },
  { id: 2, name: '小鹿', avatar: '🦌', city: '广州', orders: 452, score: 4.9, hours: 9.8, level: '钻石' },
  { id: 3, name: '阿俊', avatar: '🐆', city: '北京', orders: 421, score: 4.8, hours: 11.2, level: '钻石' },
  { id: 4, name: '大力', avatar: '🐂', city: '上海', orders: 395, score: 4.8, hours: 9.0, level: '黄金' },
  { id: 5, name: '夜枭', avatar: '🦉', city: '成都', orders: 372, score: 4.9, hours: 8.6, level: '黄金' },
  { id: 6, name: '铁骑', avatar: '🐎', city: '杭州', orders: 351, score: 4.7, hours: 10.1, level: '黄金' },
  { id: 7, name: '闪电', avatar: '⚡', city: '武汉', orders: 330, score: 4.8, hours: 8.9, level: '黄金' },
  { id: 8, name: '追风', avatar: '🌪️', city: '西安', orders: 305, score: 4.7, hours: 9.4, level: '黄金' },
  { id: 9, name: '阿飞', avatar: '🛵', city: '深圳', orders: 288, score: 4.9, hours: 8.2, level: '黄金' },
  { id: 10, name: '海燕', avatar: '🐦', city: '广州', orders: 266, score: 4.8, hours: 7.7, level: '白银' },
  { id: 11, name: '山猫', avatar: '🐯', city: '长沙', orders: 244, score: 4.6, hours: 8.8, level: '白银' },
  { id: 12, name: '老周', avatar: '🐢', city: '重庆', orders: 220, score: 4.7, hours: 7.2, level: '白银' }
]

// ============ 城市热度(8 城) ============
const CITY_LIST: CityT[] = [
  { id: 1, city: '深圳', orders: 3960, riders: 1286, hot: '爆' },
  { id: 2, city: '广州', orders: 3520, riders: 1104, hot: '爆' },
  { id: 3, city: '北京', orders: 3180, riders: 986, hot: '热' },
  { id: 4, city: '上海', orders: 2940, riders: 955, hot: '热' },
  { id: 5, city: '成都', orders: 2460, riders: 812, hot: '热' },
  { id: 6, city: '杭州', orders: 2180, riders: 743, hot: '升' },
  { id: 7, city: '武汉', orders: 1890, riders: 621, hot: '升' },
  { id: 8, city: '西安', orders: 1560, riders: 508, hot: '升' }
]

// ============ 本周接单量(7 天) ============
const WEEK_LIST: WeekT[] = [
  { id: 1, day: '周一', orders: 32, income: 412 },
  { id: 2, day: '周二', orders: 41, income: 530 },
  { id: 3, day: '周三', orders: 28, income: 366 },
  { id: 4, day: '周四', orders: 45, income: 588 },
  { id: 5, day: '周五', orders: 52, income: 704 },
  { id: 6, day: '周六', orders: 61, income: 852 },
  { id: 7, day: '周日', orders: 46, income: 608 }
]

// ============ 订单类型分布(5 类) ============
const TYPE_LIST: TypeT[] = [
  { id: 1, name: '帮我买', count: 214, pct: 42, color: '#FFD21F', icon: '🛒' },
  { id: 2, name: '帮我送', count: 132, pct: 26, color: '#FF7A2F', icon: '📦' },
  { id: 3, name: '代取件', count: 71, pct: 14, color: '#3DDC84', icon: '📭' },
  { id: 4, name: '代排队', count: 51, pct: 10, color: '#6C8CFF', icon: '🎫' },
  { id: 5, name: '其他代办', count: 41, pct: 8, color: '#636A75', icon: '🧩' }
]

// ============ 计价规则(8 条) ============
const PRICE_LIST: PriceRuleT[] = [
  { id: 1, range: '0 - 3 km', start: '¥8 起', over: '—', weight: '≤5kg 免费带', note: '基础起步价' },
  { id: 2, range: '3 - 10 km', start: '¥8 起', over: '¥1.5/km', weight: '≤5kg 免费带', note: '超出 3km 部分计费' },
  { id: 3, range: '10 - 20 km', start: '¥8 起', over: '¥2.5/km', weight: '需报备车型', note: '远途单建议换摩托' },
  { id: 4, range: '5 - 15kg', start: '+¥3', over: '—', weight: '重量加价', note: '带托盘或大背包' },
  { id: 5, range: '15 - 30kg', start: '+¥8', over: '—', weight: '重量加价', note: '需载货骑手接单' },
  { id: 6, range: '30kg 以上', start: '+¥15', over: '—', weight: '重量加价', note: '建议使用货运通道' },
  { id: 7, range: '代排队', start: '¥20/时', over: '¥0.3/分钟', weight: '—', note: '超 15 分钟按半小时计' },
  { id: 8, range: '22 点后', start: '+50%', over: '—', weight: '—', note: '夜间补贴自动计算' }
]

// ============ 账单(16 条) ============
const BILL_LIST: BillT[] = [
  { id: 1, title: '跑腿收入 · 帮我买', icon: '🛒', amount: 12.5, kind: '收入', date: '08-27 14:32', status: '已到账' },
  { id: 2, title: '提现到微信零钱', icon: '💸', amount: -100, kind: '支出', date: '08-27 10:05', status: '到账' },
  { id: 3, title: '跑腿收入 · 代排队', icon: '🎫', amount: 68, kind: '收入', date: '08-26 16:40', status: '已到账' },
  { id: 4, title: '夜间配送补贴', icon: '🌙', amount: 9.5, kind: '收入', date: '08-26 23:18', status: '已到账' },
  { id: 5, title: '购买骑手装备', icon: '🦺', amount: -89, kind: '支出', date: '08-26 09:12', status: '已到账' },
  { id: 6, title: '跑腿收入 · 帮我送', icon: '📦', amount: 22, kind: '收入', date: '08-25 15:03', status: '已到账' },
  { id: 7, title: '五星好评奖励', icon: '⭐', amount: 3, kind: '收入', date: '08-25 20:44', status: '待入账' },
  { id: 8, title: '话费充值', icon: '📱', amount: -50, kind: '支出', date: '08-25 08:30', status: '已到账' },
  { id: 9, title: '跑腿收入 · 代取件', icon: '📭', amount: 12, kind: '收入', date: '08-24 11:26', status: '已到账' },
  { id: 10, title: '周末冲单赛奖励', icon: '🏆', amount: 66, kind: '收入', date: '08-24 21:00', status: '已到账' },
  { id: 11, title: '跑腿收入 · 帮我买', icon: '🍰', amount: 10, kind: '收入', date: '08-23 14:55', status: '已到账' },
  { id: 12, title: '违章代缴', icon: '🚦', amount: -100, kind: '支出', date: '08-23 17:20', status: '已到账' },
  { id: 13, title: '跑腿收入 · 帮我送', icon: '💐', amount: 23, kind: '收入', date: '08-22 12:10', status: '已到账' },
  { id: 14, title: '骑手意外险(周缴)', icon: '🛡️', amount: -15, kind: '支出', date: '08-22 00:00', status: '已到账' },
  { id: 15, title: '跑腿收入 · 代排队', icon: '🍜', amount: 55, kind: '收入', date: '08-21 18:36', status: '已到账' },
  { id: 16, title: '新人首单奖励', icon: '🎁', amount: 8, kind: '收入', date: '08-20 09:45', status: '已到账' }
]

// ============ 地址簿(5 条) ============
const ADDR_LIST: AddrT[] = [
  { id: 1, name: '张先生', phone: '138****6688', addr: '南山区科技园中一区 5 栋 802', tag: '公司', def: true },
  { id: 2, name: '张先生', phone: '138****6688', addr: '南山区阳光花园 12 栋 2 单元 1601', tag: '家', def: false },
  { id: 3, name: '李女士', phone: '139****2255', addr: '宝安区海雅缤纷城 B 座 1103', tag: '公司', def: false },
  { id: 4, name: '张先生', phone: '138****6688', addr: '南山区后海大道 2388 号创业蓉园 3 栋', tag: '家', def: false },
  { id: 5, name: '王同学', phone: '136****9911', addr: '南山区深圳大学西南宿舍 12 栋', tag: '学校', def: false }
]

// ============ 我的宫格(12 项) ============
const MINE_MENU: MenuT[] = [
  { id: 1, name: '我的地址', icon: '📍' },
  { id: 2, name: '收货地址簿', i
      }
      .width('100%').height('100%')
    }
    .width('100%').height('100%')
  }
}


在这里插入图片描述

十四、总结

本文以逐段代码分析的方式,完整剖析了一个基于鸿蒙 ArkTS 语言编写的同城跑腿代办平台应用。该应用以"闪送帮 FLASH GO"为品牌名,采用炭黑底色搭配荧光黄主色与骑手橙辅助色的深色潮酷骑手视觉风格,构建了一个包含六个功能 Tab 页面、七个 struct 组件、八个全屏遮罩弹框、十八个全局纯函数、十二个数据模型接口、九组无限循环动画的完整应用级单文件代码工程。

在配色体系层面,应用通过 FGColor 接口和 C 常量对象建立了十五个语义化颜色角色,覆盖背景、卡片、主色、辅助色、文字层级、状态色、分割线色等全部视觉维度。这种语义化配色方案的核心价值在于将"颜色含义"与"颜色值"解耦——修改任何一个颜色值只需改动 C 对象中的一行代码,而不会影响任何组件的渲染逻辑。

在数据建模层面,十二个 interface 定义了从 Banner、订单、骑手到账单、地址、菜单的全部业务数据结构。每个接口的字段类型设计都考虑了实际使用场景:重量使用字符串枚举而非数字以避免浮点比较问题;金额使用 number 类型配合正负号区分收入与支出;状态使用中文字符串而非数字编码以直接映射用户可读文本。二十多个 Mock 数据数组为每个接口提供了充足且覆盖边界情况的测试数据,确保了 UI 渲染在开发阶段能够充分验证各种数据组合下的布局效果。

在函数式设计层面,十八个全局纯函数封装了数据筛选、计价计算、聚合统计、状态映射、文本处理等全部业务逻辑。这些函数不依赖任何组件实例状态,可以独立测试和复用。calcPrice 计价引擎函数实现了距离分段累进、重量档位加价、品类附加费三维度组合计价逻辑,在发布弹框和计价器页面中被复用。statusColor/statusBg 等映射函数建立了统一的色彩语义系统,确保同一业务状态在整个应用中的视觉表现一致。

在组件架构层面,FlashGoApp 入口组件通过 activeTab 状态变量驱动条件渲染,实现了六个子内容组件的切换路由。每个子内容组件拥有独立的 @State 状态体系,互不干扰。@Builder 装饰器封装了三十多个可复用 UI 片段,从分类宫格单元、Banner 卡片到订单卡片、骑手行、键盘按键,实现了 UI 逻辑的细粒度复用。

在布局技术层面,应用综合运用了 Column 纵向排列、Row 横向排列、Stack 层叠堆叠三种核心布局容器。Column 和 Row 的 layoutWeight 弹性权重被创造性地用于实现瀑布流分列、占比条比例、进度条比例等多种数据可视化效果。Stack 的层叠特性被用于实现柱状图(灰色背景柱+彩色前景柱)、余额卡(渐变底层+内容上层)、图标角标(基础图标+偏移角标)等复合视觉效果。

在状态管理与动画层面,五十多个 @State 变量驱动了从 Tab 切换、弹框显隐、表单数据到筛选条件的全部响应式 UI 更新。九组 animateTo 无限循环动画通过 PlayMode.Alternate 交替模式和不同时长(900ms-1700ms)实现了悬浮按钮呼吸、在线红点闪烁、皇冠脉动、金币浮动、头像缩放等微动效。所有动画都驱动 @State 变量,通过响应式机制自动更新 UI,无需手动操作 DOM。

在弹框架构层面,八个全屏弹框统一采用 modalOverlay 遮罩基底 + 内容卡片 + zIndex(999) 层级提升 + 布尔状态控制的标准模式。弹框类型涵盖底部抽屉(发布订单、充值键盘)、居中卡片(取消订单、评分、修改地址、提现确认)、居中宫格(品类选择)等多种形态,充分展示了 ArkUI 声明式语法在构建复杂交互场景时的表达能力。

Logo

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

更多推荐