在万物互联的鸿蒙生态中,声明式 UI 框架 ArkUI 正在重新定义移动端应用的开发范式。本文将以一个完整的多 Tab 物流应用为蓝本,从数据建模、状态管理、组件拆分到弹窗系统,逐层剖析 ArkTS 在复杂业务场景下的工程实践。

一、技术背景与整体架构概览

鸿蒙操作系统(HarmonyOS)提供的 ArkUI 声明式开发范式,以 @Component@Entry@State@Builder 等装饰器为核心,构建出一套数据驱动的 UI 体系。开发者只需关注数据与状态的变化,框架会自动完成视图的差量更新与渲染。这种模式相比传统的命令式开发,极大降低了状态同步的复杂度。

本代码实现的是一个名为「星际快递 · 银河物流中心」的完整物流应用。它围绕"包裹全生命周期"展开,涵盖包裹查询、在线寄件、驿站地图、物流追踪、积分商城、个人中心六大功能模块。整个应用采用单文件、多组件的工程组织方式,通过一个入口组件统一管理全局状态与弹窗挂载,再分发到各个子 Tab 组件渲染具体业务。

从技术栈维度看,该应用融合了 ArkTS 的诸多核心能力。布局上大量使用 ColumnRowStack 三大基础容器完成线性与层叠排列;状态管理上借助 @State 实现组件内可变数据的响应式更新;列表渲染上依赖 ForEach 进行数组到视图的映射;滚动场景上通过 Scroll 配合 scrollablescrollBar 实现纵向与横向滚动;复用逻辑上用 @Builder 抽取无状态 UI 片段。下面这张流程图展示了从入口到各 Tab 再到弹窗的整体调度链路。

0 包裹

1 寄件

2 驿站

3 追踪

4 积分

5 我的

@Entry Index 主组件

tabIndex 切换

PkgTab

SendTab

StationTab

TrackTab

PointTab

MineTab

@Builder 弹窗群

modalOverlay 遮罩 + Toast

在这里插入图片描述

一个值得注意的工程细节是:所有弹窗并非各 Tab 内部自行渲染,而是统一上提到主组件的 Stack 层,由 showXxx 布尔状态控制显隐,selXxx 对象承载选中数据。这种"状态上提、视图下沉"的设计使弹窗可以跨 Tab 复用,也避免了子组件无法直接覆盖全屏的层级问题。

在配色体系上,应用采用了科技蓝 #0B2F5C 渐变到 #0288D1 的主色调,搭配星橙 #FF8F00 作为强调色,辅以绿色 #43A047、警示红 #E53935、中性灰 #90A4AE 构成完整语义色板。渐变背景通过字符串形式的 linearGradient 内联表达,使卡片头部具备沉浸式视觉层次。

二、数据模型层:interface 类型契约

任何复杂应用都始于清晰的数据建模。本代码在文件开头定义了一组 interface,为整个应用的领域对象建立类型契约。首先看包裹模型。

interface Package {
  id: number;
  name: string;
  icon: string;
  no: string;
  from: string;
  to: string;
  status: string;
  statusColor: string;
  type: string;
  time: string;
  desc: string;
}

在这里插入图片描述

Package 接口描述了一个物流包裹的完整信息。其中 id 是主键,用于 ForEach 的键值生成;nameicon 负责卡片标题与图标展示,这里 icon 用的是 Emoji 字符,省去了图片资源加载;from/to 表示寄件与收件地点;statusstatusColor 是一对关联字段,状态文字与状态色绑定在一起,便于直接渲染彩色标签。

这种将颜色与状态一起冗余进数据模型的写法,是一种"以数据换逻辑"的取舍。常规做法是用函数根据 status 推导颜色(代码中确实也提供了 getStatusColor 函数),但在静态演示数据中直接预置颜色,可减少渲染时的分支判断,让 UI 代码更扁平。

接下来是驿站、快递员、物流轨迹等模型,它们结构类似但各有侧重。

interface Station {
  id: number;
  name: string;
  icon: string;
  addr: string;
  distance: string;
  open: string;
  boxes: number;
  courier: string;
  busy: string;
}

interface Courier {
  id: number;
  name: string;
  icon: string;
  phone: string;
  score: number;
  orders: number;
  area: string;
  online: boolean;
}

interface TrackLog {
  id: number;
  title: string;
  icon: string;
  place: string;
  time: string;
  done: boolean;
  desc: string;
}

在这里插入图片描述

Station 模型关注驿站的位置与运营信息,distanceopenboxesbusy 共同构成驿站卡片的核心展示要素。Courier 描述快递员画像,scoreordersonline 用于评价与状态展示,online 是布尔类型,直接驱动在线/休息标签的切换。TrackLog 是物流时间轴的每一节点,done 布尔字段决定节点是实心圆还是空心圆,是时间轴进度的关键标志。

可以看到,这些接口普遍使用 string 承载本应是数值或枚举的字段(如 distance: '800m'busy: '较忙')。这是演示型应用的常见取舍:直接存展示态字符串,省去格式化函数。在真实工程中,建议将 distance 拆为 distanceValue: numberdistanceUnit: string,便于排序与国际化。

剩余的接口包括计费规则、积分商品、快递柜格口、订单、徽章、快捷入口,它们共同支撑起寄件、积分、个人中心等模块。

interface RateRule {
  id: number;
  name: string;
  icon: string;
  base: number;
  perKg: number;
  days: number;
  desc: string;
}

interface PointGoods {
  id: number;
  name: string;
  icon: string;
  points: number;
  stock: number;
  type: string;
  desc: string;
}

interface BoxCell {
  id: number;
  boxNo: string;
  size: string;
  free: boolean;
  code: string;
  time: string;
}

interface Order {
  id: number;
  no: string;
  item: string;
  icon: string;
  date: string;
  fee: number;
  status: string;
  recipient: string;
}

interface Badge {
  id: number;
  name: string;
  icon: string;
  locked: boolean;
  desc: string;
  date: string;
}

interface QuickIcon {
  id: number;
  name: string;
  icon: string;
  color: string;
}

在这里插入图片描述

RateRule 是计费引擎的数据基础,base 起步价加 perKg 每公斤单价构成运费公式 base + perKg * weight,这个公式在寄件弹窗与运费计算器中反复出现。PointGoodspointsstock 共同决定可兑换性,兑换时会扣减积分。BoxCellfree 布尔决定格口是空闲绿还是占用橙。Badgelocked 控制徽章是否灰显。这些布尔字段都是 UI 状态分流的支点。

类型先行是鸿蒙声明式开发的最佳实践。先定义好 interface,再编写数据与组件,可以让 IDE 提供精准的智能补全与类型检查,避免运行时才暴露的字段拼写错误。ArkTS 在 TypeScript 基础上强化了静态类型约束,interface 中的字段一旦确定,组件中访问不存在属性会直接报错。

三、静态数据层:常量数组与 Mock 数据

定义完类型契约后,代码用一组 const 数组填充演示数据。这些数组是整个应用的"假后端",在没有真实接口的情况下驱动全部 UI 渲染。先看包裹数据。

const PACKAGES: Package[] = [
  { id: 1, name: '星航耳机', icon: '🎧', no: 'HX-20260826-001', from: '地球总仓', to: '火星基地', status: '运输中', statusColor: '#0288D1', type: '电子产品', time: '08-26 09:12', desc: '旗舰降噪耳机,预计后天送达。' },
  { id: 2, name: '月球岩茶', icon: '🍵', no: 'HX-20260826-002', from: '月球站', to: '地球总仓', status: '待取件', statusColor: '#FF8F00', type: '食品', time: '08-26 08:40', desc: '限定月球岩茶礼盒,请尽快取件。' },
  // ... 其余包裹
];

在这里插入图片描述

PACKAGES 包含 12 条包裹记录,覆盖了运输中、待取件、派送中、已签收、异常五种状态,确保每个状态分支都有数据可渲染。单号采用 HX-日期-序号 格式,时间字段用 MM-DD HH:mm 简化格式。这种"全覆盖"的 Mock 数据策略,使得开发者无需手动构造边界情况即可验证所有 UI 分支。

类似地,STATIONS 提供 10 家星际驿站,COURIERS 提供 8 名快递员,TRACKS 提供 7 条物流轨迹,RATES 提供 10 条计费规则,POINT_GOODS 提供 10 件积分商品,BOXES 提供 8 个快递柜格口,ORDERS 提供 10 条订单,BADGES 提供 8 枚徽章,QUICK_ICONS 提供 8 个快捷入口。每份数据都经过精心设计,覆盖各种状态组合。

const TRACKS: TrackLog[] = [
  { id: 1, title: '已揽收', icon: '📦', place: '地球总仓', time: '08-26 09:12', done: true, desc: '包裹已从地球总仓揽收' },
  { id: 2, title: '干线运输', icon: '🚀', place: '近地轨道', time: '08-26 11:30', done: true, desc: '搭乘星际货运火箭 HX-9 号' },
  { id: 3, title: '到达中转站', icon: '🛰️', place: '月面中转站', time: '08-26 18:20', done: true, desc: '包裹已抵达月面中转站' },
  // ... 其余轨迹
];

TRACKS 是物流时间轴的数据源,所有节点 done 均为 true,表示该包裹已完成全流程签收。在真实场景中,时间轴通常只有部分节点完成,未完成节点显示空心圆。本数据将进度拉满,便于展示完整轨迹链。

在工程实践中,Mock 数据与真实接口的字段结构应当严格对齐。一旦后端字段命名调整,只需修改 Mock 数组与接口适配层,UI 组件无需变动。这就是数据驱动架构带来的解耦红利。

四、工具函数层:双列拆分与状态推导

代码中段定义了大量辅助函数,它们是连接静态数据与视图渲染的桥梁。其中最引人注目的是一组"双列拆分"函数。

function getPkgRows(): Package[] {
  let rows: Package[] = [];
  for (let i = 0; i < PACKAGES.length; i += 2) {
    rows.push(PACKAGES[i]);
  }
  return rows;
}

function getPkgRows2(): Package[] {
  let rows: Package[] = [];
  for (let i = 1; i < PACKAGES.length; i += 2) {
    rows.push(PACKAGES[i]);
  }
  return rows;
}

getPkgRows 取偶数索引(0,2,4…),getPkgRows2 取奇数索引(1,3,5…),两个函数将一维数组拆成左右两列。这种拆分在瀑布流双列布局中极其常见:用两个 Column 各持一列,每列内部用 ForEach 渲染卡片,从而实现视觉上的双列错落效果。

代码为几乎每种数据都提供了对应的拆分对:getStationRows/getStationRows2getCourierRows/getCourierRows2getTrackRows/getTrackRows2getRateRows/getRateRows2getPointRows/getPointRows2getBoxRows/getBoxRows2getOrderRows/getOrderRows2getBadgeRows/getBadgeRows2getQuickRows/getQuickRows2

function getQuickRows(): QuickIcon[] {
  let rows: QuickIcon[] = [];
  for (let i = 0; i < 4; i++) {
    rows.push(QUICK_ICONS[i]);
  }
  return rows;
}

function getQuickRows2(): QuickIcon[] {
  let rows: QuickIcon[] = [];
  for (let i = 4; i < QUICK_ICONS.length; i++) {
    rows.push(QUICK_ICONS[i]);
  }
  return rows;
}

快捷入口的拆分略有不同:getQuickRows 取前 4 个,getQuickRows2 取后 4 个,构成两行四列的宫格。这与包裹的"奇偶交错"拆分逻辑不同,是按数量均分。两种拆分策略并存,说明开发者根据布局需要灵活选择。

这种成对出现的拆分函数虽然略显重复,但胜在直观可控。若追求极致精简,可用一个泛型函数 splitColumns<T>(arr: T[], col: number) 统一处理,但 ArkTS 对泛型函数的支持有限,且会牺牲类型可读性。在演示项目中,保持"一个布局一对函数"的显式风格更易理解。

除了拆分函数,还有一类状态推导函数,根据业务字段计算展示态。

function getStatusColor(s: string): string {
  if (s === '运输中') {
    return '#0288D1';
  } else if (s === '待取件') {
    return '#FF8F00';
  } else if (s === '派送中') {
    return '#43A047';
  } else if (s === '异常') {
    return '#E53935';
  }
  return '#90A4AE';
}

function getBusyColor(b: string): string {
  if (b === '繁忙') {
    return '#E53935';
  } else if (b === '较忙') {
    return '#FF8F00';
  }
  return '#43A047';
}

在这里插入图片描述

getStatusColor 将状态文字映射为颜色,是典型的"枚举到色值"转换。注意它与 Package.statusColor 字段存在功能重叠——包裹数据自带颜色,而订单数据没有,因此订单状态需要靠函数推导。getBusyColor 同理,把"繁忙/较忙/空闲"映射为红/橙/绿。

