引言

在 HarmonyOS 6.1.1 全场景智能操作系统的技术体系下,HarmonyOS ArkTS API 24 提供了强大的声明式 UI 开发范式,为开发者构建复杂业务场景的应用页面提供了坚实的底层支撑。本文以一个境外自驾租车应用为实战案例,深入剖析基于 HarmonyOS API 24 的 ArkTS 声明式 UI 架构设计、状态管理模式、多组件通信机制以及可视化图表构建技术。该应用涵盖国际租车预订、驾照翻译认证、海外道路救援、多语种翻译助手和实时汇率换算等核心功能模块,通过双排 4+3 Tab 导航架构组织七个独立功能页面,并集成五个不同交互模式的弹框组件。我们将从数据模型定义、颜色体系设计、状态变量管理、@Builder 装饰器组件拆分、ForEach 列表渲染、条件渲染分支控制、线性渐变背景、滚动容器布局、弹框遮罩层设计、进度条与柱状图可视化等多个维度,逐段拆解代码实现细节,全面展现 HarmonyOS ArkTS API 24 在复杂业务应用中的工程实践方法。

一、接口定义与颜色体系架构设计

代码段 1:ColorPalette 接口与 COLORS 常量定义

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  gold: string;
  goldLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

const COLORS: ColorPalette = {
  primary: '#283593',
  primaryLight: '#7986CB',
  primaryDark: '#1A237E',
  gold: '#FFC107',
  goldLight: '#FFF3C4',
  bg: '#E8EAF6',
  cardBg: '#FFFFFF',
  textPrimary: '#1A2050',
  textSecondary: '#5C6390',
  textHint: '#A6ACC9',
  border: '#DDE0F0',
  success: '#43A047',
  warning: '#FB8C00',
  danger: '#E53935',
  white: '#FFFFFF'
};

在这里插入图片描述

在 HarmonyOS ArkTS API 24 中,接口(interface)是定义数据结构类型契约的核心机制。本段代码定义了 ColorPalette 接口,包含十五个颜色字段,完整覆盖了一个国际化租车应用所需的全部视觉语义。深蓝色系(primary 系列)作为主色调传达国际化的专业与可信赖感,金色系(gold 系列)作为点缀色用于高亮关键操作按钮和选中态指示,辅助色(success/warning/danger)则分别映射到订单状态、费用提示和危险操作等业务语义。

将颜色常量集中定义在一个接口类型约束的对象中,是 HarmonyOS ArkTS 开发中推荐的设计模式。这样做的好处在于:第一,所有颜色取值集中管理,便于主题切换和视觉统一维护;第二,TypeScript 的类型检查机制确保每个颜色字段在引用时都有明确的类型约束,避免了字符串拼写错误导致的运行时问题;第三,在整个组件树中通过 COLORS.xxx 引用颜色值,代码可读性远高于直接使用十六进制字符串。这种设计模式在基于 HarmonyOS API 24 的中大型应用开发中尤为重要,它使得视觉设计规范的落地变得可追踪、可维护。

代码段 2:业务数据模型接口定义

interface CountryInfo {
  id: number;
  name: string;
  flag: string;
  driveSide: string;
  minAge: number;
  avgPrice: number;
  hot: boolean;
}

interface CarType {
  id: number;
  brand: string;
  model: string;
  icon: string;
  seat: number;
  gear: string;
  fuel: string;
  dayPrice: number;
  deposit: number;
  score: number;
  country: string;
  freeCancel: boolean;
}

interface DriveOrder {
  id: number;
  orderNo: string;
  country: string;
  car: string;
  pickupCity: string;
  dropCity: string;
  pickupDate: string;
  dropDate: string;
  days: number;
  total: number;
  status: string;
  insurance: string;
}

在这里插入图片描述

HarmonyOS ArkTS API 24 倡导以接口驱动的方式定义业务数据模型,这段代码定义了三个核心业务实体:国家信息(CountryInfo)、车型信息(CarType)和租车订单(DriveOrder)。每个接口的字段都精确映射了业务领域的属性。CountryInfo 中 driveSide 字段记录左舵右行或右舵左行规则,这是国际自驾业务中极为关键的信息——驾驶员在跨国自驾时必须了解目标国家的道路通行方向。CarType 接口中的 freeCancel 布尔字段直接影响 UI 上是否显示"免费取消"标签,体现了数据模型对界面渲染的直接驱动作用。DriveOrder 接口同时包含取车城市和还车城市两个独立字段,支撑了跨国异地还车这一核心业务场景。这种以接口为核心的数据建模方式,使得 ArkTS 编译器能在构建阶段执行严格的类型检查,大幅降低了运行时因字段缺失或类型不匹配引发的异常风险。

代码段 3:驾照翻译与常用语模型

interface LicenseItem {
  id: number;
  country: string;
  flag: string;
  validYears: number;
  needNotary: boolean;
  langs: string;
  price: number;
}

interface PhraseItem {
  id: number;
  scene: string;
  zh: string;
  local: string;
  pronounce: string;
  favorite: boolean;
}

在这里插入图片描述

这两个接口分别服务于驾照翻译和翻译助手两个功能模块。LicenseItem 中 needNotary 布尔字段标识该国是否需要公证,直接决定列表项是否显示红色"需公证"标签,是数据驱动 UI 的典型体现。validYearsprice 为数值类型,HarmonyOS ArkTS 的强类型系统确保它们在参与算术运算时不会出现隐式类型转换问题。PhraseItem 接口设计尤为精巧:zh 存储中文原文,local 存储目标语言原文,pronounce 存储罗马音标注,三者构成一个完整的跨语言翻译单元。favorite 布尔字段则驱动收藏状态的视觉切换和列表排序。这种将语言学习卡片所需的多维度信息封装在单一接口中的设计,使得 ForEach 渲染时可以直接通过点语法访问每个字段,代码结构清晰且渲染效率高。

二、@Observed 可观察模型与静态数据源

代码段 4:DriveOrderModel 可观察类定义

@Observed
class DriveOrderModel {
  id: number = 0
  orderNo: string = ''
  country: string = ''
  car: string = ''
  pickupCity: string = ''
  dropCity: string = ''
  pickupDate: string = ''
  dropDate: string = ''
  days: number = 0
  total: number = 0
  status: string = ''
  insurance: string = ''
  constructor(id: number, orderNo: string, country: string, car: string,
    pickupCity: string, dropCity: string, pickupDate: string, dropDate: string,
    days: number, total: number, status: string, insurance: string) {
    this.id = id; this.orderNo = orderNo; this.country = country; this.car = car
    this.pickupCity = pickupCity; this.dropCity = dropCity
    this.pickupDate = pickupDate; this.dropDate = dropDate
    this.days = days; this.total = total
    this.status = status; this.insurance = insurance
  }
}

@Observed 装饰器是 HarmonyOS ArkTS API 24 状态管理框架的核心机制之一。当类被 @Observed 修饰后,该类的实例属性变更将被 ArkUI 框架自动追踪,从而驱动依赖该实例的 UI 组件进行精确刷新。DriveOrderModel 将接口 DriveOrder 转化为可实例化的类,并提供了完整的构造函数。这一设计的关键在于:当用户在取消订单弹框中点击"确认取消"时,代码执行 this.selectedOrder.status = '已取消',由于 DriveOrderModel 被 @Observed 修饰,框架会自动感知到 status 属性的变化,并触发订单列表中对应项的状态标签颜色更新——从橙色(待取车)变为红色(已取消)。

构造函数中所有参数在单行内完成赋值的写法虽然在可读性上不如分行书写,但在 HarmonyOS ArkTS 中是完全合法的语法。每个属性都有默认初始值(0 或空字符串),这确保了即使在某些场景下通过无参方式创建实例,对象也处于合法状态。@Observed 与 @State 的配合使用构成了 ArkTS 响应式编程的基础:@State 管理组件级状态,@Observed 管理对象级状态,两者协同实现从数据变更到 UI 更新的自动传播。

代码段 5:国家与车型静态数据源

const COUNTRIES: CountryInfo[] = [
  { id: 1, name: '日本', flag: '🇯🇵', driveSide: '左舵右行', minAge: 18, avgPrice: 420, hot: true },
  { id: 2, name: '泰国', flag: '🇹🇭', driveSide: '左舵右行', minAge: 21, avgPrice: 260, hot: true },
  { id: 3, name: '新西兰', flag: '🇳🇿', driveSide: '右舵左行', minAge: 21, avgPrice: 580, hot: true },
  { id: 4, name: '澳大利亚', flag: '🇦🇺', driveSide: '右舵左行', minAge: 21, avgPrice: 620, hot: false },
  { id: 5, name: '德国', flag: '🇩🇪', driveSide: '左舵右行', minAge: 21, avgPrice: 540, hot: true },
  // ... 共10个国家
];

const CARS: CarType[] = [
  { id: 1, brand: 'Toyota', model: '普锐斯 混动', icon: '🚗', seat: 5, gear: '自动挡',
    fuel: '油电混动', dayPrice: 420, deposit: 1500, score: 4.8, country: '日本', freeCancel: true },
  // ... 共10款车型
];

在这里插入图片描述

在 HarmonyOS ArkTS API 24 中,const 声明的数组配合接口类型约束,构成了应用的数据源层。COUNTRIES 数组覆盖十个热门自驾国家,每条记录包含国旗 emoji、通行方向规则、最低驾驶年龄和日均均价。hot 字段用于驱动热门国家金刚区的高亮显示——热门国家使用金色背景,非热门国家使用浅灰背景。CARS 数组中的每款车型都关联了国家信息,实现车型与国家的交叉引用。icon 字段使用 emoji 作为车型图标,这是一种在 ArkTS 中轻量级实现视觉差异化的策略,无需引入图片资源即可获得直观的视觉识别。freeCancel 布尔字段直接决定车型卡片上是否渲染绿色"免费取消"标签,是条件渲染的典型场景。这些静态数据源在应用中通过 ForEach 组件进行渲染,ForEach 的第三个参数(键值生成器函数)使用 id 作为唯一标识,确保列表在数据变化时进行高效的增量更新而非全量重建。

代码段 6:订单、驾照、常用语与辅助数据源

const DRIVE_ORDERS: DriveOrder[] = [
  new DriveOrderModel(1, 'GD20260901001', '日本', '普锐斯 混动', '东京 羽田机场 T3',
    '大阪 关西机场 T1', '2026-09-01 10:00', '2026-09-08 10:00', 7, 2940, '待取车', '全险+零免赔'),
  // ... 共10条订单
];

const LICENSES: LicenseItem[] = [
  { id: 1, country: '日本', flag: '🇯🇵', validYears: 3, needNotary: false, langs: '日文+英文', price: 68 },
  { id: 5, country: '德国', flag: '🇩🇪', validYears: 3, needNotary: true, langs: '德文+英文', price: 98 },
  // ... 共8条
];

const PHRASES: PhraseItem[] = [
  { id: 1, scene: '取车', zh: '我预订了租车,来取车',
    local: '予約していた車を受け取りに来ました',
    pronounce: 'yoyaku shiteita kuruma wo uketori ni kimashita', favorite: true },
  // ... 共10条
];

const INSURANCE_OPTIONS: string[] = ['基础险(免赔 ¥1500)', '全险(零免赔)', '全险+异地还车'];
const PRICE_BARS: number[] = [230, 260, 380, 420, 480, 540, 620, 660, 890];
const PRICE_COUNTRIES: string[] = ['马', '泰', '法', '日', '葡', '德', '澳', '美', '冰'];
const RATE_LIST: string[] = ['JPY 100 ≈ ¥4.87', 'THB 100 ≈ ¥20.3', 'NZD 1 ≈ ¥4.21',
  'EUR 1 ≈ ¥7.83', 'USD 1 ≈ ¥7.15', 'AUD 1 ≈ ¥4.62', 'ISK 100 ≈ ¥5.19'];

这段代码集中定义了应用所需的全部数据源。DRIVE_ORDERS 使用 DriveOrderModel 实例化创建,充分利用了 @Observed 装饰器的可观察特性。订单数据覆盖了"待取车"、“已确认”、"已完成"和"已取消"四种状态,为状态颜色映射提供了完整的测试覆盖。LICENSES 中 needNotary 字段的分布体现了真实的业务逻辑:德国和法国需要公证(needNotary: true),其余国家不需要。PHRASES 数组的设计十分人性化:每条短语同时包含中文原文、目标语言原文和罗马音标注,配合场景标签(取车、加油、问路等),形成一套完整的自驾常用语学习体系。

辅助数据源中的 PRICE_BARS 和 PRICE_COUNTRIES 是两个平行数组,分别存储柱状图的数值和国家简称标签,它们在 ForEach 中通过索引关联渲染。RATE_LIST 存储了七种货币的汇率字符串,直接在列表中展示,体现了数据源的设计灵活性——既可以使用结构化对象数组,也可以使用简单字符串数组,取决于业务需求的复杂度。

三、@Entry 主组件与 @State 状态管理

代码段 7:GlobalDrivePage 主组件状态声明

@Entry
@Component
struct GlobalDrivePage {
  @State currentTab: number = 0
  @State showBookModal: boolean = false
  @State showLicenseModal: boolean = false
  @State showCancelModal: boolean = false
  @State showCarDetailModal: boolean = false
  @State showPhraseModal: boolean = false
  @State selectedCar: CarType | null = null
  @State selectedOrder: DriveOrder | null = null
  @State bookInsurance: string = '全险(零免赔)'
  @State bookDays: number = 7
  @State favPhrases: PhraseItem[] = PHRASES.slice()
  private mainTabs: string[] = ['租车', '订单', '驾照', '我的']
  private mainIcons: string[] = ['🚙', '📋', '🛂', '👤']
  private quickTabs: string[] = ['道路救援', '翻译助手', '汇率换算']
  private quickIcons: string[] = ['🆘', '🌐', '💱']

在这里插入图片描述

@Entry 标识该组件为应用入口,@Component 声明其为可复用的 UI 组件。在 HarmonyOS ArkTS API 24 中,struct 是定义组件的基本语法结构,替代了传统面向对象语言中的 class。@State 装饰器是 ArkTS 响应式状态管理的基石——被 @State 修饰的变量一旦发生变更,框架会自动重新执行 build 方法中依赖该变量的 UI 片段。

本段定义了十一个 @State 变量,可分为三类:第一类是 Tab 导航状态(currentTab),控制七个页面的切换显示;第二类是五个弹框开关布尔值,各自控制一个弹框的显示与隐藏;第三类是业务数据状态(selectedCar、selectedOrder、bookInsurance、bookDays、favPhrases),记录用户在弹框中的交互选择。selectedCar: CarType | null 使用了联合类型,初始值为 null,当用户点击"详情"或"预订"按钮时赋值为对应的 CarType 对象,弹框关闭后不会重置——这种设计允许弹框在打开时直接引用上一次选择的数据,减少状态重置的开销。

favPhrases 使用 PHRASES.slice() 创建数组的浅拷贝作为初始值,这是一个关键的细节:如果直接赋值 PHRASES,则 favPhrases 和 PHRASES 将引用同一个数组对象,修改 favPhrases 会同时修改静态数据源。使用 slice() 确保了状态变量与静态数据源的数据隔离。private 修饰的四个数组定义了双排 Tab 的标签和图标配置,上排四个主功能 Tab(租车、订单、驾照、我的)对应索引 0-3,下排三个快捷工具 Tab(道路救援、翻译助手、汇率换算)对应索引 4-6。

代码段 8:业务逻辑方法实现

  insurancePrice(ins: string): number {
    if (ins === '基础险(免赔 ¥1500)') {
      return 45
    } else if (ins === '全险(零免赔)') {
      return 88
    }
    return 128
  }

  bookTotal(): number {
    const car = this.selectedCar
    if (car === null) {
      return 0
    }
    return (car.dayPrice + this.insurancePrice(this.bookInsurance)) * this.bookDays
  }

