技术引言

HarmonyOS 6.1.1 作为华为全场景分布式操作系统的最新迭代版本,带来了全新的 ArkTS 语言框架和 API 24 级别的系统能力升级。ArkTS 是在 TypeScript 基础上扩展的声明式编程语言,专为鸿蒙生态的 UI 声明式开发范式量身打造,它融合了 TypeScript 的类型安全特性和声明式 UI 的简洁表达力,让开发者能够以更少的代码量构建出更加复杂、流畅且可维护的移动应用界面。在 HarmonyOS ArkTS API 24 中,系统提供了丰富的组件化能力,包括 @Entry、@Component、@Builder、@State、@Observed 等核心装饰器,以及 Stack、Column、Row、Scroll、Flex 等布局容器组件,支持线性渐变、粒子动画、条件渲染、列表渲染等高级视觉特性的声明式表达。本文以一个完整的地铁公交出行助手应用为载体,深入剖析基于HarmonyOS API 24的 ArkTS 声明式 UI 开发全流程,涵盖数据建模、状态管理、多视图路由、弹窗交互、粒子特效、图表可视化等核心技术场景,为鸿蒙原生应用开发者提供一份从数据层到视图层的完整工程实践参考。


一、色彩体系与设计令牌定义

interface ColorPalette {
  techBlue: string;
  techBlueDeep: string;
  cyan: string;
  cyanLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  white: string;
  border: string;
  danger: string;
  success: string;
  orange: string;
}

const COLORS: ColorPalette = {
  techBlue: '#1E6FFF',
  techBlueDeep: '#0B3B9E',
  cyan: '#00E5CC',
  cyanLight: '#D6F8F4',
  bg: '#F2F6FF',
  cardBg: '#FFFFFF',
  textPrimary: '#15243F',
  textSecondary: '#5A6B8C',
  textHint: '#A8B6D1',
  white: '#FFFFFF',
  border: '#E1EAFB',
  danger: '#F44336',
  success: '#00C48C',
  orange: '#FF9F43'
};

在这里插入图片描述

本段代码是整个应用的视觉基石——色彩设计系统。在 HarmonyOS 6.1.1 的 ArkTS 开发体系中,良好的色彩管理是构建一致性视觉体验的第一步。这里通过定义 ColorPalette 接口,将所有颜色以结构化、类型安全的方式组织起来,实现了"设计令牌"(Design Token)的设计理念。

接口设计层面ColorPalette 接口继承了 ArkTS 对 TypeScript 接口语法的完整支持。接口中定义了 14 个字符串类型的颜色属性,分别对应主色(techBlue)、深色(techBlueDeep)、荧光青(cyan)、浅青(cyanLight)、背景色(bg)、卡片背景(cardBg)、主要文本色(textPrimary)、次要文本色(textSecondary)、提示文本色(textHint)、白色(white)、边框色(border)、危险色(danger)、成功色(success)和橙色(orange)。

