鸿蒙(HarmonyOS)的声明式UI框架ArkUI,为开发者提供了一套基于ArkTS语言的现代化UI开发范式。本文将以一个完整的"员工赛车场"应用为切入点,逐段拆解从数据建模、接口定义、组件封装、状态管理到弹窗系统的全部实现细节,深入探讨Column、Row、Stack、ForEach、Scroll、@Builder、@Component、@State等核心特性的工程实践。

一、整体背景与技术栈概述

鸿蒙系统的应用开发框架ArkUI,采用声明式编程思想,开发者只需描述界面的"当前状态",框架会自动完成状态到视图的渲染映射。ArkTS语言是在TypeScript基础上扩展而来的,它保留了TS的静态类型检查能力,同时加入了鸿蒙特有的装饰器系统和UI构建语法。

在本应用中,整体技术栈完全基于纯ArkTS实现,没有引入任何第三方依赖。整个应用的架构可以划分为三个清晰的层次:第一层是数据层,包含一系列interface接口定义和常量数据集合,负责描述业务实体和数据存储;第二层是组件层,通过@Component装饰器将每个功能页面封装为独立的自定义组件,实现页面的模块化拆分;第三层是主入口层,使用@Entry和@Component装饰器标记根组件,统一管理全局状态、页面切换和弹窗系统。

这种分层架构的核心优势在于职责清晰。数据层不关心如何渲染,组件层不关心数据从哪里来,主入口层则负责协调所有子组件的交互与状态流转。整个应用通过回调函数实现父子组件通信,通过@State实现状态驱动的视图更新,通过@Builder实现UI片段的复用,形成了一套完整的、可维护的声明式UI开发体系。

数据层 Interface + 常量

工具函数层

组件层 六大Tab组件

主入口层 KartRacingApp

状态管理 @State

弹窗系统 @Builder

视图渲染

底部导航 + 内容区域

从上面的架构流程图可以看出,数据从最底层向上流动,经过工具函数的加工处理后,被各个Tab组件消费,最终汇聚到主入口组件中进行统一调度和渲染。这种单向数据流的设计,使得整个应用的数据追踪和状态调试变得非常清晰。

二、数据接口定义层

2.1 赛道数据接口

在声明式UI框架中,类型安全是确保代码健壮性的第一道防线。本应用使用interface来定义每一种业务实体的数据结构。

interface RaceTrack {
  id: number;
  emoji: string;
  name: string;
  length: number;
  turns: number;
  bestLap: string;
  level: number;
  status: string;
  price: number;
  color: string;
  desc: string;
}

在这里插入图片描述

这段代码定义了赛道的数据模型。RaceTrack接口包含了赛道的全部属性:id用于唯一标识,emoji存储表情符号用于视觉展示,name是赛道名称,length表示赛道全长(米),turns记录弯道数量,bestLap是最佳圈速记录。

其中值得注意的是level和status这两个字段。level表示赛道难度等级,是一个数值类型,后续在UI渲染时会被拼接为"Lv.X"的文本展示。status则是字符串类型,取值包括"开放中"“预约中”“维护中”,它直接决定了赛道卡片的展示状态和可交互性。

price字段存储的是每圈的消费价格,color字段存储的是十六进制颜色字符串,这个颜色会被应用到赛道卡片的头部背景或标签上,实现视觉上的区分。desc字段是赛道描述文本,会在卡片中以省略号截断的方式展示。

在ArkTS中,interface定义的接口与TypeScript中的interface语法基本一致,但ArkTS对类型检查更加严格。每个字段必须明确标注类型,且在赋值时必须严格匹配,不允许隐式类型转换。这种强类型约束在编译期就能发现大量潜在错误。

2.2 赛事信息接口

interface RaceInfo {
  id: number;
  emoji: string;
  name: string;
  type: string;
  track: string;
  time: string;
  quota: number;
  joined: number;
  reward: number;
  status: string;
  color: string;
  desc: string;
}

在这里插入图片描述

RaceInfo接口描述的是一场赛事的完整信息。其中type字段标识赛事类型,包括"排位赛"“竞速赛”“技巧赛”“娱乐赛”“耐力赛”“表演赛”“计时赛”"资格赛"等多种类型,这些类型决定了赛事的展示样式和报名规则。

quota和joined这两个字段共同描述了赛事的报名情况。quota是名额上限,joined是已报名人数,两者组合后形成"joined/quota"的展示格式,让用户一眼就能看出赛事的报名进度。reward字段是赛事奖励积分,直接显示在卡片上以吸引用户报名。

time字段存储的是开赛时间的文本表示,格式为"MM-DD HH:mm",这种格式在UI中直接展示,不需要额外的日期格式化处理。status字段的取值包括"报名中"“报名截止”“未开始”,它直接控制报名按钮的可点击性和展示文案。

2.3 车手与配件接口

interface Rider {
  id: number;
  emoji: string;
  name: string;
  team: string;
  points: number;
  wins: number;
  lap: string;
  speed: number;
  color: string;
  tag: string;
}

interface RaceParts {
  id: number;
  emoji: string;
  name: string;
  price: number;
  oldPrice: number;
  tag: string;
  stock: number;
  power: number;
  color: string;
  desc: string;
}

Rider接口定义了车手信息,其中points是赛季积分,是排名的核心依据。wins记录胜场数,lap是最快圈速,speed是综合速度评分(0-100的数值)。tag字段是一个短标签,取值包括"车神"“种子”“黑马”“稳健”“新秀”“萌新”,用于在排行榜上快速标注车手的段位。

RaceParts接口定义了赛车配件信息。这里有一个oldPrice字段,它配合price字段实现了"划线价"的展示效果——在UI中,原价会被加上删除线并显示为灰色,现价则加粗显示为红色,这是一种经典的电商价格展示手法。power字段表示配件带来的性能提升百分比,stock是库存数量,两者共同影响用户的购买决策。

在实际的鸿蒙应用开发中,interface通常只用于定义纯数据结构。如果需要在数据模型上附加方法或计算属性,更推荐使用class并配合ArkTS的面向对象特性。但在本应用中,所有数据都是静态常量,不需要方法,因此使用interface是最简洁的选择。

2.4 新闻与战绩接口

interface RaceNews {
  id: number;
  emoji: string;
  title: string;
  time: string;
  views: number;
  color: string;
  tag: string;
}

interface RaceRecord {
  id: number;
  emoji: string;
  name: string;
  track: string;
  lap: string;
  rank: number;
  date: string;
  status: string;
  color: string;
}

interface LapDay {
  day: string;
  lap: number;
}

在这里插入图片描述

RaceNews接口定义了园区新闻的轻量级数据结构,views字段记录阅读量,在UI中展示为"X 阅读"的格式。RaceRecord接口定义了个人战绩记录,rank字段是最终名次,status字段取值包括"冠军"“亚军”“季军”“完赛”“退赛”,不同状态对应不同的颜色展示。

LapDay接口最为简单,只有两个字段:day是星期几的中文文本,lap是当天的圈速数值。这个接口用于绘制周圈速趋势柱状图,是一个纯展示用途的数据结构。

三、数据常量与切片函数

3.1 数据常量定义

应用中所有的业务数据都以const常量的形式定义。这种方式在原型开发和演示场景中非常高效——数据直接内嵌在代码中,无需网络请求和异步加载,应用启动即可展示完整内容。

const RACE_TRACKS: RaceTrack[] = [
  { id: 1, emoji: '🏎️', name: '环湖竞速赛道', length: 1200, turns: 8, bestLap: '58.2s', level: 2, status: '开放中', price: 30, color: '#D32F2F', desc: '环绕员工湖一周,弯道多且节奏快,新手必打卡的第一条赛道' },
  { id: 2, emoji: '🏁', name: '极速直线大直道', length: 800, turns: 2, bestLap: '41.6s', level: 1, status: '开放中', price: 20, color: '#F57C00', desc: '两条大直道衔接 S 弯,适合刷圈速与练习起步' },
  { id: 3, emoji: '🌀', name: '螺旋回环赛道', length: 1500, turns: 12, bestLap: '79.3s', level: 3, status: '开放中', price: 45, color: '#303F9F', desc: '连续螺旋弯考验控车精度,漂移爱好者的天堂' },
  // ... 更多赛道数据
];

这段代码展示了赛道数据的定义方式。每个赛道对象都严格遵循RaceTrack接口的类型约束,字段类型、顺序和完整性都在编译期得到保证。RACE_TRACKS数组共包含12条赛道数据,覆盖了不同难度等级(1-5级)和不同状态(开放中、预约中、维护中)。

值得注意的设计细节是color字段的使用。每个赛道都被分配了一个独特的主题色,从红色的#D32F2F到绿色的#2E7D32,这些颜色不仅用于赛道卡片的头部背景,还用于相关弹窗的强调色,实现了从列表到详情的视觉一致性。

除了赛道数据外,应用还定义了RACE_LIST(10场赛事)、RIDERS(12位车手)、RACE_PARTS(12个配件)、RACE_NEWS(8条新闻)、RACE_RECORDS(8条战绩)和LAP_DAYS(7天圈速数据)等常量数组。每个数组都严格按照对应的interface类型定义,确保类型安全。

3.2 数据切片函数

function getTrackRows(): RaceTrack[] {
  return [RACE_TRACKS[0], RACE_TRACKS[2], RACE_TRACKS[4], RACE_TRACKS[6], RACE_TRACKS[8], RACE_TRACKS[10]];
}

function getTrackRows2(): RaceTrack[] {
  return [RACE_TRACKS[1], RACE_TRACKS[3], RACE_TRACKS[5], RACE_TRACKS[7], RACE_TRACKS[9], RACE_TRACKS[11]];
}

function getHomeTracks(): RaceTrack[] {
  return [RACE_TRACKS[0], RACE_TRACKS[1], RACE_TRACKS[2]];
}

数据切片函数是本应用架构中一个巧妙的设计。由于12条赛道全部展示在同一个页面中会显得过于冗长,开发者通过getTrackRows和getTrackRows2将数据分成两组,每组6条,中间用"— 更多赛道 —"的分隔文本隔开,形成视觉上的节奏感。

getHomeTracks函数则从全量数据中提取前3条,用于首页的热门赛道横向滚动展示。这种"从全量数据中按需取子集"的设计模式,确保了数据来源的唯一性——所有页面展示的赛道数据都来自同一个RACE_TRACKS常量,不会出现数据不一致的问题。

类似的切片函数还有getRaceRows/getRaceRows2(赛事分页)、getRiderRows/getRiderRows2(车手分页)、getPartsRows/getPartsRows2(配件分页)、getNewsRows/getNewsRows2(新闻分页)、getRecordRows/getRecordRows2(战绩分页)。每一对函数都将数据二分,用于在同一页面中展示"主要列表"和"更多列表"两个区域。

这种数据切片方式虽然简单,但体现了前端开发中常见的"数据视图分离"思想。原始数据是完整的,但展示时根据上下文和场景进行裁剪。如果未来需要接入真实API,只需将这些函数改为异步请求即可,UI层的ForEach渲染逻辑完全不需要改动。

3.3 选项数据函数

function getSlots(): string[] {
  return ['10:00-11:00', '14:00-15:00', '16:00-17:00', '19:00-20:00'];
}

function getLapOptions(): number[] {
  return [5, 10, 15];
}

function getRewardOptions(): number[] {
  return [100, 200, 300, 500];
}

function getQuotaOptions(): number[] {
  return [10, 16, 20, 24];
}

function getAmountOptions(): number[] {
  return [500, 1000, 2000, 5000];
}

function getPartsTags(): string[] {
  return ['🔥 热卖', '⚡ 动力', '🛡️ 安全', '🎨 外观'];
}

在这里插入图片描述

这些函数返回的是各种下拉选项和标签数据,用于表单弹窗中的可选项展示。getSlots返回预约时段,getLapOptions返回可选圈数,getRewardOptions返回赛事奖励积分选项,getQuotaOptions返回报名人数上限选项,getAmountOptions返回充值金额选项。

这些选项数据被封装为函数而非直接定义为常量,虽然在本应用中它们返回的都是固定值,但使用函数的形式为未来动态获取选项(例如从服务器拉取可用时段)预留了扩展空间。这是一种防御性的设计策略。

四、工具函数与颜色映射

4.1 数值映射函数

