鸿蒙ArkTS实战解析:员工赛车场应用的全链路架构与实现
鸿蒙(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开发体系。
从上面的架构流程图可以看出,数据从最底层向上流动,经过工具函数的加工处理后,被各个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)。这种"关闭一个弹窗、打开另一个弹窗"的连续操作流程,在用户交互设计中需要特别注意时序控制。
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更新 |
| @Builder | UI片段复用 | 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在数据可视化方面的灵活性——即使不使用图表库,也能通过基础的布局组件实现轻量级的可视化效果。
更多推荐



所有评论(0)