色彩策略层面,这套配色方案采用了"科技蓝 + 荧光青"的双主色调策略。techBlue(#1E6FFF)作为品牌主色,用于导航选中态、按钮、强调文本等场景;techBlueDeep(#0B3B9E)作为渐变深色端,用于头部背景的渐变起点;cyan(#00E5CC)作为辅助强调色,用于余额数字、骑行主题色等需要视觉跳跃的元素。

类型安全层面,通过 const COLORS: ColorPalette 的声明方式,TypeScript 编译器会在编译期检查 COLORS 对象是否完整实现了接口中定义的所有属性。任何遗漏或拼写错误都会在编译阶段被捕获,这大大降低了运行时因颜色未定义而导致的渲染异常风险。


二、底部与顶部导航标签数据模型

interface TabItem {
  label: string;
  icon: string;
}

const BOTTOM_TABS: TabItem[] = [
  { label: '出行', icon: '🚇' },
  { label: '钱包', icon: '💳' },
  { label: '行程', icon: '🧭' },
  { label: '我的', icon: '👤' }
];

const TOP_TABS: TabItem[] = [
  { label: '地铁', icon: '🚇' },
  { label: '公交', icon: '🚌' },
  { label: '骑行', icon: '🚲' },
  { label: '打车', icon: '🚕' },
  { label: '火车票', icon: '🚄' }
];

在这里插入图片描述

这段代码定义了应用的双层导航标签体系。在 HarmonyOS ArkTS API 24 中,导航栏的数据驱动模式是一种被广泛推荐的最佳实践——将 UI 数据与 UI 结构分离,通过数据数组驱动 ForEach 渲染来实现动态列表。

接口设计分析TabItem 接口极其简洁,仅包含 label(标签文字)和 icon(图标字符)两个字符串属性。这种极简设计使得 Tab 项的数据模型高度内聚,易于扩展。如果未来需要在 Tab 项中添加徽章数字、红点提示等附加信息,只需在接口中追加属性即可,不会影响现有代码结构。

底部导航策略BOTTOM_TABS 数组定义了四个主功能入口:出行、钱包、行程、我的。这四个 Tab 覆盖了出行类应用的四大核心场景:出行场景提供乘车码和实时信息;钱包场景管理余额和消费;行程场景展示历史记录;我的场景提供个人设置和功能入口。

顶部导航策略TOP_TABS 数组定义了五种出行方式:地铁、公交、骑行、打车、火车票。这五种出行方式作为"出行"底部 Tab 下的子分类,通过顶部分段控制器进行切换。值得注意的是,这里使用了 Emoji 字符作为图标,这是 ArkTS Text 组件原生支持 Unicode 字符渲染的体现。

数据驱动渲染优势,通过将标签数据提取为常量数组,后续在 @Builder 方法中只需使用 ForEach(BOTTOM_TABS, ...) 即可完成所有 Tab 项的渲染。当需要增加或修改 Tab 时,只需修改数组数据,无需改动渲染逻辑,实现了数据与视图的彻底解耦。


三、出行方式常量与周数据定义

const WEEK_DAYS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const TRIP_COUNTS: number[] = [2, 4, 2, 4, 4, 1, 3];
const TAXI_ICONS: string[] = ['🚗', '🚙', '🚘', '🚐'];

这段代码定义了三个辅助常量数组,分别服务于周出行统计图表、骑行数据可视化和打车车型展示三个不同的业务场景。虽然代码简短,但每个数组都在各自的 UI 模块中承担着重要的数据驱动角色。

周数据数组分析WEEK_DAYS 是一个包含七个中文星期标签的字符串数组,用于骑行统计模块中柱状图的 X 轴标签。TRIP_COUNTS 则对应每天的使用次数,数组中的每个数值代表该日的出行趟次。这两个数组通过索引一一对应,在 ForEach 渲染时通过 idx 参数进行关联访问,实现了"标签 + 数值"的成对展示。

柱状图数据映射TRIP_COUNTS 中的数值会在后续的 tripBarHeight 辅助函数中被转换为柱状图的高度值。例如值为 4 时对应高度 88vp,值为 1 时对应高度 22vp。这种将业务数据转换为视觉维度的做法,是声明式 UI 中数据驱动可视化的典型模式。

打车车型图标分析TAXI_ICONS 数组包含四个不同的车辆 Emoji 字符,分别对应经济、舒适、专车、拼车四种打车类型。在 ArkTS 中,Emoji 字符可以直接作为 Text 组件的内容进行渲染,系统会根据平台字体回退策略选择合适的 Emoji 字体进行显示。

常量定义最佳实践,将这类固定不变的数据定义为模块级 const 常量,而非组件内部的状态变量,有两个好处:一是避免在组件每次重渲染时重新创建数组对象,减少内存分配开销;二是语义上明确了这些数据是"配置数据"而非"运行时状态",提高了代码可读性。


四、行程记录数据模型与可观察类

@Observed
class TripRecord {
  id: number;
  mode: string;
  modeIcon: string;
  line: string;
  fromStation: string;
  toStation: string;
  fare: number;
  date: string;
  time: string;
  distance: string;
  status: string;

  constructor(id: number, mode: string, modeIcon: string, line: string, fromStation: string,
    toStation: string, fare: number, date: string, time: string, distance: string, status: string) {
    this.id = id;
    this.mode = mode;
    this.modeIcon = modeIcon;
    this.line = line;
    this.fromStation = fromStation;
    this.toStation = toStation;
    this.fare = fare;
    this.date = date;
    this.time = time;
    this.distance = distance;
    this.status = status;
  }
}

在这里插入图片描述

这段代码定义了应用中最核心的业务数据模型——行程记录类 TripRecord。在 HarmonyOS 6.1.1 ArkTS API 24 中,@Observed 装饰器是实现数据驱动 UI 响应式更新的关键机制之一。

@Observed 装饰器原理@Observed 用于修饰类,使该类的实例对象成为"可观察对象"。当一个被 @Observed 修饰的类的属性发生变化时,ArkUI 框架会自动检测到变化并触发依赖该数据的 UI 组件进行重新渲染。这是 ArkTS 响应式编程模型的核心:数据变更自动驱动视图更新,开发者无需手动调用 setStatenotify 等方法。

类属性设计分析TripRecord 类定义了 11 个属性,涵盖了出行记录的所有关键维度:id 用于唯一标识和 ForEach 的 key 生成;modemodeIcon 标识出行方式和对应图标;line 记录线路信息;fromStationtoStation 记录起讫站点;fare 记录费用金额;datetime 记录出行日期和时间;distance 记录出行距离;status 记录订单状态。

构造函数设计,类的构造函数接收全部 11 个参数并逐一赋值给实例属性。这种"全参数构造函数"的设计模式在数据模型类中非常常见,它确保了每个实例在创建时就拥有完整的属性值,避免了后续因属性未初始化而导致的 undefined 访问风险。

类型安全优势,通过为每个属性声明明确的类型(number、string),TypeScript 编译器能够在编译期检查传入参数的类型正确性。例如 fare 被声明为 number 类型,如果传入字符串类型的数据,编译器会立即报错,有效防止了类型相关的运行时异常。


五、行程数据构建函数

function buildTrips(): TripRecord[] {
  return [
    new TripRecord(1, '地铁', '🚇', '2号线', '科技园站', '春熙路站', 4, '08-24', '08:12', '11.2km', '已完成'),
    new TripRecord(2, '公交', '🚌', 'K1环线', '天府广场', '锦里站', 2, '08-23', '19:05', '3.4km', '已完成'),
    new TripRecord(3, '骑行', '🚲', '小蓝单车', '公司北门', '地铁站B口', 1.5, '08-23', '18:20', '1.2km', '已完成'),
    new TripRecord(4, '地铁', '🚇', '7号线', '火车南站', '槐树店站', 5, '08-22', '09:31', '15.8km', '已完成'),
    new TripRecord(5, '打车', '🚕', '快车·白色特斯拉', '太古里', '环球中心', 32.6, '08-21', '22:47', '12.6km', '已支付'),
    new TripRecord(6, '火车', '🚄', 'G8502次 05车12F', '成都东', '重庆北', 154, '08-20', '14:00', '302km', '已出行'),
    new TripRecord(7, '地铁', '🚇', '1号线', '金融城站', '骡马市站', 4, '08-20', '08:02', '9.6km', '已完成'),
    new TripRecord(8, '骑行', '🚲', '小蓝单车', '小区东门', '菜市场', 1.5, '08-19', '10:15', '0.8km', '已完成'),
    new TripRecord(9, '公交', '🚌', '58路', '武侯祠', '宽窄巷子', 2, '08-18', '15:40', '2.9km', '已完成'),
    new TripRecord(10, '地铁', '🚇', '3号线', '军区总医院站', '太平园站', 4, '08-18', '08:25', '10.4km', '已完成'),
    new TripRecord(11, '打车', '🚕', '专车·黑色帕萨特', '机场T2', '公司', 68.4, '08-17', '07:10', '24.3km', '已支付'),
    new TripRecord(12, '地铁', '🚇', '18号线', '世纪城站', '海昌路站', 6, '08-16', '12:33', '18.7km', '已完成'),
    new TripRecord(13, '骑行', '🚲', '小蓝单车', '地铁站C口', '健身房', 1.5, '08-15', '19:50', '1.5km', '已完成'),
    new TripRecord(14, '公交', '🚌', '82路', '动物园站', '昭觉寺', 2, '08-14', '11:20', '4.1km', '已完成'),
    new TripRecord(15, '地铁', '🚇', '5号线', '大源站', '高升桥站', 5, '08-13', '08:44', '13.9km', '已完成')
  ];
}

const TRIPS: TripRecord[] = buildTrips();

这段代码通过 buildTrips 工厂函数构建了 15 条完整的行程记录数据,并将其赋值给模块级常量 TRIPS。这是整个应用的"数据仓库",几乎所有与行程相关的 UI 模块都依赖这份数据进行渲染。

工厂函数模式分析,使用独立函数 buildTrips() 来构建数据数组,而非直接内联声明数组字面量,是一种良好的工程实践。这种模式将"数据构建逻辑"与"数据声明"分离,当数据来源从静态 mock 切换为网络请求或数据库查询时,只需修改函数内部的实现逻辑,而调用端代码 const TRIPS = buildTrips() 无需任何变更。

数据多样性分析,15 条记录覆盖了全部五种出行方式:地铁(8条)、公交(3条)、骑行(3条)、打车(2条)、火车(1条)。这种数据分布反映了真实的城市出行场景——地铁作为通勤主力占比最高,公交和骑行作为短途补充,打车和火车票作为低频但高客单价的出行方式。

数据字段丰富度,每条记录都拥有完整的 11 个字段值,包括真实感强的站点名称(如"春熙路站"“天府广场”“太古里”)、合理的费用金额(地铁 4-6 元、公交 2 元、骑行 1.5 元、打车 32-68 元、火车 154 元)、真实的时间戳和距离数据。这种高保真的 mock 数据使得应用在演示阶段就能展现出接近生产环境的视觉效果。

模块级常量声明const TRIPS 作为模块级常量,在模块加载时即完成初始化。由于它不是组件的 @State 变量,不会触发响应式更新。这种设计对于纯展示性质的静态数据是完全合理的——如果行程数据需要动态增删,则应将其迁移到组件的 @State@Link 变量中。


六、公交到站与地铁站数据模型

interface BusArrive {
  id: number;
  line: string;
  destination: string;
  arriveMinutes: number;
  plate: string;
  crowded: string;
}

const BUS_ARRIVES: BusArrive[] = [
  { id: 1, line: 'K1环线', destination: '开往府南新区', arriveMinutes: 2, plate: '川A·D82K1', crowded: '拥挤' },
  { id: 2, line: '58路', destination: '开往百花中心站', arriveMinutes: 5, plate: '川A·33F58', crowded: '适中' },
  { id: 3, line: '82路', destination: '开往昭觉寺', arriveMinutes: 9, plate: '川A·T7Z82', crowded: '空闲' },
  { id: 4, line: '16路', destination: '开往火车北站', arriveMinutes: 13, plate: '川A·M0P16', crowded: '适中' },
  { id: 5, line: '99路', destination: '开往动物园', arriveMinutes: 18, plate: '川A·K5V99', crowded: '空闲' }
];

interface MetroStation {
  id: number;
  name: string;
  transfer: string;
  isCurrent: boolean;
}

const LINE2_STATIONS: MetroStation[] = [
  { id: 1, name: '犀浦', transfer: '', isCurrent: false },
  { id: 2, name: '茶店子客运站', transfer: '27号线', isCurrent: false },
  { id: 3, name: '蜀汉路东', transfer: '', isCurrent: false },
  { id: 4, name: '白果林', transfer: '', isCurrent: false },
  { id: 5, name: '中医大省医院', transfer: '4号线 5号线', isCurrent: false },
  { id: 6, name: '通惠门', transfer: '', isCurrent: false },
  { id: 7, name: '春熙路', transfer: '3号线', isCurrent: true },
  { id: 8, name: '东门大桥', transfer: '', isCurrent: false },
  { id: 9, name: '牛王庙', transfer: '', isCurrent: false },
  { id: 10, name: '牛市口', transfer: '', isCurrent: false }
];

这段代码定义了两个核心业务数据模型:公交实时到站信息 BusArrive 和地铁站线路信息 MetroStation。这两个模型分别服务于"公交到站提醒"和"地铁线路站点图"两个关键 UI 模块。

BusArrive 接口分析,该接口定义了公交到站信息的完整数据结构:id 用于 ForEach 渲染的唯一标识;line 是公交线路名称;destination 标识行驶方向;arriveMinutes 是预计到站分钟数,这是用户最关注的核心信息;plate 是车牌号,用于帮助用户在站台快速识别目标车辆;crowded 标识拥挤程度,分为"拥挤"“适中”"空闲"三档。

公交数据真实性,五条到站数据模拟了天府广场东站附近的多条公交线路,到站时间从 2 分钟到 18 分钟不等,覆盖了"即将到站"到"较远等待"的不同时间梯度。车牌号采用了"川A"开头的成都牌照格式,增强了数据的真实感。

MetroStation 接口分析,该接口定义了地铁站信息的四个属性:id 用于唯一标识;name 是站点名称;transfer 记录可换乘的线路信息,空字符串表示该站不可换乘;isCurrent 是布尔类型,标识用户当前所在站点。

线路站点数据设计LINE2_STATIONS 数组定义了成都地铁 2 号线的 10 个站点,其中第 7 站"春熙路"被标记为 isCurrent: true,表示用户当前位于此站。多个站点(茶店子客运站、中医大省医院、春熙路)拥有换乘信息,这些信息会在 UI 中以橙色文本展示,帮助用户规划换乘路线。

数据模型与 UI 的映射关系arriveMinutes 的值会直接影响 UI 中时间数字的颜色——小于等于 3 分钟显示为红色(danger),大于 3 分钟显示为科技蓝。crowded 的值通过 crowdedColor 函数映射为不同的颜色标识。isCurrent 的值决定站点在 UI 中的视觉表现,包括图标大小、文本粗细、颜色等。这些数据到视觉的映射逻辑体现了声明式 UI 的核心设计哲学。


七、粒子特效数据模型与生成函数

interface ParticleItem {
  id: number;
  x: number;
  y: number;
  size: number;
  opacity: number;
  icon: string;
}

const PARTICLE_ICONS: string[] = ['✨', '·', '○', '✦', '·'];

function buildParticles(): ParticleItem[] {
  const arr: ParticleItem[] = [];
  for (let i = 0; i < 16; i++) {
    arr.push({
      id: i,
      x: (i * 41) % 335 + 8,
      y: 120 + (i * 79) % 500,
      size: 5 + (i * 4) % 8,
      opacity: 0.15 + (i % 4) * 0.09,
      x: (i * 41) % 335 + 8,
    });
  }
  return arr;
}

在这里插入图片描述

这段代码定义了应用中"光点粒子"特效的数据模型和初始化逻辑。粒子特效是提升应用视觉品质和沉浸感的重要手段,在 HarmonyOS 6.1.1 ArkTS API 24 中,可以通过 @State 驱动的定时器更新结合 position 定位来实现粒子动画效果。

ParticleItem 接口分析,每个粒子拥有六个属性:id 用于 ForEach 的唯一键;xy 是粒子在屏幕上的坐标位置;size 是粒子的字体大小(因为粒子使用 Text 组件渲染 Emoji 字符);opacity 是粒子的透明度,范围 0-1;icon 是粒子的视觉符号,从 PARTICLE_ICONS 数组中循环选取。

粒子初始化算法buildParticles 函数通过 for 循环生成 16 个粒子。每个粒子的坐标、大小和透明度都通过基于索引 i 的数学运算来计算,确保每个粒子拥有不同的初始参数。具体而言,X 坐标使用 (i * 41) % 335 + 8 公式,将粒子分布在 8 到 343 的水平范围内;Y 坐标使用 120 + (i * 79) % 500 公式,将粒子分布在 120 到 620 的垂直范围内。

取模运算的作用,使用取模运算(%)来生成坐标值是一种常见的"伪随机"分布技术。虽然结果不是真正的随机数,但由于乘数(41、79)与除数(335、500)互质或近似互质,生成的序列能够产生足够分散的分布效果,满足视觉上"随机散布"的需求。

透明度梯度设计0.15 + (i % 4) * 0.09 公式将透明度分为四个等级:0.15、0.24、0.33、0.42,循环分配给 16 个粒子。这种分级设计确保了粒子在视觉上有明暗层次感,而非全部统一亮度,提升了粒子群的视觉丰富度。

图标多样性PARTICLE_ICONS 数组包含五种不同的 Unicode 符号,通过 i % PARTICLE_ICONS.length 取模循环选取。其中包含闪光符号(✨)、点(·)、圆圈(○)、星形(✦)等,使得粒子群在视觉上呈现出多样化的"光点"效果。


八、粒子漂移动画函数

function driftParticles(list: ParticleItem[]): ParticleItem[] {
  const next: ParticleItem[] = [];
  for (let i = 0; i < list.length; i++) {
    const p = list[i];
    const ny = p.y - 3;
    next.push({
      id: p.id,
      x: p.x + Math.sin(p.id + p.y / 60) * 1.2,
      y: ny < 110 ? 640 : ny,
      size: p.size,
      opacity: p.opacity,
      icon: p.icon
    });
  }
  return next;
}

这段代码定义了粒子漂移动画的核心算法函数 driftParticles。该函数接收当前帧的粒子数组,计算并返回下一帧的粒子数组,是实现粒子向上漂浮动画效果的关键。

动画原理分析,在 HarmonyOS ArkTS API 24 中,动画效果通常通过 setInterval 定时器驱动状态变量更新来实现。driftParticles 函数就是这个动画驱动的"帧计算"核心——每 110 毫秒被调用一次,将所有粒子的 Y 坐标减 3,模拟"向上漂浮"的运动效果。

纵向位移逻辑const ny = p.y - 3 将粒子的 Y 坐标减少 3 个像素,使粒子向上移动。当粒子的 Y 坐标小于 110(即飘出顶部边界)时,通过 ny < 110 ? 640 : ny 的三元运算将其重置到 Y=640 的底部位置,实现粒子的循环复用。这种"出界回绕"技术使得粒子动画可以无限循环,不会因为粒子飘出屏幕而逐渐减少。

横向波动效果p.x + Math.sin(p.id + p.y / 60) * 1.2 为粒子的 X 坐标添加了基于正弦函数的微小偏移。Math.sin 的输入是 p.id + p.y / 60,这个值随着粒子的 id 和 y 坐标变化而变化,使得不同粒子拥有不同的横向波动相位和频率。乘以 1.2 后的偏移量在 -1.2 到 +1.2 像素之间,为粒子的运动添加了微妙的"飘摆"效果,模拟了空气中光点随气流飘动的真实感。

不可变更新模式,函数没有直接修改输入数组 list 中的粒子对象,而是创建了一个全新的 next 数组并填充新的粒子对象。这种不可变更新模式是 ArkTS 响应式系统的核心要求——只有当 @State 变量被赋值为全新的引用时,框架才会检测到变化并触发 UI 重渲染。如果直接修改原数组中的对象属性,框架可能无法正确检测到变化。

性能考量,每次调用都会创建 16 个新的对象实例,这在现代移动设备上的性能开销是可以忽略的。但如果粒子数量增加到数百个,可能需要考虑对象池复用等技术来减少垃圾回收压力。


九、出行方式颜色映射与辅助函数

function modeColor(mode: string): string {
  if (mode === '地铁') {
    return COLORS.techBlue;
  }
  if (mode === '公交') {
    return COLORS.success;
  }
  if (mode === '骑行') {
    return COLORS.cyan;
  }
  if (mode === '打车') {
    return COLORS.orange;
  }
  return COLORS.techBlueDeep;
}

function tripBarHeight(v: number): string {
  return (v * 22).toFixed(0) + 'vp';
}

function crowdedColor(c: string): string {
  if (c === '拥挤') {
    return COLORS.danger;
  }
  if (c === '适中') {
    return COLORS.orange;
  }
  return COLORS.success;
}

在这里插入图片描述

这段代码定义了三个辅助函数,分别用于出行方式颜色映射、柱状图高度计算和拥挤度颜色映射。这些函数是"数据到视觉"转换逻辑的核心节点,体现了 ArkTS 中纯函数在 UI 渲染中的重要作用。

modeColor 函数分析,该函数接收出行方式字符串,返回对应的品牌颜色值。地铁对应科技蓝(techBlue)、公交对应成功绿(success)、骑行对应荧光青(cyan)、打车对应橙色(orange)、其他方式(如火车)对应深蓝(techBlueDeep)。这种"出行方式-颜色"的映射策略使得用户在浏览行程列表时能够通过颜色快速识别出行方式,提升了信息获取效率。

函数式设计优势modeColor 是一个无副作用的纯函数——给定相同的输入总是返回相同的输出,不依赖也不修改任何外部状态。这种纯函数在 ArkTS 的声明式 UI 中尤为重要,因为框架可能在一次渲染周期内多次调用此类函数,纯函数保证了多次调用不会产生不一致的结果。

tripBarHeight 函数分析,该函数将出行次数(数字)转换为柱状图高度(带单位的字符串)。例如,出行次数 4 对应高度 88vp,出行次数 1 对应高度 22vptoFixed(0) 确保返回的是整数像素值,+ 'vp' 将数字转换为 ArkTS 布局系统支持的虚拟像素单位字符串。

虚拟像素单位 vp,在 HarmonyOS 中,vp(virtual pixel)是密度无关的像素单位,1vp 在不同屏幕密度的设备上对应不同的物理像素数。使用 vp 而非 px 可以确保柱状图在不同设备上保持一致的视觉比例。

crowdedColor 函数分析,该函数将拥挤程度文本映射为颜色值:拥挤对应红色(danger)、适中对应橙色(orange)、空闲对应成功绿(success)。这种语义化的颜色编码使用户在浏览公交到站信息时,能够通过颜色直观感知拥挤状况,无需仔细阅读文字。


十、入口组件与状态变量声明

@Entry
struct Index {
  @State currentBottomTab: number = 0;
  @State currentTopTab: number = 0;
  @State showRechargeModal: boolean = false;
  @State showCommuteModal: boolean = false;
  @State showDeleteModal: boolean = false;
  @State showDetailModal: boolean = false;
  @State selectedTrip: TripRecord | null = null;
  @State particles: ParticleItem[] = buildParticles();
  @State rechargeAmount: number = 0;
  @State payChannel: number = 0;
  @State commuteLine: string = '2号线';
  @State commuteTime: string = '08:10';
  @State commuteRemind: number = 0;
  private timerId: number = -1;

在这里插入图片描述

这段代码定义了应用的入口组件 Index 及其全部状态变量。在 HarmonyOS 6.1.1 ArkTS API 24 中,@Entry@State 装饰器是构建应用根组件的核心机制。

@Entry 装饰器@Entry 标识该 struct 为应用的入口组件——即整个页面的根节点。一个 ArkTS 页面文件中只能有一个被 @Entry 修饰的 struct。被 @Entry 修饰的组件会被框架自动注册为页面根节点,拥有独立的生命周期管理(aboutToAppear、aboutToDisappear)和独立的渲染上下文。

@State 装饰器原理@State 修饰的变量是组件的内部状态变量。当这些变量的值发生变化时,ArkUI 框架会自动检测到变化,并触发引用了这些变量的 UI 组件进行重新渲染。这是 ArkTS 声明式 UI "数据驱动视图"理念的技术基石。

导航状态变量currentBottomTabcurrentTopTab 两个状态变量分别控制底部 Tab 和顶部 Tab 的选中索引。它们的初始值都是 0,即默认显示"出行"底部 Tab 下的"地铁"子页面。当用户点击 Tab 时,只需修改这两个变量的值,对应的 UI 区域就会自动切换内容。

弹窗状态变量showRechargeModalshowCommuteModalshowDeleteModalshowDetailModal 四个布尔变量分别控制四个弹窗的显示与隐藏。初始值全部为 false(隐藏),当对应操作触发时设为 true(显示)。这种"布尔开关驱动弹窗"的模式是 ArkTS 中管理弹窗可见性的标准做法。

选中行程状态selectedTrip 的类型是 TripRecord | null,即可以为行程对象或 null。当用户点击某条行程记录查看详情时,该变量被赋值为对应的 TripRecord 实例;弹窗关闭后可重置为 null。使用联合类型(| null)是 ArkTS 中处理"可选值"的类型安全方式。

表单状态变量rechargeAmountpayChannelcommuteLinecommuteTimecommuteRemind 分别用于充值弹窗和通勤设置弹窗中的表单交互状态,记录用户选择的充值金额索引、支付方式索引、通勤线路、出发时间和提前提醒索引。

粒子状态particles 数组作为状态变量,初始值通过调用 buildParticles() 函数生成。后续在定时器驱动下,每 110 毫秒被赋值为 driftParticles 函数返回的新数组,触发粒子位置的重渲染。

私有定时器标识timerId 使用 private 关键字声明,表示它是一个私有成员变量,不参与 UI 渲染。它用于保存 setInterval 返回的定时器 ID,以便在 aboutToDisappear 生命周期中清理定时器,防止内存泄漏。


十一、组件生命周期管理

  aboutToAppear() {
    this.timerId = setInterval(() => {
      this.particles = driftParticles(this.particles);
    }, 110);
  }

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

这段代码实现了组件的生命周期管理逻辑,包括 aboutToAppearaboutToDisappear 两个生命周期回调函数。在 HarmonyOS 6.1.1 ArkTS API 24 中,组件生命周期管理是保证应用稳定运行和资源正确释放的关键环节。

aboutToAppear 生命周期,该回调在组件创建完成、即将挂载到 UI 树之前被调用。在这个阶段,组件的 @State 变量已经完成初始化,但 UI 尚未完成首次渲染。这是执行异步数据加载、注册事件监听、启动定时器等初始化操作的理想时机。

定时器启动逻辑,在 aboutToAppear 中,通过 setInterval 创建了一个每 110 毫秒执行一次的定时器。回调函数中调用 driftParticles(this.particles) 计算下一帧的粒子位置,并将结果赋值给 this.particles 状态变量。由于 particles@State 修饰的,赋值新数组会触发框架检测到变化,驱动粒子 UI 的重渲染,从而形成连续的动画效果。

箭头函数与 this 绑定,定时器回调使用了箭头函数 () => { ... },这确保了函数内部的 this 指向组件实例而非全局对象。在 ArkTS 中,当需要在回调函数中访问组件的状态变量时,使用箭头函数是推荐做法,它避免了 this 绑定丢失的常见陷阱。

aboutToDisappear 生命周期,该回调在组件即将从 UI 树中移除之前被调用。这是执行资源清理操作的最后机会——取消网络请求、移除事件监听、清除定时器等。如果不在此阶段清理定时器,组件销毁后定时器仍会继续执行,导致内存泄漏和潜在的错误。

定时器清理逻辑aboutToDisappear 中检查 this.timerId >= 0(定时器已成功创建)后调用 clearInterval(this.timerId) 停止定时器。这种防御性检查避免了因定时器未成功创建而调用 clearInterval 传入无效 ID 的风险。

生命周期配对原则aboutToAppear 中创建的资源必须在 aboutToDisappear 中清理——这是组件生命周期管理的黄金法则。本例中,setIntervalclearInterval 的配对使用完美体现了这一原则,确保了粒子动画在组件存活期间运行,在组件销毁时正确停止。


十二、主布局构建与视图路由

  build() {
    Stack() {
      Column() {
        this.headerBuilder()
        this.topTabsBuilder()
        Scroll() {
          Column() {
            if (this.currentBottomTab === 0) {
              this.travelContent()
            } else if (this.currentBottomTab === 1) {
              this.walletContent()
            } else if (this.currentBottomTab === 2) {
              this.historyContent()
            } else {
              this.mineContent()
            }
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
        }
        .layoutWeight(1)
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        this.bottomTabs()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)

      ForEach(this.particles, (p: ParticleItem) => {
        Text(p.icon)
          .fontSize(p.size)
          .opacity(p.opacity)
          .position({ x: p.x, y: p.y })
      }, (p: ParticleItem) => p.id.toString() + '_' + p.y.toFixed(0))

      if (this.showRechargeModal) {
        this.rechargeModal()
      }
      if (this.showCommuteModal) {
        this.commuteModal()
      }
      if (this.showDeleteModal) {
        this.deleteTripModal()
      }
      if (this.showDetailModal) {
        this.tripDetailModal()
      }
    }
    .width('100%')
    .height('100%')
  }

在这里插入图片描述

这段代码是整个应用的布局骨架——build() 方法。在 HarmonyOS 6.1.1 ArkTS API 24 中,build() 方法是每个组件的核心,它以声明式的方式描述了组件的 UI 结构和层级关系。

Stack 容器选择,最外层使用 Stack 布局容器,这是一种堆叠布局——所有子元素以绝对定位方式叠放在同一区域。这里使用 Stack 的目的是将三层内容堆叠:底层是主界面(Column),中间层是粒子特效(ForEach 渲染的 Text 列表),顶层是弹窗(条件渲染的四个模态框)。

主界面布局结构,底层 Column 采用了从上到下的垂直排列结构:头部仪表盘(headerBuilder)、顶部 Tab 导航(topTabsBuilder)、可滚动内容区域(Scroll)、底部 Tab 导航(bottomTabs)。这种"头部 + 导航 + 内容 + 底部导航"的四段式结构是移动应用中最经典的布局范式。

Scroll 组件配置,内容区域使用 Scroll 组件包裹,支持垂直滚动。layoutWeight(1) 使其占据剩余空间;scrollable(ScrollDirection.Vertical) 限定滚动方向为垂直;scrollBar(BarState.Off) 隐藏滚动条,使界面更加简洁。Scroll 内部的 Column 使用 alignItems(HorizontalAlign.Start) 确保内容左对齐。

条件渲染路由,内容区域内部通过 if-else if-else 条件判断,根据 currentBottomTab 的值选择渲染不同的 @Builder 方法。这是 ArkTS 中实现"视图路由"的轻量级方案——无需引入路由框架,通过条件渲染即可实现页面切换。当 currentBottomTab 为 0 时显示出行内容,为 1 时显示钱包内容,为 2 时显示行程内容,为 3 时显示我的内容。

粒子层渲染ForEach(this.particles, ...) 遍历粒子数组,为每个粒子渲染一个 Text 组件,通过 .position({ x: p.x, y: p.y }) 进行绝对定位。ForEach 的第三个参数是键值生成函数,使用 p.id.toString() + '_' + p.y.toFixed(0) 组合 id 和 y 坐标作为唯一键——当 y 坐标变化时键值也随之变化,确保框架将粒子视为"新元素"进行重渲染,而非复用旧元素。

弹窗层条件渲染,四个弹窗各自通过独立的 if 条件控制显示。当对应的布尔状态变量为 true 时渲染弹窗,为 false 时不渲染。这种设计确保了同一时间可以显示多个弹窗(虽然实际业务中通常只显示一个),且每个弹窗的显示/隐藏完全独立。


十三、科技仪表盘头部构建

  @Builder
  headerBuilder() {
    Column() {
      Row() {
        Column() {
          Text('🚇 QQ出行')
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('成都 · 晴 28℃ · 限行尾号3和8')
            .fontSize(11)
            .fontColor('#BFD4FF')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('💳')
            .fontSize(20)
        }
        .padding(10)
        .borderRadius(20)
        .backgroundColor('33FFFFFF')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 10, bottom: 8 })

      Row() {
        Column() {
          Text('⚡ 出行卡余额')
            .fontSize(10)
            .fontColor('#BFD4FF')
          Text('¥38.60')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.cyan)
        }
        .alignItems(HorizontalAlign.Start)
        // ... 其余三个数据列
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 10 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#0B3B9E', 0], ['#1E6FFF', 1]]
    })
  }

这段代码构建了应用的头部区域——一个科技仪表盘风格的信息面板。@Builder 装饰器是 ArkTS 中实现 UI 片段复用的核心机制,它允许将一段 UI 结构封装为可调用的方法。

@Builder 装饰器原理@Builder 修饰的方法是一个 UI 构建器——它不返回值,而是通过声明式代码描述 UI 结构。在 build() 方法中通过 this.headerBuilder() 语法调用,框架会将 Builder 方法内部的 UI 结构内联展开到调用位置。这种机制使得复杂 UI 可以按功能模块拆分到独立的 Builder 方法中,提高了代码可维护性。

头部布局结构,头部由上下两行组成。第一行是品牌标识行,左侧显示"QQ出行"品牌名和城市天气信息,右侧是钱包入口图标。使用 FlexAlign.SpaceBetween 使两端对齐分布。第二行是数据展示行,横向排列四个数据卡片:出行卡余额、本月出行次数、减碳量、积分。

线性渐变背景linearGradient 属性为头部设置了 135 度角的线性渐变背景,从深蓝(#0B3B9E)渐变到科技蓝(#1E6FFF)。colors 数组中的每个元素是一个 [颜色值, 位置] 的二元组,位置 0 表示渐变起点,位置 1 表示渐变终点。这种渐变背景是营造"科技仪表盘"视觉效果的关键手段。

半透明背景技巧,钱包图标使用 .backgroundColor('33FFFFFF') 设置了半透明白色背景。这里的 33 是十六进制的透明度值(约 20% 不透明度),后面 6 位是 RGB 颜色值。这种 ARGB 格式的颜色值在 ArkTS 中被原生支持,是实现"毛玻璃"效果背景的常用技巧。

数据卡片设计,每个数据卡片由上下两行组成:上方是 10 号字体的标签文字(如"出行卡余额"),使用浅蓝色(#BFD4FF)保证在深色背景上的可读性;下方是 16 号加粗字体的数值(如"¥38.60"),使用荧光青色或白色突出显示。余额和减碳量使用 cyan 色强调,出行次数和积分使用白色展示,形成视觉层次。


十四、顶部分段控制器与出行内容路由

  @Builder
  topTabsBuilder() {
    Row() {
      ForEach(TOP_TABS, (t: TabItem, idx: number) => {
        Column() {
          Text(t.icon)
            .fontSize(15)
          Text(t.label)
            .fontSize(10)
            .fontColor(this.currentTopTab === idx ? COLORS.white : COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .borderRadius(10)
        .backgroundColor(this.currentTopTab === idx ? COLORS.techBlue : 'transparent')
        .onClick(() => {
          this.currentTopTab = idx;
        })
      }, (t: TabItem) => t.label)
    }
    .width('100%')
    .padding({ left: 8, right: 8, top: 8, bottom: 8 })
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  travelContent() {
    Column() {
      if (this.currentTopTab === 0) {
        this.metroContent()
      } else if (this.currentTopTab === 1) {
        this.busContent()
      } else if (this.currentTopTab === 2) {
        this.bikeContent()
      } else if (this.currentTopTab === 3) {
        this.taxiContent()
      } else {
        this.trainContent()
      }
    }
    .width('100%')
    .padding(12)
    .alignItems(HorizontalAlign.Start)
  }

在这里插入图片描述

这段代码实现了顶部分段控制器的渲染和出行内容的一级路由逻辑。两个 @Builder 方法协同工作,构成了出行页面的二级导航体系。

ForEach 数据驱动渲染topTabsBuilder 中使用 ForEach(TOP_TABS, ...) 遍历顶部 Tab 数据数组,为每个 Tab 项渲染一个包含图标和文字的 Column。ForEach 的优势在于代码简洁性——无需手写 5 个独立的 Column 结构,只需一个 ForEach 即可完成所有 Tab 的渲染。当 Tab 数据变更时(如增加或减少 Tab 数量),渲染逻辑无需修改。

选中状态视觉反馈,通过三元运算符 this.currentTopTab === idx ? ... : ... 为每个 Tab 项设置了条件化的视觉属性。选中态:文字颜色为白色,背景色为科技蓝(techBlue),通过 borderRadius(10) 形成圆角药丸形背景。未选中态:文字颜色为次要文本色(textSecondary),背景为透明。这种基于状态变量的条件样式是声明式 UI 的核心范式。

layoutWeight 等分宽度,每个 Tab 项使用 .layoutWeight(1),使得五个 Tab 在 Row 中等分可用宽度。无论 Tab 文字长度如何,每个 Tab 的可点击区域宽度相同,确保了交互体验的一致性。

onClick 事件处理,点击 Tab 时执行 this.currentTopTab = idx,将选中索引赋值给状态变量。由于 currentTopTab@State 修饰的,赋值后框架自动检测到变化,驱动 topTabsBuilder 中的条件样式重新计算,同时驱动 travelContent 中的条件渲染切换内容页面。整个过程无需开发者手动调用任何刷新方法。

travelContent 二级路由travelContent 方法内部通过 if-else if-else 条件链,根据 currentTopTab 的值选择渲染不同的出行方式内容:地铁(metroContent)、公交(busContent)、骑行(bikeContent)、打车(taxiContent)、火车票(trainContent)。这种"一级路由 + 二级路由"的双层条件渲染架构,使得出行页面能够在五种出行方式之间无缝切换。

布局属性分析travelContent 的外层 Column 使用 .padding(12) 设置内边距,.alignItems(HorizontalAlign.Start) 设置子元素左对齐。这些布局属性会被内部调用的 Builder 方法继承,形成统一的间距和对齐风格。


十五、地铁乘车码与线路站点图

  @Builder
  metroContent() {
    Column() {
      Column() {
        Text('成都地铁·乘车码')
          .fontSize(13)
          .fontColor('#BFD4FF')
        Column() {
          Column() {
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6, 7], (cell: number) => {
                Column() {
                  Text(cell % 2 === 0 ? '■' : '□')
                    .fontSize(14)
                    .fontColor(COLORS.techBlue)
                }
                .width(14)
                .height(14)
                .margin(2)
              }, (cell: number) => cell.toString())
            }
          }
          .padding(8)
          .borderRadius(8)
          .backgroundColor(COLORS.white)
          .margin({ top: 12 })
          // ... 第二行二维码方块
          Text('二维码下方 2cm 处扫码过闸')
            .fontSize(10)
            .fontColor('#BFD4FF')
            .margin({ top: 12 })
        }
        .alignItems(HorizontalAlign.Center)

        Row() {
          Text('余额 ¥38.60')
            .fontSize(11)
            .fontColor(COLORS.cyan)
          Text('充值')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.techBlueDeep)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor(COLORS.white)
            .margin({ left: 10 })
            .onClick(() => {
              this.showRechargeModal = true;
            })
        }
        .margin({ top: 14, bottom: 16 })
      }
      .width('100%')
      .borderRadius(16)
      .linearGradient({
        angle: 160,
        colors: [['#0B3B9E', 0], ['#1E6FFF', 1]]
      })
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 16 })
      // ... 线路站点图
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了地铁出行模块的核心 UI——乘车码大卡和线路站点图。这是整个出行应用中视觉表现力最强、信息密度最高的模块之一。

乘车码视觉模拟,由于实际二维码需要专门的码图生成组件,这里通过 ForEach 渲染 8 个交替的方块符号(‘■’ 和 ‘□’)来模拟二维码的视觉效果。cell % 2 === 0 ? '■' : '□' 的三元运算使得奇偶位置显示不同的填充方块,形成类似二维码的黑白方格图案。两行各 8 个方块组成一个 8x2 的"伪二维码"矩阵,嵌套在白色背景的圆角容器中。

渐变背景卡片,乘车码大卡使用了 160 度角的线性渐变背景,从深蓝渐变到科技蓝。这个渐变方向与头部(135度)略有不同,为乘车码卡片营造了与头部既有呼应又有区别的视觉层次。.alignItems(HorizontalAlign.Center) 确保卡片内容居中对齐。

充值按钮交互,"充值"文本被设计为药丸形按钮,使用白色背景和深蓝色文字在渐变背景上形成强烈对比。点击时执行 this.showRechargeModal = true,触发充值弹窗的显示。这是状态驱动弹窗的典型实现——通过修改布尔状态变量来控制 UI 元素的显示与隐藏。

线路站点图渲染,在乘车码卡片下方,使用 ForEach(LINE2_STATIONS, ...) 遍历站点数据数组,为每个站点渲染一行包含站点图标、站点名称、换乘信息的布局。当前站点(isCurrent 为 true)使用蓝色圆形图标和加粗蓝色文字,并显示"A口·直达春熙路商圈"的出口信息;其他站点使用空心圆图标和正常字重的文字。拥有换乘信息的站点额外显示橙色"换乘"标签。

下一班信息提示,线路图底部使用一个浅青色背景的圆角容器显示"下一班:往龙泉驿方向 · 2分钟后到站"的实时信息,为用户提供出行决策支持。这种信息卡片的色彩设计(techBlue 文字 + cyanLight 背景)在应用中形成了统一的"提示信息"视觉语言。


十六、公交到站提醒列表

  @Builder
  busContent() {
    Column() {
      Column() {
        Row() {
          Text('📍 当前位置:天府广场东站')
            .fontSize(12)
            .fontColor(COLORS.white)
          Text('刷新')
            .fontSize(11)
            .fontColor(COLORS.cyan)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(10)
            .backgroundColor('33FFFFFF')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')
      .padding(12)
      .borderRadius(12)
      .backgroundColor(COLORS.techBlue)

      Text('🚌 附近公交实时到站')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 12, bottom: 8 })

      ForEach(BUS_ARRIVES, (b: BusArrive) => {
        Row() {
          Column() {
            Text(b.line)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
          }
          .padding({ left: 10, right: 10, top: 8, bottom: 8 })
          .borderRadius(10)
          .backgroundColor(modeColor('公交'))

          Column() {
            Text(b.destination)
              .fontSize(12)
              .fontColor(COLORS.textPrimary)
            Text(b.plate + ' · ' + b.crowded)
              .fontSize(10)
              .fontColor(crowdedColor(b.crowded))
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })

          Column() {
            Text(b.arriveMinutes.toString() + '分钟')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(b.arriveMinutes <= 3 ? COLORS.danger : COLORS.techBlue)
            Text('提醒')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              .borderRadius(10)
              .backgroundColor(COLORS.cyanLight)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .borderRadius(12)
        .backgroundColor(COLORS.cardBg)
        .margin({ bottom: 8 })
      }, (b: BusArrive) => b.id.toString())
      // ... 通勤提醒
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了公交出行模块的实时到站提醒列表。该模块以信息密度高、视觉层次丰富为特点,是出行应用中实用性最强的功能区域之一。

位置信息条,顶部使用科技蓝背景的圆角容器显示当前定位信息"天府广场东站"和"刷新"按钮。刷新按钮使用半透明白色背景(33FFFFFF)和荧光青文字,在深色背景上形成"玻璃质感"的按钮效果。FlexAlign.SpaceBetween 使位置文字和刷新按钮分布在容器两端。

列表标题设计,"附近公交实时到站"使用 14 号加粗字体和主文本色(textPrimary),与上方深色位置条形成明暗对比,清晰地划分了信息区域。

ForEach 到站信息渲染,遍历 BUS_ARRIVES 数组,为每条公交到站信息渲染一个 Row 布局卡片。每张卡片包含三列内容:左侧线路标签、中间目的地和车牌信息、右侧到站时间和提醒按钮。

线路标签设计,左侧使用 modeColor('公交') 返回的成功绿色作为背景,白色加粗文字显示线路名称(如"K1环线"“58路”),形成醒目的线路标识。圆角和内边距使标签呈现为药丸形按钮外观。

拥挤度颜色映射,中间列的拥挤度文字颜色通过 crowdedColor(b.crowded) 函数动态计算——"拥挤"显示红色、"适中"显示橙色、"空闲"显示绿色。这种语义化颜色编码使用户能够通过颜色快速感知车厢拥挤程度。

到站时间视觉强调,右侧列的到站时间使用三元运算 b.arriveMinutes <= 3 ? COLORS.danger : COLORS.techBlue 动态选择颜色——3 分钟以内显示红色(紧急),超过 3 分钟显示蓝色(正常)。这种"阈值变色"技术是实时信息展示中的常用手段,能够有效引导用户关注紧急信息。

通勤提醒入口,列表底部使用浅色卡片显示"通勤提醒:工作日 08:10 提醒我出门"文本,点击触发通勤设置弹窗。这种将"设置入口"嵌入到功能列表底部的设计模式,既不占用额外的导航空间,又能在用户需要时方便地找到设置入口。


十七、骑行统计与柱状图可视化

  @Builder
  bikeContent() {
    Column() {
      Row() {
        Text('🚲 附近可用单车 23 辆')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('扫码用车')
          .fontSize(12)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(COLORS.cyan)
          .onClick(() => {
            this.showRechargeModal = true;
          })
      }
      .width('100%')
      .margin({ bottom: 10 })

      Row() {
        // ... 三个车辆类型卡片
      }
      .width('100%')

      Column() {
        Text('📈 本周骑行数据')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row() {
          ForEach(WEEK_DAYS, (d: string, idx: number) => {
            Column() {
              Text(TRIP_COUNTS[idx].toString())
                .fontSize(9)
                .fontColor(COLORS.textSecondary)
              Column() {
                Text('')
                  .width('100%')
                  .height(1)
              }
              .width(18)
              .height(tripBarHeight(TRIP_COUNTS[idx]))
              .borderRadius({ topLeft: 4, topRight: 4 })
              .backgroundColor(TRIP_COUNTS[idx] >= 4 ? COLORS.techBlue : COLORS.cyan)
              Text(d)
                .fontSize(8)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 4 })
            }
            .margin({ left: 10, right: 10 })
            .alignItems(HorizontalAlign.Center)
          }, (d: string) => d)
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(VerticalAlign.Bottom)
        .padding({ top: 8 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .alignItems(HorizontalAlign.Start)
      .margin({ top: 12 })
      // ... 骑行记录列表
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了骑行出行模块,包含车辆选择、骑行数据柱状图和历史骑行记录三个核心区域。其中柱状图可视化是该模块的视觉亮点。

车辆类型卡片,通过 Row 等分布局展示三种骑行工具:小蓝单车 Pro、共享电滑板、还车点查询。每个卡片使用白色背景和圆角,居中显示工具图标、名称和价格/数量信息。这种三等分的卡片布局在移动端信息展示中非常常见,能够在有限的屏幕宽度内紧凑地展示多个平级选项。

柱状图数据驱动渲染,本周骑行数据的柱状图通过 ForEach(WEEK_DAYS, ...) 遍历星期数组,同时通过 idx 索引访问 TRIP_COUNTS 数组中的对应数值。每个柱状图项由三部分组成:顶部的数值标签、中间的柱体、底部的星期标签。

柱体高度计算,柱体高度通过 tripBarHeight(TRIP_COUNTS[idx]) 函数动态计算,将出行次数乘以 22 并附加 vp 单位。例如出行 4 次对应 88vp 高度,出行 1 次对应 22vp 高度。这种通过函数将数据值转换为视觉维度的做法,是声明式 UI 中"数据可视化"的核心技术。

柱体颜色条件TRIP_COUNTS[idx] >= 4 ? COLORS.techBlue : COLORS.cyan 为柱体设置条件颜色——出行次数达到 4 次及以上使用科技蓝,低于 4 次使用荧光青。这种颜色分级使"高频出行日"在视觉上更加突出,帮助用户快速识别出行规律。

柱体圆角设计.borderRadius({ topLeft: 4, topRight: 4 }) 仅为柱体的左上和右上方设置圆角,模拟了真实柱状图中常见的"顶部圆角"效果。这种精细的圆角控制使柱状图看起来更加精致和现代化。

Row 对齐策略,柱状图容器 Row 使用 .alignItems(VerticalAlign.Bottom) 设置子元素底部对齐,确保不同高度的柱体在底部对齐——这是柱状图视觉正确性的关键。如果不设置底部对齐,柱体会从顶部开始排列,导致视觉效果混乱。

骑行记录列表,在柱状图下方,使用 ForEach(TRIPS, ...) 遍历行程数据,通过 if (t.mode === '骑行') 条件判断筛选出骑行记录进行渲染。这种"遍历全部数据 + 条件过滤"的模式是 ArkTS 中实现列表筛选的轻量级方案,适用于数据量不大的场景。


十八、打车叫车与订单列表

  @Builder
  taxiContent() {
    Column() {
      Column() {
        Text('🚕 快速叫车')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Row() {
          ForEach(['经济', '舒适', '专车', '拼车'], (t: string, idx: number) => {
            Column() {
              Text(TAXI_ICONS[idx])
                .fontSize(22)
              Text(t)
                .fontSize(10)
                .fontColor(COLORS.white)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor(idx === 0 ? '33FFFFFF' : '1AFFFFFF')
            .margin({ left: 4, right: 4 })
          }, (t: string) => t)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.orange)
      .alignItems(HorizontalAlign.Start)

      Text('📋 历史打车订单')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 12, bottom: 8 })

      ForEach(TRIPS, (t: TripRecord) => {
        if (t.mode === '打车') {
          Row() {
            Column() {
              Text('🚕')
                .fontSize(24)
            }
            .width(44)
            .height(44)
            .borderRadius(12)
            .backgroundColor('#FFF2E3')

            Column() {
              Text(t.fromStation + ' → ' + t.toStation)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(t.line + ' · ' + t.date + ' ' + t.time)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
              Text(t.distance + ' · ' + t.status)
                .fontSize(9)
                .fontColor(COLORS.orange)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })

            Column() {
              Text('¥' + t.fare)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text('详情')
                .fontSize(10)
                .fontColor(COLORS.techBlue)
                .padding({ left: 10, right: 10, top: 3, bottom: 3 })
                .borderRadius(10)
                .backgroundColor(COLORS.cyanLight)
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(10)
          .borderRadius(12)
          .backgroundColor(COLORS.cardBg)
          .margin({ bottom: 8 })
          .onClick(() => {
            this.selectedTrip = t;
            this.showDetailModal = true;
          })
        }
      }, (t: TripRecord) => t.id.toString())
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了打车出行模块,包含叫车入口卡片和历史打车订单列表两个区域。该模块以橙色为主题色,与前述的蓝色/青色模块形成色彩对比,帮助用户通过颜色快速识别当前所处功能区域。

叫车入口卡片,顶部使用橙色背景的圆角卡片展示叫车功能。卡片内通过 ForEach(['经济', '舒适', '专车', '拼车'], ...) 渲染四种打车类型选项,每个选项使用 TAXI_ICONS[idx] 获取对应的车辆图标。第一个选项(经济)使用较高不透明度的白色背景(33FFFFFF),其余使用较低不透明度(1AFFFFFF),形成"默认选中"的视觉暗示。

历史订单筛选渲染,通过 ForEach(TRIPS, ...) 遍历全部行程数据,使用 if (t.mode === '打车') 条件判断仅渲染打车类型的记录。这种"全量遍历 + 条件过滤"的模式虽然在大数据量时存在性能开销,但对于演示级应用的数据规模而言完全可行,且代码实现简洁直观。

订单卡片布局,每张订单卡片使用三列布局:左侧车辆图标容器(44x44 固定尺寸,浅橙色背景)、中间行程信息(起讫站点、线路日期、距离状态)、右侧费用和详情按钮。中间列使用 layoutWeight(1) 占据剩余宽度,确保三列内容在不同数据长度下保持一致的布局比例。

详情按钮交互,"详情"按钮使用浅青色背景和科技蓝文字,点击时执行 this.selectedTrip = t; this.showDetailModal = true——先将选中的行程数据保存到状态变量,再触发详情弹窗显示。这种"先存数据再开弹窗"的模式确保了弹窗内部能够访问到正确的行程数据。

状态颜色编码,订单卡片中的状态文字(如"已支付")使用橙色显示,与打车模块的主题色保持一致。而在行程列表(historyContent)中,状态文字使用提示色(textHint)显示,两种不同的颜色策略服务于不同的信息层级需求。


十九、火车票务卡片与乘车人管理

  @Builder
  trainContent() {
    Column() {
      Column() {
        Row() {
          Column() {
            Text('成都东')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('08-20 14:00')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('G8502')
              .fontSize(11)
              .fontColor(COLORS.techBlue)
            Text('———🚄———')
              .fontSize(11)
              .fontColor(COLORS.techBlue)
              .margin({ top: 2 })
            Text('1小时47分')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Center)
          .margin({ left: 14, right: 14 })

          Column() {
            Text('重庆北')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('08-20 15:47')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)

        Divider()
          .color(COLORS.border)
          .margin({ top: 12 })

        Row() {
          Text('05车 12F · 二等座 · ¥154')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('已出行')
            .fontSize(10)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .borderRadius(8)
            .backgroundColor(COLORS.success)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.border })
      // ... 常用乘车人
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了火车票出行模块,包含票务信息卡片和常用乘车人列表两个区域。票务卡片的布局模仿了实体火车票的视觉结构,具有很强的场景还原能力。

票务卡片三段式布局,卡片主体使用 Row 横向排列三列:出发站信息(成都东 + 出发时间)、车次信息(G8502 + 列车图标 + 耗时)、到达站信息(重庆北 + 到达时间)。FlexAlign.Center 使三列在水平方向居中分布,中间列通过 margin({ left: 14, right: 14 }) 与两侧保持间距。

车次信息视觉设计,中间列使用科技蓝色文字显示车次号"G8502"和"———🚄———"的列车行驶可视化符号,下方以提示色显示"1小时47分"的行程耗时。列车 Emoji 符号嵌入在横线中间,形成了直观的"火车行驶"视觉意象,是纯文本 UI 中模拟图形元素的巧妙手法。

Divider 分隔线组件,ArkTS 提供了原生的 Divider 组件用于绘制分隔线。这里使用 .color(COLORS.border) 将分隔线颜色设为边框色,在票务信息的上下部分之间形成视觉分割。Divider 组件默认占据父容器宽度,无需手动设置宽度。

座位信息行,分隔线下方使用 Row 显示座位详情"05车 12F · 二等座 · ¥154"和状态标签"已出行"。状态标签使用白色文字和成功绿色背景的圆角小标签,FlexAlign.Center 使整行内容居中排列。

卡片边框设计,票务卡片使用 .border({ width: 1, color: COLORS.border }) 添加了 1 像素宽的浅色边框。与地铁乘车码卡片的渐变背景不同,火车票卡片使用白底加边框的设计,更接近实体票务凭证的视觉感受。

常用乘车人列表,在票务卡片下方,通过 ForEach(['本人 · 身份证3303****1234', '母亲 · 身份证3303****5678'], ...) 渲染两条乘车人信息。每行包含人员图标、姓名和脱敏身份证号、以及"编辑"操作按钮。身份证号的星号脱敏处理是出行类应用中保护用户隐私的标准做法。

候补购票提醒,列表底部以提示色显示"候补购票成功率提醒已开启"的小字提示,告知用户已开启的功能特性,体现了出行应用对用户出行决策的主动信息支持。


二十、钱包余额与消费构成

  @Builder
  walletContent() {
    Column() {
      Column() {
        Text('出行卡')
          .fontSize(12)
          .fontColor('#BFD4FF')
        Text('¥38.60')
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ top: 6 })
        Text('卡号 6222 **** **** 8843')
          .fontSize(10)
          .fontColor('#BFD4FF')
          .margin({ top: 6 })
        Row() {
          Text('充值')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.techBlueDeep)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 })
            .borderRadius(18)
            .backgroundColor(COLORS.white)
            .onClick(() => {
              this.showRechargeModal = true;
            })
          Text('账单')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 18, right: 18, top: 8, bottom: 8 })
            .borderRadius(18)
            .border({ width: 1, color: COLORS.white })
            .margin({ left: 10 })
        }
        .margin({ top: 16, bottom: 18 })
      }
      .width('100%')
      .borderRadius(16)
      .linearGradient({
        angle: 135,
        colors: [['#0B3B9E', 0], ['#00B8D4', 1]]
      })
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 18 })

      Text('💸 本月消费构成')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 14, bottom: 8 })

      ForEach([['🚇 地铁', '¥186.00', COLORS.techBlue], ['🚌 公交', '¥34.00', COLORS.success], ['🚲 骑行', '¥22.50', COLORS.cyan], ['🚕 打车', '¥101.00', COLORS.orange], ['🚄 火车', '¥154.00', COLORS.techBlueDeep]], (pair: string[]) => {
        Row() {
          Text(pair[0])
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
          Text(pair[1])
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(pair[2])
            .layoutWeight(1)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .padding({ top: 10, bottom: 10, left: 14, right: 14 })
        .borderRadius(10)
        .backgroundColor(COLORS.cardBg)
        .margin({ bottom: 6 })
      }, (pair: string[]) => pair[0])
      // ... 优惠券列表
    }
    .width('100%')
    .padding(12)
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了钱包模块的核心 UI——出行卡余额展示和消费构成分析。该模块采用了与头部不同的渐变配色方案,形成了独特的"钱包卡片"视觉风格。

余额卡片设计,顶部使用深蓝到青色(#00B8D4)的渐变背景卡片展示出行卡信息。与头部的蓝色渐变(#0B3B9E 到 #1E6FFF)不同,这里渐变的终点色为青色系,为钱包模块赋予了独特的视觉身份。卡片内容从上到下依次为:卡片类型标签(“出行卡”)、大号余额数字(32 号加粗白色字体)、脱敏卡号、充值和账单按钮。

充值与账单按钮对比,两个按钮采用了截然不同的视觉风格:充值按钮使用白色实心背景加深蓝色文字,是"主操作"按钮的视觉语言;账单按钮使用白色边框加透明背景,是"次操作"按钮的视觉语言。这种"实心 + 描边"的主次按钮对比设计,是移动端 UI 中引导用户注意力的经典手法。

消费构成数据驱动渲染,通过 ForEach 遍历一个二维字符串数组,每个元素是一个包含三个值的数组:消费类型标签、金额、颜色值。这种"数据数组 + ForEach"的渲染模式使得消费构成列表能够通过修改数据数组来动态调整,无需修改渲染代码。

金额右对齐,消费金额使用 .textAlign(TextAlign.End).layoutWeight(1) 实现右对齐效果。layoutWeight(1) 使 Text 占据行的剩余宽度,TextAlign.End 使文字在 Text 容器内右对齐。这种布局使得消费类型标签在左侧、金额在右侧,形成了清晰的"标签-数值"对照表格式。

消费金额颜色编码,每个消费金额使用对应出行方式的颜色(通过 pair[2] 获取),与 modeColor 函数返回的颜色保持一致。这种跨模块的颜色一致性使用户能够在不同页面间通过颜色快速关联信息。

优惠券列表渲染,消费构成下方使用 ForEach 渲染三张优惠券卡片。每张卡片包含优惠券名称、到期时间和"去使用"按钮,并使用对应出行方式颜色的边框进行视觉标识。边框颜色与优惠券类型相关联(地铁券蓝色边框、骑行券青色边框、打车券橙色边框),形成了一致的跨模块色彩语言。


二十一、行程历史列表与我的页面

  @Builder
  historyContent() {
    Column() {
      Row() {
        Text('🧭 我的行程')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('本月共 86 次')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
      }
      .width('100%')
      .margin({ bottom: 10 })

      ForEach(TRIPS, (t: TripRecord) => {
        Row() {
          Column() {
            Text(t.modeIcon)
              .fontSize(22)
          }
          .width(44)
          .height(44)
          .borderRadius(12)
          .backgroundColor(COLORS.cyanLight)
          .justifyContent(FlexAlign.Center)

          Column() {
            Row() {
              Text(t.mode)
                .fontSize(10)
                .fontColor(COLORS.white)
                .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                .borderRadius(6)
                .backgroundColor(modeColor(t.mode))
              Text(t.date + ' ' + t.time)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ left: 6 })
            }
            Text(t.fromStation + ' → ' + t.toStation)
              .fontSize(13)
              .fontColor(COLORS.textPrimary)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .margin({ top: 3 })
            Text(t.distance + ' · ' + t.status)
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })

          Column() {
            Text('¥' + t.fare)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('删除')
              .fontSize(10)
              .fontColor(COLORS.danger)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              .borderRadius(10)
              .border({ width: 1, color: COLORS.danger })
              .margin({ top: 4 })
              .onClick(() => {
                this.selectedTrip = t;
                this.showDeleteModal = true;
              })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .borderRadius(12)
        .backgroundColor(COLORS.cardBg)
        .margin({ bottom: 8 })
        .onClick(() => {
          this.selectedTrip = t;
          this.showDetailModal = true;
        })
      }, (t: TripRecord) => t.id.toString())
    }
    .width('100%')
    .padding(12)
    .alignItems(HorizontalAlign.Start)
  }

这段代码构建了行程历史列表,展示用户的所有出行记录。该模块是应用中数据量最大、信息密度最高的列表区域,全面展示了 ForEach 渲染、条件样式、文本溢出处理等核心技术。

标题行设计,顶部 Row 使用 FlexAlign 默认排列展示"我的行程"标题和"本月共 86 次"统计信息。标题使用 layoutWeight(1) 占据左侧空间,统计信息自然靠右排列,形成了"标题 + 统计"的经典列表标题布局。

行程卡片布局,每条行程记录渲染为三列布局:左侧出行方式图标容器(44x44 浅青色背景)、中间行程详情(方式标签 + 日期时间 + 起讫站点 + 距离状态)、右侧费用和删除按钮。中间列使用 layoutWeight(1) 自适应宽度,左右两列使用固定宽度,形成了稳定的"图标 + 详情 + 操作"三段式布局。

出行方式标签颜色编码,中间列顶部的出行方式标签使用 modeColor(t.mode) 返回的颜色作为背景,白色文字显示方式名称。这种"彩色药丸标签"在视觉上使得每条行程记录的方式一目了然,且颜色与各出行模块的主题色保持一致。

文本溢出处理,起讫站点信息使用 .maxLines(1) 限制为单行显示,.textOverflow({ overflow: TextOverflow.Ellipsis }) 设置溢出时显示省略号。这两个属性配合使用,确保了长站点名不会破坏卡片布局,而是在行尾以"…"形式截断。这是 ArkTS 中处理动态长度文本的标准技术。

双操作按钮设计,每条行程记录支持两种操作:点击卡片整体触发详情弹窗(showDetailModal),点击"删除"按钮触发删除确认弹窗(showDeleteModal)。删除按钮使用红色描边样式,与危险操作语义一致。通过 selectedTrip = t 将选中的行程数据传递给弹窗,确保弹窗显示正确的数据。

卡片点击事件.onClick() 设置在 Row 容器上,使整个卡片可点击。点击时先保存行程数据到 selectedTrip,再设置 showDetailModal = true 触发详情弹窗。这种"先传数据再开弹窗"的两步操作模式确保了弹窗数据的正确性。


二十二、个人中心与底部导航

  @Builder
  mineContent() {
    Column() {
      Column() {
        Row() {
          Text('🧑‍💼')
            .fontSize(38)
            .width(64)
            .height(64)
            .textAlign(TextAlign.Center)
            .borderRadius(32)
            .backgroundColor(COLORS.cyanLight)
          Column() {
            Text('通勤达人David')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('已省下打车费 ¥1,286 · 累计减碳 48.2kg')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
            Text('🏆 获得徽章「绿色出行家」')
              .fontSize(10)
              .fontColor(COLORS.techBlue)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
        }
        .width('100%')
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .alignItems(HorizontalAlign.Start)

      Text('⚙️ 常用功能')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 14, bottom: 8 })

      ForEach([['🚉', '我的通勤', '每日 08:10 · 2号线'], ['💳', '支付管理', '出行卡 + 免密支付'], ['🚼', '关怀模式', '大字版乘车码'], ['🛡', '行程保护', '出行意外险已开通']], (pair: string[]) => {
        Row() {
          Text(pair[0])
            .fontSize(20)
          Text(pair[1])
            .fontSize(13)
            .fontColor(COLORS.textPrimary)
            .margin({ left: 12 })
          Text(pair[2])
            .fontSize(11)
            .fontColor(COLORS.textHint)
            .layoutWeight(1)
            .textAlign(TextAlign.End)
          Text('›')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .padding({ top: 12, bottom: 12, left: 14, right: 14 })
        .borderRadius(12)
        .backgroundColor(COLORS.cardBg)
        .margin({ bottom: 6 })
        .onClick(() => {
          this.showCommuteModal = true;
        })
      }, (pair: string[]) => pair[1])
      // ... 出行年报按钮
    }
    .width('100%')
    .padding(12)
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  bottomTabs() {
    Row() {
      ForEach(BOTTOM_TABS, (t: TabItem, idx: number) => {
        Column() {
          Text(t.icon)
            .fontSize(20)
            .opacity(this.currentBottomTab === idx ? 1 : 0.45)
          Text(t.label)
            .fontSize(10)
            .fontColor(this.currentBottomTab === idx ? COLORS.techBlue : COLORS.textHint)
            .margin({ top: 3 })
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .onClick(() => {
          this.currentBottomTab = idx;
          this.currentTopTab = 0;
        })
      }, (t: TabItem) => t.label)
    }
    .width('100%')
    .backgroundColor(COLORS.cardBg)
    .border({ width: 1, color: COLORS.border })
  }

这段代码构建了"我的"个人中心模块和底部导航栏。个人中心展示用户信息和功能入口列表,底部导航提供全局页面切换能力。

用户信息卡片设计,顶部用户卡片使用 Row 横向布局:左侧 64x64 的圆形头像容器(使用 borderRadius(32) 将正方形裁切为圆形),右侧三行用户信息(昵称、省钱减碳统计、徽章)。昵称使用 18 号加粗字体,统计信息使用 11 号次要色,徽章使用 10 号科技蓝,形成了清晰的信息层级。

功能列表数据驱动渲染,通过 ForEach 遍历一个二维数组,每个元素包含图标、功能名称、状态描述三个值。每行渲染为:图标 + 功能名 + 状态描述(右对齐)+ 右箭头符号(›)。TextAlign.End 使状态描述右对齐,layoutWeight(1) 使功能名占据中间空间,右箭头固定在行尾。这种"图标 + 标题 + 副标题 + 箭头"的四元素行布局是设置页面的经典设计。

功能列表点击交互,所有功能项的点击事件统一设置为 this.showCommuteModal = true,在演示场景中将所有功能入口统一导向通勤设置弹窗。在实际应用中,可以根据 pair[1] 的功能名称分发到不同的处理逻辑。

底部导航渲染bottomTabs 使用 ForEach(BOTTOM_TABS, ...) 遍历四个底部 Tab 数据。每个 Tab 项的图标和文字通过条件样式区分选中态与未选中态:选中态图标 opacity 为 1(完全显示),文字颜色为科技蓝;未选中态图标 opacity 为 0.45(半透明),文字颜色为提示色。使用 opacity 而非颜色变化来表示选中态,是因为 Emoji 图标无法直接修改颜色,opacity 是控制 Emoji 可见度的有效手段。

底部导航联动逻辑,点击底部 Tab 时同时更新 currentBottomTabcurrentTopTab 两个状态变量:this.currentBottomTab = idx 切换主页面内容,this.currentTopTab = 0 重置顶部 Tab 为第一项。这种联动设计确保了切换主页面时顶部 Tab 不会保留上一个页面的选中状态,提供了一致的导航体验。

底部导航视觉设计,导航栏使用白色背景和顶部边框线,与内容区域形成视觉分隔。layoutWeight(1) 使四个 Tab 等分导航栏宽度。这种简洁的白色导航栏设计是移动应用中最通用的底部导航样式。


二十三、充值弹窗与表单交互

  @Builder
  rechargeModal() {
    Column() {
      Column() {
        Column() {
          Text('💳 出行卡充值')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('当前余额 ¥38.60,充值立享随机立减')
            .fontSize(11)
            .fontColor('#BFD4FF')
            .margin({ top: 4 })
        }
        .width('100%')
        .padding(16)
        .alignItems(HorizontalAlign.Start)
        .linearGradient({
          angle: 135,
          colors: [['#0B3B9E', 0], ['#00B8D4', 1]]
        })

        Column() {
          Text('选择充值金额')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .alignSelf(ItemAlign.Start)
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(['10元', '20元', '50元', '100元', '200元', '自定义'], (m: string, idx: number) => {
              Column() {
                Text(m)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.rechargeAmount === idx ? COLORS.white : COLORS.textPrimary)
                if (idx === 2 || idx === 3) {
                  Text('送2元')
                    .fontSize(9)
                    .fontColor(this.rechargeAmount === idx ? COLORS.white : COLORS.orange)
                    .margin({ top: 2 })
                }
              }
              .width('30%')
              .padding({ top: 12, bottom: 12 })
              .borderRadius(12)
              .border({
                width: this.rechargeAmount === idx ? 0 : 1,
                color: COLORS.border
              })
              .backgroundColor(this.rechargeAmount === idx ? COLORS.techBlue : COLORS.cardBg)
              .margin({ top: 8 })
              .onClick(() => {
                this.rechargeAmount = idx;
              })
            }, (m: string) => m)
          }
          .width('100%')

          Text('支付方式')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .alignSelf(ItemAlign.Start)
            .margin({ top: 16 })
          ForEach(['💬 微信支付(推荐)', '💳 出行卡直充', '🏦 银行卡快捷'], (p: string, idx: number) => {
            Row() {
              Text(p)
                .fontSize(13)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text(this.payChannel === idx ? '●' : '○')
                .fontSize(14)
                .fontColor(this.payChannel === idx ? COLORS.techBlue : COLORS.textHint)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(10)
            .backgroundColor(this.payChannel === idx ? COLORS.cyanLight : COLORS.bg)
            .margin({ top: 6 })
            .onClick(() => {
              this.payChannel = idx;
            })
          }, (p: string) => p)
          // ... 提示信息和操作按钮
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 16, right: 16 })

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .onClick(() => {
              this.showRechargeModal = false;
            })
          Text('立即充值')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .backgroundColor(COLORS.techBlue)
            .onClick(() => {
              this.showRechargeModal = false;
            })
        }
        .width('100%')
        .border({ width: 1, color: COLORS.border })
      }
      .width('90%')
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .clip(true)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('99000000')
    .onClick(() => {
      this.showRechargeModal = false;
    })
  }

这段代码构建了充值弹窗——应用中最复杂的交互式表单组件。该弹窗集成了金额选择、支付方式选择和确认操作三个交互流程,全面展示了 ArkTS 中模态弹窗的设计范式。

模态遮罩层设计,最外层 Column 设置了 .backgroundColor('99000000') 的半透明黑色背景作为遮罩层。99 是十六进制透明度值(约 60% 不透明度),000000 是纯黑色。这个遮罩层覆盖整个屏幕,使用户注意力聚焦于弹窗内容。.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) 使弹窗内容在屏幕中央居中显示。

点击遮罩关闭弹窗,遮罩层的 .onClick() 设置为 this.showRechargeModal = false,使得点击弹窗外部区域可以关闭弹窗。这是移动端弹窗交互的标准行为。但需要注意,内部的弹窗内容容器需要阻止事件冒泡,否则点击弹窗内部也会关闭弹窗——在实际应用中可能需要添加 .onClick((e) => e.stopPropagation()) 等事件阻止逻辑。

弹窗头部渐变设计,弹窗顶部使用与钱包卡片相同的深蓝到青色渐变背景,显示充值标题和促销提示。.alignItems(HorizontalAlign.Start) 使文字左对齐。.clip(true) 属性确保弹窗内容不会溢出圆角边界。

Flex 换行布局,充值金额选择区域使用 Flex({ wrap: FlexWrap.Wrap }) 组件实现自动换行的网格布局。每个金额选项宽度设为 30%,使得每行可以排列三个选项(3 x 30% = 90%,加上间距正好填满一行)。FlexWrap.Wrap 确保超出宽度的项自动换到下一行。这是 ArkTS 中实现自适应网格布局的灵活方案。

金额选中状态,通过 this.rechargeAmount === idx 判断当前选中项,选中时使用科技蓝背景和白色文字,未选中时使用白色背景、边框和深色文字。部分金额选项(50元和100元)额外显示"送2元"的促销标签,标签颜色也随选中状态变化。

支付方式单选列表,支付方式使用 ForEach 渲染三个选项,每个选项右侧显示 (选中)或 (未选中)的圆形指示符。选中项使用浅青色背景,未选中项使用浅灰色背景。通过 this.payChannel === idx 控制选中状态,实现单选行为。

底部操作按钮,弹窗底部使用 Row 横向排列"取消"和"立即充值"两个按钮,各占 50% 宽度(通过 layoutWeight(1) 实现)。取消按钮使用浅色文字,立即充值按钮使用科技蓝背景和白色加粗文字。两个按钮的点击事件都将 showRechargeModal 设为 false 来关闭弹窗(实际应用中立即充值应执行充值逻辑后再关闭)。


二十四、通勤设置与删除确认弹窗

  @Builder
  commuteModal() {
    Column() {
      Column() {
        Text('🚉 编辑我的通勤')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('设置后每天自动提醒出门时间')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 4 })

        Column() {
          Text('常用线路')
            .fontSize(12)
            .fontColor(COLORS.techBlue)
            .alignSelf(ItemAlign.Start)
          TextInput({ text: this.commuteLine })
            .fontSize(13)
            .fontColor(COLORS.textPrimary)
            .backgroundColor(COLORS.bg)
            .borderRadius(8)
            .height(40)
            .margin({ top: 6 })
            .onChange((v: string) => {
              this.commuteLine = v;
            })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .margin({ top: 16 })
        // ... 出发时间和提前提醒

        Row() {
          Text('重置')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 10, bottom: 10 })
            .borderRadius(18)
            .backgroundColor(COLORS.bg)
            .onClick(() => {
              this.commuteLine = '2号线';
              this.commuteTime = '08:10';
              this.commuteRemind = 0;
            })
          Text('保存通勤')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 20, right: 20, top: 10, bottom: 10 })
            .borderRadius(18)
            .backgroundColor(COLORS.techBlue)
            .margin({ left: 10 })
            .onClick(() => {
              this.showCommuteModal = false;
            })
        }
        .margin({ top: 18, bottom: 16 })
      }
      .width('86%')
      .padding(18)
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.cyanLight })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('99000000')
    .onClick(() => {
      this.showCommuteModal = false;
    })
  }

  @Builder
  deleteTripModal() {
    Column() {
      Column() {
        Text('🗑')
          .fontSize(36)
        Text('删除这条行程记录?')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 8 })
        Text('删除后无法恢复,报销凭证请提前导出')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 6 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(20)
            .backgroundColor(COLORS.bg)
          Text('确认删除')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(20)
            .backgroundColor(COLORS.danger)
            .margin({ left: 10 })
            .onClick(() => {
              this.showDeleteModal = false;
            })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('76%')
      .padding(20)
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('99000000')
    .onClick(() => {
      this.showDeleteModal = false;
    })
  }