function getBarHeight(v: number): number {
  return 30 + v * 0.5;
}

function getSpeedColor(p: number): string {
  if (p >= 90) {
    return '#D32F2F';
  }
  if (p >= 80) {
    return '#F57C00';
  }
  if (p >= 70) {
    return '#F9A825';
  }
  return '#78909C';
}

在这里插入图片描述

getBarHeight函数用于计算圈速趋势柱状图中每根柱子的高度。它接收一个数值参数,返回一个经过线性变换的高度值。基础高度30加上数值乘以0.5的系数,确保即使是最小的值也能有可见的柱子高度,同时最大值不会超出容器边界。

getSpeedColor函数是一个典型的分段映射函数。它将车手的速度评分映射为不同的颜色:90分以上为红色(#D32F2F),表示顶级速度;80-89分为橙色(#F57C00),表示优秀;70-79分为黄色(#F9A825),表示良好;70分以下为灰色(#78909C),表示一般。

这种分段颜色映射在数据可视化中非常常见。它将抽象的数值转化为直观的视觉信号,让用户无需仔细阅读数字就能快速感知数据的分布情况。在排行榜中,速度条的颜色直接反映了车手的实力等级,红橙黄灰的渐进色阶符合人们对"热度"的直觉认知。

4.2 状态颜色映射函数

function getStatusColor(s: string): string {
  if (s === '报名中' || s === '开放中') {
    return '#00897B';
  }
  if (s === '报名截止' || s === '维护中') {
    return '#BDBDBD';
  }
  return '#F57C00';
}

function getRaceStatusColor(s: string): string {
  if (s === '冠军') {
    return '#FFB300';
  }
  if (s === '亚军') {
    return '#90A4AE';
  }
  if (s === '季军') {
    return '#D84315';
  }
  if (s === '退赛') {
    return '#BDBDBD';
  }
  return '#00897B';
}

getStatusColor函数处理的是赛事和赛道的通用状态颜色映射。"报名中"和"开放中"映射为青绿色(#00897B),传达"可参与"的积极信号;“报名截止"和"维护中"映射为灰色(#BDBDBD),传达"不可用"的中性信号;其他状态(如"未开始”“预约中”)映射为橙色(#F57C00),传达"等待中"的提醒信号。

getRaceStatusColor函数则专门处理战绩记录中的成绩状态。"冠军"映射为金色(#FFB300),"亚军"映射为银色(#90A4AE),"季军"映射为铜色(#D84315),"退赛"映射为灰色(#BDBDBD),"完赛"映射为青绿色(#00897B)。这套颜色方案直接对应了奖牌的颜色体系,符合用户的直觉认知。

在UI开发中,颜色不仅是视觉装饰,更是信息传递的载体。将颜色映射逻辑集中到工具函数中,而不是散落在各个组件的build方法里,有两个显著好处:一是确保同一状态在不同页面中的颜色表现一致;二是当需要调整配色方案时,只需修改一处即可全局生效。

4.3 积分格式化函数

function formatPoints(p: number): string {
  if (p >= 1000) {
    return String(p / 1000) + 'k';
  }
  return String(p);
}

formatPoints函数处理的是积分数字的显示格式化。当积分大于等于1000时,它会将数字转换为"k"为单位的简写形式,例如1280会显示为"1.28k"。当积分小于1000时,直接返回数字的字符串形式。

这个函数虽然简单,但解决了UI展示中一个常见的实际问题:在有限宽度的卡片中,长数字会破坏布局。将"1280"缩短为"1.28k"不仅节省了空间,还提升了信息的可读性。在排行榜和车手详情卡片中,积分值都被这个函数处理过。

五、首页组件HomeTab

5.1 组件声明与回调定义

@Component
struct HomeTab {
  onTrackClick: (t: RaceTrack) => void = () => {};
  onRaceClick: (r: RaceInfo) => void = () => {};
  onNewsClick: (n: RaceNews) => void = () => {};
  onMoreRace: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        // ... 页面内容
      }
      .width('100%')
      .padding({ left: 14, right: 14, bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

@Component装饰器将HomeTab标记为一个自定义组件,使其可以在其他组件中被引用。这个组件定义了四个回调函数类型的成员变量:onTrackClick、onRaceClick、onNewsClick和onMoreRace。每个回调都有一个默认的空函数实现,这样即使父组件没有传入回调,组件也能正常工作。

这种通过回调函数实现父子通信的模式是ArkUI中组件间通信的基础方式。子组件不直接修改父组件的状态,而是通过调用回调函数通知父组件"发生了某个事件",由父组件决定如何响应。这种设计保证了数据流的单向性,使得状态变更可追踪。

build方法中使用了Scroll作为最外层容器,这是因为首页内容较多,需要支持上下滚动。Scroll内部嵌套Column作为内容容器,设置了左右各14的内边距和底部20的内边距。scrollBar设置为BarState.Off隐藏了滚动条,让界面更加简洁。

@Component是ArkUI中最核心的装饰器之一。被它标注的struct会被编译为一个自定义组件,可以在build方法中声明UI结构。与React的函数组件不同,ArkUI的组件使用struct而非function,这是因为struct可以拥有成员变量和成员方法,更适合管理组件的内部状态。

5.2 首页头部信息卡

Column() {
  Row() {
    Text('🏁')
      .fontSize(34)
    Column() {
      Text('极速卡丁 · 出发!')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text('今日已跑 5 圈 · 距离车神还差 320 分')
        .fontSize(10)
        .fontColor('#FFE082')
        .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Column() {
      Text('1280')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('我的积分')
        .fontSize(9)
        .fontColor('#FFE082')
    }
    .padding({ left: 14, right: 14, top: 8, bottom: 8 })
    .backgroundColor('#33FFFFFF')
    .borderRadius(12)
  }
  .width('100%')

这段代码构建了首页的头部信息卡片。最外层的Column设置了统一的内边距和圆角,并添加了阴影效果,形成一张悬浮的卡片。卡片内部使用Row进行水平布局,左侧是赛车旗表情和欢迎语,右侧是积分展示区。

Row内部的布局逻辑体现了ArkUI的弹性布局特性。左侧的Column设置了layoutWeight(1),表示它会占据剩余空间的全部权重,将右侧的积分区域推到最右边。这种"左弹性+右固定"的布局模式在移动端UI中极为常见。

积分区域使用了半透明白色背景(#33FFFFFF),这是通过在白色(#FFFFFF)前添加透明度值33(十六进制,约20%)实现的。borderRadius设置为12,让这个区域呈现出圆角胶囊的视觉效果,与整体卡片的圆角风格保持一致。

5.3 快捷功能入口

Row() {
  Column() {
    Text('🏎️')
      .fontSize(24)
    Text('预约赛道')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .margin({ right: 8 })
  .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
  .onClick(() => {
    this.onMoreRace();
  })

  Column() {
    Text('🏆')
      .fontSize(24)
    Text('赛事报名')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 8 })
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .margin({ right: 8 })
  .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
  .onClick(() => {
    this.onMoreRace();
  })

  Column() {
    Text('📊')
      .fontSize(24)
    Text('积分商城')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
  .onClick(() => {
    this.onMoreRace();
  })
}
.width('100%')
.margin({ top: 12 })

这段代码实现了三个等宽的快捷功能入口。三个Column都设置了layoutWeight(1),使它们在Row中平均分配宽度,形成三等分布局。每个入口都包含一个表情图标和功能名称,使用FlexAlign.Center实现内容居中对齐。

每个功能入口都添加了阴影效果:shadow方法接收radius(模糊半径)、color(阴影颜色)和offsetY(垂直偏移)三个参数。#14000000是一个半透明黑色(透明度约8%),配合offsetY为2的向下偏移,营造出轻微浮起的立体感。前两个入口通过margin({ right: 8 })设置了右侧间距,最后一个不设置,确保三个入口的间距均匀但最右侧不会有多余的空白。

onClick事件统一调用onMoreRace回调,这里的设计意图是点击任意功能入口都会跳转到赛事页面。在实际应用中,这些回调可以分别指向不同的页面或操作。

5.4 横向滚动赛道列表

Scroll() {
  Row() {
    ForEach(getHomeTracks(), (t: RaceTrack) => {
      Column() {
        Text(t.emoji)
          .fontSize(36)
        Text(t.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ top: 8 })
        Text(t.bestLap + ' 最快圈速')
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
        Text('Lv.' + String(t.level) + ' · ' + String(t.length) + 'm')
          .fontSize(9)
          .fontColor('#D32F2F')
          .margin({ top: 4 })
        Text(t.status)
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor(getStatusColor(t.status))
          .borderRadius(10)
          .margin({ top: 6 })
      }
      .width(120)
      .padding({ top: 14, bottom: 14, left: 8, right: 8 })
      .backgroundColor('#FFFFFF')
      .borderRadius(14)
      .margin({ right: 10 })
      .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
      .onClick(() => {
        this.onTrackClick(t);
      })
    }, (t: RaceTrack) => String(t.id))
  }
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

这里出现了一个关键的技术点:横向滚动列表的实现。外层的Scroll通过scrollable(ScrollDirection.Horizontal)设置了水平滚动方向,内部的Row作为滚动内容容器。与垂直滚动不同,横向滚动需要明确指定ScrollDirection.Horizontal,因为默认的滚动方向是垂直的。

ForEach是ArkUI中实现列表渲染的核心机制。它接收三个参数:第一个是数据源数组(getHomeTracks()返回3条赛道数据),第二个是渲染函数(为每个数据项生成UI组件),第三个是键值生成函数(用于Diff算法的优化)。第三个参数将赛道的id转换为字符串作为唯一键,确保列表更新时能够正确识别哪些项发生了变化。

每个赛道卡片固定宽度120,通过margin({ right: 10 })设置卡片间的间距。卡片内部从上到下依次是:赛道表情(36号字体)、赛道名称、最快圈速、难度等级与全长信息、状态标签。状态标签的背景色通过getStatusColor函数动态计算,实现了数据驱动的样式控制。

ForEach的第三个参数(键值生成函数)在性能优化中扮演关键角色。当列表数据发生增删或顺序变化时,Diff算法通过比较新旧键值来决定哪些DOM节点需要创建、更新或销毁。如果省略这个参数或使用不稳定的键值(如数组索引),可能导致不必要的重渲染或状态错乱。

5.5 赛事与新闻列表

ForEach(getHomeRaces(), (r: RaceInfo) => {
  Row() {
    Text(r.emoji)
      .fontSize(26)
    Column() {
      Text(r.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text(r.time + ' · ' + r.track)
        .fontSize(9)
        .fontColor('#8E8E8E')
        .margin({ top: 3 })
      Text('名额 ' + String(r.joined) + '/' + String(r.quota) + ' · 奖励 ' + String(r.reward) + ' 分')
        .fontSize(9)
        .fontColor('#D32F2F')
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Text(r.status)
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .backgroundColor(getStatusColor(r.status))
      .borderRadius(10)
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 10, bottom: 10 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 8 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onRaceClick(r);
  })
}, (r: RaceInfo) => String(r.id))

赛事列表使用了垂直排列的卡片布局。每个卡片是一个Row,左侧是赛事表情,中间是赛事信息列,右侧是状态标签。这种"图标+信息+标签"的三段式布局是列表项的经典设计模式。

中间的Column通过layoutWeight(1)占据剩余空间,将状态标签推到最右侧。Column内部的alignItems设置为HorizontalAlign.Start,使文本左对齐。三行文本分别展示赛事名称、时间与赛道、报名进度与奖励,字号从13到9递减,形成了清晰的视觉层次。

新闻列表的结构与赛事列表类似,但增加了一个maxLines和textOverflow的处理:

Text(n.title)
  .fontSize(12)
  .fontWeight(FontWeight.Bold)
  .fontColor('#333333')
  .maxLines(1)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

maxLines(1)限制标题最多显示一行,textOverflow设置为TextOverflow.Ellipsis表示超出部分以省略号截断。这种处理方式在列表项中非常必要,因为新闻标题的长度不确定,如果不做截断处理,过长的标题会撑破布局或导致多行显示影响整体视觉一致性。

六、赛道页组件TrackTab

6.1 组件结构与头部设计

@Component
struct TrackTab {
  onTrackClick: (t: RaceTrack) => void = () => {};
  onBookTrack: (t: RaceTrack) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🗺️')
              .fontSize(28)
            Column() {
              Text('赛道图鉴')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('12 条赛道 · 全部开放预约')
                .fontSize(10)
                .fontColor('#FFE082')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 14 })
        .borderRadius(14)
        .margin({ bottom: 12 })

TrackTab组件的头部设计与首页类似,但展示的是赛道图鉴的主题信息。头部卡片没有设置backgroundColor,这意味着它会使用父容器的背景色。这种设计通常配合渐变背景或主题色背景使用,让头部与页面整体融为一体。

值得注意的是,TrackTab定义了两个回调:onTrackClick和onBookTrack。前者在点击赛道卡片时触发,用于打开赛道详情弹窗;后者在点击"预约"按钮时触发,用于打开预约表单弹窗。这种分离的回调设计使得同一个卡片可以响应两种不同的用户操作——点击卡片本身查看详情,点击卡片内的预约按钮直接进入预约流程。

6.2 赛道状态统计栏

Row() {
  Column() {
    Text('开放中')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#00897B')
    Text('8 条')
      .fontSize(10)
      .fontColor('#8E8E8E')
      .margin({ top: 3 })
  }
  .layoutWeight(1)
  Column() {
    Text('预约中')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#F57C00')
    Text('2 条')
      .fontSize(10)
      .fontColor('#8E8E8E')
      .margin({ top: 3 })
  }
  .layoutWeight(1)
  Column() {
    Text('维护中')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#BDBDBD')
    Text('2 条')
      .fontSize(10)
      .fontColor('#8E8E8E')
      .margin({ top: 3 })
  }
  .layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.margin({ bottom: 12 })

这段代码实现了一个三等分的统计栏,展示不同状态的赛道数量。三个Column都使用layoutWeight(1)平均分配宽度,形成对称的三列布局。每列包含状态名称和数量,状态名称使用了与getStatusColor函数一致的颜色:开放中为青绿色、预约中为橙色、维护中为灰色。

这个统计栏的设计体现了"信息摘要"的概念——在用户浏览完整列表之前,先给出一个全局的统计概览,帮助用户快速了解赛道的整体开放情况。这种"先总后分"的信息架构在数据展示类应用中非常常见。

6.3 赛道卡片详情布局

ForEach(getTrackRows(), (t: RaceTrack) => {
  Row() {
    Text(t.emoji)
      .fontSize(34)
    Column() {
      Text(t.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text('全长 ' + String(t.length) + 'm · ' + String(t.turns) + ' 个弯 · 最佳 ' + t.bestLap)
        .fontSize(9)
        .fontColor('#8E8E8E')
        .margin({ top: 3 })
      Text(t.desc)
        .fontSize(9)
        .fontColor('#9E9E9E')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 3 })
      Row() {
        Text(t.price === 0 ? '免费' : '¥' + String(t.price) + '/圈')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D32F2F')
        Text('')
          .layoutWeight(1)
        Text('预约 ›')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .backgroundColor('#D32F2F')
          .borderRadius(12)
          .onClick(() => {
            this.onBookTrack(t);
          })
      }
      .width('100%')
      .margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onTrackClick(t);
  })
}, (t: RaceTrack) => String(t.id))

赛道卡片的布局比首页的赛道卡片更加详细。卡片采用Row布局,左侧是大号表情图标,右侧是信息Column。信息Column内部分为四层:赛道名称、赛道参数(全长、弯道数、最佳圈速)、赛道描述(单行截断)、价格与预约按钮。

价格展示使用了一个三元表达式:t.price === 0 ? '免费' : '¥' + String(t.price) + '/圈'。当价格为0时显示"免费",否则显示"¥X/圈"的格式。这种条件渲染确保了免费赛道和收费赛道在展示上的一致性。

预约按钮被嵌套在Row中,Row内的第一个Text设置了layoutWeight(1)占据剩余空间,将按钮推到最右侧。按钮的onClick事件调用onBookTrack回调并传入当前赛道数据,与卡片本身的onClick事件(调用onTrackClick)形成两种独立的交互路径。

在嵌套的事件处理中,ArkUI的事件会按照冒泡顺序传播。当用户点击"预约"按钮时,按钮的onClick会先触发,然后事件可能继续冒泡到父级Row和卡片的onClick。在本应用中,由于按钮的onClick直接调用了onBookTrack回调并打开了预约弹窗,而卡片的onClick会打开详情弹窗,两者打开的是不同的弹窗,因此需要注意事件冒泡是否会导致两个弹窗同时弹出。在实际运行中,ArkUI的onClick事件不会自动冒泡到父组件,每个组件的onClick是独立的,因此不存在这个问题。

七、赛事页组件RaceTab

7.1 赛事列表与序号标记

ForEach(getRaceRows(), (r: RaceInfo, ri: number) => {
  Row() {
    Column() {
      Text(String(ri + 1))
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
    }
    .width(30)
    .height(30)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(r.color)
    .borderRadius(15)

    Column() {
      Row() {
        Text(r.emoji)
          .fontSize(16)
        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ left: 4 })
        Text('')
          .layoutWeight(1)
        Text(r.status)
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(getStatusColor(r.status))
          .borderRadius(9)
      }
      .width('100%')

      Text(r.type + ' · ' + r.track + ' · ' + r.time)
        .fontSize(10)
        .fontColor('#8E8E8E')
        .margin({ top: 4 })

      Row() {
        Text('报名 ' + String(r.joined) + '/' + String(r.quota))
          .fontSize(9)
          .fontColor('#00897B')
        Text('')
          .layoutWeight(1)
        Text('奖励 ' + String(r.reward) + ' 分')
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D32F2F')
      }
      .width('100%')
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onRaceClick(r);
  })
}, (r: RaceInfo) => String(r.id))

赛事页的列表项引入了一个序号标记设计。ForEach的渲染函数接收了第二个参数ri(index的缩写),表示当前项在数组中的索引。通过String(ri + 1)将索引转换为从1开始的序号文本。

序号被放在一个30x30的圆形容器中,通过width(30)、height(30)和borderRadius(15)实现完美圆形(borderRadius等于宽度的一半即为圆形)。圆形容器的背景色使用了赛道数据中的color字段,每个赛事都有独特的主题色,使得序号标记在视觉上成为卡片的亮点。

ForEach的渲染函数支持两个参数:第一个是数据项,第二个是索引。这在需要展示序号、排名或根据索引计算样式的场景中非常实用。需要注意,索引从0开始,因此显示时需要加1。

7.2 我发起的赛事管理

ForEach(getRaceRows2(), (r: RaceInfo) => {
  Column() {
    Row() {
      Text(r.emoji)
        .fontSize(22)
      Column() {
        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(r.time + ' · ' + r.track)
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 8 })

      Text(r.status)
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(getStatusColor(r.status))
        .borderRadius(9)
    }
    .width('100%')

    Row() {
      Text('报名 ' + String(r.joined) + '/' + String(r.quota))
        .fontSize(9)
        .fontColor('#00897B')
      Text('')
        .layoutWeight(1)
      Text('编辑')
        .fontSize(11)
        .fontColor('#F57C00')
        .onClick(() => {
          this.onEditRace(r);
        })
      Text('删除')
        .fontSize(11)
        .fontColor('#D32F2F')
        .margin({ left: 14 })
        .onClick(() => {
          this.onDeleteRace(r);
        })
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFF8E1')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .onClick(() => {
    this.onRaceClick(r);
  })
}, (r: RaceInfo) => String(r.id))

"我发起的赛事"区域展示了用户自己创建的赛事,并提供编辑和删除操作。这个区域的卡片使用了浅黄色背景(#FFF8E1),与上方"全部赛事"的白色卡片形成视觉区分,让用户一眼就能识别出哪些是自己发起的。

卡片底部增加了一个操作行,包含"编辑"和"删除"两个文本按钮。编辑按钮使用橙色(#F57C00),删除按钮使用红色(#D32F2F),通过颜色区分操作的严重程度。两个按钮之间通过margin({ left: 14 })设置了间距。

在UI设计中,操作按钮的颜色选择需要遵循"语义化"原则。通常,绿色表示确认或安全操作,橙色表示提示或编辑类操作,红色表示危险或不可逆操作。这套颜色体系在删除确认弹窗中还会进一步强化——删除按钮使用红色背景,取消按钮使用灰色背景。

八、车手页组件RiderTab

8.1 领奖台展示

Row() {
  ForEach(getTop3Riders(), (r: Rider, ri: number) => {
    Column() {
      Text(ri === 0 ? '🥇' : (ri === 1 ? '🥈' : '🥉'))
        .fontSize(24)
      Text(r.emoji)
        .fontSize(30)
        .margin({ top: 4 })
      Text(r.name)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 4 })
      Text(formatPoints(r.points) + ' 分')
        .fontSize(9)
        .fontColor('#FFE082')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .padding({ top: 12, bottom: 12 })
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#33FFFFFF')
    .borderRadius(14)
    .margin({ right: 8 })
  }, (r: Rider) => String(r.id))
}
.width('100%')
.margin({ top: 12 })

车手页的头部设计了一个领奖台区域,展示积分前三名的车手。ForEach渲染getTop3Riders()返回的3位车手,通过索引ri判断显示哪个奖牌表情:索引0显示金牌、索引1显示银牌、索引2显示铜牌。这种基于索引的条件渲染通过嵌套三元表达式实现。

每个领奖台卡片使用layoutWeight(1)平均分配宽度,内部使用FlexAlign.Center居中对齐。背景色#33FFFFFF是半透明白色,叠在头部卡片的深色背景上会产生柔和的透明效果。积分使用formatPoints函数格式化,超过1000的积分会显示为"k"为单位。

8.2 圈速趋势柱状图

Row() {
  ForEach(LAP_DAYS, (d: LapDay, di: number) => {
    Column() {
      Column() {
        Column()
          .width(18)
          .height(getBarHeight(70 - (d.lap - 58) * 8))
          .backgroundColor(di === 6 ? '#D32F2F' : '#EF9A9A')
          .borderRadius(4)
      }
      .width('100%')
      .height(80)
      .justifyContent(FlexAlign.End)
      .alignItems(HorizontalAlign.Center)

      Text(d.day)
        .fontSize(9)
        .fontColor('#8E8E8E')
        .margin({ top: 6 })
    }
    .layoutWeight(1)
  }, (d: LapDay) => d.day)
}
.width('100%')
.padding({ top: 12, bottom: 12, left: 8, right: 8 })
.backgroundColor('#FFFFFF')
.borderRadius(12)

这是一段手工实现的柱状图代码,没有使用任何图表库。ForEach遍历LAP_DAYS中的7天数据,为每天渲染一根柱子。柱子的核心实现是一个空的Column组件,通过width和height属性控制其尺寸。

柱子高度的计算公式是getBarHeight(70 - (d.lap - 58) * 8)。这个公式的逻辑是:以58秒为基准,圈速每慢1秒,高度计算值减少8个单位,再通过getBarHeight函数(30 + value * 0.5)转换为实际像素高度。这样圈速越快(值越小),柱子越高,直观地反映了"越快越好"的语义。

柱子的颜色通过索引di判断:最后一根(索引6,即周日)使用红色(#D32F2F)突出显示最快圈速,其余使用浅红色(#EF9A9A)。柱子被放在一个高度为80的容器中,容器的justifyContent设置为FlexAlign.End,使柱子从底部向上生长,形成标准的柱状图效果。

手工实现柱状图虽然不如使用图表库灵活,但在简单场景下有两个优势:一是完全控制视觉效果,可以精确匹配应用的整体设计风格;二是避免了引入额外的依赖,减小应用体积。这种"轻量可视化"的策略在数据项较少(7根柱子)的场景下是合理的选择。

8.3 积分榜与速度条

ForEach(getRiderRows(), (r: Rider, ri: number) => {
  Row() {
    Text(String(ri + 1))
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor(ri < 3 ? '#D32F2F' : '#9E9E9E')
      .width(24)
    Text(r.emoji)
      .fontSize(24)
    Column() {
      Row() {
        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(' ' + r.team)
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ left: 4 })
      }
      Row() {
        Column()
          .width(r.speed * 2.4)
          .height(5)
          .backgroundColor(getSpeedColor(r.speed))
          .borderRadius(3)
      }
      .width(180)
      .height(5)
      .backgroundColor('#EEEEEE')
      .borderRadius(3)
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Column() {
      Text(formatPoints(r.points))
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#D32F2F')
      Text(r.tag)
        .fontSize(9)
        .fontColor(r.color)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 10, bottom: 10 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 8 })
  .onClick(() => {
    this.onRiderClick(r);
  })
}, (r: Rider) => String(r.id))

积分榜的每一行包含排名序号、车手表情、车手信息(名称+车队)、速度条、积分和段位标签。速度条是这段代码的核心亮点——它通过一个嵌套的Row和Column实现了进度条效果。

外层Row设置了固定的width(180)和height(5),背景色为浅灰色(#EEEEEE),作为进度条的轨道。内层的Column宽度通过r.speed * 2.4动态计算,表示进度条的填充部分。例如速度为96时,宽度为96 * 2.4 = 230.4,但被外层Row的180宽度裁剪。速度条的颜色通过getSpeedColor函数根据速度值动态确定。

排名序号的颜色也通过条件渲染实现:前三名(ri < 3)使用红色加粗显示,其余使用灰色。这种视觉强调让用户快速识别排行榜的前列。

九、配件页组件PartsTab

9.1 标签筛选栏

Row() {
  ForEach(getPartsTags(), (tag: string) => {
    Text(tag)
      .fontSize(11)
      .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      .backgroundColor('#FFE0B2')
      .fontColor('#E65100')
      .borderRadius(12)
      .margin({ right: 8 })
  }, (tag: string) => tag)
}
.width('100%')
.margin({ bottom: 12 })

配件页的标签筛选栏使用ForEach渲染getPartsTags()返回的4个标签。每个标签是一个Text组件,使用浅橙色背景(#FFE0B2)和深橙色文字(#E65100),形成胶囊形状的标签按钮。标签之间通过margin({ right: 8 })设置间距。

这个标签栏目前只实现了展示功能,没有添加onClick事件。在实际应用中,可以扩展为点击标签筛选对应类别的配件。标签的键值直接使用标签文本本身((tag: string) => tag),因为标签文本是唯一的。

9.2 配件卡片与价格展示

ForEach(getPartsRows(), (p: RaceParts) => {
  Column() {
    Row() {
      Text(p.emoji)
        .fontSize(32)
      Column() {
        Text(p.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(p.tag + ' · 库存 ' + String(p.stock) + ' 件')
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

      Text(p.tag)
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(p.color)
        .borderRadius(9)
    }
    .width('100%')

    Row() {
      Text('¥' + String(p.price))
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#D32F2F')
      Text(' ¥' + String(p.oldPrice))
        .fontSize(10)
        .fontColor('#BDBDBD')
        .decoration({ type: TextDecorationType.LineThrough })
        .margin({ left: 6 })
      Text('')
        .layoutWeight(1)
      Text('性能 +' + String(p.power) + '%')
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#00897B')
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onPartsClick(p);
  })
}, (p: RaceParts) => String(p.id))

配件卡片的价格展示是一个经典的双价格设计。现价使用15号字体、加粗、红色(#D32F2F)显示,原价使用10号字体、灰色(#BDBDBD)显示,并通过decoration属性添加删除线效果。

TextDecorationType.LineThrough是ArkUI提供的文本装饰类型之一,它会在文字中间画一条水平线,表示该价格已被废弃。配合字号和颜色的差异,形成了"现价突出、原价弱化"的视觉对比,这是电商场景中标准的折扣价格展示模式。

性能提升信息"性能 +X%"使用青绿色(#00897B)显示,与价格的红色形成互补色对比,让用户在同一行内既能看到成本(价格)又能看到收益(性能提升)。

十、我的页组件MyTab

10.1 个人信息卡片

Column() {
  Row() {
    Text('🏎️')
      .fontSize(40)
    Column() {
      Text('小虎')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text('雷霆车队 · 赛季第 2 名')
        .fontSize(10)
        .fontColor('#FFE082')
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })

    Text('🏅')
      .fontSize(28)
  }
  .width('100%')

  Row() {
    Column() {
      Text('1210')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('赛季积分')
        .fontSize(9)
        .fontColor('#FFE082')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    Column() {
      Text('7')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('胜场数')
        .fontSize(9)
        .fontColor('#FFE082')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    Column() {
      Text('58.9s')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('个人最佳圈速')
        .fontSize(9)
        .fontColor('#FFE082')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
  }
  .width('100%')
  .padding({ top: 14, bottom: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 18, bottom: 16 })
.borderRadius(16)
.shadow({ radius: 12, color: '#33D32F2F', offsetY: 4 })

个人页的头部卡片展示用户的个人信息和三项核心数据。卡片分为上下两部分:上半部分是头像(表情)、昵称和车队信息,下半部分是三等分的赛季积分、胜场数和个人最佳圈速。

三个数据列都使用layoutWeight(1)平均分配宽度,每列包含一个数值(16号字体、金色#FFD600、加粗)和标签(9号字体、浅金色#FFE082)。这种"大数字+小标签"的布局模式在数据展示类卡片中非常标准,能够在有限的空间内传达最多的信息。

卡片的阴影使用了带透明度的红色#33D32F2F(约20%透明度的红色),配合offsetY为4的向下偏移和radius为12的模糊半径,营造出一种红色主题的柔和投影效果,与整个应用的赛车红色调相呼应。

10.2 战绩记录列表

ForEach(getRecordRows(), (r: RaceRecord) => {
  Column() {
    Row() {
      Text(r.emoji)
        .fontSize(26)
      Column() {
        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(r.track + ' · ' + r.date)
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

      Text(r.status)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(getRaceStatusColor(r.status))
        .borderRadius(10)
    }
    .width('100%')

    Row() {
      Text('圈速 ' + r.lap)
        .fontSize(10)
        .fontColor('#D32F2F')
      Text('')
        .layoutWeight(1)
      Text('排名第 ' + String(r.rank) + ' 名')
        .fontSize(10)
        .fontColor('#8E8E8E')
      Text('删除')
        .fontSize(10)
        .fontColor('#D32F2F')
        .margin({ left: 12 })
        .onClick(() => {
          this.onDeleteRecord(r);
        })
    }
    .width('100%')
    .margin({ top: 8 })
    .padding({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onRecordClick(r);
  })
}, (r: RaceRecord) => String(r.id))

战绩记录卡片采用Column布局,分为上下两行。上行展示赛事名称、赛道和日期,以及成绩状态标签(使用getRaceStatusColor函数着色)。下行展示圈速、排名和删除按钮。

成绩状态标签的颜色映射体现了奖牌色系:冠军为金色、亚军为银色、季军为铜色、完赛为青绿色、退赛为灰色。这套颜色体系与领奖台展示的颜色一致,确保了整个应用中成绩状态的视觉统一性。

历史记录区域(getRecordRows2返回的数据)使用了更紧凑的布局样式,背景色为浅灰色(#F9F9FB),不显示删除按钮,仅展示赛事名称、日期和状态文本。这种"详细列表+紧凑列表"的分层设计,让用户优先关注近期的重要战绩,历史记录作为补充参考。

十一、主入口组件KartRacingApp

11.1 枚举与状态定义

enum KartTab {
  Home = 0,
  Track = 1,
  Race = 2,
  Rider = 3,
  Parts = 4,
  Mine = 5
}

@Entry
@Component
struct KartRacingApp {
  @State curTab: number = KartTab.Home;
  @State showTrack: boolean = false;
  @State selTrack: RaceTrack | null = null;
  @State showBook: boolean = false;
  @State bookTrack: RaceTrack | null = null;
  @State showRace: boolean = false;
  @State selRace: RaceInfo | null = null;
  @State showAddRace: boolean = false;
  @State showEditRace: boolean = false;
  @State showDelRace: boolean = false;
  @State delRace: RaceInfo | null = null;
  @State showRider: boolean = false;
  @State selRider: Rider | null = null;
  @State showParts: boolean = false;
  @State selParts: RaceParts | null = null;
  @State showPay: boolean = false;
  @State showRecord: boolean = false;
  @State selRecord: RaceRecord | null = null;
  @State showDelRecord: boolean = false;
  @State delRecord: RaceRecord | null = null;
  @State toast: string = '';
  @State fSlot: string = '10:00-11:00';
  @State fAmount: number = 500;
  @State fReward: number = 200;
  @State fName: string = '';
  @State fTime: string = '';

KartTab枚举定义了六个页面的索引值,这种用枚举代替魔法数字的做法提高了代码的可读性。在底部导航的切换逻辑中,使用KartTab.Home比直接使用0更加清晰。

@Entry装饰器将KartRacingApp标记为应用的入口组件,每个页面只有一个组件可以被标记为@Entry。@Component装饰器将其标记为自定义组件。@State装饰器标记的变量是组件的响应式状态——当这些变量的值发生变化时,引用它们的UI部分会自动重新渲染。

状态变量可以分为四类。第一类是页面导航状态(curTab),控制当前显示哪个Tab页面。第二类是弹窗显示状态(showTrack、showBook、showRace等),控制各个弹窗的显示与隐藏。第三类是选中数据状态(selTrack、selRace、selRider等),存储当前被选中并需要在弹窗中展示的数据。第四类是表单状态(fSlot、fAmount、fReward、fName、fTime),存储表单弹窗中的用户输入。

@State是ArkUI状态管理的基石。它实现了"状态驱动视图"的核心机制:当@State变量被修改时,框架会自动找到build方法中引用该变量的UI片段,并重新执行渲染。开发者不需要手动调用setState或requestUpdate,只需修改变量值即可。但需要注意的是,@State只能监测赋值操作,对于数组内部元素的修改(如push、splice)可能不会触发更新,需要赋值整个数组。

11.2 内容区域构建器

@Builder
contentArea() {
  if (this.curTab === KartTab.Home) {
    HomeTab({
      onTrackClick: (t: RaceTrack) => {
        this.selTrack = t;
        this.showTrack = true;
      },
      onRaceClick: (r: RaceInfo) => {
        this.selRace = r;
        this.showRace = true;
      },
      onNewsClick: (n: RaceNews) => {
        this.toast = '公告:' + n.title;
      },
      onMoreRace: () => {
        this.curTab = KartTab.Race;
      }
    })
  } else if (this.curTab === KartTab.Track) {
    TrackTab({
      onTrackClick: (t: RaceTrack) => {
        this.selTrack = t;
        this.showTrack = true;
      },
      onBookTrack: (t: RaceTrack) => {
        this.bookTrack = t;
        this.fSlot = '10:00-11:00';
        this.showBook = true;
      }
    })
  } else if (this.curTab === KartTab.Race) {
    RaceTab({
      onRaceClick: (r: RaceInfo) => {
        this.selRace = r;
        this.showRace = true;
      },
      onAddRace: () => {
        this.fName = '';
        this.fTime = '';
        this.fReward = 200;
        this.showAddRace = true;
      },
      onEditRace: (r: RaceInfo) => {
        this.selRace = r;
        this.fTime = r.time;
        this.showEditRace = true;
      },
      onDeleteRace: (r: RaceInfo) => {
        this.delRace = r;
        this.showDelRace = true;
      }
    })
  }
  // ... 其他Tab页面
}

@Builder装饰器定义了一个UI构建方法。与build方法不同,@Builder方法可以被多次调用或在其他@Builder方法中引用,实现UI片段的复用。contentArea方法通过if-else条件判断,根据curTab的值渲染对应的Tab组件。

每个Tab组件在初始化时都接收了一组回调函数。这些回调函数的实现逻辑高度一致:将点击的数据项赋值给对应的sel状态变量,然后将show状态变量设为true以打开弹窗。这种"数据赋值+状态切换"的回调模式是整个应用处理用户交互的核心范式。

onAddRace回调在打开新增赛事弹窗前,会先重置表单状态(fName清空、fTime清空、fReward设为默认值200),确保每次打开弹窗时表单都是干净的。onEditRace回调则会将当前赛事的时间赋值给fTime,实现"编辑预填"的效果。这种区分"新增"和"编辑"的表单初始化逻辑,是表单管理的标准实践。

11.3 底部导航项构建器

@Builder
bottomTabItem(icon: string, label: string, idx: number) {
  Column() {
    Text(icon)
      .fontSize(19)
      .opacity(this.curTab === idx ? 1 : 0.55)
    Text(label)
      .fontSize(9)
      .fontColor(this.curTab === idx ? '#D32F2F' : '#9E9E9E')
      .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .padding({ top: 5, bottom: 5 })
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.curTab = idx;
  })
}

bottomTabItem是一个带参数的@Builder方法,用于构建底部导航的单个Tab项。它接收三个参数:icon(图标表情)、label(文字标签)和idx(索引值)。通过比较this.curTab和idx,动态设置选中态和未选中态的样式差异。

选中态的图标opacity为1(完全不透明),文字为红色加粗;未选中态的图标opacity为0.55(半透明),文字为灰色常规字重。这种通过opacity而非颜色变化来区分图标状态的技巧,在表情图标场景中特别有效——因为表情图标的颜色无法直接修改,但透明度可以。

onClick事件将curTab设为当前索引值,触发页面切换。由于curTab是@State变量,其值变化会自动触发contentArea的重新渲染,切换到对应的Tab页面。

@Builder方法支持参数传递,这使得UI片段的复用更加灵活。在本应用中,六个底部导航项通过同一个@Builder方法渲染,只需传入不同的图标和标签即可。如果没有@Builder,开发者需要在build方法中重复编写六次相同的Column-Text-Text结构,代码会变得冗长且难以维护。

11.4 遮罩层构建器

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

modalOverlay是所有弹窗共用的遮罩层构建器。它接收一个onClose回调函数,生成一个全屏半透明黑色遮罩。遮罩使用rgba颜色格式,其中0.55的透明度确保背景内容仍能隐约可见,同时将视觉焦点集中到弹窗内容上。

点击遮罩层会触发onClose回调,关闭对应的弹窗。这种"点击外部关闭"的交互模式是移动端弹窗的标准行为,提供了除关闭按钮之外的另一种退出方式,提升了用户体验。

将遮罩层抽取为独立的@Builder方法是DRY原则的体现。应用中有11个弹窗,每个弹窗都需要遮罩层,如果在每个弹窗的代码中都重复编写遮罩层逻辑,会造成大量代码冗余。通过@Builder方法,遮罩层的实现只需编写一次,在每个弹窗中调用即可。

十二、弹窗系统详解

12.1 赛道详情弹窗

@Builder
trackModal() {
  Column() {
    Column() {
      Row() {
        Text(this.selTrack!.emoji)
          .fontSize(36)
        Column() {
          Text(this.selTrack!.name)
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('Lv.' + String(this.selTrack!.level) + ' 难度 · ' + this.selTrack!.status)
            .fontSize(10)
            .fontColor('#FFE082')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text('✕')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .padding(8)
          .onClick(() => {
            this.showTrack = false;
          })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 16 })

    Column() {
      Row() {
        Column() {
          Text(String(this.selTrack!.length) + 'm')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#D32F2F')
          Text('全长')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(String(this.selTrack!.turns))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#D32F2F')
          Text('弯道数')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(this.selTrack!.bestLap)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#D32F2F')
          Text('最佳圈速')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#FFF5F5')
      .borderRadius(12)

      Text(this.selTrack!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 12 })

      Text('立即预约')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#D32F2F')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.bookTrack = this.selTrack;
          this.fSlot = '10:00-11:00';
          this.showTrack = false;
          this.showBook = true;
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('82%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

赛道详情弹窗是一个上下拼接的卡片设计。上半部分是头部区域,展示赛道表情、名称、难度等级和状态,背景色继承自父容器的主题色。下半部分是白色背景的内容区域,展示三项关键数据(全长、弯道数、最佳圈速)、赛道描述和预约按钮。

弹窗使用了非空断言操作符(!)来访问selTrack的属性。这是因为selTrack的类型是RaceTrack | null(可能为null),但在弹窗显示时,selTrack一定已经被赋值了。在build方法的条件判断中,if (this.showTrack && this.selTrack !== null)确保了只有当selTrack不为null时才会渲染trackModal,因此在trackModal内部使用非空断言是安全的。

弹窗的transition属性设置了TransitionEffect.OPACITY.animation({ duration: 200 }),表示弹窗出现和消失时会有200毫秒的淡入淡出动画。这种过渡动画让弹窗的出现更加平滑自然,避免了突然弹出造成的视觉突兀感。

预约按钮的onClick事件实现了一个弹窗跳转逻辑:先将当前赛道赋值给bookTrack,重置时段选择为默认值,关闭详情弹窗(showTrack设为false),然后打开预约弹窗(showBook设为true)。这种"关闭一个弹窗、打开另一个弹窗"的连续操作流程,在用户交互设计中需要特别注意时序控制。

点击关闭

点击立即预约

点击确认预约

点击关闭

点击赛道卡片

打开详情弹窗

用户操作

关闭弹窗

关闭详情弹窗

打开预约弹窗

选择时段和圈数

关闭预约弹窗

显示Toast提示

12.2 预约赛道弹窗

@Builder
bookModal() {
  Column() {
    Column() {
      Row() {
        Text('📋 预约赛道')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showBook = false;
          })
      }
      .width('100%')

      Row() {
        Text(this.bookTrack!.emoji)
          .fontSize(24)
        Text(this.bookTrack!.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ left: 8 })
        Text('')
          .layoutWeight(1)
        Text('¥' + String(this.bookTrack!.price) + '/圈')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D32F2F')
      }
      .width('100%')
      .padding({ top: 12, bottom: 12, left: 12, right: 12 })
      .backgroundColor('#FFF5F5')
      .borderRadius(12)
      .margin({ top: 12 })

      Text('选择时段')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14, bottom: 8 })

      Row() {
        ForEach(getSlots(), (slot: string) => {
          Text(slot)
            .fontSize(11)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.fSlot === slot ? '#D32F2F' : '#F5F5F5')
            .fontColor(this.fSlot === slot ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.fSlot = slot;
            })
        }, (slot: string) => slot)
      }
      .width('100%')
      .margin({ top: 2 })

      Text('选择圈数')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14, bottom: 8 })

      Row() {
        ForEach(getLapOptions(), (lap: number) => {
          Text(String(lap) + ' 圈')
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor('#F5F5F5')
            .fontColor('#616161')
            .borderRadius(10)
            .margin({ right: 8 })
        }, (lap: number) => String(lap))
      }
      .width('100%')
      .margin({ top: 2 })

      Text('确认预约')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#D32F2F')
        .borderRadius(24)
        .margin({ top: 16 })
        .onClick(() => {
          this.showBook = false;
          this.toast = '预约成功:' + this.bookTrack!.name + ' ' + this.fSlot;
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 20 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .constraintSize({ maxHeight: '80%' })
  }
  .width('100%')
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

预约弹窗是一个底部抽屉式弹窗,与赛道详情弹窗的居中卡片式弹窗形成对比。底部抽屉的特点是宽度占满屏幕(width(‘100%’)),只有顶部两个角为圆角(borderRadius({ topLeft: 18, topRight: 18 })),从屏幕底部向上滑出。

时段选择使用了单选标签组的设计。ForEach渲染getSlots()返回的4个时段选项,每个选项的背景色和文字色通过this.fSlot === slot条件判断来决定:选中时为红色背景白字,未选中时为浅灰色背景深灰字。点击选项时将fSlot设为该时段值,触发UI更新。

圈数选择虽然也使用了ForEach渲染,但没有添加选中态的视觉反馈——所有选项都显示为浅灰色背景。这可能是开发者有意为之(圈数选择是可选的而非必选的),也可能是后续需要完善的功能点。

constraintSize({ maxHeight: ‘80%’ })限制了弹窗的最大高度为屏幕的80%,防止内容过多时弹窗超出屏幕范围。当内容超出最大高度时,由于外层没有Scroll包裹,超出部分会被裁剪。在实际应用中,可能需要为弹窗内容添加Scroll以支持内容滚动。

底部抽屉弹窗是移动端UI中非常常见的一种弹窗形式。与居中弹窗相比,底部抽屉更适合表单类操作——用户的手指自然停留在屏幕底部,操作路径更短。同时,底部抽屉可以从底部滑入,视觉上更加符合"从下方升起"的空间隐喻。

12.3 新增赛事表单弹窗

@Builder
addRaceModal() {
  Column() {
    Row() {
      Text('+ 发起新赛事')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showAddRace = false;
        })
    }
    .width('100%')

    Text('赛事名称')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 6 })
    Text(this.fName.length > 0 ? this.fName : '例如:午间极速娱乐赛')
      .fontSize(12)
      .fontColor(this.fName.length > 0 ? '#333333' : '#BDBDBD')
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor('#F5F5F5')
      .borderRadius(10)

    Text('开赛时间')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 12, bottom: 6 })
    Text(this.fTime.length > 0 ? this.fTime : '选择日期与时段')
      .fontSize(12)
      .fontColor(this.fTime.length > 0 ? '#333333' : '#BDBDBD')
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor('#F5F5F5')
      .borderRadius(10)

    Text('奖励积分')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 12, bottom: 6 })
    Row() {
      ForEach(getRewardOptions(), (rw: number) => {
        Text(String(rw) + ' 分')
          .fontSize(11)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.fReward === rw ? '#D32F2F' : '#F5F5F5')
          .fontColor(this.fReward === rw ? '#FFFFFF' : '#616161')
          .borderRadius(10)
          .margin({ right: 8 })
          .onClick(() => {
            this.fReward = rw;
          })
      }, (rw: number) => String(rw))
    }
    .width('100%')
    .margin({ top: 2 })

    Text('确认发起')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#D32F2F')
      .borderRadius(24)
      .margin({ top: 16 })
      .onClick(() => {
        this.showAddRace = false;
        this.toast = '赛事发起成功,等待审核';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

新增赛事弹窗是一个完整的表单弹窗,包含赛事名称、开赛时间和奖励积分三个表单字段。表单字段的展示使用了一种"伪输入框"的设计——用Text组件模拟输入框的外观,通过条件渲染来区分"有值"和"无值"两种状态。

当fName为空时(this.fName.length > 0为false),Text显示占位文本"例如:午间极速娱乐赛",文字颜色为浅灰色(#BDBDBD);当fName有值时,显示实际值,文字颜色为深色(#333333)。这种设计模式在ArkUI中被称为"占位符"效果,通过条件渲染实现了类似HTML中placeholder属性的功能。

奖励积分选择使用了与时段选择相同的单选标签组模式。ForEach渲染getRewardOptions()返回的4个积分选项(100、200、300、500),通过fReward === rw判断选中态。点击选项时更新fReward的值,触发UI重新渲染。

在本应用中,表单输入框使用Text组件而非TextInput组件来实现。这是一种演示场景下的简化处理——Text组件不支持用户直接输入文字。在实际生产应用中,赛事名称和开赛时间字段应该使用TextInput或DatePicker组件来实现真正的输入功能。但这种"伪表单"设计在原型展示中是合理的,它降低了实现复杂度,同时完整展示了表单的视觉设计。

12.4 删除确认弹窗

@Builder
delRaceModal() {
  Column() {
    Column() {
      Text('🗑️')
        .fontSize(40)
      Text('删除赛事')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text('删除后不可恢复,已报名员工将收到通知')
        .fontSize(10)
        .fontColor('#FFE082')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 20, bottom: 20 })
    .justifyContent(FlexAlign.Center)

    Column() {
      Text(this.delRace!.emoji + ' ' + this.delRace!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14 })

      Row() {
        Text('取消')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#616161')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#F5F5F5')
          .borderRadius(22)
          .onClick(() => {
            this.showDelRace = false;
          })

        Text('确认删除')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#D32F2F')
          .borderRadius(22)
          .onClick(() => {
            this.showDelRace = false;
            this.toast = '赛事已删除';
          })
      }
      .width('100%')
      .margin({ top: 14, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
  .width('80%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPPLICATE.animation({ duration: 200 }))
}

删除确认弹窗采用居中卡片式设计,宽度为屏幕的80%。弹窗分为上下两部分:上半部分是警示区域,展示垃圾桶图标、"删除赛事"标题和风险提示文案,背景色继承主题色;下半部分是白色背景的操作区域,展示待删除的赛事名称和两个操作按钮。

两个操作按钮"取消"和"确认删除"并排排列,各占48%的宽度。取消按钮使用浅灰色背景和深灰文字,确认删除按钮使用红色背景和白色文字。这种颜色对比明确传达了操作的风险等级——红色按钮表示危险操作,需要用户谨慎确认。

删除确认弹窗是所有涉及不可逆操作的标准交互模式。它的核心目的是通过"二次确认"机制防止用户误操作。在本应用中,确认按钮被点击后会关闭弹窗并显示Toast提示"赛事已删除"。在实际应用中,这里还需要补充实际的删除逻辑——从数据源中移除对应的赛事记录并触发列表重新渲染。

12.5 充值积分弹窗

@Builder
payModal() {
  Column() {
    Row() {
      Text('⚡ 积分充值')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showPay = false;
        })
    }
    .width('100%')

    Text('选择充值金额')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 10 })

    Row() {
      ForEach(getAmountOptions(), (am: number) => {
        Column() {
          Text(String(am))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.fAmount === am ? '#D32F2F' : '#333333')
          Text('积分')
            .fontSize(9)
            .fontColor(this.fAmount === am ? '#D32F2F' : '#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .justifyContent(FlexAlign.Center)
        .backgroundColor(this.fAmount === am ? '#FFEBEE' : '#F5F5F5')
        .borderRadius(12)
        .margin({ right: 8 })
        .onClick(() => {
          this.fAmount = am;
        })
      }, (am: number) => String(am))
    }
    .width('100%')

    Text('¥' + String(Math.floor(this.fAmount / 10)) + '.00 元')
      .fontSize(13)
      .fontColor('#8E8E8E')
      .margin({ top: 12 })
      .alignSelf(ItemAlign.Start)

    Text('确认充值')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#D32F2F')
      .borderRadius(24)
      .margin({ top: 14 })
      .onClick(() => {
        this.showPay = false;
        this.toast = '充值成功:+' + String(this.fAmount) + ' 积分';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

充值弹窗的金额选择使用了格点式布局,每个选项是一个Column容器,通过layoutWeight(1)平均分配宽度。选中态的视觉反馈更加丰富:不仅文字颜色变为红色,背景色也变为浅红色(#FFEBEE),形成了"选中高亮"的效果。

金额与价格的换算通过Math.floor(this.fAmount / 10)实现,即每10积分等于1元。例如选择500积分时,显示"¥50.00 元";选择5000积分时,显示"¥500.00 元"。这种实时计算的价格展示让用户在选择金额时就能看到对应的花费。

alignSelf(ItemAlign.Start)是一个特殊的对齐属性,它允许单个子元素覆盖父容器的对齐设置。在这里,价格文本通过alignSelf设置为左对齐,覆盖了Column默认的居中对齐,使价格显示在弹窗的左侧。

十三、主构建方法与Stack布局

13.1 Stack层叠布局

build() {
  Stack() {
    Column() {
      Column() {
        Text('🏁 极速卡丁 · 员工赛车场')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Row() {
          Text('🚦')
            .fontSize(12)
          Text(' 赛季进行中 · 距总决赛还有 14 天')
            .fontSize(9)
            .fontColor('#FFE082')
            .margin({ left: 2 })
        }
        .margin({ top: 3 })
      }
      .width('100%')
      .padding({ top: 14, bottom: 12 })
      .justifyContent(FlexAlign.Center)

      this.contentArea()

      Row() {
        ForEach(this.tabs, (t: string, ti: number) => {
          this.bottomTabItem(this.tabIcons[ti], t, ti)
        }, (t: string) => t)
      }
      .width('100%')
      .padding({ top: 4, bottom: 4 })
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F7')

主入口的build方法使用Stack作为最外层容器。Stack是ArkUI中的层叠布局容器,它允许子元素在Z轴方向上层叠排列——后添加的子元素会覆盖在前面的子元素之上。

Stack的第一层是主内容区域,包含顶部标题栏、页面内容区和底部导航栏。整个Column设置了浅灰色背景(#F5F5F7)作为页面的基础底色。顶部标题栏使用FlexAlign.Center居中显示应用名称和赛季信息。底部导航栏使用ForEach渲染六个Tab项,通过调用bottomTabItem构建器生成每个导航项,并添加了向上的阴影(offsetY为负值)模拟导航栏悬浮在内容上方的效果。

Stack布局是弹窗系统的基础。在移动端UI中,弹窗通常需要覆盖在主内容之上,Stack的层叠特性天然适合这种需求。通过将弹窗作为Stack的后续子元素添加,它们会自动覆盖在主内容之上。配合条件渲染(if语句),弹窗只在需要显示时才会被渲染到Stack中。

13.2 弹窗的层叠渲染

    if (this.showTrack && this.selTrack !== null) {
      this.modalOverlay(() => {
        this.showTrack = false;
      })
      this.trackModal()
    }
    if (this.showBook && this.bookTrack !== null) {
      this.modalOverlay(() => {
        this.showBook = false;
      })
      this.bookModal()
    }
    if (this.showRace && this.selRace !== null) {
      this.modalOverlay(() => {
        this.showRace = false;
      })
      this.raceModal()
    }
    // ... 其他弹窗的条件渲染

每个弹窗的渲染都由两个部分组成:遮罩层(modalOverlay)和弹窗内容(trackModal、bookModal等)。条件判断使用了&&短路求值——只有当show状态为true且数据不为null时,才会渲染遮罩层和弹窗内容。

这种双重条件判断是防御性编程的体现。show状态控制弹窗的显示与隐藏,数据非空判断确保弹窗在渲染时能够安全地访问数据属性。如果在关闭弹窗时先设置了show为false但没有清空数据,或者先清空了数据但没有设置show为false,都不会导致渲染错误——只要其中一个条件不满足,弹窗就不会渲染。

遮罩层的onClose回调统一采用"将show状态设为false"的逻辑。这种简洁的关闭方式确保了每个弹窗都能通过点击遮罩层关闭,提供了统一的交互体验。

13.3 Toast提示系统

    if (this.toast.length > 0) {
      Column() {
        Text('✅ ' + this.toast)
          .fontSize(13)
          .fontColor('#FFFFFF')
          .padding({ left: 18, right: 18, top: 10, bottom: 10 })
          .backgroundColor('#333333')
          .borderRadius(18)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .position({ x: 0, y: '72%' })
    }
  }
  .width('100%')
  .height('100%')
}

Toast提示系统是整个应用的反馈机制。当toast字符串非空时,会在屏幕72%高度的位置显示一个黑色圆角提示条。Toast内容以"✅ "开头表示成功状态,背景色为半透明黑色(#333333),文字为白色。

position({ x: 0, y: ‘72%’ })使用绝对定位将Toast固定在屏幕的指定位置。这种定位方式不受Stack层叠顺序的影响——Toast作为Stack的最后一个子元素,会覆盖在所有其他内容(包括弹窗)之上,确保用户在任何操作后都能看到反馈提示。

Toast的显示逻辑分散在各个弹窗的确认按钮回调中。每当用户执行了一个操作(预约成功、报名成功、删除成功等),对应的回调会将toast设置为一条提示消息。由于toast是@State变量,其值变化会立即触发Toast的渲染显示。

在本应用中,Toast没有自动消失的机制——一旦显示,它会一直停留在屏幕上。在实际应用中,通常需要配合setTimeout或定时器在几秒后自动清除toast的值,让Toast自动消失。此外,连续多次触发Toast时,新的消息会直接覆盖旧的消息,不会出现多个Toast同时显示的情况。

十四、核心特性对比与总结

下面通过表格对应用中使用的核心ArkUI特性进行系统对比:

特性作用本应用中的使用场景关键注意事项
@Component标记自定义组件六个Tab组件+主入口组件每个组件必须包含build方法
@Entry标记入口组件KartRacingApp每个页面仅一个@Entry
@State响应式状态管理curTab、show系列、sel系列、f系列修改值自动触发UI更新
@BuilderUI片段复用modalOverlay、contentArea、11个弹窗支持参数传递
Column垂直布局所有卡片的内部布局主轴垂直,交叉轴水平
Row水平布局列表项、标签栏、导航栏主轴水平,交叉轴垂直
Stack层叠布局主入口的弹窗层叠后添加的子元素在上层
ForEach列表渲染所有数据列表和选项组需要提供键值生成函数
Scroll滚动容器所有Tab页面的外层支持水平和垂直滚动
layoutWeight弹性权重三等分布局、左弹性右固定权重值决定空间分配比例
FlexAlign对齐方式居中、底部对齐控制主轴方向的对齐
transition过渡动画所有弹窗的淡入淡出需配合TransitionEffect使用
弹窗类型宽度策略圆角策略典型用途
居中卡片80%-82%四角圆角18详情展示、确认操作
底部抽屉100%顶部两角圆角18表单输入、选项选择
警示卡80%四角圆角18删除确认、风险提示
通信方式方向实现机制典型场景
回调函数子到父父组件传入回调,子组件调用点击事件、表单提交
@State内部变量修改触发重新渲染页面切换、弹窗开关
条件渲染状态到视图if判断控制组件是否渲染弹窗显示、Toast显示
数据切片函数数据到视图从全量数据取子集列表分页、首页精选

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface RaceTrack {
  id: number;
  emoji: string;
  name: string;
  length: number;
  turns: number;
  bestLap: string;
  level: number;
  status: string;
  price: number;
  color: string;
  desc: string;
}

interface RaceInfo {
  id: number;
  emoji: string;
  name: string;
  type: string;
  track: string;
  time: string;
  quota: number;
  joined: number;
  reward: number;
  status: string;
  color: string;
  desc: string;
}

interface Rider {
  id: number;
  emoji: string;
  name: string;
  team: string;
  points: number;
  wins: number;
  lap: string;
  speed: number;
  color: string;
  tag: string;
}

interface RaceParts {
  id: number;
  emoji: string;
  name: string;
  price: number;
  oldPrice: number;
  tag: string;
  stock: number;
  power: number;
  color: string;
  desc: string;
}

interface RaceNews {
  id: number;
  emoji: string;
  title: string;
  time: string;
  views: number;
  color: string;
  tag: string;
}

interface RaceRecord {
  id: number;
  emoji: string;
  name: string;
  track: string;
  lap: string;
  rank: number;
  date: string;
  status: string;
  color: string;
}

interface LapDay {
  day: string;
  lap: number;
}

const RACE_TRACKS: RaceTrack[] = [
  { id: 1, emoji: '🏎️', name: '环湖竞速赛道', length: 1200, turns: 8, bestLap: '58.2s', level: 2, status: '开放中', price: 30, color: '#D32F2F', desc: '环绕员工湖一周,弯道多且节奏快,新手必打卡的第一条赛道' },
  { id: 2, emoji: '🏁', name: '极速直线大直道', length: 800, turns: 2, bestLap: '41.6s', level: 1, status: '开放中', price: 20, color: '#F57C00', desc: '两条大直道衔接 S 弯,适合刷圈速与练习起步' },
  { id: 3, emoji: '🌀', name: '螺旋回环赛道', length: 1500, turns: 12, bestLap: '79.3s', level: 3, status: '开放中', price: 45, color: '#303F9F', desc: '连续螺旋弯考验控车精度,漂移爱好者的天堂' },
  { id: 4, emoji: '🌉', name: '高架桥挑战赛', length: 1800, turns: 10, bestLap: '96.8s', level: 4, status: '预约中', price: 60, color: '#7B1FA2', desc: '两段高架桥上下层交错,视野开阔但落差大' },
  { id: 5, emoji: '🌙', name: '夜光霓虹赛道', length: 1400, turns: 9, bestLap: '85.1s', level: 3, status: '开放中', price: 50, color: '#00838F', desc: '仅周五夜晚开放,霓虹灯带勾勒赛道轮廓,氛围感拉满' },
  { id: 6, emoji: '⛈️', name: '雨战湿地赛道', length: 1600, turns: 11, bestLap: '108.4s', level: 5, status: '维护中', price: 70, color: '#455A64', desc: '模拟雨天湿滑路面,配有喷淋系统,职业车手的必修课' },
  { id: 7, emoji: '🏔️', name: '山地越野赛道', length: 2000, turns: 14, bestLap: '132.9s', level: 5, status: '开放中', price: 80, color: '#558B2F', desc: '起伏坡道加砂石路面,卡丁车也能开出越野的感觉' },
  { id: 8, emoji: '🎯', name: '迷宫技巧赛道', length: 1100, turns: 16, bestLap: '72.5s', level: 4, status: '开放中', price: 55, color: '#C2185B', desc: '十六连发卡弯,锻炼车手最细腻的方向盘控制' },
  { id: 9, emoji: '🚀', name: '火箭弹射赛道', length: 1300, turns: 6, bestLap: '63.7s', level: 2, status: '开放中', price: 40, color: '#E65100', desc: '三段弹射加速区,最高时速可突破 80km/h' },
  { id: 10, emoji: '🪐', name: '星际主题赛道', length: 1700, turns: 13, bestLap: '99.2s', level: 4, status: '预约中', price: 65, color: '#4527A0', desc: '星球投影与陨石装饰,外圈逆时针行驶别有一番风味' },
  { id: 11, emoji: '🌿', name: '森林秘境赛道', length: 1450, turns: 7, bestLap: '81.9s', level: 2, status: '开放中', price: 35, color: '#2E7D32', desc: '穿梭于园区林间,弯道平缓适合亲子双人车' },
  { id: 12, emoji: '🏆', name: '冠军总决赛道', length: 2200, turns: 15, bestLap: '148.6s', level: 5, status: '维护中', price: 100, color: '#B71C1C', desc: '一年一度总决赛专用赛道,仅对年度积分榜前 20 开放' }
];

const RACE_LIST: RaceInfo[] = [
  { id: 1, emoji: '🏆', name: '季度车神争霸赛', type: '排位赛', track: '环湖竞速赛道', time: '08-28 14:00', quota: 24, joined: 24, reward: 500, status: '报名截止', color: '#D32F2F', desc: '季度积分最高者封号车神,奖励定制奖杯与年假 1 天' },
  { id: 2, emoji: '⚡', name: '夜间闪电杯', type: '竞速赛', track: '夜光霓虹赛道', time: '08-29 19:30', quota: 18, joined: 12, reward: 300, status: '报名中', color: '#00838F', desc: '周五夜晚灯光赛,前三名瓜分 300 积分奖励' },
  { id: 3, emoji: '🌀', name: '漂移大师挑战', type: '技巧赛', track: '螺旋回环赛道', time: '08-30 15:00', quota: 16, joined: 9, reward: 260, status: '报名中', color: '#303F9F', desc: '裁判根据漂移角度与连贯性打分,不以圈速论英雄' },
  { id: 4, emoji: '👨‍👩‍👧', name: '亲子欢乐跑', type: '娱乐赛', track: '森林秘境赛道', time: '09-01 10:00', quota: 30, joined: 18, reward: 150, status: '报名中', color: '#2E7D32', desc: '双人卡丁车,带娃体验家庭竞速乐趣' },
  { id: 5, emoji: '⛈️', name: '雨战生存赛', type: '耐力赛', track: '雨战湿地赛道', time: '09-02 16:00', quota: 12, joined: 3, reward: 400, status: '报名中', color: '#455A64', desc: '喷淋系统全开,跑满 30 圈者胜,考验轮胎管理' },
  { id: 6, emoji: '🚀', name: '极速弹射表演赛', type: '表演赛', track: '火箭弹射赛道', time: '09-03 18:00', quota: 20, joined: 20, reward: 220, status: '报名截止', color: '#E65100', desc: '表演车队弹射起步技巧,现场观众可投票最佳镜头' },
  { id: 7, emoji: '🏔️', name: '山地耐力王', type: '耐力赛', track: '山地越野赛道', time: '09-05 09:00', quota: 14, joined: 5, reward: 450, status: '报名中', color: '#558B2F', desc: '越野赛道 50 圈耐力对决,中途可进站换胎一次' },
  { id: 8, emoji: '🎯', name: '迷宫绕桩计时赛', type: '计时赛', track: '迷宫技巧赛道', time: '09-06 13:30', quota: 16, joined: 11, reward: 280, status: '报名中', color: '#C2185B', desc: '迷宫赛道单圈计时,最快者赢取技巧王称号' },
  { id: 9, emoji: '🪐', name: '星际主题夜赛', type: '竞速赛', track: '星际主题赛道', time: '09-07 20:00', quota: 22, joined: 8, reward: 350, status: '报名中', color: '#4527A0', desc: '星空投影赛道夜赛,安全车领跑一圈后正赛开跑' },
  { id: 10, emoji: '🌉', name: '年度总决赛资格赛', type: '资格赛', track: '高架桥挑战赛', time: '09-10 14:00', quota: 20, joined: 0, reward: 600, status: '未开始', color: '#7B1FA2', desc: '积分榜前 20 自动晋级,冲刺冠军总决赛道终极对决' }
];

const RIDERS: Rider[] = [
  { id: 1, emoji: '🦅', name: '阿飞', team: '雷霆车队', points: 1280, wins: 8, lap: '58.2s', speed: 96, color: '#D32F2F', tag: '车神' },
  { id: 2, emoji: '🐯', name: '小虎', team: '雷霆车队', points: 1210, wins: 7, lap: '58.9s', speed: 93, color: '#E65100', tag: '种子' },
  { id: 3, emoji: '🦊', name: '阿狸', team: '银河车队', points: 1150, wins: 6, lap: '59.4s', speed: 90, color: '#7B1FA2', tag: '种子' },
  { id: 4, emoji: '🐺', name: '大灰', team: '风暴车队', points: 1080, wins: 5, lap: '60.1s', speed: 88, color: '#303F9F', tag: '黑马' },
  { id: 5, emoji: '🐆', name: '闪电猫', team: '银河车队', points: 1020, wins: 5, lap: '60.6s', speed: 86, color: '#00838F', tag: '黑马' },
  { id: 6, emoji: '🐂', name: '蛮牛', team: '风暴车队', points: 960, wins: 4, lap: '61.0s', speed: 84, color: '#455A64', tag: '稳健' },
  { id: 7, emoji: '🐎', name: '赤兔', team: '烈焰车队', points: 900, wins: 3, lap: '61.7s', speed: 81, color: '#C2185B', tag: '稳健' },
  { id: 8, emoji: '🐍', name: '眼镜蛇', team: '烈焰车队', points: 850, wins: 3, lap: '62.3s', speed: 79, color: '#2E7D32', tag: '新秀' },
  { id: 9, emoji: '🦏', name: '铁皮', team: '磐石车队', points: 780, wins: 2, lap: '63.1s', speed: 76, color: '#558B2F', tag: '新秀' },
  { id: 10, emoji: '🐬', name: '海豚', team: '磐石车队', points: 720, wins: 2, lap: '63.8s', speed: 74, color: '#00897B', tag: '新秀' },
  { id: 11, emoji: '🐹', name: '仓仓', team: '闪电车队', points: 650, wins: 1, lap: '64.5s', speed: 71, color: '#F9A825', tag: '萌新' },
  { id: 12, emoji: '🐸', name: '跳跳', team: '闪电车队', points: 580, wins: 1, lap: '65.2s', speed: 68, color: '#689F38', tag: '萌新' }
];

const RACE_PARTS: RaceParts[] = [
  { id: 1, emoji: '🔧', name: '涡轮增压套件', price: 1880, oldPrice: 2280, tag: '动力', stock: 6, power: 12, color: '#D32F2F', desc: '提升引擎输出功率 12%,直线尾速显著提升' },
  { id: 2, emoji: '🛞', name: '半热熔轮胎', price: 1280, oldPrice: 1580, tag: '抓地', stock: 12, power: 10, color: '#F57C00', desc: '暖胎后抓地力暴涨,弯心速度提升约 10%' },
  { id: 3, emoji: '🪂', name: '碳纤维尾翼', price: 980, oldPrice: 1200, tag: '空气', stock: 9, power: 8, color: '#303F9F', desc: '高速弯稳定性增强,下压力提升 8%' },
  { id: 4, emoji: '⚙️', name: '轻量化底盘', price: 2580, oldPrice: 2980, tag: '减重', stock: 4, power: 14, color: '#455A64', desc: '整车减重 15kg,加速与转向响应全面提升' },
  { id: 5, emoji: '🎨', name: '霓虹灯带', price: 380, oldPrice: 480, tag: '外观', stock: 20, power: 2, color: '#00838F', desc: '底盘霓虹灯带,夜晚赛道最靓的仔' },
  { id: 6, emoji: '🛡️', name: '防滚架加固', price: 1580, oldPrice: 1880, tag: '安全', stock: 8, power: 6, color: '#C62828', desc: '高强度钢管防滚架,冲撞防护等级提升' },
  { id: 7, emoji: '💺', name: '赛车桶椅', price: 680, oldPrice: 860, tag: '舒适', stock: 15, power: 4, color: '#7B1FA2', desc: '六点式安全带加持,过弯不再被甩离座椅' },
  { id: 8, emoji: '📊', name: '数据仪表盘', price: 880, oldPrice: 1080, tag: '科技', stock: 10, power: 7, color: '#00897B', desc: '实时显示圈速、G 值与胎温,辅助调校' },
  { id: 9, emoji: '🔊', name: '引擎音浪升级', price: 480, oldPrice: 620, tag: '音效', stock: 18, power: 3, color: '#E65100', desc: 'V10 引擎音浪模拟,听觉享受拉满' },
  { id: 10, emoji: '🧤', name: '麂皮手套套装', price: 260, oldPrice: 340, tag: '装备', stock: 25, power: 2, color: '#C2185B', desc: '防滑麂皮材质,方向盘操控更细腻' },
  { id: 11, emoji: '🥽', name: '防雾护目镜', price: 180, oldPrice: 240, tag: '装备', stock: 30, power: 1, color: '#2E7D32', desc: '纳米防雾涂层,雨战视野依旧清晰' },
  { id: 12, emoji: '🏆', name: '年度限定涂装', price: 3280, oldPrice: 3880, tag: '限定', stock: 2, power: 16, color: '#B71C1C', desc: '冠军限定金黑涂装,全园区仅 2 套' }
];

const RACE_NEWS: RaceNews[] = [
  { id: 1, emoji: '📰', title: '本季度车神争霸赛报名通道即将关闭', time: '08-26', views: 1024, color: '#D32F2F', tag: '热门' },
  { id: 2, emoji: '🏎️', title: '雨战湿地赛道喷淋系统完成升级', time: '08-25', views: 866, color: '#455A64', tag: '公告' },
  { id: 3, emoji: '🏆', title: '阿飞刷新环湖赛道最快圈速纪录', time: '08-24', views: 1532, color: '#F57C00', tag: '纪录' },
  { id: 4, emoji: '🚧', title: '冠军总决赛道 9 月将进入维护期', time: '08-23', views: 742, color: '#7B1FA2', tag: '公告' },
  { id: 5, emoji: '🎁', title: '周四会员日:全场赛道 8 折优惠', time: '08-22', views: 2105, color: '#00838F', tag: '福利' },
  { id: 6, emoji: '👑', title: '积分商城上新:限定涂装限时兑换', time: '08-21', views: 1288, color: '#B71C1C', tag: '上新' },
  { id: 7, emoji: '📸', title: '亲子欢乐跑活动摄影征集开始', time: '08-20', views: 654, color: '#2E7D32', tag: '活动' },
  { id: 8, emoji: '💡', title: '新手课堂:三步学会弹射起步', time: '08-19', views: 934, color: '#E65100', tag: '教程' }
];

const RACE_RECORDS: RaceRecord[] = [
  { id: 1, emoji: '🏆', name: '季度车神争霸赛', track: '环湖竞速赛道', lap: '58.6s', rank: 1, date: '08-20', status: '冠军', color: '#FFB300' },
  { id: 2, emoji: '⚡', name: '夜间闪电杯', track: '夜光霓虹赛道', lap: '85.4s', rank: 2, date: '08-16', status: '亚军', color: '#90A4AE' },
  { id: 3, emoji: '🌀', name: '漂移大师挑战', track: '螺旋回环赛道', lap: '80.9s', rank: 3, date: '08-10', status: '季军', color: '#D84315' },
  { id: 4, emoji: '🚀', name: '极速弹射表演赛', track: '火箭弹射赛道', lap: '64.2s', rank: 5, date: '08-02', status: '完赛', color: '#00897B' },
  { id: 5, emoji: '🎯', name: '迷宫绕桩计时赛', track: '迷宫技巧赛道', lap: '73.1s', rank: 4, date: '07-28', status: '完赛', color: '#00897B' },
  { id: 6, emoji: '⛈️', name: '雨战生存赛', track: '雨战湿地赛道', lap: '110.3s', rank: 7, date: '07-20', status: '完赛', color: '#00897B' },
  { id: 7, emoji: '🪐', name: '星际主题夜赛', track: '星际主题赛道', lap: '100.8s', rank: 6, date: '07-12', status: '完赛', color: '#00897B' },
  { id: 8, emoji: '🏔️', name: '山地耐力王', track: '山地越野赛道', lap: '135.7s', rank: 9, date: '07-05', status: '退赛', color: '#BDBDBD' }
];

const LAP_DAYS: LapDay[] = [
  { day: '周一', lap: 62.4 },
  { day: '周二', lap: 61.8 },
  { day: '周三', lap: 62.1 },
  { day: '周四', lap: 60.9 },
  { day: '周五', lap: 60.2 },
  { day: '周六', lap: 59.7 },
  { day: '周日', lap: 58.6 }
];

function getTrackRows(): RaceTrack[] {
  return [RACE_TRACKS[0], RACE_TRACKS[2], RACE_TRACKS[4], RACE_TRACKS[6], RACE_TRACKS[8], RACE_TRACKS[10]];
}

function getTrackRows2(): RaceTrack[] {
  return [RACE_TRACKS[1], RACE_TRACKS[3], RACE_TRACKS[5], RACE_TRACKS[7], RACE_TRACKS[9], RACE_TRACKS[11]];
}

function getRaceRows(): RaceInfo[] {
  return [RACE_LIST[0], RACE_LIST[1], RACE_LIST[2], RACE_LIST[3], RACE_LIST[4]];
}

function getRaceRows2(): RaceInfo[] {
  return [RACE_LIST[5], RACE_LIST[6], RACE_LIST[7], RACE_LIST[8], RACE_LIST[9]];
}

function getRiderRows(): Rider[] {
  return [RIDERS[0], RIDERS[1], RIDERS[2], RIDERS[3], RIDERS[4], RIDERS[5]];
}

function getRiderRows2(): Rider[] {
  return [RIDERS[6], RIDERS[7], RIDERS[8], RIDERS[9], RIDERS[10], RIDERS[11]];
}

function getPartsRows(): RaceParts[] {
  return [RACE_PARTS[0], RACE_PARTS[2], RACE_PARTS[4], RACE_PARTS[6], RACE_PARTS[8], RACE_PARTS[10]];
}

function getPartsRows2(): RaceParts[] {
  return [RACE_PARTS[1], RACE_PARTS[3], RACE_PARTS[5], RACE_PARTS[7], RACE_PARTS[9], RACE_PARTS[11]];
}

function getNewsRows(): RaceNews[] {
  return [RACE_NEWS[0], RACE_NEWS[1], RACE_NEWS[2], RACE_NEWS[3]];
}

function getNewsRows2(): RaceNews[] {
  return [RACE_NEWS[4], RACE_NEWS[5], RACE_NEWS[6], RACE_NEWS[7]];
}

function getRecordRows(): RaceRecord[] {
  return [RACE_RECORDS[0], RACE_RECORDS[1], RACE_RECORDS[2], RACE_RECORDS[3]];
}

function getRecordRows2(): RaceRecord[] {
  return [RACE_RECORDS[4], RACE_RECORDS[5], RACE_RECORDS[6], RACE_RECORDS[7]];
}

function getHomeTracks(): RaceTrack[] {
  return [RACE_TRACKS[0], RACE_TRACKS[1], RACE_TRACKS[2]];
}

function getHomeRaces(): RaceInfo[] {
  return [RACE_LIST[0], RACE_LIST[1], RACE_LIST[2]];
}

function getHomeNews(): RaceNews[] {
  return [RACE_NEWS[0], RACE_NEWS[1], RACE_NEWS[2]];
}

function getTop3Riders(): Rider[] {
  return [RIDERS[0], RIDERS[1], RIDERS[2]];
}

function getSlots(): string[] {
  return ['10:00-11:00', '14:00-15:00', '16:00-17:00', '19:00-20:00'];
}

function getLapOptions(): number[] {
  return [5, 10, 15];
}

function getRewardOptions(): number[] {
  return [100, 200, 300, 500];
}

function getQuotaOptions(): number[] {
  return [10, 16, 20, 24];
}

function getAmountOptions(): number[] {
  return [500, 1000, 2000, 5000];
}

function getPartsTags(): string[] {
  return ['🔥 热卖', '⚡ 动力', '🛡️ 安全', '🎨 外观'];
}

function getBarHeight(v: number): number {
  return 30 + v * 0.5;
}

function getSpeedColor(p: number): string {
  if (p >= 90) {
    return '#D32F2F';
  }
  if (p >= 80) {
    return '#F57C00';
  }
  if (p >= 70) {
    return '#F9A825';
  }
  return '#78909C';
}

function getStatusColor(s: string): string {
  if (s === '报名中' || s === '开放中') {
    return '#00897B';
  }
  if (s === '报名截止' || s === '维护中') {
    return '#BDBDBD';
  }
  return '#F57C00';
}

function getRaceStatusColor(s: string): string {
  if (s === '冠军') {
    return '#FFB300';
  }
  if (s === '亚军') {
    return '#90A4AE';
  }
  if (s === '季军') {
    return '#D84315';
  }
  if (s === '退赛') {
    return '#BDBDBD';
  }
  return '#00897B';
}

function formatPoints(p: number): string {
  if (p >= 1000) {
    return String(p / 1000) + 'k';
  }
  return String(p);
}

// ============ 首页 ============
@Component
struct HomeTab {
  onTrackClick: (t: RaceTrack) => void = () => {};
  onRaceClick: (r: RaceInfo) => void = () => {};
  onNewsClick: (n: RaceNews) => void = () => {};
  onMoreRace: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏁')
              .fontSize(34)
            Column() {
              Text('极速卡丁 · 出发!')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('今日已跑 5 圈 · 距离车神还差 320 分')
                .fontSize(10)
                .fontColor('#FFE082')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })

            Column() {
              Text('1280')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD600')
              Text('我的积分')
                .fontSize(9)
                .fontColor('#FFE082')
            }
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor('#33FFFFFF')
            .borderRadius(12)
          }
          .width('100%')

          Row() {
            Text('🚦 下一场:夜间闪电杯 08-29 19:30 开跑')
              .fontSize(10)
              .fontColor('#FFE082')
            Text('')
              .layoutWeight(1)
            Text('去报名 ›')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFD600')
              .onClick(() => {
                this.onMoreRace();
              })
          }
          .width('100%')
          .padding({ top: 8, bottom: 8, left: 12, right: 12 })
          .backgroundColor('#33FFFFFF')
          .borderRadius(10)
          .margin({ top: 12 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 16, bottom: 16 })
        .borderRadius(16)
        .shadow({ radius: 12, color: '#33D32F2F', offsetY: 4 })

        Row() {
          Column() {
            Text('🏎️')
              .fontSize(24)
            Text('预约赛道')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .padding({ top: 12, bottom: 12 })
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ right: 8 })
          .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
          .onClick(() => {
            this.onMoreRace();
          })

          Column() {
            Text('🏆')
              .fontSize(24)
            Text('赛事报名')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .padding({ top: 12, bottom: 12 })
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ right: 8 })
          .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
          .onClick(() => {
            this.onMoreRace();
          })

          Column() {
            Text('📊')
              .fontSize(24)
            Text('积分商城')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .padding({ top: 12, bottom: 12 })
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
          .onClick(() => {
            this.onMoreRace();
          })
        }
        .width('100%')
        .margin({ top: 12 })

        Row() {
          Text('🔥 热门赛道')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2E2E2E')
          Text('')
            .layoutWeight(1)
          Text('全部 ›')
            .fontSize(11)
            .fontColor('#8E8E8E')
            .onClick(() => {
              this.onMoreRace();
            })
        }
        .width('100%')
        .margin({ top: 16, bottom: 10 })

        Scroll() {
          Row() {
            ForEach(getHomeTracks(), (t: RaceTrack) => {
              Column() {
                Text(t.emoji)
                  .fontSize(36)
                Text(t.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#333333')
                  .margin({ top: 8 })
                Text(t.bestLap + ' 最快圈速')
                  .fontSize(9)
                  .fontColor('#8E8E8E')
                  .margin({ top: 3 })
                Text('Lv.' + String(t.level) + ' · ' + String(t.length) + 'm')
                  .fontSize(9)
                  .fontColor('#D32F2F')
                  .margin({ top: 4 })
                Text(t.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor(getStatusColor(t.status))
                  .borderRadius(10)
                  .margin({ top: 6 })
              }
              .width(120)
              .padding({ top: 14, bottom: 14, left: 8, right: 8 })
              .backgroundColor('#FFFFFF')
              .borderRadius(14)
              .margin({ right: 10 })
              .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
              .onClick(() => {
                this.onTrackClick(t);
              })
            }, (t: RaceTrack) => String(t.id))
          }
        }
        .width('100%')
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)

        Row() {
          Text('🏁 近期赛事')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2E2E2E')
          Text('')
            .layoutWeight(1)
          Text('更多 ›')
            .fontSize(11)
            .fontColor('#8E8E8E')
            .onClick(() => {
              this.onMoreRace();
            })
        }
        .width('100%')
        .margin({ top: 16, bottom: 10 })

        ForEach(getHomeRaces(), (r: RaceInfo) => {
          Row() {
            Text(r.emoji)
              .fontSize(26)
            Column() {
              Text(r.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#333333')
              Text(r.time + ' · ' + r.track)
                .fontSize(9)
                .fontColor('#8E8E8E')
                .margin({ top: 3 })
              Text('名额 ' + String(r.joined) + '/' + String(r.quota) + ' · 奖励 ' + String(r.reward) + ' 分')
                .fontSize(9)
                .fontColor('#D32F2F')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })

            Text(r.status)
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        
          .borderRadius(12)
          .
        .position({ x: 0, y: '72%' })
      }
    }
    .width('100%')
    .height('100%')
  }
}


在这里插入图片描述

十五、总结

本文以一个完整的鸿蒙员工赛车场应用为案例,逐段分析了从数据层到视图层的全部实现代码。整个应用虽然是一个原型级别的演示项目,但其架构设计和代码组织方式包含了大量值得借鉴的工程实践。

在数据层方面,应用通过interface定义了7种业务实体的类型结构,确保了全应用的类型安全。所有业务数据以const常量形式内嵌在代码中,通过数据切片函数按需取子集,实现了数据来源的统一性和数据视图的分离。工具函数将颜色映射、数值格式化等跨页面共享的逻辑集中管理,避免了代码重复。

在组件层方面,六个Tab页面(首页、赛道、赛事、车手、配件、我的)各自封装为独立的@Component组件,通过回调函数与父组件通信。每个组件内部大量使用了Column、Row嵌套来实现复杂的卡片布局,通过layoutWeight实现弹性空间分配,通过ForEach实现列表渲染,通过Scroll实现内容滚动。这些ArkUI的基础布局组件在本应用中得到了充分的实践运用。

在状态管理方面,主入口组件通过20余个@State变量统一管理全局状态。状态变量分为四类——导航状态、弹窗开关、选中数据和表单数据——每类状态各司其职。通过修改@State变量的值,框架自动完成视图的更新渲染,开发者无需手动操作DOM。这种声明式的状态管理方式是ArkUI的核心优势。

在弹窗系统方面,应用实现了11个不同类型的弹窗,涵盖详情展示、表单输入、确认操作、充值选择等多种交互场景。所有弹窗通过@Builder方法构建,配合modalOverlay遮罩层和条件渲染实现显示控制。弹窗的transition属性提供了淡入淡出的过渡动画,提升了交互的流畅感。Stack层叠布局确保了弹窗正确覆盖在主内容之上,Toast提示系统作为最后渲染的子元素覆盖在所有内容之上。

在视觉设计方面,应用采用了以红色(#D32F2F)为主色调、配合金色(#FFD600)、青绿色(#00897B)、橙色(#F57C00)的配色体系。通过shadow属性实现卡片阴影效果,通过borderRadius实现圆角设计,通过半透明颜色(#33FFFFFF、#14000000等)实现层次感。颜色映射函数确保了同一状态在不同位置的视觉一致性。手工实现的柱状图和速度条展示了ArkUI在数据可视化方面的灵活性——即使不使用图表库,也能通过基础的布局组件实现轻量级的可视化效果。

Logo

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

更多推荐