还有一些统计聚合函数,用于计算各类汇总数字。

function getOnRoadCount(): number {
  let n = 0;
  for (let i = 0; i < PACKAGES.length; i++) {
    if (PACKAGES[i].status === '运输中' || PACKAGES[i].status === '派送中') {
      n = n + 1;
    }
  }
  return n;
}

function getFreeBoxCount(): number {
  let n = 0;
  for (let i = 0; i < BOXES.length; i++) {
    if (BOXES[i].free) {
      n = n + 1;
    }
  }
  return n;
}

function getCanBuyCount(points: number): number {
  let n = 0;
  for (let i = 0; i < POINT_GOODS.length; i++) {
    if (POINT_GOODS[i].points <= points) {
      n = n + 1;
    }
  }
  return n;
}

getOnRoadCount 统计在途包裹数,getFreeBoxCount 统计空闲格口数,getCanBuyCount 根据当前积分计算可兑换商品数。这些函数在标题栏以"X 个待处理"“X 格空闲”"X 件可兑换"的形式实时呈现,是数据到摘要文字的转换层。值得注意的是 getCanBuyCount 接收 points 参数,使其能随积分变化动态重算。

function getBarHeight(val: number, max: number): string {
  let p = max > 0 ? (val / max) * 80 : 0;
  return p.toFixed(0) + 'vp';
}

在这里插入图片描述

getBarHeight 是柱状图的关键函数,将数值按最大值比例映射为 0-80vp 的高度字符串。vp 是鸿蒙的虚拟像素单位,随屏幕密度自适应。这个函数让积分走势柱状图能根据数据动态伸缩,最高柱(1380)撑满 80vp,其余按比例缩短。

五、主组件 Index:状态中枢与布局骨架

进入组件层,Index 是整个应用的入口与状态中枢。它由 @Entry 标记为页面入口,@Component 声明为自定义组件。

@Entry
@Component
struct Index {
  @State tabIndex: number = 0;
  @State showToast: boolean = false;
  @State toast: string = '';
  @State points: number = 3860;
  @State memberLevel: number = 2;
  // 弹框开关
  @State showPkg: boolean = false;
  @State showSend: boolean = false;
  // ... 其余弹框开关
  @State showVip: boolean = false;
  // 选中对象
  @State selPkg: Package | null = null;
  @State selSend: RateRule | null = null;
  // ... 其余选中对象
  @State selUrgent: Order | null = null;
  // 表单字段
  @State fWeight: number = 5;
  @State fInsure: boolean = false;
  @State fTypeIdx: number = 0;
  @State fDayIdx: number = 0;
  @State fTimeIdx: number = 0;
  @State fQty: number = 1;
  @State fMsg: string = '';
  @State fAddr: string = '';
  @State fScore: number = 5;
  @State fStars: number = 5;