这段代码构建了通勤设置弹窗和删除确认弹窗两个模态组件。通勤设置弹窗展示了 ArkTS 中表单输入组件的使用方式,删除确认弹窗展示了警告类弹窗的视觉设计模式。

TextInput 输入组件,通勤设置弹窗使用了 TextInput({ text: this.commuteLine }) 组件接收用户输入。text 参数绑定到状态变量 commuteLine,使得输入框的初始值为当前通勤线路。.onChange((v: string) => { this.commuteLine = v; }) 回调在用户输入时实时更新状态变量,实现了双向数据绑定。

表单字段结构,每个表单字段由 Column 容器包裹,包含标签 Text 和输入 TextInput 两个子元素。标签使用科技蓝色 12 号字体,输入框使用 13 号字体和浅灰背景。.alignSelf(ItemAlign.Start) 确保标签文字左对齐(因为父 Column 默认居中对齐)。

提前提醒选择器,"提前提醒"字段使用 ForEach 渲染三个时间段选项(10分钟、20分钟、30分钟),通过 this.commuteRemind === idx 控制选中状态。选中项使用荧光青背景和白色文字,未选中项使用浅灰背景和次要文字色。这是一种"药丸标签单选器"设计,比传统的下拉选择器更加直观和触控友好。

重置功能,重置按钮点击时将三个表单状态变量恢复为默认值:commuteLine = '2号线'commuteTime = '08:10'commuteRemind = 0。由于这些变量都是 @State 修饰的,赋值后框架自动检测到变化并更新输入框和选择器的显示值,实现了"一键重置"的交互效果。