  maxBar(): number {
    let m = 0
    for (let i = 0; i < PRICE_BARS.length; i++) {
      if (PRICE_BARS[i] > m) {
        m = PRICE_BARS[i]
      }
    }
    return m
  }

  barHeight(v: number): number {
    return Math.round(v / this.maxBar() * 80)
  }

  orderColor(s: string): string {
    if (s === '待取车') {
      return COLORS.warning
    } else if (s === '已确认') {
      return COLORS.primary
    } else if (s === '已取消') {
      return COLORS.danger
    }
    return COLORS.textSecondary
  }

在这里插入图片描述

这五个方法分别处理保险定价、订单总价计算、柱状图高度计算和订单状态颜色映射。insurancePrice 方法通过字符串匹配返回对应的每日保险价格,逻辑简单但体现了业务规则集中管理的优势——当保险套餐调整时只需修改此方法。bookTotal 方法是预订弹框的核心计算逻辑,将日均租金、保险日均价格和租赁天数三者相乘得到总价。这里对 this.selectedCar 进行了 null 检查,防止在 selectedCar 未赋值时访问属性导致运行时异常,这是 ArkTS 安全编程的基本范式。

maxBar 方法通过遍历 PRICE_BARS 数组找到最大值,作为柱状图高度归一化的基准。barHeight 方法将任意价格值映射为 0-80 像素的柱状高度,使用 Math.round 确保返回整数像素值,避免子像素渲染导致的视觉模糊。orderColor 方法将订单状态字符串映射为颜色值,四种状态对应四种视觉语义颜色:橙色(待取车/进行中)、深蓝色(已确认/品牌色)、红色(已取消/危险)和灰色(已完成/中性)。这种基于字符串条件分支的颜色映射策略,在状态种类有限且固定的场景下是最直接的实现方式。

四、build 方法与页面骨架架构

代码段 9:Stack 布局与头部区域构建

  build() {
    Stack() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('国际自驾')
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('30 国 · 机场取还 · 中文客服 24h')
                .fontSize(12)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)

            Row() {
              Text('🌐')
                .fontSize(18)
              Text('中文 / CNY')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primaryDark)
                .margin({ left: 4 })
            }
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(COLORS.gold)
            .borderRadius(14)
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 14, bottom: 14 })
          .justifyContent(FlexAlign.SpaceBetween)
          .alignItems(VerticalAlign.Center)

在这里插入图片描述

build() 方法是 ArkTS 组件的核心入口。最外层使用 Stack 容器——这是构建弹框遮罩层的关键架构:Stack 的子元素按声明顺序层叠排列,后声明的子元素覆盖在先声明的子元素之上。这里第一层是页面主体内容 Column,第二层(后续代码中)是条件渲染的弹框遮罩层,通过 zIndex: 999 确保弹框始终位于内容之上。

头部区域使用了 linearGradient 属性实现从深蓝到浅蓝的线性渐变背景。angle: 160 指定渐变角度为 160 度(接近从左上到右下),colors 数组定义了三个色标:0% 处使用最深的 primaryDark,65% 处使用标准 primary,100% 处使用较浅的 ‘#3949AB’。这种三色渐变创造了有层次的视觉深度。头部内部的 Row 使用 justifyContent(FlexAlign.SpaceBetween) 将标题和语言切换胶囊分置两端。金色胶囊样式的语言切换按钮使用 borderRadius(14) 实现圆角,配合 backgroundColor(COLORS.gold) 形成与深蓝背景的强对比,是国际风设计语言中的经典点缀手法。

代码段 10:待取车横幅与热门国家金刚区

          Row() {
            Text('🇯🇵')
              .fontSize(26)
            Column() {
              Text('东京行程还有 9 天取车')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('普锐斯混动 · 羽田 T3 取 · 09-01 10:00')
                .fontSize(11)
                .fontColor('#C5CAE9')
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
            .layoutWeight(1)

            Text('详情')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .backgroundColor(COLORS.gold)
              .borderRadius(12)
          }
          .width('94%')
          .padding(12)
          .borderRadius(14)
          .backgroundColor('rgba(255,255,255,0.12)')
          .margin({ top: 4 })
          .alignItems(VerticalAlign.Center)

          Row() {
            ForEach(COUNTRIES.slice(0, 5), (c: CountryInfo) => {
              Column() {
                Text(c.flag)
                  .fontSize(24)
                Text(c.name)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.white)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)
            }, (c: CountryInfo) => c.id.toString())
          }
          .width('94%')
          .margin({ top: 12, bottom: 14 })
        }

在这里插入图片描述

待取车横幅使用半透明白色背景 rgba(255,255,255,0.12) 叠加在深蓝渐变之上,创造了磨砂玻璃的视觉效果——这是 HarmonyOS ArkTS 中实现毛玻璃质感的轻量级方案。横幅中的 layoutWeight(1) 让中间的文本列占据剩余空间,将国旗和"详情"按钮推向两端。热门国家金刚区使用 COUNTRIES.slice(0, 5) 取前五个国家进行展示,配合 ForEach 渲染五个等宽列。每列使用 layoutWeight(1) 实现等分布局,这是 ArkTS 中实现均分 Flex 布局的标准手法。ForEach 的键值生成器使用 c.id.toString() 确保每个国家有唯一标识,当数据源变化时框架能精确识别哪些项需要更新。

金刚区(Diamond Zone)这一设计模式源自移动端首页中常见的功能入口区域。在此应用中,五个热门国家的国旗 emoji 作为视觉识别符号,配合国家名称和等分布局,形成了简洁直观的国家快速入口。值得注意的是,头部区域整体使用 borderRadius({ bottomLeft: 24, bottomRight: 24 }) 只对底部两角进行圆角处理,使头部与下方内容区域形成自然的视觉过渡。

五、内容区条件渲染与双排 Tab 导航