  private tabs: string[] = ['包裹', '寄件', '驿站', '追踪', '积分', '我的'];
  private tabIcons: string[] = ['📦', '📮', '🏪', '🔍', '🛍️', '👤'];

在这里插入图片描述

@State 装饰的状态变量是组件响应式更新的核心。每当 @State 变量的值改变,框架会自动重新执行 build() 中依赖该变量的部分,完成视图差量刷新。这里状态可分为三类:全局业务态(pointsmemberLevel)、弹窗显隐态(showXxx 布尔群)、选中数据态(selXxx 对象)、表单输入态(fXxx 字段)。

points 初始值 3860、memberLevel 初始值 2 是全局业务数据,积分会在兑换商品时扣减,会员等级会在弹窗中点击升级时自增。showPkg/showSend 等 16 个布尔开关分别对应 16 个弹窗,构成一个集中式弹窗调度表。selPkg/selSend 等选中对象与开关成对出现,开关控制显隐、对象承载数据。fWeightfQtyfStars 等表单字段是弹窗内交互的临时状态。

private tabstabIcons 是普通成员变量(非 @State),因为标签项是静态的,无需响应式更新。这种"该响应的才加 @State"的克制,是性能优化的基本功。

@State 的更新检测基于赋值触发。对于对象类型,直接修改对象属性(如 this.selPkg.name = 'x')不会触发刷新,必须整体重新赋值(this.selPkg = {...})或在构建时使用 @Observed/@ObjectLink。本代码采用"重新赋值选中对象"的方式规避了这一陷阱。

showTip 是一个通用的轻提示方法,封装了 Toast 的显示与自动隐藏逻辑。

showTip(msg: string): void {
  this.toast = msg;
  this.showToast = true;
  setTimeout(() => {
    this.showToast = false;
  }, 1800);
}

设置 toast 文本与 showToast 为 true 后,通过 setTimeout 在 1800 毫秒后自动复位。这是用 @State + 定时器模拟原生 Toast 的经典手法。toastBox 这个 @Builder 会在 showToast 为真时渲染在 Stack 顶层。

接下来是 build() 的核心结构,它用 Stack 作为最外层容器,实现"主内容 + 弹窗覆盖层"的层叠布局。

build() {
  Stack() {
    Column() {
      // ========== 主内容区 ==========
      if (this.tabIndex === 0) {
        PkgTab({ onPkg: (p: Package) => { this.selPkg = p; this.showPkg = true; }, ... })
      } else if (this.tabIndex === 1) {
        SendTab({ onSend: (r: RateRule) => { this.selSend = r; this.fWeight = 5; this.showSend = true; }, ... })
      } else if (this.tabIndex === 2) {
        StationTab({ onStation: (s: Station) => { this.selStation = s; this.showStation = true; }, ... })
      } else if (this.tabIndex === 3) {
        TrackTab({ onPkg: (p: Package) => { this.selPkg = p; this.showPkg = true; }, ... })
      } else if (this.tabIndex === 4) {
        PointTab({ points: this.points, onPoint: (g: PointGoods) => { this.selPoint = g; this.fQty = 1; this.showPoint = true; }, ... })
      } else {
        MineTab({ points: this.points, memberLevel: this.memberLevel, onVip: () => { this.showVip = true; }, ... })
      }
      // ========== 底部 Tab ==========
      Row() {
        ForEach(this.tabs, (label: string, idx: number) => {
          this.bottomTabItem(this.tabIcons[idx], label, idx)
        }, (label: string, idx: number) => label + idx)
      }
      .width('100%')
      .height(56)
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%')
    .height('100%')

    // ========== 弹框挂载 ==========
    if (this.showPkg && this.selPkg !== null) {
      this.modalOverlay(() => { this.showPkg = false })
      this.pkgModal()
    }
    // ... 其余弹窗同理
    if (this.showToast) {
      this.toastBox()
    }
  }
  .width('100%')
  .height('100%')
}

Stack 是层叠布局容器,后入的子元素覆盖在先入的之上。这里第一层是 Column(主内容 + 底部 Tab),第二层起是各弹窗的遮罩与卡片,最后一层是 Toast。这种"主内容在底、弹窗在中、Toast 在顶"的层级安排,保证了交互优先级正确。

主内容区用 if/else if 根据 tabIndex 渲染对应 Tab 组件。注意这里不是用 Tabs 容器,而是手动 if 分发,这意味着切换 Tab 时旧 Tab 会被销毁、新 Tab 重新构建,没有缓存。对于演示应用这是可接受的简化,但在频繁切换且子 Tab 重的场景,建议用 Tabs 配合 cachedCount 保留状态。

子组件通过构造参数接收回调函数,这是 ArkTS 的"子到父通信"模式。子组件触发 onPkg(p) 回调,父组件在回调中更新 selPkgshowPkg,从而打开弹窗。这种"事件上抛、状态留在父"的设计,使子组件保持无状态、可复用。

PointTabMineTab 还接收 pointsmemberLevel 作为 @Prop 式的向下数据流,父组件积分变化会自动同步到子组件展示。底部 Tab 用 Row + ForEach + bottomTabItem 渲染,shadow 的负 offsetY 让阴影投射向上,模拟从底部浮起的视觉。

弹窗挂载段是一连串 if (this.showXxx && this.selXxx !== null),每个弹窗由 modalOverlay(半透明遮罩)+ 具体 @Builder 卡片组成。双重条件判断确保既有开关又数据才渲染,避免空对象访问。Toast 因为无选中对象,只需 showToast 单条件。

Stack 的层叠特性是构建弹窗、引导层、悬浮按钮的利器。相比 Web 中依赖 position: fixedz-index,ArkUI 的 Stack 天然支持层级,且子元素尺寸默认撑满父容器,遮罩全屏覆盖只需一行 .width('100%').height('100%')

六、弹窗系统:@Builder 的复用艺术

弹窗是本应用最密集的 UI 单元,共 16 个 @Builder。先看包裹详情弹窗,它是典型的居中卡片样式。

@Builder
pkgModal() {
  Column() {
    Row() {
      Text(this.selPkg!.icon).fontSize(40)
      Column({ space: 4 }) {
        Text(this.selPkg!.name).fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
        Text('单号 ' + this.selPkg!.no).fontSize(12).fontColor('#90A4AE')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })
      Row() {
        Text(this.selPkg!.status)
          .fontSize(12)
          .fontColor('#FFFFFF')
          .backgroundColor(this.selPkg!.statusColor)
          .borderRadius(12)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      }
    }
    .width('100%')

    Row() {
      Column({ space: 2 }) {
        Text('寄件').fontSize(11).fontColor('#90A4AE')
        Text(this.selPkg!.from).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
      }
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
      Text('🚀').fontSize(20)
      Column({ space: 2 }) {
        Text('收件').fontSize(11).fontColor('#90A4AE')
        Text(this.selPkg!.to).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
      }
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
    }
    .width('100%')
    .backgroundColor('#F1F8FF')
    .borderRadius(14)
    .padding({ top: 12, bottom: 12 })
    .margin({ top: 14 })
    // ... 信息行与按钮
  }
  .width('84%')
  .backgroundColor('#FFFFFF')
  .borderRadius(20)
  .padding(20)
  .constraintSize({ maxHeight: '82%' })
  .clip(true)
}

@Builder 是 ArkUI 的 UI 复用单元,它编译后展开为内联视图,没有独立组件实例与状态,因此访问的 this.selPkg 直接是宿主组件的状态。! 非空断言表明开发者确信渲染时 selPkg 已非空,这与外层 if (this.selPkg !== null) 的守卫呼应。

头部 Row 用图标 + 名称列 + 状态标签三段式布局,layoutWeight(1) 让名称列占据剩余空间,状态标签靠右。Column({ space: 4 })space 参数控制子元素纵向间距,是替代逐个 margin 的批量间距语法。状态标签的彩色背景直接取自 selPkg.statusColor,呼应数据预置颜色的设计。

中间的寄件-收件区用两个 Column 各占 layoutWeight(1),中间夹一个火箭 Emoji,形成"左站 - 箭头 - 右站"的流向示意。alignItems(HorizontalAlign.Center) 让文字居中对齐,视觉对称。淡蓝背景 #F1F8FF 与圆角构成信息块卡片感。

卡片容器 .width('84%') 居中、.constraintSize({ maxHeight: '82%' }) 限高、.clip(true) 裁剪溢出,这三件套是居中弹窗的标准配置。clip(true) 配合 borderRadius 让顶部渐变背景不超出圆角,是渐变 + 圆角组合的必备项。

@Builder@Component 的区别在于:@Builder 是"代码片段"复用,无独立状态与生命周期;@Component 是"独立组件"复用,有状态与参数。当一段 UI 只依赖宿主状态、无需独立管理时,用 @Builder 更轻量;当需要跨页面复用且自带状态时,用 @Component。本应用的弹窗因共享宿主状态,统一用 @Builder

寄件弹窗是底部抽屉样式,展示了表单交互与动态计费。

@Builder
sendModal() {
  Column() {
    Row() {
      Text('寄快递').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
      Text('✕')
        .fontSize(16)
        .fontColor('#90A4AE')
        .padding(6)
        .onClick(() => { this.showSend = false })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    // ... 线路选择、重量步进、保价开关、地址输入、运费预估
    Row() {
      Text('预估运费').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
      Text('¥ ' + (this.selSend!.base + this.selSend!.perKg * this.fWeight))
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FF8F00')
    }
    .width('100%')
    .margin({ top: 12 })
    // ... 确认下单按钮
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 22, topRight: 22 })
  .padding(20)
  .padding({ bottom: 30 })
  .constraintSize({ maxHeight: '84%' })
}

底部抽屉的标志是 .width('100%') 全宽 + .borderRadius({ topLeft: 22, topRight: 22 }) 仅顶部圆角,视觉上从屏幕底部滑出。头部用 justifyContent(FlexAlign.SpaceBetween) 让标题靠左、关闭按钮靠右两端对齐。FlexAlign.SpaceBetweenRow/Column 主轴对齐的常用值,实现首尾贴边、中间均分。

运费计算 this.selSend!.base + this.selSend!.perKg * this.fWeight 是计费公式的直接体现。由于 fWeight@State,每次点击"+/-"按钮改变重量,运费数字会即时刷新——这是响应式 UI 的直观体现。线路选择标签点击后更新 selSend,同样触发运费重算。

重量步进器用两个 Text 充当按钮,onClick 内做边界保护 if (this.fWeight > 1),防止负数重量。这种用 Text 模拟按钮的做法省去了 Button 的默认样式干扰,但要自行处理点击反馈样式。保价服务用 Toggle({ type: ToggleType.Switch, isOn: this.fInsure }) 开关,onChange 回调同步状态,是表单开关的标准组件。

layoutWeight 与百分比 width 的取舍:当希望元素按剩余空间分配时用 layoutWeight,当希望按固定比例时用百分比。本代码大量混用——标签列用 layoutWeight(1) 撑满、操作列用固定 width(32) 定宽,形成"弹性 + 定宽"的经典布局组合。

扫码取件弹窗展示了 Stack 的层叠与动画。

@Builder
scanModal() {
  Column() {
    Text('扫码取件').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
    Text('请将包裹条形码对准取件柜扫描口').fontSize(12).fontColor('#B3E5FC').margin({ top: 6 })

    Stack() {
      Column() {
        Text('▚▞').fontSize(54).fontColor('#FF8F00').opacity(0.9)
      }
      .width(160)
      .height(120)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#1AFFFFFF')
      .borderRadius(12)
      Row() {
        Text('—').fontSize(24).fontColor('#FF8F00')
      }
      .width('60%')
      .justifyContent(FlexAlign.Center)
      .translate({ y: 30 })
      .animation({ duration: 900, curve: Curve.EaseInOut })
    }
    .width(200)
    .height(140)
    .margin({ top: 20 })
    // ... 包裹信息与按钮
  }
  .width('86%')
  .backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#01579B", 1]] })')
  .borderRadius(20)
  .padding(20)
  .constraintSize({ maxHeight: '82%' })
  .clip(true)
}

Stack 内层叠了两层:底层是扫描框背景(半透明白 #1AFFFFFF),上层是一条扫描线 Text('—'),通过 translate({ y: 30 }) 偏移并配 animation 实现扫描线上下扫动的动效。Curve.EaseInOut 是先慢后快再慢的缓动曲线,让扫描线运动更自然。深蓝渐变背景 #0B2F5C#01579B 营造科技感。

.opacity(0.9).translate.scale.animation 都是 ArkUI 的属性动画体系。声明 .animation 后,紧邻它的属性变化(如 translate 的 y 值改变)会以指定时长与曲线过渡,而非瞬变。本代码在取件码、积分数字、会员升级按钮等多处用 scale({ x: 1.05, y: 1.05 }).animation(...) 制造"放大强调"的微动效。

点击扫码

Stack 层叠

底层扫描框

上层扫描线

animation 动画

translate 上下扫动

模拟扫描成功

Toast 提示

异常处理弹窗用红色渐变制造警示氛围。

@Builder
abnormalModal() {
  Column() {
    Row() {
      Text('⚠️').fontSize(34)
      Text('包裹运输异常')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ left: 10 })
        .opacity(1)
        .animation({ duration: 600, curve: Curve.EaseInOut })
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    // ... 异常详情与处理方案按钮
  }
  .width('84%')
  .backgroundColor('linearGradient({ colors: [["#B71C1C", 0], ["#E53935", 1]] })')
  .borderRadius(20)
  .padding(20)
  .constraintSize({ maxHeight: '80%' })
  .clip(true)
}

深红到亮红的渐变 #B71C1C#E53935 是警示语义色,与正常弹窗的蓝白形成强烈对比。处理方案用"重新派送"(绿)与"申请退款"(红)两个 Button,颜色即语义。layoutWeight(1) 让两按钮均分宽度。

积分明细弹窗内置了一个柱状图,是数据可视化的亮点。

ForEach(['6 月', '7 月', '8 月', '9 月', '10 月', '11 月'], (m: string, mi: number) => {
  Column({ space: 4 }) {
    Text([620, 880, 1200, 950, 1380, 860][mi] + '')
      .fontSize(9)
      .fontColor('#90A4AE')
    Column() {
      Row() {
        Column() {
        }
        .width(20)
        .height(getBarHeight([620, 880, 1200, 950, 1380, 860][mi], 1380))
        .backgroundColor(mi === 4 ? '#FF8F00' : '#81D4FA')
        .borderRadius({ topLeft: 4, topRight: 4 })
        .scale({ y: 1.02 })
        .animation({ duration: 500, curve: Curve.EaseOut })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .height(86)
    .justifyContent(FlexAlign.End)
    .backgroundColor('#F5F8FC')
    .borderRadius(8)
    Text(m).fontSize(9).fontColor('#90A4AE')
  }
  .layoutWeight(1)
}, (m: string) => m)

柱状图由 6 个 Column 组成,每个 Column 内含数值标签、柱体、月份标签三段。柱体是一个空 Column 设定 .height(getBarHeight(...)) 与背景色,高度由数据按最大值 1380 比例换算。mi === 4(即 10 月,最高值)用强调橙,其余用浅蓝。外层 Column 高 86vp、justifyContent(FlexAlign.End) 让柱体从底部向上生长,模拟柱状图的对齐方式。

ForEach 的第三个参数是键值生成器 (m: string) => m,用月份字符串作键。键值的稳定性决定 ForEach 的 diff 效率——若键不变,框架复用旧视图;若键变化,则重建。这里月份固定,键稳定,渲染高效。

ArkUI 没有内置图表组件,柱状图、进度条都需要用基础容器手动搭建。这种"用 Column 的高度模拟柱体"的手法是声明式 UI 的典型技巧:把数值映射为尺寸属性,借响应式更新实现数据驱动图表。配合 animation 可获得柱体生长动画。

会员升级弹窗展示了进度条的实现。

Row() {
  Row() {
    Column() {
    }
    .width('68%')
    .height(8)
    .backgroundColor('linearGradient({ colors: [["#FFB300", 0], ["#FF8F00", 1]] })')
    .borderRadius(4)
  }
  .width('100%')
  .height(8)
  .backgroundColor('#F0F4F8')
  .borderRadius(4)
}
.width('100%')
.margin({ top: 6 })

进度条是两个嵌套 Row:外层灰色背景全宽,内层渐变填充 68% 宽度。这种"灰底 + 彩填充"的双层结构是进度条的标准实现,百分比直接写在内层 width。若要动态进度,将 '68%' 替换为 this.progress + '%' 即可响应式更新。

七、包裹 Tab:PkgTab 的瀑布流与横滑

进入各 Tab 子组件,先看包裹页 PkgTab。它是用户首屏看到的主页面,信息密度最高。

@Component
struct PkgTab {
  onPkg: (p: Package) => void = () => {};
  onPickup: (p: Package) => void = () => {};
  onScan: (p: Package) => void = () => {};
  onAbnormal: (p: Package) => void = () => {};
  onSend: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        // 头部:搜索框 + 扫一扫
        Row() {
          Text('🔍 搜索包裹单号 / 驿站').fontSize(13).fontColor('#90A4AE').layoutWeight(1)
          Text('📷').fontSize(18)
        }
        .width('100%')
        .height(38)
        .backgroundColor('#F1F8FF')
        .borderRadius(19)
        .padding({ left: 14, right: 14 })
        .margin({ top: 10 })
        // ... banner、快捷宫格、在途横滑、双列包裹
      }
      .width('100%')
      .padding({ left: 14, right: 14 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }
}

PkgTab 通过成员属性声明回调函数并给默认空实现,这是 ArkTS 子组件接收父回调的语法。父组件构造时传入实际回调,子组件内部触发时调用。Scroll 包裹整个 Column.scrollBar(BarState.Off) 隐藏滚动条,.layoutWeight(1)Scroll 占据父容器剩余高度。

搜索框用 Row 模拟,左侧占位文字 layoutWeight(1) 撑满、右侧相机图标定宽。borderRadius(19)height(38) 构成药丸形搜索框。这种用 Text 模拟输入框的方式是演示应用的简化,真实场景应替换为 TextInput

// 横幅 banner
Row() {
  Column({ space: 4 }) {
    Text('星际快递 · 七夕专场').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
    Text('全场运费 8 折 · 星球直达').fontSize(12).fontColor('#B3E5FC')
  }
  .alignItems(HorizontalAlign.Start)
  .layoutWeight(1)
  Text('🚀').fontSize(40)
}
.width('100%')
.height(96)
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
.borderRadius(16)
.padding(16)
.margin({ top: 10 })

横幅 Banner 是典型的"左文右图"布局:左侧 Column 持标题与副标题,alignItems(HorizontalAlign.Start) 左对齐;右侧大号火箭 Emoji 作装饰。深蓝渐变背景承载活动信息,是首页视觉焦点。

在途包裹区用了横向 Scroll

Scroll() {
  Row({ space: 10 }) {
    ForEach(getTopPackages(), (p: Package) => {
      this.flyCard(p)
    }, (p: Package) => p.id + 'fly')
  }
  .padding({ right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(150)
.margin({ top: 8 })

scrollable(ScrollDirection.Horizontal)Scroll 切为横向滚动模式,内层 Row 排列 flyCard 卡片。getTopPackages 取前 6 个包裹,构成横滑卡片轮播。ForEach 键值用 p.id + 'fly' 拼接,避免与其他 ForEach 的键冲突——同一组件树内键值必须全局唯一,否则 diff 会错乱。

全部包裹用双列瀑布流。

Row({ space: 10 }) {
  Column({ space: 10 }) {
    ForEach(getPkgRows(), (p: Package) => {
      this.pkgCard(p)
    }, (p: Package) => p.id + 'pa')
  }
  .layoutWeight(1)
  Column({ space: 10 }) {
    ForEach(getPkgRows2(), (p: Package) => {
      this.pkgCard(p)
    }, (p: Package) => p.id + 'pb')
  }
  .layoutWeight(1)
}
.width('100%')
.margin({ top: 8 })

两个 ColumnlayoutWeight(1) 均分宽度,内部各自 ForEach 渲染拆分后的半数卡片。Column({ space: 10 })space 控制卡片间纵向间距。这种"外 Row 定宽均分 + 内 Column 纵向堆叠"是瀑布流的经典骨架。键值后缀 'pa'/'pb' 区分左右列,保证唯一。

pkgCardflyCard 是两个 @Builder,前者是竖向卡片用于双列、后者是横向卡片用于横滑。它们都通过 this.onPkg(p) 把点击事件上抛给父组件,由父组件打开详情弹窗。这种"卡片只负责展示与点击上报、弹窗由父统一管理"的分工,是组件解耦的关键。

ForEach 是 ArkUI 列表渲染的唯一原生方案。它的性能取决于键值生成器的质量:稳定且唯一的键让框架精准 diff,仅更新变化项;不稳定的键(如数组索引)会导致全量重建。本代码普遍用业务 id 加后缀作键,是良好实践。

八、寄件 Tab:SendTab 的步骤条与计费卡

SendTab 聚焦寄件流程,头部是一张深蓝渐变主卡,内含步骤条。

@Component
struct SendTab {
  onSend: (r: RateRule) => void = () => {};
  onRate: (r: RateRule) => void = () => {};
  onAppoint: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Column({ space: 3 }) {
              Text('在线寄件').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('一键下单 · 星球直达').fontSize(12).fontColor('#B3E5FC')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('📮').fontSize(36)
          }
          .width('100%')
          Row({ space: 8 }) {
            this.sendStep(1, '填单', true)
            this.sendStep(2, '揽收', false)
            this.sendStep(3, '运输', false)
            this.sendStep(4, '签收', false)
          }
          .width('100%')
          .margin({ top: 14 })
        }
        .width('100%')
        .backgroundColor('linearGradient({ colors: [["#0288D1", 0], ["#0B2F5C", 1]] })')
        .borderRadius(16)
        .padding(16)
        .margin({ top: 10 })
        // ... 快捷入口、运输线路、寄件历史
      }
      .width('100%')
      .padding({ left: 14, right: 14 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }
}

sendStep 是步骤项 @Builder,接收序号、标签、是否激活三参。激活态用黄底深字、非激活态用半透明白字,构成横向四步进度条。active 参数硬编码为 true, false, false, false,表示当前处于第一步。真实场景应绑定到流程状态变量。

@Builder
sendStep(step: number, label: string, active: boolean) {
  Column({ space: 4 }) {
    Text(step + '')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(active ? '#0B2F5C' : '#90A4AE')
      .width(22)
      .height(22)
      .textAlign(TextAlign.Center)
      .backgroundColor(active ? '#FFD54F' : '#33FFFFFF')
      .borderRadius(11)
    Text(label).fontSize(10).fontColor(active ? '#FFD54F' : '#B3E5FC')
  }
  .layoutWeight(1)
}

圆形序号用 width(22).height(22).borderRadius(11)(半径=直径一半)构成正圆,textAlign(TextAlign.Center) 让数字居中。#33FFFFFF 是带透明度的白,在渐变背景上呈现半透明灰白效果。

运输线路区用双列展示计费规则卡片。

@Builder
rateCard(r: RateRule) {
  Column() {
    Row() {
      Text(r.icon).fontSize(22)
      Text(r.days + ' 天达')
        .fontSize(10)
        .fontColor('#FFFFFF')
        .backgroundColor(r.days <= 1 ? '#E53935' : '#FF8F00')
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    Text(r.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#0B2F5C').margin({ top: 6 })
    Text(r.desc).fontSize(10).fontColor('#90A4AE').margin({ top: 3 })
    Row() {
      Text('¥' + r.base).fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
      Text(' 起').fontSize(10).fontColor('#90A4AE')
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .margin({ top: 6 })
  }
  .layoutWeight(1)
  .height(110)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .padding(10)
  .shadow({ radius: 4, color: '#12000000', offsetY: 2 })
  .onClick(() => {
    this.onSend(r);
  })
}

rateCard 是计费卡,r.days <= 1 当日达/次日达用红色标签强调,其余橙色。shadow({ radius: 4, color: '#12000000', offsetY: 2 }) 给卡片加投影,offsetY: 2 让阴影向下偏移模拟自然光照。点击卡片触发 onSend(r) 回调,打开寄件弹窗。卡片用 layoutWeight(1) 在双列中均分宽度,height(110) 固定高度保证视觉齐整。

九、驿站 Tab:StationTab 的位置服务与快递员

StationTab 围绕"附近的驿站"展开,头部是定位横幅。

@Component
struct StationTab {
  onStation: (s: Station) => void = () => {};
  onBox: (b: BoxCell) => void = () => {};
  onCourier: (c: Courier) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('📍').fontSize(22)
          Column({ space: 2 }) {
            Text('当前定位 · 地球总仓').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('附近 10 家驿站营业中').fontSize(11).fontColor('#B3E5FC')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 8 })
          Text('🛰️').fontSize(28)
        }
        .width('100%')
        .height(64)
        .backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
        .borderRadius(16)
        .padding(14)
        .margin({ top: 10 })
        // ... 附近驿站双列、智能快递柜横滑、快递员列表
      }
      .width('100%')
      .padding({ left: 14, right: 14 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }
}

定位横幅用定位图标 + 定位文本 + 卫星装饰 Emoji 三段式。StationCard 是驿站卡片,展示驿站名、距离、营业时间、空闲格口数,点击触发 onStation 打开驿站详情弹窗。

快递柜横滑区直接遍历 BOXES 全量数据。

Scroll() {
  Row({ space: 10 }) {
    ForEach(BOXES, (b: BoxCell) => {
      this.boxCard(b)
    }, (b: BoxCell) => b.id + 'bx')
  }
  .padding({ right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(96)
.margin({ top: 8 })

boxCard 根据 b.free 切换绿色空闲或橙色占用样式,点击触发 onBox 打开快递柜格口弹窗。快递员列表用 courierRow 行渲染,在线状态用绿底"在线"标签、离线用灰底"休息"。

十、追踪 Tab:TrackTab 的时间轴

TrackTab 的核心是物流时间轴,用 trackLine @Builder 渲染每个节点。

@Builder
trackLine(t: TrackLog) {
  Row() {
    Column() {
      Text(t.done ? '●' : '○').fontSize(12).fontColor(t.done ? '#0288D1' : '#B0BEC5')
      Row() {
        Column() {
        }
        .width(2)
        .height(34)
        .backgroundColor('#DDE8F5')
      }
    }
    .width(18)
    .margin({ left: 10 })
    Column({ space: 3 }) {
      Text(t.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(t.done ? '#0B2F5C' : '#90A4AE')
      Text(t.place + ' · ' + t.desc).fontSize(11).fontColor('#90A4AE')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 6 })
    Text(t.time).fontSize(10).fontColor('#B0BEC5')
  }
  .width('100%')
  .height(52)
  .margin({ right: 10 })
}

时间轴节点用 Column 纵向排列圆点与连线:顶部是实心圆 (已完成)或空心圆 (未完成),下方是一条 2vp 宽 34vp 高的灰色竖线连接下一节点。圆点颜色根据 done 切换蓝灰。右侧 Column 持节点标题与地点描述,layoutWeight(1) 撑满,最右是时间。这种"圆点 + 竖线 + 内容"三段式是时间轴的通用骨架。

异常包裹区用红色浅底 #FFEBEE 突出警示。

ForEach(getAbnormalPkgs(), (p: Package) => {
  Row() {
    Text(p.icon).fontSize(22)
    Column({ space: 2 }) {
      Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
      Text(p.no + ' · ' + p.desc).fontSize(10).fontColor('#90A4AE')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 8 })
    Text('处理 ›').fontSize(12).fontColor('#E53935')
  }
  .width('100%')
  .height(52)
  .backgroundColor('#FFEBEE')
  .borderRadius(12)
  .padding({ left: 10, right: 10 })
  .onClick(() => {
    this.onAbnormal(p);
  })
}, (p: Package) => p.id + 'abn')

getAbnormalPkgs 过滤出 status === '异常' 的包裹,红色背景与"处理 ›"红字引导用户处理。点击触发 onAbnormal 打开异常处理弹窗。

十一、积分 Tab:PointTab 的柱状图与商城

PointTab 头部是积分余额卡,含余额、明细入口、三条积分获取规则。

@Component
struct PointTab {
  points: number = 0;
  onPoint: (g: PointGoods) => void = () => {};
  onDetail: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('💰').fontSize(30)
            Column({ space: 2 }) {
              Text(this.points + '').fontSize(26).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('我的积分').fontSize(12).fontColor('#B3E5FC')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Text('明细 ›').fontSize(12).fontColor('#FFD54F')
              .onClick(() => {
                this.onDetail();
              })
          }
          .width('100%')
          Row({ space: 8 }) {
            this.pointStat('📮', '寄件得积分', '+50/单')
            this.pointStat('⭐', '评价得积分', '+20/次')
            this.pointStat('🎯', '每日任务', '+100/天')
          }
          .width('100%')
          .margin({ top: 12 })
        }
        .width('100%')
        .backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
        .borderRadius(16)
        .padding(16)
        .margin({ top: 10 })
        // ... 月度柱状图、积分商城双列
      }
      .width('100%')
      .padding({ left: 14, right: 14 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }
}

points 是父组件传入的属性,父组件积分变化时自动同步刷新。pointStat 是三等分的规则卡,半透明白底 #1AFFFFFF。柱状图与弹窗中的实现一致,用 getBarHeight 动态算高。积分商城双列用 goodsCard 展示商品,点击触发 onPoint 打开兑换弹窗。

十二、我的 Tab:MineTab 的会员与徽章

MineTab 是个人中心,头部用户卡 + 会员卡 + 订单 + 徽章墙。

@Component
struct MineTab {
  points: number = 0;
  memberLevel: number = 1;
  onVip: () => void = () => {};
  onBadge: () => void = () => {};
  onOrder: (o: Order) => void = () => {};
  onUrgent: (o: Order) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🧑‍🚀').fontSize(34)
          Column({ space: 3 }) {
            Text('星际骑士').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Row({ space: 6 }) {
              Text('Lv.' + this.memberLevel).fontSize(11).fontColor('#FFD54F')
              Text('· 会员').fontSize(11).fontColor('#B3E5FC')
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
          Column({ space: 2 }) {
            Text(this.points + '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFD54F')
            Text('积分').fontSize(10).fontColor('#B3E5FC')
          }
          .onClick(() => {
            this.onBadge();
          })
        }
        .width('100%')
        .height(72)
        .backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
        .borderRadius(16)
        .padding(14)
        .margin({ top: 10 })
        // ... 会员卡、订单、徽章墙
      }
      .width('100%')
      .padding({ left: 14, right: 14 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }
}

用户卡左侧头像 + 昵称 + 等级,右侧积分块可点击跳转徽章。memberLevelpoints 都从父组件透传,保证全局一致。会员卡是金色 #FFF8E1 背景,内嵌进度条展示升级进度,"升级 ›"触发 onVip 打开会员弹窗。

徽章墙用横向 Scroll 展示所有徽章。

Scroll() {
  Row({ space: 10 }) {
    ForEach(BADGES, (b: Badge) => {
      Column({ space: 4 }) {
        Text(b.icon).fontSize(26).opacity(b.locked ? 0.35 : 1)
        Text(b.name).fontSize(10).fontColor(b.locked ? '#B0BEC5' : '#37474F')
      }
      .width(64)
      .height(64)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(b.locked ? '#ECEFF1' : '#FFF8E1')
      .borderRadius(12)
      .onClick(() => {
        this.onBadge();
      })
    }, (b: Badge) => b.id + 'bd')
  }
  .padding({ right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(84)
.margin({ top: 8 })

b.locked 控制徽章灰显:opacity(0.35) 半透明 + 灰底 #ECEFF1;已解锁徽章 opacity(1) 全显 + 金底 #FFF8E1opacity 是 ArkUI 的透明度属性,0-1 区间,常用于禁用态视觉降级。

订单项 orderItem 根据 o.status 分流:运输中显示"加急 ›"橙色可点击触发 onUrgent,其他状态显示彩色状态标签。这是条件渲染在列表项中的应用。

@Builder
orderItem(o: Order) {
  Row() {
    Text(o.icon).fontSize(18)
    Column({ space: 2 }) {
      Text(o.item).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#37474F')
      Text(o.date + ' · ¥' + o.fee).fontSize(10).fontColor('#90A4AE')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 8 })
    if (o.status === '运输中') {
      Text('加急 ›')
        .fontSize(11)
        .fontColor('#FF8F00')
        .onClick(() => {
          this.onUrgent(o);
        })
    } else {
      Text(o.status)
        .fontSize(11)
        .fontColor('#FFFFFF')
        .backgroundColor(getStatusColor(o.status))
        .borderRadius(8)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    }
  }
  .width('100%')
  .height(48)
  .onClick(() => {
    this.onOrder(o);
  })
}

if/else@Builder 内根据状态渲染不同 Text,是 ArkUI 条件渲染的基本用法。整行 onClick 触发 onOrder 打开投诉弹窗,"加急 ›"额外 onClick 触发 onUrgent——但注意内层点击会冒泡到外层,可能同时触发两个回调,这是潜在交互瑕疵,真实工程需用 stopPropagation 或调整结构。

十三、公共 Builder:遮罩、Toast、Tab 项

最后看几个贯穿全局的公共 @Builder。遮罩层是弹窗的半透明背景。

@Builder
modalOverlay(onClose: () => void) {
  Column() {
    Row() {
      Column() {
      }
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#66000000')
    .onClick(() => {
      onClose();
    })
  }
  .width('100%')
  .height('100%')
}

modalOverlay 接收一个 onClose 回调,用 #66000000(40% 黑)覆盖全屏,点击空白处关闭弹窗。onClose 是箭头函数参数,调用时执行 () => { this.showXxx = false }。这种"遮罩逻辑复用、关闭行为注入"的设计,是 @Builder 参数化的典型用法。注意嵌套的空 Column/Row 是为了让背景色撑满,ArkUI 中空容器需要显式设尺寸。

Toast 是顶层轻提示。

@Builder
toastBox() {
  Row() {
    Text(this.toast).fontSize(14).fontColor('#FFFFFF')
  }
  .padding({ left: 18, right: 18, top: 10, bottom: 10 })
  .backgroundColor('#CC333333')
  .borderRadius(20)
}

toastBox#CC333333(80% 黑灰)圆角药丸承载 toast 文本。它挂在 Stack 最顶层,由 showToast 控制显隐,showTip 方法定时复位。由于没有居中对齐约束,Toast 默认在 Stack 内居中显示。

底部 Tab 项是导航骨架。

@Builder
bottomTabItem(icon: string, label: string, idx: number) {
  Column({ space: 2 }) {
    Text(icon).fontSize(20)
    Text(label)
      .fontSize(11)
      .fontColor(this.tabIndex === idx ? '#0288D1' : '#90A4AE')
      .fontWeight(this.tabIndex === idx ? FontWeight.Bold : FontWeight.Normal)
  }
  .layoutWeight(1)
  .height(56)
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.tabIndex = idx;
  })
}

bottomTabItemtabIndex === idx 三元表达式切换激活态的颜色与字重。layoutWeight(1) 让 6 个 Tab 项均分底部 Row 宽度,justifyContent(FlexAlign.Center) 让图标与文字纵向居中。点击修改 tabIndex,触发主内容区 if/else 重新分发。

@Builder 接收参数时,参数变化会触发重建。bottomTabItemidx 固定,但 tabIndex 变化时,由于 fontColor/fontWeight 依赖 this.tabIndex,这些属性会响应式更新,无需手动刷新。这就是声明式的核心红利:开发者只管改数据,视图自洽。

十四、关键装饰器与布局容器对比总结

下表汇总了本代码涉及的核心装饰器与容器的职责定位。

装饰器/容器职责定位本代码典型用法响应式特性
@Entry标记页面入口组件struct Index 唯一入口标识作用,无运行时响应
@Component声明自定义组件PkgTab/SendTab 等子 Tab仅声明,状态由内部 @State 驱动
@State组件内可变状态tabIndex/points/showPkg赋值即触发依赖视图刷新
@BuilderUI 片段复用pkgModal/rateCard/trackLine随宿主状态自动更新
Column纵向线性布局卡片内部纵向堆叠、双列子列支持子元素 layoutWeight
Row横向线性布局标题行、按钮组、双列外层支持 FlexAlign 主轴对齐
Stack层叠布局弹窗挂载、扫描线特效后入元素覆盖先入
Scroll滚动容器各 Tab 整页纵向、横滑卡片scrollable 切方向、scrollBar 控条
ForEach列表渲染包裹双列、轨迹时间轴等依赖键值生成器 diff
FlexAlign主轴对齐枚举SpaceBetween/Center/End配合 justifyContent
layoutWeight剩余空间分配标签列撑满、Tab 项均分弹性布局核心

下表对比了代码中几种典型的弹窗样式与适用场景。

弹窗样式类型宽度/圆角特征典型场景
包裹详情居中卡84% 全圆角信息展示为主
寄件表单底部抽屉100% 顶圆角表单填写、需避让键盘
扫码取件居中深色卡86% 全圆角沉浸式操作
异常处理居中红渐变84% 全圆角警示与决策
积分明细居中卡84% 全圆角数据图表展示
快递柜格口底部抽屉100% 顶圆角宫格选择

十五、总结

纵观整段代码,这是一个结构完整、视觉精致的鸿蒙声明式 UI 应用范本。它以星际快递为题材,把包裹、寄件、驿站、追踪、积分、个人中心六大模块装进单页面多 Tab 架构,用纯前端 Mock 数据驱动全部交互,完整呈现了一个物流应用从首屏到深度交互的全链路体验。

在架构层面,最值得借鉴的是"状态上提、视图下沉"的弹窗调度模式。16 个弹窗的显隐开关与选中对象全部集中在主组件 Index@State 中,子 Tab 组件只负责触发回调上报,不持有弹窗状态。这种集中式状态管理让弹窗可跨 Tab 复用(如包裹详情弹窗在包裹 Tab 与追踪 Tab 都能打开),也避免了子组件层级不足以覆盖全屏的问题。子组件通过构造参数接收回调函数与下行数据,形成清晰的单向数据流:父到子用属性、子到父用回调。

在布局层面,代码系统性地运用了 Column/Row/Stack 三大容器配合 layoutWeightFlexAlignspace 构建从卡片到列表到弹窗的各种结构。双列瀑布流用"外 Row 均分 + 内 Column 纵堆 + 拆分函数"三件套实现;横向轮播用 Scroll + scrollable(Horizontal) 实现;时间轴用"圆点 + 竖线 + 内容"三段式 Row 实现;柱状图用 Column 高度映射数值实现;进度条用双层 Row 灰底彩填实现。这些手法是声明式 UI 的通用积木,可迁移到任何鸿蒙应用。

在交互层面,代码大量运用 @State 的响应式特性:重量步进改变 fWeight 后运费即时重算、积分兑换后余额即时扣减、星级点击后 fStars 更新点亮、Tab 切换后 tabIndex 驱动整页重渲染。配合 animationscaletranslate 等属性动画,关键数字与按钮都有微动效反馈。showTip@State + setTimeout 模拟原生 Toast,是纯前端实现轻提示的经典手法。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 主题:科技橙蓝 #0B2F5C -> #0288D1 + 星橙 #FF8F00
// 6 Tab 单排:包裹 / 寄件 / 驿站 / 追踪 / 积分 / 我的
interface Package {
  id: number;
  name: string;
  icon: string;
  no: string;
  from: string;
  to: string;
  status: string;
  statusColor: string;
  type: string;
  time: string;
  desc: string;
}

interface Station {
  id: number;
  name: string;
  icon: string;
  addr: string;
  distance: string;
  open: string;
  boxes: number;
  courier: string;
  busy: string;
}

interface Courier {
  id: number;
  name: string;
  icon: string;
  phone: string;
  score: number;
  orders: number;
  area: string;
  online: boolean;
}

interface TrackLog {
  id: number;
  title: string;
  icon: string;
  place: string;
  time: string;
  done: boolean;
  desc: string;
}

interface RateRule {
  id: number;
  name: string;
  icon: string;
  base: number;
  perKg: number;
  days: number;
  desc: string;
}

interface PointGoods {
  id: number;
  name: string;
  icon: string;
  points: number;
  stock: number;
  type: string;
  desc: string;
}

interface BoxCell {
  id: number;
  boxNo: string;
  size: string;
  free: boolean;
  code: string;
  time: string;
}

interface Order {
  id: number;
  no: string;
  item: string;
  icon: string;
  date: string;
  fee: number;
  status: string;
  recipient: string;
}

interface Badge11 {
  id: number;
  name: string;
  icon: string;
  locked: boolean;
  desc: string;
  date: string;
}

interface QuickIcon {
  id: number;
  name: string;
  icon: string;
  color: string;
}

const PACKAGES: Package[] = [
  { id: 1, name: '星航耳机', icon: '🎧', no: 'HX-20260826-001', from: '地球总仓', to: '火星基地', status: '运输中', statusColor: '#0288D1', type: '电子产品', time: '08-26 09:12', desc: '旗舰降噪耳机,预计后天送达。' },
  { id: 2, name: '月球岩茶', icon: '🍵', no: 'HX-20260826-002', from: '月球站', to: '地球总仓', status: '待取件', statusColor: '#FF8F00', type: '食品', time: '08-26 08:40', desc: '限定月球岩茶礼盒,请尽快取件。' },
  { id: 3, name: '星图绘本', icon: '📚', no: 'HX-20260825-018', from: '地球总仓', to: '木星轨道站', status: '派送中', statusColor: '#43A047', type: '图书', time: '08-25 20:15', desc: '儿童星图绘本套装,快递员正在派送。' },
  { id: 4, name: '火星护目镜', icon: '🥽', no: 'HX-20260825-006', from: '金星驿站', to: '火星基地', status: '已签收', statusColor: '#90A4AE', type: '装备', time: '08-24 16:30', desc: '火星尘暴护目镜,已于昨日签收。' },
  { id: 5, name: '星际巧克力', icon: '🍫', no: 'HX-20260824-023', from: '地球总仓', to: '土星环站', status: '运输中', statusColor: '#0288D1', type: '食品', time: '08-24 11:05', desc: '零重力巧克力,全程冷链运输。' },
  { id: 6, name: '量子手环', icon: '⌚', no: 'HX-20260824-011', from: '地球总仓', to: '月球站', status: '待取件', statusColor: '#FF8F00', type: '电子产品', time: '08-23 19:20', desc: '健康监测手环,已入快递柜。' },
  { id: 7, name: '星尘香氛', icon: '🌌', no: 'HX-20260823-015', from: '海王星站', to: '地球总仓', status: '已签收', statusColor: '#90A4AE', type: '美妆', time: '08-22 10:45', desc: '星尘味限定香氛,已签收。' },
  { id: 8, name: '复古留声机', icon: '📻', no: 'HX-20260823-002', from: '地球总仓', to: '金星驿站', status: '异常', statusColor: '#E53935', type: '家电', time: '08-22 14:00', desc: '运输中碰撞,需要您确认处理方案。' },
  { id: 9, name: '太空种子包', icon: '🌱', no: 'HX-20260822-020', from: '木星轨道站', to: '火星基地', status: '已签收', statusColor: '#90A4AE', type: '农业', time: '08-21 13:25', desc: '太空育种种子,已签收。' },
  { id: 10, name: '星云拼图', icon: '🧩', no: 'HX-20260822-009', from: '地球总仓', to: '月球站', status: '运输中', statusColor: '#0288D1', type: '玩具', time: '08-21 09:50', desc: '10000 片星云拼图,运输中。' },
  { id: 11, name: '超导体鞋', icon: '👟', no: 'HX-20260821-014', from: '金星驿站', to: '地球总仓', status: '已签收', statusColor: '#90A4AE', type: '服饰', time: '08-20 17:35', desc: '悬浮运动鞋,已签收。' },
  { id: 12, name: '星际蛋糕', icon: '🎂', no: 'HX-20260821-003', from: '地球总仓', to: '火星基地', status: '派送中', statusColor: '#43A047', type: '生鲜', time: '08-20 08:00', desc: '生日蛋糕,冷链配送,请注意查收。' }
];

const STATIONS: Station[] = [
  { id: 1, name: '地球总仓驿站', icon: '🌍', addr: '赤道轨道区 12 号', distance: '800m', open: '06:00-22:00', boxes: 12, courier: '老王', busy: '较忙' },
  { id: 2, name: '月球站驿站', icon: '🌕', addr: '静海基地 A3 区', distance: '3.2km', open: '24 小时', boxes: 8, courier: '小月', busy: '空闲' },
  { id: 3, name: '火星基地驿站', icon: '🔴', addr: '奥林帕斯山脚', distance: '5.6km', open: '07:00-23:00', boxes: 20, courier: '阿火', busy: '繁忙' },
  { id: 4, name: '金星驿站', icon: '🟠', addr: '云层观测站 2 层', distance: '9.8km', open: '08:00-20:00', boxes: 6, courier: '金金', busy: '较忙' },
  { id: 5, name: '木星轨道站', icon: '🟤', addr: '大红斑观景台旁', distance: '12km', open: '09:00-21:00', boxes: 10, courier: '木木', busy: '空闲' },
  { id: 6, name: '土星环站', icon: '🪐', addr: 'B 环飘浮区 7 号', distance: '15km', open: '10:00-20:00', boxes: 8, courier: '环环', busy: '繁忙' },
  { id: 7, name: '海王星站', icon: '🔵', addr: '蓝色风暴观测站', distance: '20km', open: '11:00-19:00', boxes: 4, courier: '小海', busy: '空闲' },
  { id: 8, name: '冥王星站', icon: '🩶', addr: '心形平原站', distance: '28km', open: '12:00-18:00', boxes: 3, courier: '冥冥', busy: '较忙' },
  { id: 9, name: '小行星带站', icon: '☄️', addr: '谷神星中转点', distance: '18km', open: '09:30-19:30', boxes: 5, courier: '阿带', busy: '空闲' },
  { id: 10, name: '银河枢纽站', icon: '🌌', addr: '银心十字路口', distance: '30km', open: '24 小时', boxes: 30, courier: '枢纽王', busy: '繁忙' }
];

const COURIERS: Courier[] = [
  { id: 1, name: '老王', icon: '🧑‍🦱', phone: '13****6201', score: 4.9, orders: 3820, area: '地球总仓', online: true },
  { id: 2, name: '小月', icon: '👩', phone: '13****8302', score: 5.0, orders: 2960, area: '月球站', online: true },
  { id: 3, name: '阿火', icon: '🧑', phone: '13****4403', score: 4.8, orders: 3150, area: '火星基地', online: false },
  { id: 4, name: '金金', icon: '👩‍🦰', phone: '13****2504', score: 4.7, orders: 2230, area: '金星', online: true },
  { id: 5, name: '木木', icon: '👨', phone: '13****6605', score: 4.9, orders: 1870, area: '木星轨道', online: true },
  { id: 6, name: '环环', icon: '👧', phone: '13****0706', score: 4.6, orders: 1540, area: '土星环', online: false },
  { id: 7, name: '小海', icon: '👨‍🦲', phone: '13****4807', score: 4.8, orders: 1320, area: '海王星', online: true },
  { id: 8, name: '枢纽王', icon: '🧔', phone: '13****9908', score: 4.9, orders: 5210, area: '银河枢纽', online: true }
];

const TRACKS: TrackLog[] = [
  { id: 1, title: '已揽收', icon: '📦', place: '地球总仓', time: '08-26 09:12', done: true, desc: '包裹已从地球总仓揽收' },
  { id: 2, title: '干线运输', icon: '🚀', place: '近地轨道', time: '08-26 11:30', done: true, desc: '搭乘星际货运火箭 HX-9 号' },
  { id: 3, title: '到达中转站', icon: '🛰️', place: '月面中转站', time: '08-26 18:20', done: true, desc: '包裹已抵达月面中转站' },
  { id: 4, title: '二次运输', icon: '🛸', place: '地火航线', time: '08-27 03:45', done: true, desc: '转入火星航线运载舰' },
  { id: 5, title: '到达目的地', icon: '🏁', place: '火星基地驿站', time: '08-27 10:15', done: true, desc: '包裹已到达火星基地驿站' },
  { id: 6, title: '派送中', icon: '🏃', place: '火星基地', time: '08-27 11:40', done: true, desc: '快递员阿火正在派送' },
  { id: 7, title: '已签收', icon: '✅', place: '火星基地', time: '08-27 14:02', done: true, desc: '包裹已被本人签收' }
];

const RATES: RateRule[] = [
  { id: 1, name: '行星快递', icon: '🌍', base: 12, perKg: 4, days: 2, desc: '同行星 2 天达' },
  { id: 2, name: '星际快递', icon: '🚀', base: 28, perKg: 8, days: 4, desc: '跨行星 4 天达' },
  { id: 3, name: '星系速递', icon: '🌌', base: 45, perKg: 12, days: 7, desc: '跨星系 7 天达' },
  { id: 4, name: '极速专线', icon: '⚡', base: 68, perKg: 16, days: 1, desc: '加急 1 天达' },
  { id: 5, name: '冷链生鲜', icon: '❄️', base: 36, perKg: 10, days: 3, desc: '全程零下运输' },
  { id: 6, name: '贵重保价', icon: '💎', base: 52, perKg: 14, days: 4, desc: '最高保价 ¥10 万' },
  { id: 7, name: '同城闪送', icon: '⚡', base: 8, perKg: 3, days: 0, desc: '站内 2 小时达' },
  { id: 8, name: '大件货运', icon: '📦', base: 80, perKg: 6, days: 6, desc: '重物大件专线' },
  { id: 9, name: '顺风捎带', icon: '🛸', base: 15, perKg: 5, days: 5, desc: '顺路捎带优惠' },
  { id: 10, name: '定时达', icon: '⏰', base: 32, perKg: 9, days: 3, desc: '指定时段送达' }
];

const POINT_GOODS: PointGoods[] = [
  { id: 1, name: '星航钥匙扣', icon: '🔑', points: 300, stock: 200, type: '周边', desc: '星际快递限定钥匙扣' },
  { id: 2, name: '快递员贴纸', icon: '🏷️', points: 150, stock: 500, type: '周边', desc: '火箭贴纸一套 12 张' },
  { id: 3, name: '保温杯', icon: '🥤', points: 800, stock: 80, type: '生活', desc: '真空保温 12 小时' },
  { id: 4, name: '火箭模型', icon: '🚀', points: 1200, stock: 30, type: '收藏', desc: 'HX-9 号火箭 1:100 模型' },
  { id: 5, name: '快递袋盲盒', icon: '🎁', points: 500, stock: 100, type: '盲盒', desc: '随机神秘礼品盲盒' },
  { id: 6, name: '星球抱枕', icon: '🛋️', points: 650, stock: 60, type: '生活', desc: '火星主题抱枕' },
  { id: 7, name: '星图帆布包', icon: '👜', points: 700, stock: 50, type: '生活', desc: '银河星图印花帆布包' },
  { id: 8, name: '无人机摆件', icon: '🛸', points: 900, stock: 40, type: '收藏', desc: '配送无人机桌面摆件' },
  { id: 9, name: '降噪耳塞', icon: '👂', points: 400, stock: 150, type: '数码', desc: '星际旅途助眠耳塞' },
  { id: 10, name: '徽章套装', icon: '🎖️', points: 1000, stock: 35, type: '收藏', desc: '全星球驿站徽章套装' }
];

const BOXES: BoxCell[] = [
  { id: 1, boxNo: 'A-01', size: '小格', free: true, code: '', time: '' },
  { id: 2, boxNo: 'A-02', size: '小格', free: false, code: '3842', time: '08-27 10:20' },
  { id: 3, boxNo: 'A-03', size: '小格', free: true, code: '', time: '' },
  { id: 4, boxNo: 'A-04', size: '中格', free: false, code: '7956', time: '08-27 09:05' },
  { id: 5, boxNo: 'B-01', size: '中格', free: true, code: '', time: '' },
  { id: 6, boxNo: 'B-02', size: '中格', free: true, code: '', time: '' },
  { id: 7, boxNo: 'B-03', size: '大格', free: false, code: '6120', time: '08-26 21:40' },
  { id: 8, boxNo: 'B-04', size: '大格', free: true, code: '', time: '' }
];

const ORDERS: Order[] = [
  { id: 1, no: 'HX-20260826-001', item: '星航耳机', icon: '🎧', date: '08-26 09:00', fee: 32, status: '运输中', recipient: '火星 · 阿强' },
  { id: 2, no: 'HX-20260825-012', item: '月球岩茶', icon: '🍵', date: '08-25 19:30', fee: 18, status: '已签收', recipient: '地球 · 小美' },
  { id: 3, no: 'HX-20260824-007', item: '星图绘本', icon: '📚', date: '08-24 14:20', fee: 26, status: '已签收', recipient: '木星 · 球球' },
  { id: 4, no: 'HX-20260824-003', item: '火星护目镜', icon: '🥽', date: '08-24 10:10', fee: 45, status: '已签收', recipient: '火星 · 小战' },
  { id: 5, no: 'HX-20260823-019', item: '星际巧克力', icon: '🍫', date: '08-23 16:45', fee: 22, status: '运输中', recipient: '土星 · 环仔' },
  { id: 6, no: 'HX-20260823-004', item: '量子手环', icon: '⌚', date: '08-23 11:00', fee: 38, status: '已签收', recipient: '月球 · 静海' },
  { id: 7, no: 'HX-20260822-016', item: '星尘香氛', icon: '🌌', date: '08-22 18:30', fee: 30, status: '已签收', recipient: '地球 · 香香' },
  { id: 8, no: 'HX-20260822-008', item: '复古留声机', icon: '📻', date: '08-22 09:15', fee: 88, status: '异常', recipient: '金星 · 金金' },
  { id: 9, no: 'HX-20260821-011', item: '太空种子包', icon: '🌱', date: '08-21 15:40', fee: 15, status: '已签收', recipient: '火星 · 农农' },
  { id: 10, no: 'HX-20260821-002', item: '星际蛋糕', icon: '🎂', date: '08-21 08:20', fee: 65, status: '派送中', recipient: '火星 · 寿星' }
];

const Badge11S: Badge11[] = [
  { id: 1, name: '快递新星', icon: '✨', locked: false, desc: '完成首单寄件', date: '2026.07.20' },
  { id: 2, name: '百单达人', icon: '📦', locked: false, desc: '累计寄件 100 单', date: '2026.08.10' },
  { id: 3, name: '准时狂魔', icon: '⏰', locked: false, desc: '连续 30 天准时收件', date: '2026.08.18' },
  { id: 4, name: '快递柜玩家', icon: '🗄️', locked: false, desc: '使用快递柜 50 次', date: '2026.08.24' },
  { id: 5, name: '银河包邮王', icon: '👑', locked: true, desc: '单月寄件消费满 ¥1000', date: '未解锁' },
  { id: 6, name: '极速传说', icon: '⚡', locked: true, desc: '使用极速专线 20 次', date: '未解锁' },
  { id: 7, name: '星级鉴定师', icon: '⭐', locked: true, desc: '评价快递员 100 次', date: '未解锁' },
  { id: 8, name: '积分大户', icon: '💰', locked: true, desc: '累计获得 10000 积分', date: '未解锁' }
];

const QUICK_ICONS: QuickIcon[] = [
  { id: 1, name: '寄快递', icon: '📮', color: '#FF8F00' },
  { id: 2, name: '查件', icon: '🔍', color: '#0288D1' },
  { id: 3, name: '取件码', icon: '🎫', color: '#43A047' },
  { id: 4, name: '驿站', icon: '🏪', color: '#7C4DFF' },
  { id: 5, name: '运费计算', icon: '🧮', color: '#FFB300' },
  { id: 6, name: '积分商城', icon: '🛍️', color: '#E91E63' },
  { id: 7, name: '预约取件', icon: '📅', color: '#00ACC1' },
  { id: 8, name: '客服', icon: '🎧', color: '#5C6BC0' }
];

function getTopPackages(): Package[] {
  let rows: Package[] = [];
  for (let i = 0; i < 6 && i < PACKAGES.length; i++) {
    rows.push(PACKAGES[i]);
  }
  return rows;
}

function getPkgRows(): Package[] {
  let rows: Package[] = [];
  for (let i = 0; i < PACKAGES.length; i += 2) {
    rows.push(PACKAGES[i]);
  }
  return rows;
}

function getPkgRows2(): Package[] {
  let rows: Package[] = [];
  for (let i = 1; i < PACKAGES.length; i += 2) {
    rows.push(PACKAGES[i]);
  }
  return rows;
}

function getStationRows(): Station[] {
  let rows: Station[] = [];
  for (let i = 0; i < STATIONS.length; i += 2) {
    rows.push(STATIONS[i]);
  }
  return rows;
}

function getStationRows2(): Station[] {
  let rows: Station[] = [];
  for (let i = 1; i < STATIONS.length; i += 2) {
    rows.push(STATIONS[i]);
  }
  return rows;
}

function getCourierRows(): Courier[] {
  let rows: Courier[] = [];
  for (let i = 0; i < COURIERS.length; i += 2) {
    rows.push(COURIERS[i]);
  }
  return rows;
}

function getCourierRows2(): Courier[] {
  let rows: Courier[] = [];
  for (let i = 1; i < COURIERS.length; i += 2) {
    rows.push(COURIERS[i]);
  }
  return rows;
}

function getTrackRows(): TrackLog[] {
  let rows: TrackLog[] = [];
  for (let i = 0; i < TRACKS.length; i += 2) {
    rows.push(TRACKS[i]);
  }
  return rows;
}

function getTrackRows2(): TrackLog[] {
  let rows: TrackLog[] = [];
  for (let i = 1; i < TRACKS.length; i += 2) {
    rows.push(TRACKS[i]);
  }
  return rows;
}

function getRateRows(): RateRule[] {
  let rows: RateRule[] = [];
  for (let i = 0; i < RATES.length; i += 2) {
    rows.push(RATES[i]);
  }
  return rows;
}

function getRateRows2(): RateRule[] {
  let rows: RateRule[] = [];
  for (let i = 1; i < RATES.length; i += 2) {
    rows.push(RATES[i]);
  }
  return rows;
}

function getPointRows(): PointGoods[] {
  let rows: PointGoods[] = [];
  for (let i = 0; i < POINT_GOODS.length; i += 2) {
    rows.push(POINT_GOODS[i]);
  }
  return rows;
}

function getPointRows2(): PointGoods[] {
  let rows: PointGoods[] = [];
  for (let i = 1; i < POINT_GOODS.length; i += 2) {
    rows.push(POINT_GOODS[i]);
  }
  return rows;
}

function getBoxRows(): BoxCell[] {
  let rows: BoxCell[] = [];
  for (let i = 0; i < BOXES.length; i += 2) {
    rows.push(BOXES[i]);
  }
  return rows;
}

function getBoxRows2(): BoxCell[] {
  let rows: BoxCell[] = [];
  for (let i = 1; i < BOXES.length; i += 2) {
    rows.push(BOXES[i]);
  }
  return rows;
}

function getOrderRows(): Order[] {
  let rows: Order[] = [];
  for (let i = 0; i < ORDERS.length; i += 2) {
    rows.push(ORDERS[i]);
  }
  return rows;
}

function getOrderRows2(): Order[] {
  let rows: Order[] = [];
  for (let i = 1; i < ORDERS.length; i += 2) {
    rows.push(ORDERS[i]);
  }
  return rows;
}

function getBadge11Rows(): Badge11[] {
  let rows: Badge11[] = [];
  for (let i = 0; i < Badge11S.length; i += 2) {
    rows.push(Badge11S[i]);
  }
  return rows;
}

function getBadge11Rows2(): Badge11[] {
  let rows: Badge11[] = [];
  for (let i = 1; i < Badge11S.length; i += 2) {
    rows.push(Badge11S[i]);
  }
  return rows;
}

function getQuickRows(): QuickIcon[] {
  let rows: QuickIcon[] = [];
  for (let i = 0; i < 4; i++) {
    rows.push(QUICK_ICONS[i]);
  }
  return rows;
}

function getQuickRows2(): QuickIcon[] {
  let rows: QuickIcon[] = [];
  for (let i = 4; i < QUICK_ICONS.length; i++) {
    rows.push(QUICK_ICONS[i]);
  }
  return rows;
}

function getBarHeight(val: number, max: number): string {
  let p = max > 0 ? (val / max) * 80 : 0;
  return p.toFixed(0) + 'vp';
}

function getPercent(val: number, total: number): string {
  let p = total > 0 ? (val / total) * 100 : 0;
  return p.toFixed(0) + '%';
}

function getStatusColor(s: string): string {
  if (s === '运输中') {
    return '#0288D1';
  } else if (s === '待取件') {
    return '#FF8F00';
  } else if (s === '派送中') {
    return '#43A047';
  } else if (s === '异常') {
    return '#E53935';
  }
  return '#90A4AE';
}

function getBusyColor(b: string): string {
  if (b === '繁忙') {
    return '#E53935';
  } else if (b === '较忙') {
    return '#FF8F00';
  }
  return '#43A047';
}

function getAbnormalPkgs(): Package[] {
  let rows: Package[] = [];
  for (let i = 0; i < PACKAGES.length; i++) {
    if (PACKAGES[i].status === '异常') {
      rows.push(PACKAGES[i]);
    }
  }
  return rows;
}

function getOnRoadCount(): number {
  let n = 0;
  for (let i = 0; i < PACKAGES.length; i++) {
    if (PACKAGES[i].status === '运输中' || PACKAGES[i].status === '派送中') {
      n = n + 1;
    }
  }
  return n;
}

function getFastCount(): number {
  let n = 0;
  for (let i = 0; i < RATES.length; i++) {
    if (RATES[i].days === 0 || RATES[i].days === 1) {
      n = n + 1;
    }
  }
  return n;
}

function getFreeBoxCount(): number {
  let n = 0;
  for (let i = 0; i < BOXES.length; i++) {
    if (BOXES[i].free) {
      n = n + 1;
    }
  }
  return n;
}

function getOnlineCount(): number {
  let n = 0;
  for (let i = 0; i < COURIERS.length; i++) {
    if (COURIERS[i].online) {
      n = n + 1;
    }
  }
  return n;
}

function getCanBuyCount(points: number): number {
  let n = 0;
  for (let i = 0; i < POINT_GOODS.length; i++) {
    if (POINT_GOODS[i].points <= points) {
      n = n + 1;
    }
  }
  return n;
}

function getUnlockedCount(): number {
  let n = 0;
  for (let i = 0; i < Badge11S.length; i++) {
    if (!Badge11S[i].locked) {
      n = n + 1;
    }
  }
  return n;
}
@Entry
@Component
struct Index {
  @State tabIndex1: number = 0;
  @State showToast: boolean = false;
  @State toast: string = '';
  @State points: number = 3860;
  @State memberLevel: number = 2;
  // 弹框开关
  @State showPkg: boolean = false;
  @State showSend: boolean = false;
  @State showStation: boolean = false;
  @State showPickup: boolean = false;
  @State showScan: boolean = false;
  @State showRate: boolean = false;
  @State showPoint: boolean = false;
  @State showBox: boolean = false;
  @State showAbnormal: boolean = false;
  @State showComplaint: boolean = false;
  @State showCourier: boolean = false;
  @State showChangeAddr: boolean = false;
  @State showAppoint: boolean = false;
  @State showUrgent: boolean = false;
  @State showPointDetail: boolean = false;
  @State showVip: boolean = false;
  // 选中对象
  @State selPkg: Package | null = null;
  @State selSend: RateRule | null = null;
  @State selStation: Station | null = null;
  @State selPickup: Package | null = null;
  @State selScan: Package | null = null;
  @State selRate: RateRule | null = null;
  @State selPoint: PointGoods | null = null;
  @State selBox: BoxCell | null = null;
  @State selAbn: Package | null = null;
  @State selComplaint: Order | null = null;
  @State selCourier: Courier | null = null;
  @State selAddr: Package | null = null;
  @State selUrgent: Order | null = null;
  // 表单字段
  @State fWeight: number = 5;
  @State fInsure: boolean = false;
  @State fTypeIdx: number = 0;
  @State fDayIdx: number = 0;
  @State fTimeIdx: number = 0;
  @State fQty: number = 1;
  @State fMsg: string = '';
  @State fAddr: string = '';
  @State fScore: number = 5;
  @State fStars: number = 5;

  private tabs: string[] = ['包裹', '寄件', '驿站', '追踪', '积分', '我的'];
  private tabIcons: string[] = ['📦', '📮', '🏪', '🔍', '🛍️', '👤'];

  showTip(msg: string): void {
    this.toast = msg;
    this.showToast = true;
    setTimeout(() => {
      this.showToast = false;
    }, 1800);
  }

  build() {
    Stack() {
      Column() {
        // ========== 主内容区 ==========
        if (this.tabIndex1 === 0) {
          PkgTab({
            onPkg: (p: Package) => {
              this.selPkg = p;
              this.showPkg = true;
            },
            onPickup: (p: Package) => {
              this.selPickup = p;
              this.showPickup = true;
            },
            onScan: (p: Package) => {
              this.selScan = p;
              this.showScan = true;
            },
            onAbnormal: (p: Package) => {
              this.selAbn = p;
              this.showAbnormal = true;
            },
            onSend: () => {
              this.selSend = RATES[1];
              this.fWeight = 5;
              this.showSend = true;
            }
          })
        } else if (this.tabIndex1 === 1) {
          SendTab({
            onSend: (r: RateRule) => {
              this.selSend = r;
              this.fWeight = 5;
              this.showSend = true;
            },
            onRate: (r: RateRule) => {
              this.selRate = r;
              this.fWeight = 5;
              this.showRate = true;
            },
            onAppoint: () => {
              this.fDayIdx = 0;
              this.fTimeIdx = 0;
              this.showAppoint = true;
            }
          })
        } else if (this.tabIndex1 === 2) {
          StationTab({
            onStation: (s: Station) => {
              this.selStation = s;
              this.showStation = true;
            },
            onBox: (b: BoxCell) => {
              this.selBox = b;
              this.showBox = true;
            },
            onCourier: (c: Courier) => {
              this.selCourier = c;
              this.fStars = 5;
              this.showCourier = true;
            }
          })
        } else if (this.tabIndex1 === 3) {
          TrackTab({
            onPkg: (p: Package) => {
              this.selPkg = p;
              this.showPkg = true;
            },
            onAbnormal: (p: Package) => {
              this.selAbn = p;
              this.showAbnormal = true;
            }
          })
        } else if (this.tabIndex1 === 4) {
          PointTab({
            points: this.points,
            onPoint: (g: PointGoods) => {
              this.selPoint = g;
              this.fQty = 1;
              this.showPoint = true;
            },
            onDetail: () => {
              this.showPointDetail = true;
            }
          })
        } else {
          MineTab({
            points: this.points,
            memberLevel: this.memberLevel,
            onVip: () => {
              this.showVip = true;
            },
            onBadge11: () => {
              this.showPointDetail = true;
            },
            onOrder: (o: Order) => {
              this.selComplaint = o;
              this.showComplaint = true;
            },
            onUrgent: (o: Order) => {
              this.selUrgent = o;
              this.showUrgent = true;
            }
          })
        }
        // ========== 底部 Tab ==========
        Row() {
          ForEach(this.tabs, (label: string, idx: number) => {
            this.bottomTabItem(this.tabIcons[idx], label, idx)
          }, (label: string, idx: number) => label + idx)
        }
        .width('100%')
        .height(56)
        .backgroundColor('#FFFFFF')
        .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
      }
      .width('100%')
      .height('100%')

      // ========== 弹框挂载 ==========
      if (this.showPkg && this.selPkg !== null) {
        this.modalOverlay(() => { this.showPkg = false })
        this.pkgModal()
      }
      if (this.showSend && this.selSend !== null) {
        this.modalOverlay(() => { this.showSend = false })
        this.sendModal()
      }
      if (this.showStation && this.selStation !== null) {
        this.modalOverlay(() => { this.showStation = false })
        this.stationModal()
      }
      if (this.showPickup && this.selPickup !== null) {
        this.modalOverlay(() => { this.showPickup = false })
        this.pickupModal()
      }
      if (this.showScan && this.selScan !== null) {
        this.modalOverlay(() => { this.showScan = false })
        this.scanModal()
      }
      if (this.showRate && this.selRate !== null) {
        this.modalOverlay(() => { this.showRate = false })
        this.rateModal()
      }
      if (this.showPoint && this.selPoint !== null) {
        this.modalOverlay(() => { this.showPoint = false })
        this.pointModal()
      }
      if (this.showBox && this.selBox !== null) {
        this.modalOverlay(() => { this.showBox = false })
        this.boxModal()
      }
      if (this.showAbnormal && this.selAbn !== null) {
        this.modalOverlay(() => { this.showAbnormal = false })
        this.abnormalModal()
      }
      if (this.showComplaint && this.selComplaint !== null) {
        this.modalOverlay(() => { this.showComplaint = false })
        this.complaintModal()
      }
      if (this.showCourier && this.selCourier !== null) {
        this.modalOverlay(() => { this.showCourier = false })
        this.courierModal()
      }
      if (this.showChangeAddr && this.selAddr !== null) {
        this.modalOverlay(() => { this.showChangeAddr = false })
        this.changeAddrModal()
      }
      if (this.showAppoint) {
        this.modalOverlay(() => { this.showAppoint = false })
        this.appointModal()
      }
      if (this.showUrgent && this.selUrgent !== null) {
        this.modalOverlay(() => { this.showUrgent = false })
        this.urgentModal()
      }
      if (this.showPointDetail) {
        this.modalOverlay(() => { this.showPointDetail = false })
        this.pointDetailModal()
      }
      if (this.showVip) {
        this.modalOverlay(() => { this.showVip = false })
        this.vipModal()
      }
      if (this.showToast) {
        this.toastBox()
      }
    }
    .width('100%')
    .height('100%')
  }
  // ========== 弹框① 包裹详情(居中卡 蓝白) ==========
  @Builder
  pkgModal() {
    Column() {
      Row() {
        Text(this.selPkg!.icon).fontSize(40)
        Column({ space: 4 }) {
          Text(this.selPkg!.name).fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
          Text('单号 ' + this.selPkg!.no).fontSize(12).fontColor('#90A4AE')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
        Row() {
          Text(this.selPkg!.status)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .backgroundColor(this.selPkg!.statusColor)
            .borderRadius(12)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        }
      }
      .width('100%')

      Row() {
        Column({ space: 2 }) {
          Text('寄件').fontSize(11).fontColor('#90A4AE')
          Text(this.selPkg!.from).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)
        Text('🚀').fontSize(20)
        Column({ space: 2 }) {
          Text('收件').fontSize(11).fontColor('#90A4AE')
          Text(this.selPkg!.to).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)
      }
      .width('100%')
      .backgroundColor('#F1F8FF')
      .borderRadius(14)
      .padding({ top: 12, bottom: 12 })
      .margin({ top: 14 })

      Column({ space: 10 }) {
        this.infoLine('🧾 类型', this.selPkg!.type)
        this.infoLine('🕐 时间', this.selPkg!.time)
        this.infoLine('📝 备注', this.selPkg!.desc)
      }
      .width('100%')
      .margin({ top: 12 })

      Row({ space: 10 }) {
        Button() {
          Text('追踪包裹').fontSize(14).fontColor('#FFFFFF')
        }
        .backgroundColor('#FF8F00')
        .borderRadius(20)
        .height(40)
        .layoutWeight(1)
        .onClick(() => {
          this.showPkg = false;
          this.tabIndex1 = 3;
        })
        Button() {
          Text('改地址').fontSize(14).fontColor('#0288D1')
        }
        .backgroundColor('#E1F0FF')
        .borderRadius(20)
        .height(40)
        .layoutWeight(1)
        .onClick(() => {
          this.selAddr = this.selPkg;
          this.showPkg = false;
          this.showChangeAddr = true;
        })
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('84%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .padding(20)
    .constraintSize({ maxHeight: '82%' })
    .clip(true)
  }

  // ========== 弹框② 寄件表单(底部抽屉) ==========
  @Builder
  sendModal() {
    Column() {
      Row() {
        Text('寄快递').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
        Text('✕')
          .fontSize(16)
          .fontColor('#90A4AE')
          .padding(6)
          .onClick(() => { this.showSend = false })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      Row() {
        Text(this.selSend!.icon).fontSize(26)
        Column({ space: 3 }) {
          Text(this.selSend!.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
          Text(this.selSend!.desc + ' · ' + this.selSend!.days + ' 天达').fontSize(12).fontColor('#90A4AE')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })
        Text('已选').fontSize(11).fontColor('#FFFFFF').backgroundColor('#0288D1').borderRadius(10).padding({ left: 8, right: 8, top: 3, bottom: 3 })
      }
      .width('100%')
      .backgroundColor('#F1F8FF')
      .borderRadius(14)
      .padding(12)
      .margin({ top: 12 })

      Row({ space: 8 }) {
        ForEach(getRateRows(), (r: RateRule, i: number) => {
          Text(r.name)
            .fontSize(12)
            .fontColor(this.selSend!.id === r.id ? '#FFFFFF' : '#0B2F5C')
            .backgroundColor(this.selSend!.id === r.id ? '#0288D1' : '#F1F8FF')
            .borderRadius(14)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .onClick(() => { this.selSend = RATES[i * 2] })
        }, (r: RateRule) => r.id + 'a')
        ForEach(getRateRows2(), (r: RateRule, i: number) => {
          Text(r.name)
            .fontSize(12)
            .fontColor(this.selSend!.id === r.id ? '#FFFFFF' : '#0B2F5C')
            .backgroundColor(this.selSend!.id === r.id ? '#0288D1' : '#F1F8FF')
            .borderRadius(14)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .onClick(() => { this.selSend = RATES[i * 2 + 1] })
        }, (r: RateRule) => r.id + 'b')
      }
      .width('100%')
      .margin({ top: 10 })

      Row() {
        Text('重量').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
        Text('-')
          .fontSize(20)
          .fontColor('#0288D1')
          .width(32)
          .height(32)
          .textAlign(TextAlign.Center)
          .backgroundColor('#E1F0FF')
          .borderRadius(16)
          .onClick(() => {
            if (this.fWeight > 1) {
              this.fWeight = this.fWeight - 1;
            }
          })
        Text(this.fWeight + ' kg').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#0B2F5C').width(64).textAlign(TextAlign.Center)
        Text('+')
          .fontSize(20)
          .fontColor('#0288D1')
          .width(32)
          .height(32)
          .textAlign(TextAlign.Center)
          .backgroundColor('#E1F0FF')
          .borderRadius(16)
          .onClick(() => {
            this.fWeight = this.fWeight + 1;
          })
      }
      .width('100%')
      .margin({ top: 12 })

      Row() {
        Text('保价服务').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
        Text('最高赔付 ¥2000').fontSize(11).fontColor('#FF8F00')
        Toggle({ type: ToggleType.Switch, isOn: this.fInsure })
          .selectedColor('#FF8F00')
          .onChange((v: boolean) => {
            this.fInsure = v;
          })
      }
      .width('100%')
      .margin({ top: 10 })

      TextInput({ placeholder: '填写收件地址', text: this.fAddr })
        .fontSize(13)
        .height(40)
        .backgroundColor('#F5F8FC')
        .borderRadius(10)
        .margin({ top: 10 })
        .onChange((v: string) => {
          this.fAddr = v;
        })

      Row() {
        Text('预估运费').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
        Text('¥ ' + (this.selSend!.base + this.selSend!.perKg * this.fWeight))
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF8F00')
      }
      .width('100%')
      .margin({ top: 12 })

      Button() {
        Text('确认下单').fontSize(15).fontColor('#FFFFFF')
      }
      .width('100%')
      .height(44)
      .backgroundColor('#0288D1')
      .borderRadius(22)
      .margin({ top: 14 })
      .onClick(() => {
        this.showSend = false;
        this.showTip('寄件下单成功');
      })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 22, topRight: 22 })
    .padding(20)
    .padding({ bottom: 30 })
    .constraintSize({ maxHeight: '84%' })
  }

  // ========== 弹框③ 驿站详情(上下拼接卡) ==========
  @Builder
  stationModal() {
    Column() {
      Row() {
        Text(this.selStation!.icon).fontSize(38)
        Column({ space: 4 }) {
          Text(this.selStation!.name).fontSize(19).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Row({ space: 6 }) {
            Text('距您 ' + this.selStation!.distance).fontSize(12).fontColor('#B3E5FC')
            Text(this.selStation!.busy)
              .fontSize(11)
              .fontColor('#0B2F5C')
              .backgroundColor('#FFD54F')
              .borderRadius(9)
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
      }
      .width('100%')
      .backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
      .padding(18)

      Column({ space: 10 }) {
        Row() {
          Text('🏠').fontSize(16)
          Text(this.selStation!.addr).fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
        }
        .width('100%')
        Row() {
          Text('🕐').fontSize(16)
          Text('营业时间  ' + this.selStation!.open).fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
        }
        .width('100%')
        Row() {
          Text('🗄️').fontSize(16)
          Text('空闲格口  ' + this.selStation!.boxes + ' 个').fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
        }
        .width('100%')
        Row() {
          Text('🧑').fontSize(16)
          Text('驻站快递员  ' + this.selStation!.courier).fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
        }
        .width('100%')

        Row({ space: 8 }) {
          this.serveItem('📮', '寄件')
          this.serveItem('📦', '取件')
          this.serveItem('🛅', '暂存')
          this.serveItem('🖨️', '打印')
        }
        .width('100%')
        .margin({ top: 6 })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding(16)

      Row({ space: 10 }) {
        Button() {
          Text('导航前往').fontSize(14).fontColor('#FFFFFF')
        }
        .backgroundColor('#FF8F00')
        .borderRadius(20)
        .height(40)
        .layoutWeight(1)
        .onClick(() => {
          this.showStation = false;
          this.showTip('已发起星际导航');
        })
        Button() {
          Text('知道了').fontSize(14).fontColor('#0288D1')
        }
        .backgroundColor('#E1F0FF')
        .borderRadius(20)
        .height(40)
        .layoutWeight(1)
        .onClick(() => { this.showStation = false })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ left: 16, right: 16, bottom: 18 })
    }
    .width('84%')
    .borderRadius(20)
    .clip(true)
    .constraintSize({ maxHeight: '82%' })
  }

  // ========== 弹框④ 取件确认(票根卡) ==========
  @Builder
  pickupModal() {
    Column() {
      Text('取件码').fontSize(12).fontColor('#0288D1').margin({ top: 18 })
      Text(this.selPickup!.no.slice(-4))
        .fontSize(44)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0B2F5C')
        .letterSpacing(10)
        .margin({ top: 6 })
        .scale({ x: 1.05, y: 1.05 })
        .animation({ duration: 500, curve: Curve.EaseOut })

      Row() {
        Text('请至驿站快递柜输入取件码')
          .fontSize(12)
          .fontColor('#90A4AE')
          .textAlign(TextAlign.Center)
          .width('70%')
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 4 })

      Divider()
        .strokeWidth(1)
        .color('#B3E5FC')
        .margin({ top: 14, bottom: 14 })

      Row() {
        Text(this.selPickup!.icon).fontSize(28)
        Column({ space: 3 }) {
          Text(this.selPickup!.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
          Text('地球总仓驿站 · ' + this.selPickup!.time).fontSize(12).fontColor('#90A4AE')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
        Text(this.selPickup!.status)
          .fontSize(11)
          .fontColor('#FFFFFF')
          .backgroundColor('#FF8F00')
          .borderRadius(10)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      }
      .width('100%')

      Button() {
        Text('确认取件').fontSize(15).fontColor('#FFFFFF')
      }
      .width('100%')
      .height(44)
      .backgroundColor('#0288D1')
      .borderRadius(22)
      .margin({ top: 18 })
      .onClick(() => {
        this.showPickup = false;
        this.showTip('取件成功,柜门已开');
      })
    }
    .width('84%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .padding({ left: 20, right: 20, bottom: 20 })
    .constraintSize({ maxHeight: '80%' })
    .clip(true)
  }

  // ========== 弹框⑤ 扫码取件(深色卡 + 扫描特效) ==========
  @Builder
  scanModal() {
    Column() {
      Text('扫码取件').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('请将包裹条形码对准取件柜扫描口').fontSize(12).fontColor('#B3E5FC').margin({ top: 6 })

      Stack() {
        Column() {
          Text('▚▞').fontSize(54).fontColor('#FF8F00').opacity(0.9)
        }
        .width(160)
        .height(120)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#1AFFFFFF')
        .borderRadius(12)
        Row() {
          Text('—').fontSize(24).fontColor('#FF8F00')
        }
        .width('60%')
        .justifyContent(FlexAlign.Center)
        .translate({ y: 30 })
        .animation({ duration: 900, curve: Curve.EaseInOut })
      }
      .width(200)
      .height(140)
      .margin({ top: 20 })
      .scale({ x: 1, y: 1 })
      .animation({ duration: 600, curve: Curve.EaseOut })

      Row() {
        Text(this.selScan!.icon).fontSize(22)
        Column({ space: 2 }) {
          Text(this.selScan!.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text(this.selScan!.no).fontSize(11).fontColor('#90A4AE')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })
      }
      .width('100%')
      .backgroundColor('#1AFFFFFF')
      .borderRadius(12)
      .padding(12)
      .margin({ top: 16 })

      Row({ space: 10 }) {
        Button() {
          Text('模拟扫描').fontSize(14).fontColor('#FFFFFF')
        }
        .backgroundColor('#FF8F00')
        .borderRadius(20)
        .height(40)
        .layoutWeight(1)
        .onClick(() => {
          this.showScan = false;
          this.showTip('扫描成功,柜门已开启');
        })
        Button() {
          Text('关闭').fontSize(14).fontColor('#FFFFFF')
        }
        .backgroundColor('#455A64')
        .borderRadius(20)
        .height(40)
        .layoutWeight(1)
        .onClick(() => { this.showScan = false })
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('86%')
    .backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#01579B", 1]] })')
    .borderRadius(20)
    .padding(20)
    .constraintSize({ maxHeight: '82%' })
    .clip(true)
  }

  // ========== 弹框⑥ 运费计算器(居中卡) ==========
  @Builder
  rateModal() {
    Column() {
      Text('运费计算器').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
      Text('根据线路与重量预估运费').fontSize(12).fontColor('#90A4AE').margin({ top: 4 })

      Row({ space: 8 }) {
        ForEach(getRateRows(), (r: RateRule, i: number) => {
          Column({ space: 2 }) {
            Text(r.icon).fontSize(18)
            Text(r.name).fontSize(11).fontColor(this.selRate!.id === r.id ? '#0288D1' : '#90A4AE')
          }
          .width(56)
          .height(60)
          .justifyContent(FlexAlign.Center)
          .backgroundColor(this.selRate!.id === r.id ? '#E1F0FF' : '#F5F8FC')
          .borderRadius(12)
          .onClick(() => { this.selRate = RATES[i * 2] })
        }, (r: RateRule) => r.id + 'c')
      }
      .width('100%')
      .margin({ top: 14 })

    }
    .width('100%')
    .height(48)
    .onClick(() => {
      this.onOrder(o);
    })
  }
}


在工程细节层面,代码展现了若干值得注意的实践。ForEach 普遍用业务 id 加后缀作键,保证 diff 稳定;@Builder 大量参数化复用(如 modalOverlay(onClose)infoLine(label, value)),减少重复;条件渲染 if/else 在列表项内根据状态分流展示;constraintSize({ maxHeight }) 限制弹窗最大高度防止内容过长溢出;.clip(true) 配合圆角裁剪渐变背景。这些细节共同构成一个生产级的 UI 工程范本。

当然,代码也有一些可优化之处。例如用 Text 模拟按钮与输入框、onClick 嵌套冒泡、if/else if 手动分发 Tab 无缓存、状态颜色与数据冗余等,在真实工程中可分别用 Button/TextInputstopPropagationTabs 容器、函数推导等方式改进。但作为声明式 UI 的教学与范例代码,它已经把 ArkTS 的核心能力——类型契约、状态驱动、组件拆分、复用 Builder、列表渲染、层叠布局、动画反馈——完整地串联在一处,是一份极具学习价值的鸿蒙开发参考。

数据建模 interface

Mock 常量数组

工具函数拆分/推导

主组件 @State 状态中枢

Stack 层叠: 主内容+弹窗+Toast

if 分发 6 个 Tab 子组件

子组件回调上报

父更新 showXxx/selXxx

16 个 @Builder 弹窗渲染

响应式视图刷新

在这里插入图片描述

掌握这份代码的精髓,等于掌握了鸿蒙声明式 UI 的半壁江山:从数据到状态、从布局到动画、从组件到弹窗,每一步都有清晰的范式可循。无论是初学者系统入门,还是有经验的开发者查漏补缺,都能从中获得可直接复用的工程模式与设计思路。

Logo

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

更多推荐