弹窗边框设计,通勤设置弹窗使用 .border({ width: 1, color: COLORS.cyanLight }) 设置了浅青色边框,与弹窗内部的科技蓝主题色形成呼应。这种"主题色边框"设计使弹窗在视觉上与应用整体风格保持一致。

删除确认弹窗设计,删除弹窗采用了简洁的居中卡片设计:顶部大号垃圾桶图标、中间警告标题和说明文字、底部取消和确认按钮。弹窗宽度仅 76%,是四个弹窗中最窄的,符合"警告弹窗应紧凑居中"的设计原则。

危险操作视觉强调,"确认删除"按钮使用红色(danger)背景和白色加粗文字,通过颜色强烈传达"危险操作"的语义。取消按钮使用浅灰背景和次要文字色,视觉上弱化。这种"主操作醒目 + 次操作弱化"的按钮对比设计,有效引导用户谨慎执行危险操作。


二十五、行程详情弹窗与费用明细

  @Builder
  tripDetailModal() {
    Column() {
      Column() {
        Column() {
          Row() {
            Text('🧭 行程详情')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('✕')
              .fontSize(16)
              .fontColor(COLORS.textSecondary)
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              .onClick(() => {
                this.showDetailModal = false;
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 14 })
        .backgroundColor(COLORS.cyanLight)

        Scroll() {
          Column() {
            Row() {
              Column() {
                Text(this.selectedTrip!.modeIcon)
                  .fontSize(30)
              }
              .width(52)
              .height(52)
              .borderRadius(13)
              .backgroundColor(COLORS.cyanLight)
              .justifyContent(FlexAlign.Center)

              Column() {
                Text(this.selectedTrip!.mode + ' · ' + this.selectedTrip!.line)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(this.selectedTrip!.date + ' ' + this.selectedTrip!.time + ' · ' + this.selectedTrip!.distance)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
                Text(this.selectedTrip!.status)
                  .fontSize(9)
                  .fontColor(COLORS.white)
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(modeColor(this.selectedTrip!.mode))
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })
            }
            .width('100%')

            Column() {
              Row() {
                Text('🟢 ' + this.selectedTrip!.fromStation)
                  .fontSize(13)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text('上车')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
              Row() {
                Text('│')
                  .fontSize(12)
                  .fontColor(COLORS.techBlue)
                  .margin({ left: 5 })
                Text('途经 ' + (this.selectedTrip!.id % 6 + 3) + ' 站 · 用时' + (this.selectedTrip!.id % 20 + 12) + '分钟')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
                  .margin({ left: 10 })
              }
              .width('100%')
              Row() {
                Text('🔴 ' + this.selectedTrip!.toStation)
                  .fontSize(13)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text('下车')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor(COLORS.bg)
            .margin({ top: 12 })

            Text('💰 费用明细')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .margin({ top: 14 })

            ForEach(['起步价 ¥2.00', '里程费 ¥' + (this.selectedTrip!.fare - 2).toFixed(2), '优惠 -¥0.00'], (line: string) => {
              Row() {
                Text(line)
                  .fontSize(12)
                  .fontColor(COLORS.textSecondary)
                  .layoutWeight(1)
                Text('—')
                  .fontSize(12)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
              .padding({ top: 8, bottom: 8 })
              .borderRadius(8)
              .backgroundColor(COLORS.cardBg)
              .margin({ top: 4 })
            }, (line: string) => line)

            Row() {
              Text('合计')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text('¥' + this.selectedTrip!.fare)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.techBlue)
            }
            .width('100%')
            .padding(12)
            .borderRadius(10)
            .backgroundColor(COLORS.cyanLight)
            .margin({ top: 8 })

            Row() {
              Text('🗑 删除行程')
                .fontSize(13)
                .fontColor(COLORS.danger)
                .padding({ left: 14, right: 14, top: 10, bottom: 10 })
                .borderRadius(18)
                .border({ width: 1, color: COLORS.danger })
                .onClick(() => {
                  this.showDetailModal = false;
                  this.showDeleteModal = true;
                })
              Text('🧾 开发票')
                .fontSize(13)
                .fontColor(COLORS.white)
                .padding({ left: 14, right: 14, top: 10, bottom: 10 })
                .borderRadius(18)
                .backgroundColor(COLORS.techBlue)
                .margin({ left: 10 })
              Text('🚉 再来一程')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
                .padding({ left: 14, right: 14, top: 10, bottom: 10 })
                .borderRadius(18)
                .backgroundColor(COLORS.cyan)
                .margin({ left: 10 })
                .onClick(() => {
                  this.showDetailModal = false;
                })
            }
            .margin({ top: 16, bottom: 16 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 16, right: 16 })
        }
        .constraintSize({ maxHeight: '60%' })
        .scrollBar(BarState.Off)
      }
      .width('92%')
      .borderRadius(16)
      .backgroundColor(COLORS.cardBg)
      .clip(true)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('99000000')
    .onClick(() => {
      this.showDetailModal = false;
    })
  }

这段代码构建了行程详情弹窗——应用中信息量最大的弹窗组件。该弹窗展示了行程的完整信息,包括出行方式、起讫站点、途经站点、费用明细和操作按钮,是 ArkTS 复杂弹窗设计的集大成者。

非空断言操作符,代码中大量使用 this.selectedTrip!.属性 的语法访问选中行程的属性。! 是 TypeScript 的非空断言操作符,它告诉编译器"我确定这个值不为 null"。由于 selectedTrip 的类型是 TripRecord | null,直接访问属性会触发编译器的空值检查警告,使用 ! 断言可以绕过检查。在实际应用中,应确保在弹窗显示前 selectedTrip 已被正确赋值。

弹窗头部设计,详情弹窗头部使用浅青色背景的标题栏,左侧显示"行程详情"标题,右侧显示"✕"关闭按钮。FlexAlign.SpaceBetween 使标题和关闭按钮分布在两端。头部背景色与弹窗内容的浅青色主题保持一致,形成统一的视觉风格。

Scroll 可滚动内容区,弹窗内容区使用 Scroll 组件包裹,.constraintSize({ maxHeight: '60%' }) 限制最大高度为屏幕的 60%。当内容超过此高度时,用户可以在弹窗内滚动查看全部信息。.scrollBar(BarState.Off) 隐藏滚动条,保持弹窗视觉简洁。这种"限高 + 滚动"的设计使得弹窗能够适配不同信息量的内容展示。

行程概览卡片,内容区顶部使用 Row 展示出行方式图标和行程概要信息。图标容器使用 52x52 的圆角方形(borderRadius 13),与行程列表中的 44x44 图标容器形成大小对比,强调详情页面的重要性。出行方式标签使用 modeColor 函数返回的颜色作为背景,与列表中的颜色编码保持一致。

站点序列可视化,使用三个 Row 模拟"上车 - 途经 - 下车"的站点序列:绿色圆点 + 上车站名、竖线 + 途经站数和用时、红色圆点 + 下车站名。途经站数和用时通过 this.selectedTrip!.id % 6 + 3this.selectedTrip!.id % 20 + 12 公式基于行程 ID 动态计算,为不同行程生成不同的数值。

费用明细列表,使用 ForEach 渲染三行费用明细:起步价、里程费、优惠。里程费通过 (this.selectedTrip!.fare - 2).toFixed(2) 动态计算(总费用减去起步价 2 元),保留两位小数。合计行使用浅青色背景和科技蓝色金额数字突出显示。

多操作按钮,底部 Row 排列三个操作按钮:删除行程(红色描边)、开发票(蓝色实心)、再来一程(青色实心)。三个按钮使用了不同的视觉样式——红色描边表示危险操作,蓝色实心表示常规操作,青色实心表示推荐操作。这种"描边 + 实心 + 强调实心"的三级按钮视觉层次,使用户能够快速识别操作的性质和优先级。

弹窗间跳转,"删除行程"按钮的点击事件执行 this.showDetailModal = false; this.showDeleteModal = true——先关闭详情弹窗,再打开删除确认弹窗。这种"弹窗间跳转"的设计使用户能够在查看详情后直接发起删除操作,无需返回列表再点击删除按钮,优化了交互流程。


架构流程图

下面通过 Mermaid 流程图来可视化整个应用的核心架构和数据流向。

图一:应用整体架构图

应用入口 @Entry Index

状态管理层 @State

生命周期管理 aboutToAppear/aboutToDisappear

UI构建层 build

导航状态 currentBottomTab/currentTopTab

弹窗状态 showRechargeModal等

表单状态 rechargeAmount/payChannel等

粒子状态 particles

选中数据 selectedTrip

定时器启动 setInterval 110ms

定时器清理 clearInterval

Stack 堆叠容器

主界面层 Column

粒子特效层 ForEach

弹窗层 条件渲染

头部仪表盘 headerBuilder

顶部Tab topTabsBuilder

可滚动内容区 Scroll

底部Tab bottomTabs

出行内容 travelContent

钱包内容 walletContent

行程内容 historyContent

我的内容 mineContent

地铁 metroContent

公交 busContent

骑行 bikeContent

打车 taxiContent

火车票 trainContent

充值弹窗 rechargeModal

通勤弹窗 commuteModal

删除弹窗 deleteTripModal

详情弹窗 tripDetailModal

图二:粒子动画数据流图

渲染错误: Mermaid 渲染失败: Parse error on line 2: ...icles 初始化16个粒子] --> B[@State particles 状 -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

图三:组件生命周期与交互流程图

挂载前

setInterval

UI构建

粒子动画循环

onClick

currentBottomTab变更

onClick

currentTopTab变更

onClick

showRechargeModal=true

rechargeAmount变更

payChannel变更

onClick

showRechargeModal=false

onClick

showDetailModal=true

onClick

showDetailModal=false

showDeleteModal=true

onClick

showDeleteModal=false

卸载前

clearInterval

组件创建

aboutToAppear

启动定时器

build首次渲染

运行中

底部Tab切换

内容区切换

顶部Tab切换

出行方式切换

点击充值按钮

充值弹窗显示

选择金额

选择支付方式

点击充值/取消

充值弹窗隐藏

点击行程详情

详情弹窗显示

查看费用明细

点击删除行程

详情弹窗隐藏

删除弹窗显示

确认/取消删除

删除弹窗隐藏

组件销毁

aboutToDisappear

清理定时器


技术对比表格

表格一:ArkTS 状态管理装饰器对比

装饰器 作用范围 响应式能力 典型用途 数据流向 本应用使用场景
@State 组件内部 自变量,自身变化触发UI更新 组件内部状态管理 单向:状态→UI currentBottomTab、showRechargeModal等13个状态变量
@Observed 类级别 使类实例属性变化可被观测 数据模型类修饰 配合@ObjectLink使用 TripRecord行程记录类
@Prop 父→子 因变量,仅同步父组件传入的值 父子单向数据传递 单向:父→子 本应用未使用(单组件架构)
@Link 父→子 双向同步,父子任一方变化均同步 父子双向数据绑定 双向:父↔子 本应用未使用(单组件架构)
@Builder 方法级别 非状态相关,UI片段复用 封装可复用的UI结构 无数据流,仅结构复用 headerBuilder、metroContent等15个Builder方法
@Entry 组件级别 标识入口组件 应用根组件标识 无数据流 Index入口组件

表格二:ArkTS 布局容器对比

容器组件 布局方向 子元素排列方式 典型场景 本应用使用位置 核心属性
Column 垂直 从上到下 纵向信息排列、页面主体 几乎所有Builder方法内部 alignItems、justifyContent
Row 水平 从左到右 横向信息排列、按钮组 头部数据行、Tab导航、列表行 justifyContent、layoutWeight
Stack 堆叠 绝对定位叠加 弹窗层、特效层、覆盖层 build()根容器(三层堆叠) 无特殊属性,子元素position定位
Scroll 滚动 可滚动容器 长内容区域、弹窗内容 主内容区、详情弹窗内容区 scrollable、scrollBar、layoutWeight
Flex 弹性 支持换行的弹性布局 网格布局、自适应排列 充值金额选择区域 wrap(FlexWrap.Wrap)
ForEach 列表渲染 数据驱动迭代 列表、Tab、粒子渲染 所有数据驱动列表 itemGenerator、keyGenerator

表格三:弹窗交互模式对比

弹窗类型 触发方式 视觉风格 宽度比例 交互复杂度 状态变量 典型操作
充值弹窗 点击充值按钮 渐变头部+白色表单 90% 高(金额选择+支付方式) showRechargeModal、rechargeAmount、payChannel 选择金额、选择支付方式、确认充值
通勤弹窗 点击通勤设置 青色描边+表单输入 86% 中(文本输入+选择) showCommuteModal、commuteLine、commuteTime、commuteRemind 输入线路、输入时间、选择提醒、重置、保存
删除弹窗 点击删除按钮 简洁居中警告卡 76% 低(确认/取消) showDeleteModal、selectedTrip 确认删除、取消
详情弹窗 点击行程记录 浅青色头部+滚动内容 92% 高(信息展示+多操作) showDetailModal、selectedTrip 查看详情、删除行程、开发票、再来一程

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 风格:科技蓝 + 荧光青 · 未来交通风
// 底部4tab:出行 / 钱包 / 行程 / 我的
// 顶部5tab:地铁 / 公交 / 骑行 / 打车 / 火车票
// 弹框:充值(新增) / 通勤设置(编辑) / 删除行程(删除) / 行程详情
// 特效:光点粒子 + 每周出行次数柱状图 + 线路站点图
// ============================================================


    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('99000000')
    .onClick(() => {
      this.showDetailModal = false;
    })
  }
}


在这里插入图片描述

总结

本文基于 HarmonyOS 6.1.1 和 ArkTS API 24 的技术体系,以一个完整的地铁公交出行助手应用为案例,系统性地剖析了鸿蒙原生应用从数据建模到 UI 构建的全流程开发实践。通过 25 个代码段的逐段深入分析,我们全面覆盖了 ArkTS 声明式 UI 开发的核心技术要点。

在数据建模层面,我们看到了 @Observed 装饰器如何使类实例成为可观察对象,配合 @State 状态变量实现了"数据变更自动驱动视图更新"的响应式编程模型。TripRecordBusArriveMetroStationParticleItem 四个接口/类定义,配合 buildTripsbuildParticlesdriftParticles 等工厂函数和辅助函数,构建了完整的"数据层 → 逻辑层 → 视图层"三层架构。

在 UI 构建层面,@Entry 入口组件通过 build() 方法以 Stack 堆叠容器为根,实现了"主界面层 + 粒子特效层 + 弹窗层"的三层视觉叠加架构。15 个 @Builder 方法将复杂 UI 拆分为可维护的功能模块——头部仪表盘、顶部 Tab、五个出行方式内容页、钱包、行程、个人中心、底部 Tab 以及四个交互弹窗,每个模块都独立封装、职责清晰。ForEach 数据驱动渲染贯穿全应用,从 Tab 导航到列表渲染、从粒子特效到柱状图可视化,体现了声明式 UI 的核心优势。

在交互设计层面,13 个 @State 状态变量覆盖了导航切换、弹窗显隐、表单输入、数据选中四类交互场景。aboutToAppear/aboutToDisappear 生命周期管理确保了定时器资源的正确创建与释放。四个弹窗组件展示了从简单确认到复杂表单的不同层级交互设计模式。粒子动画通过 setInterval 驱动状态更新、配合 position 绝对定位和 ForEach 重渲染,实现了轻量级的视觉效果。

在视觉设计层面,"科技蓝 + 荧光青"的双主色调策略通过 ColorPalette 接口和 COLORS 常量实现了设计令牌化管理,确保了 14 种颜色在全应用中的一致使用。linearGradient 线性渐变、ARGB 半透明色值、条件化三元运算样式等技术手段,构建了富有科技感和层次感的视觉体验。这些技术实践为鸿蒙生态开发者提供了一份从设计系统到工程实现的完整参考指南。

Logo

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

更多推荐