代码段 11:内容区条件分支路由

        Column() {
          if (this.currentTab === 0) {
            this.rentTab()
          } else if (this.currentTab === 1) {
            this.orderTab()
          } else if (this.currentTab === 2) {
            this.licenseTab()
          } else if (this.currentTab === 3) {
            this.mineTab()
          } else if (this.currentTab === 4) {
            this.rescueTab()
          } else if (this.currentTab === 5) {
            this.phraseTab()
          } else {
            this.rateTab()
          }
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

HarmonyOS ArkTS API 24 支持在 build 方法内使用 if-else 条件语句进行条件渲染分支。这段代码通过判断 this.currentTab 的值,调用对应的 @Builder 方法来渲染不同的页面内容。当 currentTab 变化时,@State 机制触发此 Column 的重新构建,旧页面内容被卸载,新页面内容被挂载。这种基于条件分支的页面切换方式比使用 Tabs 组件更加灵活,因为每个 Tab 页面可以完全独立地管理自己的滚动状态和数据展示逻辑。

使用 layoutWeight(1) 使内容区占据头部和底部 Tab 栏之间的全部剩余空间。alignItems(HorizontalAlign.Start) 确保子内容从左侧开始排列,这在列表型页面中是常见的对齐策略。值得注意的是,条件渲染的最后一个分支使用了 else 而非 else if,这确保了当 currentTab 为 6(汇率换算)时执行 rateTab() 的渲染,同时也作为兜底逻辑处理任何意外的 currentTab 值。七个 Tab 页面通过 @Builder 装饰器定义为独立的方法,每个方法返回完整的页面 UI 结构,实现了页面逻辑的模块化拆分。

代码段 12:双排 Tab 底部导航栏

        Column() {
          Row() {
            ForEach(this.mainTabs, (name: string, idx: number) => {
              Column() {
                Text(this.mainIcons[idx])
                  .fontSize(19)
                  .opacity(this.currentTab === idx ? 1 : 0.5)
                Text(name)
                  .fontSize(11)
                  .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
                  .fontColor(this.currentTab === idx ? COLORS.primary : COLORS.textSecondary)
                  .margin({ top: 2 })
                Column()
                  .width(this.currentTab === idx ? 22 : 0)
                  .height(3)
                  .backgroundColor(COLORS.gold)
                  .borderRadius(2)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .onClick(() => {
                this.currentTab = idx
              })
            }, (name: string, idx: number) => name + idx.toString() + this.currentTab.toString())
          }
          .width('100%')
          .padding({ top: 8 })

          Row() {
            ForEach(this.quickTabs, (name: string, idx: number) => {
              Row() {
                Text(this.quickIcons[idx])
                  .fontSize(13)
                Text(name)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.currentTab === idx + 4 ? COLORS.primaryDark : COLORS.textSecondary)
                  .margin({ left: 4 })
              }
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .backgroundColor(this.currentTab === idx + 4 ? COLORS.gold : COLORS.bg)
              .borderRadius(14)
              .scale({ x: this.currentTab === idx + 4 ? 1.05 : 1.0, y: this.currentTab === idx + 4 ? 1.05 : 1.0 })
              .animation({ duration: 200, curve: Curve.EaseOut })
              .onClick(() => {
                this.currentTab = idx + 4
              })
            }, (name: string, idx: number) => name + idx.toString() + this.currentTab.toString())
          }
          .width('100%')
          .padding({ top: 8, bottom: 8 })
          .justifyContent(FlexAlign.Center)
        }
        .width('100%')
        .backgroundColor(COLORS.white)
        .shadow({ radius: 14, color: 'rgba(26,35,126,0.12)', offsetX: 0, offsetY: -4 })

这是双排 4+3 Tab 导航架构的核心实现。上排四个主功能 Tab 使用 Column 布局,每个 Tab 项包含图标、文字和选中指示器三部分。选中态通过四重视觉变化来强化反馈:图标透明度从 0.5 提升到 1.0、文字粗细从 Normal 变为 Bold、文字颜色从灰色变为主色、底部出现金色下划线指示器。下划线指示器使用一个 Column 组件,通过 width(this.currentTab === idx ? 22 : 0) 控制其宽度——选中时宽度为 22,未选中时宽度为 0,实现平滑的显示/隐藏效果。

下排三个快捷工具 Tab 采用胶囊样式设计,与上排的图标+文字样式形成视觉区分。选中态通过 scale 属性实现 1.05 倍的放大效果,配合 animation({ duration: 200, curve: Curve.EaseOut }) 实现平滑的弹性动画过渡。Curve.EaseOut 是 ArkTS 内置的缓动曲线,提供先快后慢的动画节奏,使放大效果具有自然的弹性感。背景色在选中时变为金色,未选中时为浅灰色,形成了强烈的选中态对比。

ForEach 的键值生成器中巧妙地包含了 this.currentTab.toString(),这意味着当 currentTab 变化时,所有 Tab 项的键值都会改变,触发 ForEach 的全量重新渲染。虽然这在性能上不如增量更新高效,但对于只有 4+3=7 个 Tab 项的场景来说,全量重渲染的开销可以忽略不计,而这种方式确保了选中态视觉的绝对一致性。底部 Tab 栏整体使用 shadow 属性添加向上方向的阴影(offsetY: -4),模拟了悬浮在内容之上的视觉层次。

六、弹框层条件渲染架构

代码段 13:五个弹框的统一挂载逻辑

      if (this.showBookModal) {
        this.bookModalOverlay(() => {
          this.showBookModal = false
        })
      }
      if (this.showLicenseModal) {
        this.licenseModalOverlay(() => {
          this.showLicenseModal = false
        })
      }
      if (this.showCancelModal) {
        this.cancelModalOverlay(() => {
          this.showCancelModal = false
        })
      }
      if (this.showCarDetailModal) {
        this.carDetailModalOverlay(() => {
          this.showCarDetailModal = false
        })
      }
      if (this.showPhraseModal) {
        this.phraseModalOverlay(() => {
          this.showPhraseModal = false
        })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

这段代码位于 Stack 容器的第二层,实现了五个弹框的统一挂载管理。每个弹框由一个布尔状态变量控制其显示与隐藏,当状态为 true 时调用对应的 Overlay Builder 方法进行渲染。这种设计模式的关键在于:每个 Overlay 方法接收一个 onClose: () => void 回调函数作为参数,该回调将对应的布尔状态设为 false 来关闭弹框。回调函数作为参数传递的方式,使得遮罩层的点击关闭逻辑可以在 Overlay 内部统一实现,而不需要每个弹框内部重复编写关闭逻辑。

五个弹框分别对应不同的业务场景和交互模式:bookModalOverlay(预订租车,居中+可滚动)、licenseModalOverlay(驾照翻译申请,居中表单)、cancelModalOverlay(取消订单,紧凑警告框)、carDetailModalOverlay(车型详情,头部渐变+可滚动内容区)和 phraseModalOverlay(常用语编辑,底部滑出)。由于这些弹框在 Stack 中是并列声明的,当多个弹框状态同时为 true 时,后声明的弹框会覆盖先声明的弹框。但在实际业务逻辑中,应用通过合理的交互设计确保同一时间只有一个弹框处于打开状态。这种基于条件渲染的弹框管理方式,相比传统的路由跳转更加轻量,弹框的打开和关闭不会触发页面路由栈的变化,用户体验更加流畅。

七、租车 Tab 页面深度解析

代码段 14:国家横滑列表与精选车型卡片

  @Builder
  rentTab() {
    Scroll() {
      Column() {
        Scroll() {
          Row() {
            ForEach(COUNTRIES, (c: CountryInfo) => {
              Column() {
                Text(c.flag)
                  .fontSize(28)
                  .padding(8)
                  .backgroundColor(c.hot ? COLORS.goldLight : COLORS.bg)
                  .borderRadius(18)
                Text(c.name)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .margin({ top: 4 })
                Text('¥' + c.avgPrice + '/日均')
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center)
              .padding(8)
              .backgroundColor(COLORS.white)
              .borderRadius(14)
              .margin({ left: 8 })
            }, (c: CountryInfo) => c.id.toString())
          }
          .padding({ left: 8, right: 8 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .width('100%')
        .margin({ top: 12 })

@Builder 装饰器是 HarmonyOS ArkTS API 24 中实现 UI 组件复用的核心机制。被 @Builder 修饰的方法返回一段 UI 结构,可以在 build 方法或其他 @Builder 方法中通过 this.xxxTab() 的方式调用。rentTab 是租车页面的入口 Builder,使用嵌套的 Scroll 结构:外层 Scroll 实现垂直滚动,内层 Scroll 实现国家列表的水平滚动。这种嵌套滚动容器的设计是移动端列表页面的标准架构。

国家横滑列表中,每个国家项使用 ForEach 渲染,通过 c.hot 条件判断背景色:热门国家使用金色浅色背景 goldLight,非热门国家使用浅灰背景 bg。这种基于数据驱动视觉差异的设计,使得用户能一眼识别哪些国家是热门推荐。横滑 Scroll 通过 scrollable(ScrollDirection.Horizontal) 指定水平滚动方向。国家项中的日均价格以极小字号(9vp)展示,与国家名称形成信息层次差异——名称是主要识别信息,价格是辅助参考信息。每个国家项使用白色卡片背景和圆角,配合左侧 margin 形成卡片间的间距,这是 ArkTS 中实现横向滚动卡片列表的经典布局策略。

代码段 15:车型卡片详情与预订操作区

        Column() {
          ForEach(CARS, (c: CarType) => {
            Column() {
              Row() {
                Column() {
                  Text(c.icon)
                    .fontSize(40)
                    .padding(14)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(18)
                  Text(c.country)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center)

                Column() {
                  Row() {
                    Text(c.brand + ' ' + c.model)
                      .fontSize(14)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.textPrimary)
                    if (c.freeCancel) {
                      Text('免费取消')
                        .fontSize(8)
                        .fontColor(COLORS.success)
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                        .backgroundColor('#E8F5E9')
                        .borderRadius(8)
                        .margin({ left: 6 })
                    }
                  }
                  .alignItems(VerticalAlign.Center)
                  Text(c.seat + '座 · ' + c.gear + ' · ' + c.fuel)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })

车型卡片是租车页面中最核心的信息展示单元。每张卡片使用 Row 布局将内容分为左侧图标区和右侧信息区。左侧图标区使用 40vp 字号的 emoji 配合 14vp 的内边距和浅灰背景,形成视觉锚点。右侧信息区采用 Column 垂直排列,包含品牌车型名称、规格信息(座位数、变速箱、燃料类型)、评分和押金、日均价格等多层信息。

if (c.freeCancel) 条件渲染是 ArkTS 中数据驱动 UI 的典型应用:当车型的 freeCancel 属性为 true 时,在品牌名称右侧渲染一个绿色背景的小标签"免费取消"。这种条件渲染的粒度精细到单个 Text 组件级别,确保了 UI 元素的存在与否完全由数据决定。评分展示使用 c.score.toFixed(1) 将浮点数格式化为一位小数,确保显示的一致性。日均价格使用 alignItems(VerticalAlign.Bottom) 让价格数字和"/天"后缀底部对齐,因为两者字号差异较大(18vp vs 10vp),底部对齐能形成更协调的视觉关系。

卡片右侧的操作区包含"详情"和"预订"两个按钮,分别使用不同的背景色(浅灰和深蓝)进行视觉区分。点击"详情"按钮时执行 this.selectedCar = cthis.showCarDetailModal = true,点击"预订"按钮时额外设置 this.bookDays = 7this.bookInsurance = '全险(零免赔)' 重置预订参数后再打开弹框——这种在打开弹框前重置交互参数的设计,确保了每次打开弹框时都处于初始状态,避免了上次交互残留状态对本次操作的干扰。

代码段 16:各国均价柱状图可视化

        Column() {
          Text('各国日均租车价(¥)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Row() {
            ForEach(PRICE_COUNTRIES, (c: string, i: number) => {
              Column() {
                Text(PRICE_BARS[i].toString())
                  .fontSize(8)
                  .fontColor(COLORS.primaryDark)
                  .fontWeight(FontWeight.Bold)
                Column()
                  .width(16)
                  .height(this.barHeight(PRICE_BARS[i]))
                  .linearGradient({
                    angle: 180,
                    colors: [[COLORS.primaryLight, 0], [COLORS.primary, 1]]
                  })
                  .borderRadius({ topLeft: 4, topRight: 4 })
                  .margin({ top: 4 })
                Text(c)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Center)
              .margin({ left: 8, right: 8 })
            }, (c: string, i: number) => c + i.toString())
          }
          .justifyContent(FlexAlign.Center)
          .margin({ top: 12 })
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 16, left: 12, right: 12, bottom: 20 })

这是 HarmonyOS ArkTS API 24 中纯代码实现柱状图可视化的经典案例。整个柱状图不依赖任何第三方图表库,完全通过 ForEach 渲染 Column 组件实现。PRICE_COUNTRIES 和 PRICE_BARS 是两个平行数组,ForEach 同时遍历它们:使用 PRICE_COUNTRIES 作为遍历源,通过索引 i 访问 PRICE_BARS 中对应的价格值。每个柱子由三个垂直排列的元素组成:顶部的价格数值标签、中间的柱体和底部的国家简称标签。

柱体使用 height(this.barHeight(PRICE_BARS[i])) 动态计算高度,barHeight 方法将价格值归一化到 0-80vp 的像素范围。柱体使用 linearGradient 实现从上到下的双色渐变(浅蓝到深蓝),配合 borderRadius({ topLeft: 4, topRight: 4 }) 只对顶部两角进行圆角处理,形成了圆润的柱顶效果。这种纯 ArkTS 代码实现柱状图的方式,虽然代码量不小,但优势在于:第一,不引入额外的图表库依赖,减小应用体积;第二,柱状图的每个元素都可以使用 ArkTS 的样式系统进行精细控制;第三,柱状图数据变化时可以享受 ArkTS 的响应式刷新机制,自动更新柱体高度。justifyContent(FlexAlign.Center) 使柱状图整体在 Row 中水平居中排列,视觉效果均衡。

八、订单 Tab 与驾照 Tab 解析

代码段 17:订单列表与租期条可视化

  @Builder
  orderTab() {
    Scroll() {
      Column() {
        ForEach(DRIVE_ORDERS, (o: DriveOrder) => {
          Column() {
            Row() {
              Text(o.orderNo)
                .fontSize(10)
                .fontColor(COLORS.textHint)
                .layoutWeight(1)
              Text(o.status)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.orderColor(o.status))
            }
            .width('100%')

            Row() {
              Text('🚗 ' + o.car)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text('¥' + o.total.toLocaleString())
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primaryDark)
            }
            .margin({ top: 8 })

            Column() {
              Row() {
                Column() {
                  Text(o.pickupDate)
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                  Text(o.pickupCity)
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                    .margin({ top: 2 })
                  Circle()
                    .width(8).height(8)
                    .fill(COLORS.gold)
                    .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Start)

                Column() {
                  Rect()
                    .width('100%').height(2)
                    .fill(COLORS.border)
                    .margin({ top: 22 })
                  Text(o.days + ' 天 · ' + o.insurance)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })
                }
                .layoutWeight(1)
                .margin({ left: 8, right: 8 })

                Column() {
                  Text(o.dropDate)
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                  Text(o.dropCity)
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                    .margin({ top: 2 })
                  Circle()
                    .width(8).height(8)
                    .fill(COLORS.primary)
                    .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.End)
              }
            }
            .padding({ top: 12 })

订单列表的租期条可视化是本应用中最精巧的 UI 设计之一。每个订单卡片包含三层信息:顶部是订单号和状态标签,中间是车型名称和总金额,底部是取还车的租期条。租期条由三个水平排列的 Column 组成:左侧取车信息列(日期+城市+金色圆点)、中间连接线列(灰色横线+租期天数+保险信息)和右侧还车信息列(日期+城市+深蓝色圆点)。

金色圆点和深蓝色圆点分别代表取车点和还车点,中间的灰色横线通过 Rect().width('100%').height(2).fill(COLORS.border) 实现。Rect 组件是 ArkTS 中的基础图形绘制组件,通过指定宽高和填充色可以绘制任意矩形。这里使用 Rect 而非 Border 来绘制连接线,是因为连接线需要独立于 Column 的边框控制,且需要在垂直方向精确居中——通过 margin({ top: 22 }) 将连接线向下偏移 22vp,使其与两侧圆点在视觉上处于同一水平线上。

o.total.toLocaleString() 使用 JavaScript 的内置 toLocaleString 方法将数字格式化为千分位分隔的字符串(如 2940 显示为 2,940),这是 ArkTS 中处理金额显示的便捷方式。订单状态颜色通过 this.orderColor(o.status) 方法动态映射,当取消订单后 status 变为"已取消",orderColor 方法返回红色,状态标签自动变为红色——这一切都由 @State 和 @Observed 的响应式机制自动驱动,无需手动操作 DOM。ForEach 的键值生成器使用 o.id.toString() + '-' + o.status,包含了 status 字段,确保当订单状态变化时,对应的列表项会被重新渲染。

代码段 18:驾照翻译卡与办理进度

  @Builder
  licenseTab() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('国际驾照翻译认证件')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('已办理 · 2028-08-30 前有效')
                .fontSize(11)
                .fontColor('#C5CAE9')
                .margin({ top: 4 })
            }
            .layoutWeight(1)

            Text('🛂')
              .fontSize(38)
          }

          Row() {
            Text('覆盖 200+ 国家 · 与中国驾照同时出示有效')
              .fontSize(10)
              .fontColor('#C5CAE9')
              .layoutWeight(1)
            Text('续期 ¥48')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(COLORS.gold)
              .borderRadius(10)
          }
          .margin({ top: 12 })
        }
        .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]] })
        .borderRadius(18)

驾照翻译卡片使用 135 度线性渐变背景(从左上到右下),与头部区域的 160 度渐变形成微妙的视觉差异,避免了全应用使用统一渐变角度的单调感。卡片右上角的护照 emoji(38vp 大字号)作为视觉焦点,与左上角的文字标题形成对角线构图,充分利用了卡片的对角空间。

“续期 ¥48” 按钮使用金色背景配合深蓝色文字,这种配色组合在深蓝渐变背景上具有极高的对比度和辨识度。驾照认可国家列表使用 ForEach 渲染 LICENSES 数据,每个国家项中通过 if (l.needNotary) 条件渲染判断是否显示红色"需公证"标签。列表项使用 border({ width: { bottom: 1 }, color: COLORS.border }) 只添加底边框,形成分隔线效果,这是 ArkTS 中实现列表分隔线的标准手法——相比使用 Divider 组件,border 方式可以更精确地控制颜色和宽度。

办理进度区域使用 ForEach 渲染四个步骤(提交驾照、人工翻译、双语认证、邮寄到家),每个步骤使用 Circle 组件作为进度指示点。已完成步骤使用绿色 Circle,未完成步骤使用灰色 Circle。idx < 3 的条件判断标识前三个步骤已完成。下方使用 Progress({ value: 100, total: 100, type: ProgressType.Linear }) 显示 100% 的线性进度条,配合物流信息文本,形成完整的办理进度可视化。Progress 组件是 HarmonyOS ArkTS API 24 内置的进度指示组件,通过 type 属性可以指定线性(Linear)、环形(Ring)等多种进度展示形式。

九、我的 Tab 与道路救援 Tab 解析

代码段 19:用户信息卡与统计面板

  @Builder
  mineTab() {
    Scroll() {
      Column() {
        Row() {
          Text('🧳')
            .fontSize(40)
            .padding(10)
            .backgroundColor(COLORS.bg)
            .borderRadius(26)
          Column() {
            Text('旅行的意义')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('金卡旅行家 · 已自驾 8 国 56 天')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .layoutWeight(1)

          Column() {
            Text('12,680')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
            Text('里程积分')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
        }

        Row() {
          Column() {
            Text('10').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
            Text('租车订单').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 3 })
          }
          .layoutWeight(1)
          Column() {
            Text('8').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.primaryLight)
            Text('自驾国家').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 3 })
          }
          .layoutWeight(1)
          Column() {
            Text('¥26,560').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
            Text('累计消费').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 3 })
          }
          .layoutWeight(1)
        }

"我的"页面的用户信息卡使用 Row 布局将行李 emoji 头像、用户信息和里程积分三部分水平排列。头像区域使用浅灰背景和 26vp 的大圆角,形成类似头像的圆形/胶囊效果。用户信息使用 layoutWeight(1) 占据中间空间,里程积分使用金色文字突出展示。

统计面板是"我的"页面中最具信息密度的区域。三个统计项(租车订单数、自驾国家数、累计消费)使用等分 layoutWeight(1) 的三列布局,每列包含一个大号数字(22vp)和一个小号标签(10vp)。三个数字使用不同的颜色(主色 primary、浅主色 primaryLight、金色 gold)进行视觉区分,传达不同维度的信息重要性。这种三色统计面板的设计在移动端个人中心页面中非常常见,ArkTS 的实现方式简洁高效——每列只需要两个 Text 组件配合 layoutWeight 即可完成。

自驾足迹区域使用 ForEach 遍历 COUNTRIES 数组,将每个国家的国旗 emoji 以小图标形式排列展示,形成"足迹地图"的视觉效果。每面旗帜使用浅灰背景和小圆角,配合 margin 形成网格间距。下方的功能菜单列表使用 ForEach 渲染五个字符串菜单项,每项包含名称和箭头符号,使用底边框分隔。这种字符串数组驱动的菜单列表是 ArkTS 中实现简单设置列表的最简方式。

代码段 20:道路救援页面与救援服务列表

  @Builder
  rescueTab() {
    Scroll() {
      Column() {
        Column() {
          Text('🆘')
            .fontSize(44)
            .margin({ top: 20 })
          Text('海外道路救援')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .margin({ top: 8 })
          Text('24 小时中文客服 · 覆盖全部租车国家')
            .fontSize(11)
            .fontColor('#C5CAE9')
            .margin({ top: 4 })

          Text('呼叫救援')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDark)
            .padding({ left: 40, right: 40, top: 14, bottom: 14 })
            .backgroundColor(COLORS.gold)
            .borderRadius(28)
            .margin({ top: 18, bottom: 8 })
            .shadow({ radius: 12, color: 'rgba(255,193,7,0.5)', offsetX: 0, offsetY: 4 })
        }
        .linearGradient({ angle: 150, colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]] })
        .borderRadius(20)

        Column() {
          ForEach(['搭电启动 · 电瓶亏电', '换备胎 · 爆胎/胎压异常',
            '送油服务 · 燃油耗尽(油费自付)', '拖车 100km · 事故或机械故障',
            '开锁服务 · 钥匙锁车内', '事故翻译协助 · 当地交警沟通'], (r: string) => {
            Row() {
              Text('✓')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.success)
              Text(r)
                .fontSize(13)
                .fontColor(COLORS.textPrimary)
                .margin({ left: 10 })
                .layoutWeight(1)
              Text('免费')
                .fontSize(10)
                .fontColor(COLORS.success)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor('#E8F5E9')
                .borderRadius(8)
            }
            .border({ width: { bottom: 1 }, color: COLORS.border })
          }, (r: string) => r)
        }

道路救援页面的头部区域使用 150 度渐变背景,配合大号求助 emoji(44vp)和金色"呼叫救援"按钮,形成了紧急救援场景应有的视觉冲击力。金色按钮使用 borderRadius(28) 实现大圆角胶囊效果,配合 shadow({ radius: 12, color: 'rgba(255,193,7,0.5)', offsetY: 4 }) 添加金色发光阴影,模拟了实体按钮的悬浮效果和紧急感。阴影颜色使用半透明金色,与按钮背景色形成同色系呼应,这是 ArkTS 中实现"发光"视觉效果的技巧——通过使用比背景色更透明的同色阴影,可以模拟出光晕扩散的效果。

救援服务列表使用字符串数组直接作为 ForEach 的数据源,每项包含绿色勾选标记、服务描述和绿色"免费"标签。六个救援服务项目覆盖了搭电、换胎、送油、拖车、开锁和事故翻译等常见的海外自驾紧急场景,每项都标注为免费服务,传递了救援服务的价值主张。使用须知区域使用 \n 换行符在单个 Text 组件中实现多行文本展示,配合 lineHeight(20) 设置行高,确保多行文本的可读性。这种在单个 Text 中使用换行符的方式,适用于内容固定的提示性文本,比使用多个 Text 组件更加简洁。

十、翻译助手与汇率换算 Tab 解析

代码段 21:翻译助手列表与收藏切换逻辑

  @Builder
  phraseTab() {
    Scroll() {
      Column() {
        Column() {
          ForEach(this.favPhrases, (p: PhraseItem) => {
            Column() {
              Row() {
                Text(p.scene)
                  .fontSize(10)
                  .fontColor(COLORS.white)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .backgroundColor(COLORS.primaryLight)
                  .borderRadius(8)
                if (p.favorite) {
                  Text('★ 已收藏')
                    .fontSize(9)
                    .fontColor(COLORS.gold)
                    .margin({ left: 8 })
                }
              }
              Text(p.zh)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .margin({ top: 8 })
              Text(p.local)
                .fontSize(13)
                .fontColor(COLORS.primary)
                .margin({ top: 6 })
              Text(p.pronounce)
                .fontSize(10)
                .fontColor(COLORS.textHint)
                .margin({ top: 4 })

              Row() {
                Text('🔊 播放')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .fontWeight(FontWeight.Bold)
                  .layoutWeight(1)
                if (p.favorite) {
                  Text('取消收藏')
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                    .onClick(() => { this.toggleFav(p.id) })
                } else {
                  Text('收藏')
                    .fontSize(11)
                    .fontColor(COLORS.gold)
                    .fontWeight(FontWeight.Bold)
                    .onClick(() => { this.toggleFav(p.id) })
                }
              }
            }
          }, (p: PhraseItem) => p.id.toString() + '-' + p.favorite.toString())
        }

翻译助手页面使用 this.favPhrases 作为 ForEach 的数据源(而非静态 PHRASES 数组),这是因为收藏状态需要动态变化,必须使用 @State 修饰的可变数组。每张短语卡片包含四层信息:场景标签+收藏状态、中文原文、目标语言原文、罗马音标注。场景标签使用浅蓝色背景胶囊,收藏状态使用金色星标——两者通过 Row 水平排列在同一行。

收藏切换逻辑是翻译助手页面的核心交互。当用户点击"收藏"或"取消收藏"时,调用 this.toggleFav(p.id) 方法。该方法遍历 favPhrases 数组,找到对应 id 的项,创建一个新的 PhraseItem 对象(将 favorite 取反),替换原项后重新赋值给 favPhrases。这里采用了不可变数据更新模式——不直接修改原对象的 favorite 属性,而是创建新对象并替换数组。这种模式在 ArkTS 中确保了 @State 能正确检测到数组内容的变化,从而触发 ForEach 的重新渲染。ForEach 的键值生成器使用 p.id.toString() + '-' + p.favorite.toString(),包含了 favorite 字段,确保收藏状态变化时对应卡片的键值改变,触发精确的增量更新。

代码段 22:toggleFav 不可变数据更新方法

  toggleFav(id: number): void {
    const next: PhraseItem[] = []
    for (let i = 0; i < this.favPhrases.length; i++) {
      const p = this.favPhrases[i]
      if (p.id === id) {
        const np: PhraseItem = {
          id: p.id, scene: p.scene, zh: p.zh,
          local: p.local, pronounce: p.pronounce,
          favorite: !p.favorite
        }
        next.push(np)
      } else {
        next.push(p)
      }
    }
    this.favPhrases = next
  }

这段代码展示了 HarmonyOS ArkTS API 24 中实现不可变状态更新的标准模式。toggleFav 方法不直接修改 favPhrases 数组中的对象属性,而是创建一个全新的数组 next,遍历原数组将每个元素复制到新数组中,当遇到目标 id 的元素时创建一个 favorite 取反的新对象。最终将 next 赋值给 this.favPhrases,触发 @State 的变更检测和 UI 刷新。

这种模式的重要性在于:ArkTS 的 @State 对数组的变更检测依赖于引用比较。如果直接修改数组内对象的属性(如 this.favPhrases[i].favorite = !this.favPhrases[i].favorite),数组引用本身没有变化,@State 可能无法检测到内部属性的变化。通过创建新数组并重新赋值,确保了数组引用的变更,从而可靠地触发响应式更新。虽然这种方式在数据量大时存在一定的内存开销(需要创建新数组),但对于短语列表这种数据量有限的场景来说,性能影响可以忽略。这种不可变数据更新模式与 React/Flutter 中的状态管理理念一脉相承,是声明式 UI 框架中状态管理的最佳实践。

代码段 23:汇率换算与多币种计算方法

  @Builder
  rateTab() {
    Scroll() {
      Column() {
        Column() {
          Text('快速换算(CNY)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)

          Row() {
            ForEach(['100', '500', '1000', '5000'], (amt: string) => {
              Column() {
                Text('¥' + amt)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.primaryDark)
                Text('≈ ' + this.jpyOf(amt) + ' JPY')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Text('≈ ' + this.usdOf(amt) + ' USD')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
                Text('≈ ' + this.eurOf(amt) + ' EUR')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .backgroundColor(COLORS.bg)
              .borderRadius(14)
              .margin({ left: 5, right: 5, top: 12 })
            }, (amt: string) => amt)
          }

          Text('更新于今日 10:32 · 仅供参考')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ top: 10 })
        }

汇率换算页面展示了 ArkTS 中方法驱动 UI 渲染的模式。快速换算区域使用 ForEach 遍历四个金额字符串(100、500、1000、5000),每个金额卡片通过调用 this.jpyOf(amt)this.usdOf(amt)this.eurOf(amt) 三个方法,分别计算并显示对应的日元、美元和欧元换算结果。这种在 UI 声明中直接调用方法的方式,使得每次页面重新渲染时都会重新执行计算,确保展示结果的实时性。

三个汇率计算方法的实现十分简洁:

  jpyOf(amt: string): string {
    return (Number(amt) / 4.87 * 100).toFixed(0)
  }
  usdOf(amt: string): string {
    return (Number(amt) / 7.15).toFixed(1)
  }
  eurOf(amt: string): string {
    return (Number(amt) / 7.83).toFixed(1)
  }

这三个方法分别使用固定的汇率常量进行换算。Number(amt) 将字符串转换为数值类型进行除法运算,toFixed 方法控制小数位数——日元取整(因为日元面值较大),美元和欧元保留一位小数。返回值统一为 string 类型,直接在 Text 组件中展示。虽然这里使用的是固定汇率,在实际应用中应替换为实时汇率 API 获取的数据,但这种方法封装的设计使得替换实时数据源时只需修改方法内部实现,不影响 UI 层的渲染逻辑。

热门货币汇率列表使用 ForEach 渲染 RATE_LIST 字符串数组,每项包含兑换符号和汇率文本。各国油价参考区域使用 o.split(' ') 对字符串进行分割,提取国家名称和油价数值分别渲染,这是 ArkTS 中处理简单结构化字符串的便捷方式。两列布局中,国家名称使用 layoutWeight(1) 占据左侧空间,油价数值右对齐展示,形成了清晰的对比表格视觉效果。

十一、弹框组件深度解析

代码段 24:预订租车弹框与费用计算

  @Builder
  bookModal() {
    Column() {
      Row() {
        Text('预订租车')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textHint)
          .onClick(() => { this.showBookModal = false })
      }

      if (this.selectedCar !== null) {
        Row() {
          Text(this.selectedCar.icon).fontSize(34)
          Column() {
            Text(this.selectedCar.brand + ' ' + this.selectedCar.model)
            Text(this.selectedCar.country + ' · ' + this.selectedCar.seat + '座 · ' + this.selectedCar.gear)
          }
          Column() {
            Text('¥' + this.selectedCar.dayPrice)
            Text('/天')
          }
        }

        Column() {
          Text('租期:' + this.bookDays + ' 天')
          Row() {
            ForEach([3, 5, 7, 10, 14], (d: number) => {
              Text(d + '天')
                .fontColor(this.bookDays === d ? COLORS.white : COLORS.textSecondary)
                .backgroundColor(this.bookDays === d ? COLORS.primary : COLORS.bg)
                .borderRadius(12)
                .onClick(() => { this.bookDays = d })
            }, (d: number) => d.toString() + this.bookDays.toString())
          }
          Text('满 7 天享 95 折 · 满 14 天享 9 折(已计入)')
            .fontColor(COLORS.gold)
        }

        Column() {
          ForEach(INSURANCE_OPTIONS, (ins: string) => {
            Row() {
              Text(ins).layoutWeight(1)
              Text('¥' + this.insurancePrice(ins) + '/天')
              if (this.bookInsurance === ins) {
                Text('✓').fontColor(COLORS.primary)
              }
            }
            .onClick(() => { this.bookInsurance = ins })
          }, (ins: string) => ins + this.bookInsurance)
        }

预订租车弹框是五个弹框中业务逻辑最复杂的一个。弹框内容分为六个区块:标题栏、车型信息卡、租期选择区、保险套餐选择区、驾龄确认区和费用明细区。租期选择区使用 ForEach 渲染五个预设天数(3、5、7、10、14 天),选中态通过白色文字+深蓝背景与未选中态的灰色文字+浅灰背景进行区分。ForEach 的键值生成器使用 d.toString() + this.bookDays.toString(),包含了当前选中天数,确保选中态变化时所有天数标签都能正确更新。

保险套餐选择区使用 ForEach 渲染 INSURANCE_OPTIONS 数组,每项包含保险名称、每日价格和选中标记。点击任一保险项时执行 this.bookInsurance = ins,触发保险选择状态和费用明细的同步更新。费用明细区通过 this.bookTotal().toLocaleString() 调用 bookTotal 方法实时计算预订总费用,该方法将日均租金、保险日均价格和租赁天数相乘——当用户切换租期或保险套餐时,@State 机制自动触发费用明细区域的重新渲染,实现总价的实时更新。弹框底部的"确认预订"按钮文字直接包含动态计算的总价:'确认预订 ¥' + this.bookTotal().toLocaleString(),让用户在点击前就能看到最终金额,提升了交互透明度。

代码段 25:弹框遮罩层 Overlay 架构

  @Builder
  bookModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => { onClose() })

      Scroll() {
        Column() {
          this.bookModal()
        }
      }
      .scrollable(ScrollDirection.Vertical)
      .constraintSize({ maxHeight: '88%' })
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
  }

Overlay 架构是五个弹框共享的统一设计模式。每个 Overlay 方法接收一个 onClose: () => void 回调参数,包含两层结构:第一层是一个全屏的半透明遮罩 Column,使用 backgroundColor('rgba(26,32,80,0.55)') 实现深蓝色半透明背景,通过 position({ x: 0, y: 0 }) 定位到屏幕左上角,点击遮罩区域执行 onClose() 回调关闭弹框。

第二层是实际的弹框内容。bookModalOverlay 使用 Scroll 包裹 bookModal 内容,配合 constraintSize({ maxHeight: '88%' }) 限制弹框最大高度为屏幕的 88%。当弹框内容超出此高度时,用户可以在弹框内部垂直滚动查看全部内容。这种设计解决了长内容弹框在屏幕上的溢出问题——预订弹框包含车型信息、租期选择、保险选择、驾龄确认和费用明细等多个区块,在小屏设备上很可能超出屏幕高度,Scroll + constraintSize 的组合确保了弹框内容的完整可访问性。

zIndex(999) 确保弹框层位于页面所有其他内容之上。justifyContent(FlexAlign.Center) 使弹框内容在遮罩层中垂直居中。不同弹框的 Overlay 有细微差异:licenseModalOverlay 和 cancelModalOverlay 直接包裹内容(无 Scroll),因为这些弹框内容较短不需要滚动;carDetailModalOverlay 直接包裹 carDetailModal(内部已有 Scroll);phraseModalOverlay 使用 justifyContent(FlexAlign.End) 使弹框从底部滑出,模拟 BottomSheet 的交互模式。这种统一的 Overlay 架构配合不同的内部布局策略,实现了居中弹框、居中表单、紧凑警告、头部渐变详情和底部滑出五种不同的弹框交互模式。

代码段 26:取消订单弹框与状态修改

  @Builder
  cancelModal() {
    Column() {
      Text('⚠️')
        .fontSize(32)
        .margin({ top: 18 })
      Text('取消租车订单?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.danger)
        .margin({ top: 8 })

      if (this.selectedOrder !== null) {
        Text(this.selectedOrder.orderNo)
        Text('取车时间:' + this.selectedOrder.pickupDate)
        Column() {
          Row() {
            Text('订单金额').layoutWeight(1)
            Text('¥' + this.selectedOrder.total.toLocaleString())
          }
          Row() {
            Text('距取车 > 48h 免费取消').layoutWeight(1)
            Text('¥0 手续费').fontColor(COLORS.success)
          }
          Row() {
            Text('预计原路退回').layoutWeight(1)
            Text('¥' + this.selectedOrder.total.toLocaleString()).fontColor(COLORS.primaryDark)
          }
        }

        Row() {
          Text('再想想')
            .backgroundColor(COLORS.bg)
            .onClick(() => { this.showCancelModal = false })
          Text('确认取消')
            .backgroundColor(COLORS.danger)
            .onClick(() => {
              if (this.selectedOrder !== null) {
                this.selectedOrder.status = '已取消'
                this.showCancelModal = false
              }
            })
        }
      }
    }
    .width('82%')
    .backgroundColor(COLORS.white)
    .borderRadius(18)
    .alignItems(HorizontalAlign.Center)
  }

取消订单弹框是一个紧凑的警告确认框。弹框顶部使用大号警告 emoji(32vp)和红色标题"取消租车订单?"营造警示氛围。弹框宽度设为 82%,比其他弹框更窄,形成紧凑的视觉比例。内容区显示订单号、取车时间、订单金额、手续费和退款金额,其中"¥0 手续费"使用绿色文字,"距取车 > 48h 免费取消"的文案暗示了取消政策的时间窗口。

“确认取消"按钮的 onClick 回调执行两个操作:this.selectedOrder.status = '已取消'this.showCancelModal = false。由于 selectedOrder 引用的是 DRIVE_ORDERS 数组中的 DriveOrderModel 实例(被 @Observed 修饰),修改其 status 属性会触发 ArkUI 框架的响应式更新——订单列表中对应项的状态标签会自动从橙色变为红色,文字从"待取车"变为"已取消”。这种数据双向绑定是 @Observed + @State 组合的核心优势:一处数据修改,多处 UI 自动同步。@Observed 的精细追踪机制确保只有真正依赖该属性的 UI 片段会重新渲染,而非整个列表全量重建。

代码段 27:车型详情弹框与特性标签

  @Builder
  carDetailModal() {
    Column() {
      if (this.selectedCar !== null) {
        Column() {
          Text(this.selectedCar.icon).fontSize(56).margin({ top: 22 })
          Text(this.selectedCar.brand + ' ' + this.selectedCar.model)
            .fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
          Row() {
            Text('⭐ ' + this.selectedCar.score.toFixed(1)).fontColor(COLORS.gold)
            Text(' · ' + this.selectedCar.country + ' · ' + this.selectedCar.fuel)
              .fontColor('#C5CAE9')
          }
        }
        .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]] })
        .borderRadius({ topLeft: 18, topRight: 18 })

        Scroll() {
          Column() {
            Row() {
              Column() { Text('座位'); Text(this.selectedCar.seat + ' 座') }
              Column() { Text('变速箱'); Text(this.selectedCar.gear) }
              Column() { Text('燃料'); Text(this.selectedCar.fuel) }
              Column() { Text('押金'); Text('¥' + this.selectedCar.deposit.toLocaleString()) }
            }
            // 每列 layoutWeight(1) 等分

            Row() {
              ForEach(this.selectedCar.freeCancel
                ? ['机场柜台取还', '中文客服支持', '免费取消', '无限里程']
                : ['机场柜台取还', '中文客服支持', '限时取消', '无限里程'],
                (f: string) => {
                  Text('✓ ' + f)
                    .fontColor(COLORS.primary)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(10)
                }, (f: string) => f)
            }
          }
        }
        .scrollable(ScrollDirection.Vertical)
      }
    }
    .width('86%')
    .backgroundColor(COLORS.white)
    .borderRadius(18)
    .clip(true)
  }

车型详情弹框采用了头部渐变+可滚动内容区的分层结构。弹框头部使用 135 度线性渐变背景,展示大号车型 emoji(56vp)、品牌车型名称和评分+国家+燃料的概要信息。头部的 borderRadius({ topLeft: 18, topRight: 18 }) 只对顶部两角进行圆角处理,与弹框整体的 borderRadius(18) 配合 clip(true) 实现内容裁剪——头部渐变区的顶部两角与弹框圆角对齐,底部两角为直角,与下方的白色内容区无缝衔接。clip(true) 是关键属性:它确保子内容(如渐变背景)不会溢出父容器的圆角边界。

内容区的四列规格面板使用等分 layoutWeight 布局展示座位数、变速箱、燃料类型和押金。特性标签区使用 ForEach 渲染条件数组——当 freeCancel 为 true 时渲染"免费取消",为 false 时渲染"限时取消"。这种基于条件选择不同数组的写法,是 ArkTS 中实现条件标签列表的简洁方式。每个标签使用浅灰背景和深蓝文字,配合圆角形成标签胶囊效果。底部的"立即预订"按钮在点击时先关闭详情弹框 this.showCarDetailModal = false,再打开预订弹框 this.showBookModal = true,实现了弹框间的链式调用——由于 selectedCar 在打开详情弹框时已赋值,预订弹框可以直接引用同一 selectedCar 数据,无需重复传参。

代码段 28:常用语编辑底部弹框

  @Builder
  phraseModal() {
    Column() {
      Row() {
        Column()
          .width(40).height(4)
          .backgroundColor(COLORS.textHint)
          .borderRadius(2)
          .margin({ top: 10 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

      Row() {
        Text('编辑常用语')
          .fontSize(16).fontWeight(FontWeight.Bold).layoutWeight(1)
        Text('✕')
          .fontSize(16).fontColor(COLORS.textHint)
          .onClick(() => { this.showPhraseModal = false })
      }
      .padding({ left: 18, right: 18, top: 14 })

      Column() {
        ForEach(this.favPhrases, (p: PhraseItem) => {
          Row() {
            Text(p.scene)
              .backgroundColor(COLORS.primaryLight).borderRadius(8)
            Text(p.zh)
              .maxLines(1).layoutWeight(1)
            if (p.favorite) {
              Text('★').fontColor(COLORS.gold)
                .onClick(() => { this.toggleFav(p.id) })
            } else {
              Text('☆').fontColor(COLORS.textHint)
                .onClick(() => { this.toggleFav(p.id) })
            }
          }
          .border({ width: { bottom: 1 }, color: COLORS.border })
        }, (p: PhraseItem) => 'edit-' + p.id.toString() + '-' + p.favorite.toString())
      }

      Text('完成')
        .backgroundColor(COLORS.primary).borderRadius(16)
        .onClick(() => { this.showPhraseModal = false })
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .borderRadius({ topLeft: 22, topRight: 22 })
  }

  @Builder
  phraseModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%').height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => { onClose() })

      Column() {
        this.phraseModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

常用语编辑弹框是五个弹框中唯一从底部滑出的弹框。弹框顶部使用一个 40vp 宽、4vp 高的灰色圆角短条作为拖拽指示器,这是移动端 BottomSheet 设计的经典视觉元素。弹框使用 width('100%') 占满屏幕宽度,配合 borderRadius({ topLeft: 22, topRight: 22 }) 只对顶部两角进行大圆角处理,形成了从底部滑出的卡片效果。

phraseModalOverlay 的布局与其他 Overlay 不同:内层 Column 使用 justifyContent(FlexAlign.End) 将弹框内容推到底部。遮罩层仍然是全屏半透明背景,点击可关闭。弹框内容使用 ForEach 遍历 favPhrases 渲染编辑列表,每项包含场景标签、中文原文(maxLines(1) 限制单行显示防止溢出)和收藏切换星标。实心星号 ★ 表示已收藏(金色),空心星号 ☆ 表示未收藏(灰色),点击任一星标调用 toggleFav 方法切换收藏状态。由于 favPhrases 是 @State 变量,收藏状态变化后弹框内的列表会自动更新星标显示,同时翻译助手页面的列表也会同步更新——因为两个页面引用的是同一个 favPhrases 数组。ForEach 的键值生成器使用 'edit-' + p.id.toString() + '-' + p.favorite.toString(),添加了前缀 ‘edit-’ 以避免与翻译助手页面的 ForEach 键值冲突,同时包含 favorite 字段确保收藏状态变化时正确更新。

十二、应用架构流程图

整体页面架构流程图

currentTab=0

currentTab=1

currentTab=2

currentTab=3

currentTab=4

currentTab=5

currentTab=6

应用入口 @Entry

GlobalDrivePage 主组件

Stack 层叠容器

第一层:页面主体 Column

第二层:弹框遮罩层

头部区域 - 渐变背景

内容区 - 条件渲染

底部双排 Tab 导航

标题 + 语言切换

待取车横幅

热门国家金刚区

rentTab 租车页面

orderTab 订单页面

licenseTab 驾照页面

mineTab 我的页面

rescueTab 道路救援

phraseTab 翻译助手

rateTab 汇率换算

上排4主功能Tab

下排3快捷工具Tab

bookModalOverlay 预订弹框

licenseModalOverlay 驾照弹框

cancelModalOverlay 取消弹框

carDetailModalOverlay 详情弹框

phraseModalOverlay 常用语弹框

预订流程状态流转图

查看详情后关闭

点击立即预订

点击取消

点击确认

用户浏览车型列表

点击详情按钮

打开车型详情弹框

用户决策

返回列表

关闭详情弹框

打开预订弹框

选择租期天数

选择保险套餐

确认驾龄声明

确认预订

关闭弹框

关闭弹框完成预订

订单状态流转图

已确认

待取车

已完成

已取消

蓝色 primary

橙色 warning

灰色 textSecondary

红色 danger

十三、核心技术对比分析

对比表格 1:ArkTS 状态装饰器对比

装饰器作用范围响应粒度典型场景性能影响
@State组件内部变量级Tab 切换、弹框开关、表单输入低,仅触发当前组件重渲染
@Observed类实例属性级订单状态变更、对象属性修改中,追踪对象属性访问
@Builder方法级无状态页面片段复用、弹框内容封装无额外开销,编译期内联
@Entry应用级无状态标识入口组件无运行时开销
@Component组件级无状态声明可复用组件无运行时开销

对比表格 2:五种弹框交互模式对比

弹框名称显示位置内容布局可滚动Overlay 布局策略触发来源
预订租车居中多区块表单是(Scroll+constraintSize)FlexAlign.Center + zIndex 999车型卡片"预订"按钮
驾照翻译居中表单+标签选择FlexAlign.Center + zIndex 999驾照页面"新办翻译件"
取消订单居中紧凑警告+确认FlexAlign.Center + zIndex 999订单卡片"取消订单"按钮
车型详情居中渐变头部+内容是(内部 Scroll)FlexAlign.Center + zIndex 999车型卡片"详情"按钮
常用语编辑底部列表+完成按钮FlexAlign.End + zIndex 999翻译助手"编辑收藏"

对比表格 3:ForEach 键值生成策略对比

使用场景数据源键值生成策略包含动态字段更新策略
国家列表COUNTRIESid.toString()增量更新
车型列表CARSid.toString()增量更新
订单列表DRIVE_ORDERSid + ‘-’ + status是(status)状态变化时全量刷新该项
主功能 TabmainTabsname + idx + currentTab是(currentTab)切换时全量刷新
快捷 TabquickTabsname + idx + currentTab是(currentTab)切换时全量刷新
租期选择[3,5,7,10,14]d + bookDays是(bookDays)选择变化时全量刷新
保险选择INSURANCE_OPTIONSins + bookInsurance是(bookInsurance)选择变化时全量刷新
短语列表favPhrasesid + ‘-’ + favorite是(favorite)收藏变化时刷新该项
编辑短语favPhrases‘edit-’ + id + ‘-’ + favorite是(favorite)收藏变化时刷新该项

对比表格 4:ArkTS 可视化组件对比

可视化组件实现方式数据驱动交互能力适用场景
柱状图ForEach + Column 高度barHeight 方法计算无(静态展示)价格对比、数据排名
进度条Progress 组件value/total 属性无(静态展示)办理进度、完成度
租期条Circle + Rect + TextForEach 数据绑定无(信息展示)取还车时间轴
金刚区ForEach + Column 等分数组 sliceonClick 可扩展快捷入口、分类导航
统计面板Row + Column 等分静态数值数据概览、统计汇总
足迹展示ForEach + Text 排列数组遍历标签云、足迹地图

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 场景:境外自驾租车(驾照翻译 · 跨国取还 · 道路救援)
// 风格:深蓝国际风 + 金色点缀
// Tab 样式:双排 4+3(上排主功能,下排快捷工具,选中金色下划线)
// 弹框:预订租车 / 驾照翻译申请 / 取消订单 / 车型详情 / 常用语编辑
// ============================================================

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  gold: string;
  goldLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

const COLORS: ColorPalette = {
  primary: '#283593',
  primaryLight: '#7986CB',
  primaryDark: '#1A237E',
  gold: '#FFC107',
  goldLight: '#FFF3C4',
  bg: '#E8EAF6',
  cardBg: '#FFFFFF',
  textPrimary: '#1A2050',
  textSecondary: '#5C6390',
  textHint: '#A6ACC9',
  border: '#DDE0F0',
  success: '#43A047',
  warning: '#FB8C00',
  danger: '#E53935',
  white: '#FFFFFF'
};

interface CountryInfo {
  id: number;
  name: string;
  flag: string;
  driveSide: string;
  minAge: number;
  avgPrice: number;
  hot: boolean;
}

interface CarType {
  id: number;
  brand: string;
  model: string;
  icon: string;
  seat: number;
  gear: string;
  fuel: string;
  dayPrice: number;
  deposit: number;
  score: number;
  country: string;
  freeCancel: boolean;
}

interface DriveOrder {
  id: number;
  orderNo: string;
  country: string;
  car: string;
  pickupCity: string;
  dropCity: string;
  pickupDate: string;
  dropDate: string;
  days: number;
  total: number;
  status: string;
  insurance: string;
}

interface LicenseItem {
  id: number;
  country: string;
  flag: string;
  validYears: number;
  needNotary: boolean;
  langs: string;
  price: number;
}

interface PhraseItem {
  id: number;
  scene: string;
  zh: string;
  local: string;
  pronounce: string;
  favorite: boolean;
}

@Observed
class DriveOrderModel {
  id: number = 0
  orderNo: string = ''
  country: string = ''
  car: string = ''
  pickupCity: string = ''
  dropCity: string = ''
  pickupDate: string = ''
  dropDate: string = ''
  days: number = 0
  total: number = 0
  status: string = ''
  insurance: string = ''
  constructor(id: number, orderNo: string, country: string, car: string, pickupCity: string, dropCity: string, pickupDate: string, dropDate: string, days: number, total: number, status: string, insurance: string) {
    this.id = id; this.orderNo = orderNo; this.country = country; this.car = car
    this.pickupCity = pickupCity; this.dropCity = dropCity
    this.pickupDate = pickupDate; this.dropDate = dropDate
    this.days = days; this.total = total
    this.status = status; this.insurance = insurance
  }
}

const COUNTRIES: CountryInfo[] = [
  { id: 1, name: '日本', flag: '🇯🇵', driveSide: '左舵右行', minAge: 18, avgPrice: 420, hot: true },
  { id: 2, name: '泰国', flag: '🇹🇭', driveSide: '左舵右行', minAge: 21, avgPrice: 260, hot: true },
  { id: 3, name: '新西兰', flag: '🇳🇿', driveSide: '右舵左行', minAge: 21, avgPrice: 580, hot: true },
  { id: 4, name: '澳大利亚', flag: '🇦🇺', driveSide: '右舵左行', minAge: 21, avgPrice: 620, hot: false },
  { id: 5, name: '德国', flag: '🇩🇪', driveSide: '左舵右行', minAge: 21, avgPrice: 540, hot: true },
  { id: 6, name: '美国', flag: '🇺🇸', driveSide: '左舵右行', minAge: 21, avgPrice: 660, hot: true },
  { id: 7, name: '法国', flag: '🇫🇷', driveSide: '左舵右行', minAge: 18, avgPrice: 520, hot: false },
  { id: 8, name: '冰岛', flag: '🇮🇸', driveSide: '左舵右行', minAge: 20, avgPrice: 890, hot: false },
  { id: 9, name: '马来西亚', flag: '🇲🇾', driveSide: '右舵左行', minAge: 23, avgPrice: 230, hot: false },
  { id: 10, name: '葡萄牙', flag: '🇵🇹', driveSide: '左舵右行', minAge: 21, avgPrice: 480, hot: false }
];

const CARS: CarType[] = [
  { id: 1, brand: 'Toyota', model: '普锐斯 混动', icon: '🚗', seat: 5, gear: '自动挡', fuel: '油电混动', dayPrice: 420, deposit: 1500, score: 4.8, country: '日本', freeCancel: true },
  { id: 2, brand: 'Honda', model: '飞度 Compact', icon: '🚙', seat: 5, gear: '自动挡', fuel: '汽油', dayPrice: 280, deposit: 1000, score: 4.7, country: '泰国', freeCancel: true },
  { id: 3, brand: 'Toyota', model: '阿尔法 MPV', icon: '🚐', seat: 7, gear: '自动挡', fuel: '汽油', dayPrice: 1180, deposit: 3000, score: 4.9, country: '日本', freeCancel: false },
  { id: 4, brand: 'Subaru', model: '傲虎 四驱', icon: '🚙', seat: 5, gear: '自动挡', fuel: '汽油', dayPrice: 580, deposit: 1800, score: 4.8, country: '新西兰', freeCancel: true },
  { id: 5, brand: 'Toyota', model: '陆地巡洋舰', icon: '🛻', seat: 7, gear: '自动挡', fuel: '柴油', dayPrice: 920, deposit: 2800, score: 4.9, country: '澳大利亚', freeCancel: false },
  { id: 6, brand: 'VW', model: '高尔夫 旅行版', icon: '🚗', seat: 5, gear: '自动挡', fuel: '汽油', dayPrice: 540, deposit: 1600, score: 4.7, country: '德国', freeCancel: true },
  { id: 7, brand: 'Tesla', model: 'Model 3', icon: '⚡', seat: 5, gear: '自动挡', fuel: '纯电', dayPrice: 760, deposit: 2500, score: 4.8, country: '美国', freeCancel: true },
  { id: 8, brand: 'Renault', model: 'Clio 掀背', icon: '🚗', seat: 5, gear: '手动挡', fuel: '汽油', dayPrice: 380, deposit: 1200, score: 4.5, country: '法国', freeCancel: true },
  { id: 9, brand: 'Suzuki', model: '吉姆尼 越野', icon: '🚙', seat: 4, gear: '手动挡', fuel: '汽油', dayPrice: 890, deposit: 2600, score: 4.9, country: '冰岛', freeCancel: false },
  { id: 10, brand: 'Perodua', model: 'Myvi 经济型', icon: '🚗', seat: 5, gear: '自动挡', fuel: '汽油', dayPrice: 230, deposit: 800, score: 4.6, country: '马来西亚', freeCancel: true }
];

const DRIVE_ORDERS: DriveOrder[] = [
  new DriveOrderModel(1, 'GD20260901001', '日本', '普锐斯 混动', '东京 羽田机场 T3', '大阪 关西机场 T1', '2026-09-01 10:00', '2026-09-08 10:00', 7, 2940, '待取车', '全险+零免赔'),
  new DriveOrderModel(2, 'GD20261012002', '新西兰', '傲虎 四驱', '基督城机场店', '皇后镇机场店', '2026-10-12 09:00', '2026-10-22 09:00', 10, 5800, '已确认', '全险+异地还车'),
  new DriveOrderModel(3, 'GD20260506003', '泰国', '飞度 Compact', '曼谷素万那普机场', '曼谷素万那普机场', '2026-05-06 13:00', '2026-05-11 13:00', 5, 1400, '已完成', '基础险'),
  new DriveOrderModel(4, 'GD20260318004', '德国', '高尔夫 旅行版', '慕尼黑中央车站', '法兰克福机场 T1', '2026-03-18 10:00', '2026-03-25 10:00', 7, 3780, '已完成', '全险'),
  new DriveOrderModel(5, 'GD20260102005', '冰岛', '吉姆尼 越野', '雷克雅未克凯夫拉机场', '雷克雅未克凯夫拉机场', '2026-01-02 08:00', '2026-01-06 08:00', 4, 3560, '已完成', '碎石险+沙尘险'),
  new DriveOrderModel(6, 'GD20251128006', '日本', '阿尔法 MPV', '名古屋中部机场', '东京羽田机场 T3', '2025-11-28 11:00', '2025-12-02 11:00', 4, 4720, '已完成', '全险+儿童座椅'),
  new DriveOrderModel(7, 'GD20250914007', '美国', 'Model 3', '洛杉矶机场 LAX', '旧金山机场 SFO', '2025-09-14 09:00', '2025-09-21 09:00', 7, 5320, '已完成', '全险'),
  new DriveOrderModel(8, 'GD20250705008', '澳大利亚', '陆地巡洋舰', '凯恩斯机场', '凯恩斯机场', '2025-07-05 10:00', '2025-07-09 10:00', 4, 3680, '已完成', '全险+露营装备'),
  new DriveOrderModel(9, 'GD20250420009', '法国', 'Clio 掀背', '巴黎戴高乐机场 T2', '巴黎戴高乐机场 T2', '2025-04-20 10:00', '2025-04-24 10:00', 4, 1520, '已取消', '基础险'),
  new DriveOrderModel(10, 'GD20250210010', '马来西亚', 'Myvi 经济型', '吉隆坡机场 KLIA2', '吉隆坡机场 KLIA2', '2025-02-10 12:00', '2025-02-14 12:00', 4, 920, '已完成', '基础险')
];

const LICENSES: LicenseItem[] = [
  { id: 1, country: '日本', flag: '🇯🇵', validYears: 3, needNotary: false, langs: '日文+英文', price: 68 },
  { id: 2, country: '泰国', flag: '🇹🇭', validYears: 1, needNotary: false, langs: '泰文+英文', price: 58 },
  { id: 3, country: '新西兰', flag: '🇳🇿', validYears: 2, needNotary: false, langs: '英文', price: 48 },
  { id: 4, country: '澳大利亚', flag: '🇦🇺', validYears: 2, needNotary: false, langs: '英文', price: 48 },
  { id: 5, country: '德国', flag: '🇩🇪', validYears: 3, needNotary: true, langs: '德文+英文', price: 98 },
  { id: 6, country: '美国', flag: '🇺🇸', validYears: 2, needNotary: false, langs: '英文', price: 48 },
  { id: 7, country: '法国', flag: '🇫🇷', validYears: 3, needNotary: true, langs: '法文+英文', price: 98 },
  { id: 8, country: '冰岛', flag: '🇮🇸', validYears: 2, needNotary: false, langs: '英文', price: 48 }
];

const PHRASES: PhraseItem[] = [
  { id: 1, scene: '取车', zh: '我预订了租车,来取车', local: '予約していた車を受け取りに来ました', pronounce: 'yoyaku shiteita kuruma wo uketori ni kimashita', favorite: true },
  { id: 2, scene: '加油', zh: '请加满普通汽油', local: 'レギュラー満タンでお願いします', pronounce: 'regular mantan de onegai shimasu', favorite: true },
  { id: 3, scene: '问路', zh: '请问最近的加油站在哪里?', local: '最寄りのガソリンスタンドはどこですか', pronounce: 'moyori no gasorin sutaando wa doko desu ka', favorite: false },
  { id: 4, scene: '停车', zh: '这里可以停车吗?', local: 'ここに駐車できますか', pronounce: 'koko ni chuusha dekimasu ka', favorite: false },
  { id: 5, scene: '事故', zh: '发生事故了,请报警', local: '事故が発生しました。警察を呼んでください', pronounce: 'jiko ga hassei shimashita. keisatsu wo yonde kudasai', favorite: true },
  { id: 6, scene: '还车', zh: '我来还车', local: '車を返しに来ました', pronounce: 'kuruma wo kaeshi ni kimashita', favorite: false },
  { id: 7, scene: '求助', zh: '请叫道路救援', local: 'ロードサービスを呼んでください', pronounce: 'roodo saabisu wo yonde kudasai', favorite: false },
  { id: 8, scene: '买单', zh: '可以用信用卡吗?', local: 'クレジットカードは使えますか', pronounce: 'kurejitto kaado wa tsukaemasu ka', favorite: false },
  { id: 9, scene: '高速', zh: '这个高速入口怎么走?', local: 'この高速道路の入り口はどう行きますか', pronounce: 'kono kousoku douro no iriguchi wa dou ikimasu ka', favorite: false },
  { id: 10, scene: '道歉', zh: '抱歉,我第一次在这里开车', local: 'すみません、初めてここで運転します', pronounce: 'sumimasen, hajimete koko de unten shimasu', favorite: false }
];

const INSURANCE_OPTIONS: string[] = ['基础险(免赔 ¥1500)', '全险(零免赔)', '全险+异地还车'];
const PRICE_BARS: number[] = [230, 260, 380, 420, 480, 540, 620, 660, 890];
const PRICE_COUNTRIES: string[] = ['马', '泰', '法', '日', '葡', '德', '澳', '美', '冰'];
const RATE_LIST: string[] = ['JPY 100 ≈ ¥4.87', 'THB 100 ≈ ¥20.3', 'NZD 1 ≈ ¥4.21', 'EUR 1 ≈ ¥7.83', 'USD 1 ≈ ¥7.15', 'AUD 1 ≈ ¥4.62', 'ISK 100 ≈ ¥5.19'];

// ============ 主页面 ============
@Entry
@Component
struct GlobalDrivePage {
  @State currentTab: number = 0
  @State showBookModal: boolean = false
  @State showLicenseModal: boolean = false
  @State showCancelModal: boolean = false
  @State showCarDetailModal: boolean = false
  @State showPhraseModal: boolean = false
  @State selectedCar: CarType | null = null
  @State selectedOrder: DriveOrder | null = null
  @State bookInsurance: string = '全险(零免赔)'
  @State bookDays: number = 7
  @State favPhrases: PhraseItem[] = PHRASES.slice()
  private mainTabs: string[] = ['租车', '订单', '驾照', '我的']
  private mainIcons: string[] = ['🚙', '📋', '🛂', '👤']
  private quickTabs: string[] = ['道路救援', '翻译助手', '汇率换算']
  private quickIcons: string[] = ['🆘', '🌐', '💱']

  insurancePrice(ins: string): number {
    if (ins === '基础险(免赔 ¥1500)') {
      return 45
    } else if (ins === '全险(零免赔)') {
      return 88
    }
    return 128
  }

  bookTotal(): number {
    const car = this.selectedCar
    if (car === null) {
      return 0
    }
    return (car.dayPrice + this.insurancePrice(this.bookInsurance)) * this.bookDays
  }

  maxBar(): number {
    let m = 0
    for (let i = 0; i < PRICE_BARS.length; i++) {
      if (PRICE_BARS[i] > m) {
        m = PRICE_BARS[i]
      }
    }
    return m
  }

  barHeight(v: number): number {
    return Math.round(v / this.maxBar() * 80)
  }

  orderColor(s: string): string {
    if (s === '待取车') {
      return COLORS.warning
    } else if (s === '已确认') {
      return COLORS.primary
    } else if (s === '已取消') {
      return COLORS.danger
    }
    return COLORS.textSecondary
  }

  build() {
    Stack() {
      Column() {
        // ============ 头部(国际租车风 · 无动画) ============
        Column() {
          Row() {
            Column() {
              Text('国际自驾')
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('30 国 · 机场取还 · 中文客服 24h')
                .fontSize(12)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)

            Row() {
              Text('🌐')
                .fontSize(18)
              Text('中文 / CNY')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primaryDark)
                .margin({ left: 4 })
            }
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(COLORS.gold)
            .borderRadius(14)
          }
          .width('100%')
          .padding({ left: 18, right: 18, top: 14, bottom: 14 })
          .justifyContent(FlexAlign.SpaceBetween)
          .alignItems(VerticalAlign.Center)

          // 待取车横幅
          Row() {
            Text('🇯🇵')
              .fontSize(26)
            Column() {
              Text('东京行程还有 9 天取车')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('普锐斯混动 · 羽田 T3 取 · 09-01 10:00')
                .fontSize(11)
                .fontColor('#C5CAE9')
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
            .layoutWeight(1)

            Text('详情')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .backgroundColor(COLORS.gold)
              .borderRadius(12)
          }
          .width('94%')
          .padding(12)
          .borderRadius(14)
          .backgroundColor('rgba(255,255,255,0.12)')
          .margin({ top: 4 })
          .alignItems(VerticalAlign.Center)

          // 热门国家金刚
          Row() {
            ForEach(COUNTRIES.slice(0, 5), (c: CountryInfo) => {
              Column() {
                Text(c.flag)
                  .fontSize(24)
                Text(c.name)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.white)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)
            }, (c: CountryInfo) => c.id.toString())
          }
          .width('94%')
          .margin({ top: 12, bottom: 14 })
        }
        .width('100%')
        .linearGradient({
          angle: 160,
          colors: [[COLORS.primaryDark, 0], [COLORS.primary, 0.65], ['#3949AB', 1]]
        })
        .borderRadius({ bottomLeft: 24, bottomRight: 24 })

        // ============ 内容区 ============
        Column() {
          if (this.currentTab === 0) {
            this.rentTab()
          } else if (this.currentTab === 1) {
            this.orderTab()
          } else if (this.currentTab === 2) {
            this.licenseTab()
          } else if (this.currentTab === 3) {
            this.mineTab()
          } else if (this.currentTab === 4) {
            this.rescueTab()
          } else if (this.currentTab === 5) {
            this.phraseTab()
          } else {
            this.rateTab()
          }
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

        // ============ 底部双排 4+3 Tab ============
        Column() {
          // 上排主功能(金色下划线)
          Row() {
            ForEach(this.mainTabs, (name: string, idx: number) => {
              Column() {
                Text(this.mainIcons[idx])
                  .fontSize(19)
                  .opacity(this.currentTab === idx ? 1 : 0.5)
                Text(name)
                  .fontSize(11)
                  .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
                  .fontColor(this.currentTab === idx ? COLORS.primary : COLORS.textSecondary)
                  .margin({ top: 2 })
                Column()
                  .width(this.currentTab === idx ? 22 : 0)
                  .height(3)
                  .backgroundColor(COLORS.gold)
                  .borderRadius(2)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .onClick(() => {
                this.currentTab = idx
              })
            }, (name: string, idx: number) => name + idx.toString() + this.currentTab.toString())
          }
          .width('100%')
          .padding({ top: 8 })

          // 下排快捷工具(胶囊)
          Row() {
            ForEach(this.quickTabs, (name: string, idx: number) => {
              Row() {
                Text(this.quickIcons[idx])
                  .fontSize(13)
                Text(name)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.currentTab === idx + 4 ? COLORS.primaryDark : COLORS.textSecondary)
                  .margin({ left: 4 })
              }
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .backgroundColor(this.currentTab === idx + 4 ? COLORS.gold : COLORS.bg)
              .borderRadius(14)
              .scale({ x: this.currentTab === idx + 4 ? 1.05 : 1.0, y: this.currentTab === idx + 4 ? 1.05 : 1.0 })
              .animation({ duration: 200, curve: Curve.EaseOut })
              .onClick(() => {
                this.currentTab = idx + 4
              })
            }, (name: string, idx: number) => name + idx.toString() + this.currentTab.toString())
          }
          .width('100%')
          .padding({ top: 8, bottom: 8 })
          .justifyContent(FlexAlign.Center)
        }
        .width('100%')
        .backgroundColor(COLORS.white)
        .shadow({
          radius: 14,
          color: 'rgba(26,35,126,0.12)',
          offsetX: 0,
          offsetY: -4
        })
      }
      .width('100%')
      .height('100%')

      // ============ 弹框层 ============
      if (this.showBookModal) {
        this.bookModalOverlay(() => {
          this.showBookModal = false
        })
      }
      if (this.showLicenseModal) {
        this.licenseModalOverlay(() => {
          this.showLicenseModal = false
        })
      }
      if (this.showCancelModal) {
        this.cancelModalOverlay(() => {
          this.showCancelModal = false
        })
      }
      if (this.showCarDetailModal) {
        this.carDetailModalOverlay(() => {
          this.showCarDetailModal = false
        })
      }
      if (this.showPhraseModal) {
        this.phraseModalOverlay(() => {
          this.showPhraseModal = false
        })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

  // ============ Tab0 租车(国家横滑 + 车型大卡 + 均价图) ============
  @Builder
  rentTab() {
    Scroll() {
      Column() {
        // 国家横滑
        Scroll() {
          Row() {
            ForEach(COUNTRIES, (c: CountryInfo) => {
              Column() {
                Text(c.flag)
                  .fontSize(28)
                  .padding(8)
                  .backgroundColor(c.hot ? COLORS.goldLight : COLORS.bg)
                  .borderRadius(18)
                Text(c.name)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .margin({ top: 4 })
                Text('¥' + c.avgPrice + '/日均')
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center)
              .padding(8)
              .backgroundColor(COLORS.white)
              .borderRadius(14)
              .margin({ left: 8 })
            }, (c: CountryInfo) => c.id.toString())
          }
          .padding({ left: 8, right: 8 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .width('100%')
        .margin({ top: 12 })

        Text('精选车型 · 共 10 款')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
          .padding({ left: 16, right: 16, top: 16 })

        Column() {
          ForEach(CARS, (c: CarType) => {
            Column() {
              Row() {
                Column() {
                  Text(c.icon)
                    .fontSize(40)
                    .padding(14)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(18)
                  Text(c.country)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center)

                Column() {
                  Row() {
                    Text(c.brand + ' ' + c.model)
                      .fontSize(14)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.textPrimary)
                    if (c.freeCancel) {
                      Text('免费取消')
                        .fontSize(8)
                        .fontColor(COLORS.success)
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                        .backgroundColor('#E8F5E9')
                        .borderRadius(8)
                        .margin({ left: 6 })
                    }
                  }
                  .alignItems(VerticalAlign.Center)
                  Text(c.seat + '座 · ' + c.gear + ' · ' + c.fuel)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })
                  Row() {
                    Text('⭐ ' + c.score.toFixed(1))
                      .fontSize(10)
                      .fontColor(COLORS.warning)
                    Text('押金 ¥' + c.deposit)
                      .fontSize(10)
                      .fontColor(COLORS.textHint)
                      .margin({ left: 10 })
                  }
                  .margin({ top: 4 })
                  Row() {
                    Text('¥' + c.dayPrice)
                      .fontSize(18)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.primaryDark)
                    Text('/天')
                      .fontSize(10)
                      .fontColor(COLORS.textHint)
                  }
                  .alignItems(VerticalAlign.Bottom)
                  .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 12 })
                .layoutWeight(1)

                Column() {
                  Text('详情')
                    .fontSize(11)
                    .fontColor(COLORS.primary)
                    .fontWeight(FontWeight.Bold)
                    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                    .backgroundColor(COLORS.bg)
                    .borderRadius(10)
                    .onClick(() => {
                      this.selectedCar = c
                      this.showCarDetailModal = true
                    })
                  Text('预订')
                    .fontSize(11)
                    .fontColor(COLORS.white)
                    .fontWeight(FontWeight.Bold)
                    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                    .backgroundColor(COLORS.primary)
                    .borderRadius(10)
                    .margin({ top: 8 })
                    .onClick(() => {
                      this.selectedCar = c
                      this.bookDays = 7
                      this.bookInsurance = '全险(零免赔)'
                      this.showBookModal = true
                    })
                }
                .alignItems(HorizontalAlign.Center)
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS.white)
            .borderRadius(16)
            .margin({ top: 10 })
            .alignItems(HorizontalAlign.Start)
          }, (c: CarType) => c.id.toString())
        }
        .width('100%')
        .padding({ left: 12, right: 12 })

        // 各国均价柱状图
        Column() {
          Text('各国日均租车价(¥)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Row() {
            ForEach(PRICE_COUNTRIES, (c: string, i: number) => {
              Column() {
                Text(PRICE_BARS[i].toString())
                  .fontSize(8)
                  .fontColor(COLORS.primaryDark)
                  .fontWeight(FontWeight.Bold)
                Column()
                  .width(16)
                  .height(this.barHeight(PRICE_BARS[i]))
                  .linearGradient({
                    angle: 180,
                    colors: [[COLORS.primaryLight, 0], [COLORS.primary, 1]]
                  })
                  .borderRadius({ topLeft: 4, topRight: 4 })
                  .margin({ top: 4 })
                Text(c)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Center)
              .margin({ left: 8, right: 8 })
            }, (c: string, i: number) => c + i.toString())
          }
          .justifyContent(FlexAlign.Center)
          .margin({ top: 12 })
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 16, left: 12, right: 12, bottom: 20 })
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  // ============ Tab1 订单(租期条订单列表) ============
  @Builder
  orderTab() {
    Scroll() {
      Column() {
        Row() {
          Text('租车订单')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('共 10 单 · 累计 56 天')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })

        Column() {
          ForEach(DRIVE_ORDERS, (o: DriveOrder) => {
            Column() {
              Row() {
                Text(o.orderNo)
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
                  .layoutWeight(1)
                Text(o.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.orderColor(o.status))
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)

              Row() {
                Text('🚗 ' + o.car)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text('¥' + o.total.toLocaleString())
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.primaryDark)
              }
              .width('100%')
              .margin({ top: 8 })
              .alignItems(VerticalAlign.Center)

              // 租期条
              Column() {
                Row() {
                  Column() {
                    Text(o.pickupDate)
                      .fontSize(9)
                      .fontColor(COLORS.textHint)
                    Text(o.pickupCity)
                      .fontSize(11)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.textPrimary)
                      .margin({ top: 2 })
                    Circle()
                      .width(8)
                      .height(8)
                      .fill(COLORS.gold)
                      .margin({ top: 6 })
                  }
                  .alignItems(HorizontalAlign.Start)

                  Column() {
                    Rect()
                      .width('100%')
                      .height(2)
                      .fill(COLORS.border)
                      .margin({ top: 22 })
                    Text(o.days + ' 天 · ' + o.insurance)
                      .fontSize(9)
                      .fontColor(COLORS.textSecondary)
                      .margin({ top: 4 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .layoutWeight(1)
                  .margin({ left: 8, right: 8 })

                  Column() {
                    Text(o.dropDate)
                      .fontSize(9)
                      .fontColor(COLORS.textHint)
                    Text(o.dropCity)
                      .fontSize(11)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.textPrimary)
                      .margin({ top: 2 })
                    Circle()
                      .width(8)
                      .height(8)
                      .fill(COLORS.primary)
                      .margin({ top: 6 })
                  }
                  .alignItems(HorizontalAlign.End)
                }
                .width('100%')
                .alignItems(VerticalAlign.Top)
              }
              .width('100%')
              .padding({ top: 12 })

              if (o.status === '待取车') {
                Row() {
                  Text('🌍 ' + o.country)
                    .fontSize(10)
                    .fontColor(COLORS.textSecondary)
                    .layoutWeight(1)
                  Text('取消订单')
                    .fontSize(10)
                    .fontColor(COLORS.danger)
                    .fontWeight(FontWeight.Bold)
                    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                    .backgroundColor('#FDECEA')
                    .borderRadius(10)
                    .onClick(() => {
                      this.selectedOrder = o
                      this.showCancelModal = true
                    })
                  Text('取车凭证')
                    .fontSize(10)
                    .fontColor(COLORS.white)
                    .fontWeight(FontWeight.Bold)
                    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                    .backgroundColor(COLORS.primary)
                    .borderRadius(10)
                    .margin({ left: 8 })
                }
                .width('100%')
                .margin({ top: 10 })
                .alignItems(VerticalAlign.Center)
              }
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS.white)
            .borderRadius(16)
            .margin({ top: 10 })
            .alignItems(HorizontalAlign.Start)
          }, (o: DriveOrder) => o.id.toString() + '-' + o.status)
        }
        .width('100%')
        .padding({ left: 12, right: 12, bottom: 20 })
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  // ============ Tab2 驾照(翻译件 + 认可国家) ============
  @Builder
  licenseTab() {
    Scroll() {
      Column() {
        // 驾照翻译卡
        Column() {
          Row() {
            Column() {
              Text('国际驾照翻译认证件')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('已办理 · 2028-08-30 前有效')
                .fontSize(11)
                .fontColor('#C5CAE9')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('🛂')
              .fontSize(38)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

          Row() {
            Text('覆盖 200+ 国家 · 与中国驾照同时出示有效')
              .fontSize(10)
              .fontColor('#C5CAE9')
              .layoutWeight(1)
            Text('续期 ¥48')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(COLORS.gold)
              .borderRadius(10)
          }
          .width('100%')
          .margin({ top: 12 })
          .alignItems(VerticalAlign.Center)
        }
        .width('94%')
        .padding(16)
        .borderRadius(18)
        .linearGradient({
          angle: 135,
          colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]]
        })
        .margin({ top: 14, left: 12, right: 12 })

        Row() {
          Text('各国驾照规则')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('+ 新办翻译件')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.primary)
            .borderRadius(10)
            .onClick(() => {
              this.showLicenseModal = true
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 16 })

        Column() {
          ForEach(LICENSES, (l: LicenseItem) => {
            Row() {
              Text(l.flag)
                .fontSize(26)

              Column() {
                Row() {
                  Text(l.country)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  if (l.needNotary) {
                    Text('需公证')
                      .fontSize(8)
                      .fontColor(COLORS.danger)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor('#FDECEA')
                      .borderRadius(8)
                      .margin({ left: 6 })
                  }
                }
                .alignItems(VerticalAlign.Center)
                Text(l.langs + ' · 翻译件有效期 ' + l.validYears + ' 年')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              .layoutWeight(1)

              Text('¥' + l.price)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primaryDark)
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
            .alignItems(VerticalAlign.Center)
          }, (l: LicenseItem) => l.id.toString())
        }
        .width('94%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 10, left: 12, right: 12 })

        // 办理进度
        Column() {
          Text('翻译件办理流程')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Row() {
            ForEach(['提交驾照', '人工翻译', '双语认证', '邮寄到家'], (step: string, idx: number) => {
              Column() {
                Circle()
                  .width(22)
                  .height(22)
                  .fill(idx < 3 ? COLORS.success : COLORS.border)
                Text(step)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)
            }, (step: string, idx: number) => step + idx.toString())
          }
          .width('100%')
          .margin({ top: 12 })
          Progress({ value: 100, total: 100, type: ProgressType.Linear })
            .width('100%')
            .height(6)
            .style({ strokeWidth: 6 })
            .margin({ top: 10 })
          Text('您的翻译件已寄出 · 顺丰 SF1234567890')
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .margin({ top: 8 })
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 14, left: 12, right: 12, bottom: 20 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  // ============ Tab3 我的 ============
  @Builder
  mineTab() {
    Scroll() {
      Column() {
        Row() {
          Text('🧳')
            .fontSize(40)
            .padding(10)
            .backgroundColor(COLORS.bg)
            .borderRadius(26)
          Column() {
            Text('旅行的意义')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('金卡旅行家 · 已自驾 8 国 56 天')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 12 })
          .layoutWeight(1)

          Column() {
            Text('12,680')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
            Text('里程积分')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .alignItems(HorizontalAlign.Center)
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(18)
        .margin({ top: 14, left: 12, right: 12 })
        .alignItems(VerticalAlign.Center)

        Row() {
          Column() {
            Text('10')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('租车订单')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
          .layoutWeight(1)

          Column() {
            Text('8')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryLight)
            Text('自驾国家')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
          .layoutWeight(1)

          Column() {
            Text('¥26,560')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
            Text('累计消费')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
          .layoutWeight(1)
        }
        .width('94%')
        .padding({ top: 14, bottom: 14 })
        .backgroundColor(COLORS.white)
        .borderRadius(18)
        .margin({ top: 10, left: 12, right: 12 })

        // 自驾足迹
        Column() {
          Text('自驾足迹')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Row() {
            ForEach(COUNTRIES, (c: CountryInfo) => {
              Text(c.flag)
                .fontSize(22)
                .padding(6)
                .backgroundColor(COLORS.bg)
                .borderRadius(14)
                .margin({ left: 6, top: 8 })
            }, (c: CountryInfo) => 'foot-' + c.id.toString())
          }
          .width('100%')
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(18)
        .margin({ top: 10, left: 12, right: 12 })
        .alignItems(HorizontalAlign.Start)

        Column() {
          ForEach(['驾照翻译件管理', '会员权益', '优惠券(2 张)', '常用驾驶人', '客服与救援热线'], (item: string) => {
            Row() {
              Text(item)
                .fontSize(13)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text('›')
                .fontSize(18)
                .fontColor(COLORS.textHint)
            }
            .width('100%')
            .padding({ top: 13, bottom: 13 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
          }, (item: string) => item)
        }
        .width('94%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white)
        .borderRadius(18)
        .margin({ top: 10, left: 12, right: 12, bottom: 20 })
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  // ============ Tab4 道路救援 ============
  @Builder
  rescueTab() {
    Scroll() {
      Column() {
        Column() {
          Text('🆘')
            .fontSize(44)
            .margin({ top: 20 })
          Text('海外道路救援')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .margin({ top: 8 })
          Text('24 小时中文客服 · 覆盖全部租车国家')
            .fontSize(11)
            .fontColor('#C5CAE9')
            .margin({ top: 4 })

          Text('呼叫救援')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDark)
            .padding({ left: 40, right: 40, top: 14, bottom: 14 })
            .backgroundColor(COLORS.gold)
            .borderRadius(28)
            .margin({ top: 18, bottom: 8 })
            .shadow({
              radius: 12,
              color: 'rgba(255,193,7,0.5)',
              offsetX: 0,
              offsetY: 4
            })

          Text('全球热线 +86-571-xxxx-xxxx')
            .fontSize(11)
            .fontColor('#C5CAE9')
            .margin({ top: 8, bottom: 20 })
        }
        .width('94%')
        .borderRadius(20)
        .linearGradient({
          angle: 150,
          colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]]
        })
        .alignItems(HorizontalAlign.Center)
        .margin({ top: 14, left: 12, right: 12 })

        Text('救援服务项目')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
          .padding({ left: 16, right: 16, top: 16 })

        Column() {
          ForEach(['搭电启动 · 电瓶亏电', '换备胎 · 爆胎/胎压异常', '送油服务 · 燃油耗尽(油费自付)', '拖车 100km · 事故或机械故障', '开锁服务 · 钥匙锁车内', '事故翻译协助 · 当地交警沟通'], (r: string) => {
            Row() {
              Text('✓')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.success)
              Text(r)
                .fontSize(13)
                .fontColor(COLORS.textPrimary)
                .margin({ left: 10 })
                .layoutWeight(1)
              Text('免费')
                .fontSize(10)
                .fontColor(COLORS.success)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor('#E8F5E9')
                .borderRadius(8)
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
            .alignItems(VerticalAlign.Center)
          }, (r: string) => r)
        }
        .width('94%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 10, left: 12, right: 12 })

        // 使用提示
        Column() {
          Text('使用须知')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('1. 呼叫时请提供订单号、当前定位与车牌号\n2. 等待救援期间请开启双闪并在安全位置等候\n3. 事故请先联系当地警方,保留报案编号\n4. 救援完成后请签署服务确认单')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .lineHeight(20)
            .margin({ top: 10 })
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 12, left: 12, right: 12, bottom: 20 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  // ============ Tab5 翻译助手 ============
  @Builder
  phraseTab() {
    Scroll() {
      Column() {
        Row() {
          Text('自驾常用语 · 日本语')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('编辑收藏')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.primary)
            .borderRadius(10)
            .onClick(() => {
              this.showPhraseModal = true
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })

        Column() {
          ForEach(this.favPhrases, (p: PhraseItem) => {
            Column() {
              Row() {
                Text(p.scene)
                  .fontSize(10)
                  .fontColor(COLORS.white)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .backgroundColor(COLORS.primaryLight)
                  .borderRadius(8)
                if (p.favorite) {
                  Text('★ 已收藏')
                    .fontSize(9)
                    .fontColor(COLORS.gold)
                    .margin({ left: 8 })
                }
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)

              Text(p.zh)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .margin({ top: 8 })
              Text(p.local)
                .fontSize(13)
                .fontColor(COLORS.primary)
                .margin({ top: 6 })
              Text(p.pronounce)
                .fontSize(10)
                .fontColor(COLORS.textHint)
                .margin({ top: 4 })

              Row() {
                Text('🔊 播放')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .fontWeight(FontWeight.Bold)
                  .layoutWeight(1)
                if (p.favorite) {
                  Text('取消收藏')
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                    .onClick(() => {
                      this.toggleFav(p.id)
                    })
                } else {
                  Text('收藏')
                    .fontSize(11)
                    .fontColor(COLORS.gold)
                    .fontWeight(FontWeight.Bold)
                    .onClick(() => {
                      this.toggleFav(p.id)
                    })
                }
              }
              .width('100%')
              .margin({ top: 10 })
              .alignItems(VerticalAlign.Center)
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS.white)
            .borderRadius(14)
            .margin({ top: 10 })
            .alignItems(HorizontalAlign.Start)
          }, (p: PhraseItem) => p.id.toString() + '-' + p.favorite.toString())
        }
        .width('100%')
        .padding({ left: 12, right: 12, bottom: 20 })
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  // ============ Tab6 汇率换算 ============
  @Builder
  rateTab() {
    Scroll() {
      Column() {
        Column() {
          Text('快速换算(CNY)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)

          Row() {
            ForEach(['100', '500', '1000', '5000'], (amt: string) => {
              Column() {
                Text('¥' + amt)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.primaryDark)
                Text('≈ ' + this.jpyOf(amt) + ' JPY')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Text('≈ ' + this.usdOf(amt) + ' USD')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
                Text('≈ ' + this.eurOf(amt) + ' EUR')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .padding({ top: 12, bottom: 12 })
              .backgroundColor(COLORS.bg)
              .borderRadius(14)
              .margin({ left: 5, right: 5, top: 12 })
              .alignItems(HorizontalAlign.Center)
            }, (amt: string) => amt)
          }
          .width('100%')

          Text('更新于今日 10:32 · 仅供参考')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ top: 10 })
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 14, left: 12, right: 12 })
        .alignItems(HorizontalAlign.Start)

        Text('热门货币汇率')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
          .padding({ left: 16, right: 16, top: 16 })

        Column() {
          ForEach(RATE_LIST, (r: string) => {
            Row() {
              Text('💱')
                .fontSize(16)
              Text(r)
                .fontSize(13)
                .fontColor(COLORS.textPrimary)
                .margin({ left: 10 })
                .layoutWeight(1)
              Text('›')
                .fontSize(16)
                .fontColor(COLORS.textHint)
            }
            .width('100%')
            .padding({ top: 13, bottom: 13 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
            .alignItems(VerticalAlign.Center)
          }, (r: string) => r)
        }
        .width('94%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 10, left: 12, right: 12 })

        // 各国油价参考
        Column() {
          Text('各国油价参考(¥/L)')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Column() {
            ForEach(['日本 ¥8.9', '泰国 ¥7.6', '德国 ¥14.2', '美国 ¥6.8', '冰岛 ¥12.4'], (o: string) => {
              Row() {
                Text(o.split(' ')[0])
                  .fontSize(12)
                  .fontColor(COLORS.textSecondary)
                  .layoutWeight(1)
                Text(o.split(' ')[1])
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.primaryDark)
              }
              .width('100%')
              .padding({ top: 8, bottom: 8 })
            }, (o: string) => o)
          }
          .width('100%')
          .margin({ top: 6 })
        }
        .width('94%')
        .padding(14)
        .backgroundColor(COLORS.white)
        .borderRadius(16)
        .margin({ top: 12, left: 12, right: 12, bottom: 20 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
  }

  jpyOf(amt: string): string {
    return (Number(amt) / 4.87 * 100).toFixed(0)
  }

  usdOf(amt: string): string {
    return (Number(amt) / 7.15).toFixed(1)
  }

  eurOf(amt: string): string {
    return (Number(amt) / 7.83).toFixed(1)
  }

  toggleFav(id: number): void {
    const next: PhraseItem[] = []
    for (let i = 0; i < this.favPhrases.length; i++) {
      const p = this.favPhrases[i]
      if (p.id === id) {
        const np: PhraseItem = {
          id: p.id, scene: p.scene, zh: p.zh,
          local: p.local, pronounce: p.pronounce,
          favorite: !p.favorite
        }
        next.push(np)
      } else {
        next.push(p)
      }
    }
    this.favPhrases = next
  }

  // ============ 弹框1:预订租车 ============
  @Builder
  bookModal() {
    Column() {
      Row() {
        Text('预订租车')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textHint)
          .onClick(() => {
            this.showBookModal = false
          })
      }
      .width('100%')
      .margin({ top: 16 })

      if (this.selectedCar !== null) {
        Row() {
          Text(this.selectedCar.icon)
            .fontSize(34)
            .padding(10)
            .backgroundColor(COLORS.bg)
            .borderRadius(14)
          Column() {
            Text(this.selectedCar.brand + ' ' + this.selectedCar.model)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(this.selectedCar.country + ' · ' + this.selectedCar.seat + '座 · ' + this.selectedCar.gear)
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Column() {
            Text('¥' + this.selectedCar.dayPrice)
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
            Text('/天')
              .fontSize(10)
              .fontColor(COLORS.textHint)
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.bg)
        .borderRadius(12)
        .margin({ top: 12 })
        .alignItems(VerticalAlign.Center)

        // 租期
        Column() {
          Text('租期:' + this.bookDays + ' 天')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Row() {
            ForEach([3, 5, 7, 10, 14], (d: number) => {
              Text(d + '天')
                .fontSize(12)
                .fontColor(this.bookDays === d ? COLORS.white : COLORS.textSecondary)
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .backgroundColor(this.bookDays === d ? COLORS.primary : COLORS.bg)
                .borderRadius(12)
                .margin({ left: 6, top: 8 })
                .onClick(() => {
                  this.bookDays = d
                })
            }, (d: number) => d.toString() + this.bookDays.toString())
          }
          Text('满 7 天享 95 折 · 满 14 天享 9 折(已计入)')
            .fontSize(9)
            .fontColor(COLORS.gold)
            .margin({ top: 8 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .margin({ top: 12 })

        // 保险
        Column() {
          Text('保险套餐')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Column() {
            ForEach(INSURANCE_OPTIONS, (ins: string) => {
              Row() {
                Text(ins)
                  .fontSize(12)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text('¥' + this.insurancePrice(ins) + '/天')
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                if (this.bookInsurance === ins) {
                  Text('✓')
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.primary)
                    .margin({ left: 8 })
                }
              }
              .width('100%')
              .padding({ top: 11, bottom: 11 })
              .border({ width: { bottom: 1 }, color: COLORS.border })
              .alignItems(VerticalAlign.Center)
              .onClick(() => {
                this.bookInsurance = ins
              })
            }, (ins: string) => ins + this.bookInsurance)
          }
          .width('100%')
          .margin({ top: 6 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .margin({ top: 12 })

        // 驾龄确认
        Row() {
          Text('我确认驾龄 ≥ 2 年且持有效驾照')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
          Text('已确认')
            .fontSize(10)
            .fontColor(COLORS.success)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .backgroundColor('#E8F5E9')
            .borderRadius(10)
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)

        // 费用
        Column() {
          Row() {
            Text('租金 ¥' + this.selectedCar.dayPrice + ' × ' + this.bookDays + ' 天')
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text('¥' + (this.selectedCar.dayPrice * this.bookDays).toLocaleString())
              .fontSize(12)
              .fontColor(COLORS.textPrimary)
          }
          .width('100%')
          Row() {
            Text('保险 ¥' + this.insurancePrice(this.bookInsurance) + ' × ' + this.bookDays + ' 天')
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text('¥' + (this.insurancePrice(this.bookInsurance) * this.bookDays).toLocaleString())
              .fontSize(12)
              .fontColor(COLORS.textPrimary)
          }
          .width('100%')
          .margin({ top: 6 })
          Row() {
            Text('押金(还车后退)')
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text('¥' + this.selectedCar.deposit.toLocaleString())
              .fontSize(12)
              .fontColor(COLORS.warning)
          }
          .width('100%')
          .margin({ top: 6 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(12)
        .margin({ top: 12 })
        .alignItems(HorizontalAlign.Start)
      }

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.bg)
          .borderRadius(14)
          .onClick(() => {
            this.showBookModal = false
          })
        Text('确认预订 ¥' + this.bookTotal().toLocaleString())
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .layoutWeight(2)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.primary)
          .borderRadius(14)
          .margin({ left: 10 })
          .onClick(() => {
            this.showBookModal = false
          })
      }
      .width('100%')
      .margin({ top: 16, bottom: 18 })
    }
    .width('90%')
    .backgroundColor(COLORS.white)
    .borderRadius(20)
    .padding({ left: 16, right: 16 })
  }

  @Builder
  bookModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Scroll() {
        Column() {
          this.bookModal()
        }
      }
      .scrollable(ScrollDirection.Vertical)
      .constraintSize({ maxHeight: '88%' })
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
  }

  // ============ 弹框2:驾照翻译申请(居中表单) ============
  @Builder
  licenseModal() {
    Column() {
      Row() {
        Text('新办驾照翻译件')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textHint)
          .onClick(() => {
            this.showLicenseModal = false
          })
      }
      .width('100%')
      .margin({ top: 16 })

      Column() {
        Row() {
          Text('驾照号')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .width(64)
          TextInput({ placeholder: '中国驾照证芯编号' })
            .fontSize(13)
            .layoutWeight(1)
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .padding({ left: 10, right: 10 })
        }
        .width('100%')
        .margin({ top: 12 })
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('有效期至')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .width(64)
          TextInput({ placeholder: '如:2028-06-30' })
            .fontSize(13)
            .layoutWeight(1)
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .padding({ left: 10, right: 10 })
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('收件地址')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .width(64)
          TextInput({ placeholder: '翻译件纸质版邮寄地址' })
            .fontSize(13)
            .layoutWeight(1)
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .padding({ left: 10, right: 10 })
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')

      Text('翻译语种(可多选)')
        .fontSize(12)
        .fontColor(COLORS.textSecondary)
        .width('100%')
        .margin({ top: 14 })

      Row() {
        ForEach(['英文', '日文', '德文', '法文', '泰文'], (l: string) => {
          Text(l)
            .fontSize(11)
            .fontColor(l === '英文' ? COLORS.white : COLORS.textSecondary)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(l === '英文' ? COLORS.primary : COLORS.bg)
            .borderRadius(12)
            .margin({ right: 8, top: 8 })
        }, (l: string) => l)
      }
      .width('100%')

      Row() {
        Text('办理费')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('¥48(3 个工作日出证)')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.gold)
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(VerticalAlign.Center)

      Text('提交申请')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.primary)
        .borderRadius(14)
        .margin({ top: 14, bottom: 16 })
        .onClick(() => {
          this.showLicenseModal = false
        })
    }
    .width('84%')
    .backgroundColor(COLORS.white)
    .borderRadius(18)
    .padding({ left: 16, right: 16 })
  }

  @Builder
  licenseModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      this.licenseModal()
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
  }

  // ============ 弹框3:取消订单(紧凑警告) ============
  @Builder
  cancelModal() {
    Column() {
      Text('⚠️')
        .fontSize(32)
        .margin({ top: 18 })

      Text('取消租车订单?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.danger)
        .margin({ top: 8 })

      if (this.selectedOrder !== null) {
        Text(this.selectedOrder.orderNo)
          .fontSize(11)
          .fontColor(COLORS.textHint)
          .margin({ top: 6 })
        Text('取车时间:' + this.selectedOrder.pickupDate)
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 4 })
        Column() {
          Row() {
            Text('订单金额')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text('¥' + this.selectedOrder.total.toLocaleString())
              .fontSize(11)
              .fontColor(COLORS.textPrimary)
          }
          .width('100%')
          Row() {
            Text('距取车 > 48h 免费取消')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text('¥0 手续费')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.success)
          }
          .width('100%')
          .margin({ top: 6 })
          Row() {
            Text('预计原路退回')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
            Text('¥' + this.selectedOrder.total.toLocaleString())
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
          }
          .width('100%')
          .margin({ top: 6 })
        }
        .width('86%')
        .padding(10)
        .backgroundColor(COLORS.bg)
        .borderRadius(12)
        .margin({ top: 10 })
        .alignItems(HorizontalAlign.Start)
      }

      Row() {
        Text('再想想')
          .fontSize(14)
          .fontColor(COLORS.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.bg)
          .borderRadius(12)
          .onClick(() => {
            this.showCancelModal = false
          })
        Text('确认取消')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.danger)
          .borderRadius(12)
          .margin({ left: 10 })
          .onClick(() => {
            if (this.selectedOrder !== null) {
              this.selectedOrder.status = '已取消'
              this.showCancelModal = false
            }
          })
      }
      .width('86%')
      .margin({ top: 16, bottom: 18 })
    }
    .width('82%')
    .backgroundColor(COLORS.white)
    .borderRadius(18)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  cancelModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      this.cancelModal()
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
  }

  // ============ 弹框4:车型详情 ============
  @Builder
  carDetailModal() {
    Column() {
      if (this.selectedCar !== null) {
        Column() {
          Text(this.selectedCar.icon)
            .fontSize(56)
            .margin({ top: 22 })
          Text(this.selectedCar.brand + ' ' + this.selectedCar.model)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .margin({ top: 8 })
          Row() {
            Text('⭐ ' + this.selectedCar.score.toFixed(1))
              .fontSize(12)
              .fontColor(COLORS.gold)
            Text(' · ' + this.selectedCar.country + ' · ' + this.selectedCar.fuel)
              .fontSize(12)
              .fontColor('#C5CAE9')
          }
          .margin({ top: 6, bottom: 22 })
        }
        .width('100%')
        .linearGradient({
          angle: 135,
          colors: [[COLORS.primary, 0], [COLORS.primaryDark, 1]]
        })
        .borderRadius({ topLeft: 18, topRight: 18 })
        .alignItems(HorizontalAlign.Center)

        Scroll() {
          Column() {
            Row() {
              Column() {
                Text('座位')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
                Text(this.selectedCar.seat + ' 座')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)

              Column() {
                Text('变速箱')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
                Text(this.selectedCar.gear)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)

              Column() {
                Text('燃料')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
                Text(this.selectedCar.fuel)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)

              Column() {
                Text('押金')
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
                Text('¥' + this.selectedCar.deposit.toLocaleString())
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Center)
              .layoutWeight(1)
            }
            .width('100%')
            .margin({ top: 14 })

            Row() {
              ForEach(this.selectedCar.freeCancel ? ['机场柜台取还', '中文客服支持', '免费取消', '无限里程'] : ['机场柜台取还', '中文客服支持', '限时取消', '无限里程'], (f: string) => {
                Text('✓ ' + f)
                  .fontSize(9)
                  .fontColor(COLORS.primary)
                  .padding({ left: 8, right: 8, top: 6, bottom: 6 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(10)
                  .margin({ left: 4, top: 6 })
              }, (f: string) => f)
            }
            .width('100%')

            Row() {
              Text('日均租金')
                .fontSize(13)
                .fontColor(COLORS.textSecondary)
                .layoutWeight(1)
              Text('¥' + this.selectedCar.dayPrice)
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primaryDark)
            }
            .width('100%')
            .margin({ top: 14 })
            .alignItems(VerticalAlign.Center)

            Row() {
              Text('关闭')
                .fontSize(14)
                .fontColor(COLORS.textSecondary)
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .padding({ top: 11, bottom: 11 })
                .backgroundColor(COLORS.bg)
                .borderRadius(12)
                .onClick(() => {
                  this.showCarDetailModal = false
                })
              Text('立即预订')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
                .layoutWeight(2)
                .textAlign(TextAlign.Center)
                .padding({ top: 11, bottom: 11 })
                .backgroundColor(COLORS.primary)
                .borderRadius(12)
                .margin({ left: 10 })
                .onClick(() => {
                  this.showCarDetailModal = false
                  this.showBookModal = true
                })
            }
            .width('100%')
            .margin({ top: 14, bottom: 14 })
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
        }
        .scrollable(ScrollDirection.Vertical)
        .width('100%')
      }
    }
    .width('86%')
    .backgroundColor(COLORS.white)
    .borderRadius(18)
    .clip(true)
  }

  @Builder
  carDetailModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      this.carDetailModal()
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
  }

  // ============ 弹框5:常用语编辑(底部滑出) ============
  @Builder
  phraseModal() {
    Column() {
      Row() {
        Column()
          .width(40)
          .height(4)
          .backgroundColor(COLORS.textHint)
          .borderRadius(2)
          .margin({ top: 10 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

      Row() {
        Text('编辑常用语')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textHint)
          .onClick(() => {
            this.showPhraseModal = false
          })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 14 })

      Column() {
        ForEach(this.favPhrases, (p: PhraseItem) => {
          Row() {
            Text(p.scene)
              .fontSize(10)
              .fontColor(COLORS.white)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor(COLORS.primaryLight)
              .borderRadius(8)
            Text(p.zh)
              .fontSize(12)
              .fontColor(COLORS.textPrimary)
              .margin({ left: 10 })
              .layoutWeight(1)
              .maxLines(1)
            if (p.favorite) {
              Text('★')
                .fontSize(16)
                .fontColor(COLORS.gold)
                .onClick(() => {
                  this.toggleFav(p.id)
                })
            } else {
              Text('☆')
                .fontSize(16)
                .fontColor(COLORS.textHint)
                .onClick(() => {
                  this.toggleFav(p.id)
                })
            }
          }
          .width('100%')
          .padding({ top: 12, bottom: 12 })
          .border({ width: { bottom: 1 }, color: COLORS.border })
          .alignItems(VerticalAlign.Center)
        }, (p: PhraseItem) => 'edit-' + p.id.toString() + '-' + p.favorite.toString())
      }
      .width('100%')
      .padding({ left: 18, right: 18 })

      Text('完成')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .width('90%')
        .textAlign(TextAlign.Center)
        .padding({ top: 13, bottom: 13 })
        .backgroundColor(COLORS.primary)
        .borderRadius(16)
        .margin({ top: 18, bottom: 20 })
        .onClick(() => {
          this.showPhraseModal = false
        })
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .borderRadius({ topLeft: 22, topRight: 22 })
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  phraseModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(26,32,80,0.55)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Column() {
        this.phraseModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }
}


在这里插入图片描述

十四、总结

本文以基于 HarmonyOS API 24 的 ArkTS 声明式 UI 开发范式为技术主线,完整剖析了一个国际自驾租车应用的源代码实现。从整体架构来看,该应用充分运用了 HarmonyOS ArkTS API 24 的核心能力:@Entry 和 @Component 装饰器构建应用入口组件,@State 装饰器管理十一个组件级状态变量实现响应式 UI 更新,@Observed 装饰器配合 DriveOrderModel 实现对象级属性的精细追踪,@Builder 装饰器将七个 Tab 页面和五个弹框拆分为独立可复用的 UI 构造方法。Stack 容器的层叠布局配合条件渲染,实现了页面内容层和弹框遮罩层的分离管理,五个弹框通过统一的 Overlay 架构和 onClose 回调参数实现了高度一致的关闭逻辑。

在数据模型层面,六个接口定义了完整的业务实体类型契约,@Observed 类将接口转化为可实例化的可观察对象,静态数据源使用 const 数组配合接口约束确保类型安全。在 UI 渲染层面,ForEach 的键值生成器设计尤为关键——对于包含动态状态的列表(如订单状态、收藏状态、选中态),键值必须包含动态字段才能确保响应式更新的正确触发;对于纯静态列表,使用 id 作为键值即可实现高效的增量更新。toggleFav 方法采用的不可变数据更新模式(创建新数组替换原数组)是 ArkTS 状态管理的最佳实践,确保了 @State 能可靠检测到数组内容的变化。

在可视化方面,柱状图完全通过 ForEach + Column 动态高度实现,不依赖任何第三方图表库,展示了 ArkTS 基础组件的图形构建能力。租期条通过 Circle 和 Rect 基础图形组件绘制时间轴节点和连接线,配合 Column 的精准 margin 偏移实现了视觉对齐。linearGradient 属性的广泛应用(头部、卡片、弹框头部、柱状图柱体)为应用营造了深蓝国际风的视觉层次。双排 4+3 Tab 导航架构通过上下两排不同样式的 Tab 设计(上排图标+文字+下划线,下排胶囊+缩放动画)实现了主功能与快捷工具的视觉区分,selectedState 通过透明度、字重、颜色、宽度、缩放比、背景色等多维度变化强化了选中态反馈。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