智慧停车应用:HarmonyOS ArkTS API 24实践,内部是一个 Row,通过ForEach遍历zoneOptions数组(“全部”“A区”“B区”“C区”),为每个选项生成一个胶囊形标签
一、引言:智慧停车的时代背景与现实意义
1.1 城市停车难题的日益严峻
随着我国城镇化进程的不断推进以及居民汽车保有量的持续攀升,"停车难"已经从一个偶发的烦恼,演变为困扰几乎所有大中城市的系统性问题。无论是在大型商业综合体、写字楼宇,还是在医院、学校、老旧小区,找车位、排队进场、绕行寻找空位所消耗的时间与燃油成本,已经成为城市出行体验中的一大痛点。根据相关行业报告,一位驾驶员在日常出行中,平均有将近百分之三十的时间花费在寻找停车位这件事上,这不仅是时间的浪费,更是城市交通效率的隐性损耗。

与此同时,停车场经营方也面临着管理粗放、信息不透明、收费争议频发、车位周转率偏低等一系列运营难题。传统的人工巡检、纸质登记、现金收费模式,既难以应对高峰时段的车流压力,也无法为车主提供及时、准确的车位信息。这种供需双方信息严重不对称的局面,正是智慧停车系统诞生的根本动因。
1.2 移动互联网与物联网的双重赋能
智慧停车的核心思路,是借助物联网感知设备(如地磁传感器、车牌识别摄像头、超声波探测器)实时采集车位状态,再通过移动互联网将数据推送到车主手中,从而实现"人—车—位"三方信息的实时联动。车主可以在抵达停车场之前,就通过手机应用查看空余车位分布、预估停车费用、预约心仪车位;停好车后,应用还能记录车辆所停的具体位置,帮助车主在偌大的停车场中快速寻回爱车;离场时,移动支付让缴费变得便捷无感,所有停车记录也可随时追溯查询。
这样一套闭环体验,将原本割裂的"找位—停车—寻车—缴费—记录"五个环节,串联成一个连贯的数字化服务流程,极大提升了停车效率与用户体验。
1.3 鸿蒙生态与跨设备停车体验的想象空间
在万物互联的时代背景下,鸿蒙操作系统凭借其分布式能力和统一的 ArkUI 声明式开发范式,为智慧停车应用带来了更广阔的想象空间。基于 ArkTS 语言和 ArkUI 框架,开发者可以用一套声明式代码,描述出界面结构与状态驱动的渲染逻辑,并在手机、平板、车机、智慧屏等多种终端设备上获得一致的渲染效果。这意味着,未来车主既可以在手机上规划停车,也可以在车机大屏上一键导航至空位,甚至在智慧屏上完成家庭车辆的费用代缴,体验真正无缝衔接的全场景停车服务。
1.4 本应用的功能全景
本文将要剖析的,正是一个功能完整、结构清晰的智慧停车应用。它围绕车主的真实使用场景,构建了五大核心功能模块:
- 停车模块:以网格俯视图的形式直观展示停车场内所有车位的实时状态,支持按区域筛选、查看车位详情、统计车位使用率。
- 寻车模块:记录车辆每次停放的具体区域与车位编号,并提供车牌号搜索功能,帮助车主在大型停车场中快速定位爱车。
- 缴费模块:集中展示所有待支付的停车账单,支持选择账单并模拟缴费流程,缴费进度以进度条形式可视化呈现。
- 记录模块:完整保存历史停车记录,支持按支付状态(全部、已支付、待支付、免费)进行筛选查看。
- 我的模块:管理用户名下所有车辆信息,支持新增、编辑、删除车辆,并展示累计费用、已支付费用、待支付费用的统计概览。
整个应用采用了分层架构设计,从类型契约、数据模型、Mock 数据、工具函数到 UI 组件层层递进,既保证了代码的可读性与可维护性,也为后续接入真实后端接口留出了清晰的扩展空间。接下来,我们将逐段深入剖析这份代码的每一个组成部分。
二、整体架构概览:分层设计与模块组成
在正式进入代码细节之前,我们有必要先从宏观层面把握整个应用的架构脉络。这份代码大致可以划分为七个层次,每一层各司其职、相互配合:
第一层是类型契约层,通过一系列 interface 定义,明确了应用中涉及的所有数据结构形状,包括颜色调色板、车位、停车记录、车辆信息、寻车记录等。这些接口是整个应用的数据字典,所有模块之间的数据流转都以它们为契约。
第二层是常量层,集中定义了应用的全局颜色调色板 COLORS,将主色、辅助色、状态强调色、文本色、分隔线色等视觉变量统一管理,确保整个应用的视觉风格一致。
第三层是枚举层,通过 TabType 枚举定义了底部导航栏的五个标签页及其对应的数值索引,为标签切换逻辑提供类型安全的基础。
第四层是可观察数据模型层,使用 @Observed 装饰器将数据模型类标记为可观察对象,配合 UI 组件中的 @State 等装饰器,实现数据变化驱动界面自动刷新的响应式机制。
第五层是Mock 数据层,构造了一批贴近真实场景的模拟数据,包括二十五个车位、二十条停车记录、十辆车辆、十条寻车记录,使应用在无后端的情况下也能呈现完整的功能效果。
第六层是工具函数层,封装了车位状态统计、区域统计、费用汇总、状态颜色映射等纯函数,供各 UI 组件复用。
第七层是UI 组件层,从 @Entry 入口组件到五个功能 Tab 组件,构建出完整的交互界面。每个 Tab 组件内部又通过 @State 管理局部状态,通过 @Builder 封装可复用的 UI 片段,通过条件渲染实现弹窗的显示与隐藏。
这种分层设计带来的好处是显而易见的:数据与视图解耦,业务逻辑集中在工具函数中,UI 组件只关注如何渲染和响应用户交互;当需要接入真实接口时,只需替换 Mock 数据层和工具函数的数据来源,UI 组件几乎无需改动。
三、类型契约:Interface 定义层
3.1 颜色调色板接口 ColorPalette
应用的第一个接口定义的是颜色调色板的形状:
interface ColorPalette {
primary: string;
secondary: string;
background: string;
card: string;
accentFree: string;
accentOccupied: string;
accentReserved: string;
accentMaintenance: string;
textPrimary: string;
textSecondary: string;
divider: string;
white: string;
}

这段代码定义了一个名为 ColorPalette 的接口,它约束了一个颜色调色板对象应当包含哪些字段。primary 和 secondary 分别代表应用的主色和辅助色,用于标题栏、按钮、选中态等关键视觉元素;background 是页面整体的背景色;card 是卡片组件的背景色,通常为白色,用于与背景形成层次对比。
接下来的四个 accent 开头的字段,是与车位状态一一对应的强调色:accentFree 表示空闲车位的颜色(绿色),accentOccupied 表示占用车位的颜色(红色),accentReserved 表示预留车位的颜色(黄色),accentMaintenance 表示维修车位的颜色(灰色)。这种"一种状态对应一种颜色"的设计,让用户在浏览车位网格时能够通过颜色快速识别车位状态,符合信息可视化的最佳实践。
最后四个字段是文本与分隔相关的基础色:textPrimary 是主要文本色(深色),textSecondary 是次要文本色(灰色),divider 是分隔线颜色,white 是纯白色。将它们抽象为接口字段而非散落的字面量,使得未来切换深色模式或更换主题时,只需提供一份新的调色板对象即可。
3.2 车位接口 ParkingSpot
interface ParkingSpot {
id: number;
zone: string;
number: string;
status: string;
type: string;
price: number;
isCharging: boolean;
duration: number;
}

ParkingSpot 接口描述了一个停车位所承载的全部信息。id 是车位的唯一数字标识;zone 是车位所属区域,如"A区"“B区”“C区”;number 是车位编号,如"A001";status 是车位当前状态,取值为"空闲"“占用”“预留”“维修"四种之一;type 是车位类型,如"普通”“VIP”“新能源”"无障碍"等。
price 字段是该车位的小时费率,以元为单位;isCharging 是一个布尔值,标识该车位是否配备充电桩,这对新能源车主而言是非常重要的信息;duration 表示车位已被占用的时长(分钟数),仅当状态为"占用"时才有意义。这个接口的字段设计相当贴近真实停车场的业务模型,涵盖了车位管理所需的核心维度。
3.3 停车记录接口 ParkingRecord
interface ParkingRecord {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
enterTime: string;
exitTime: string;
duration: string;
fee: number;
status: string;
paymentMethod: string;
}

ParkingRecord 接口描述的是一次完整的停车行为记录。id 是记录唯一标识;plateNumber 是车牌号;zone 和 spotNumber 分别记录车辆停放的区域和车位编号;enterTime 是入场时间字符串;exitTime 是出场时间字符串,若车辆仍在场内则为空字符串;duration 是停车时长,用字符串描述如"9小时"“2.5小时"或"进行中”。
fee 字段是本次停车产生的费用金额;status 是该记录的支付状态,取值为"已支付"“待支付”“免费”;paymentMethod 是支付方式,如"微信支付"“支付宝”“银联卡”,未支付时为空字符串。这个接口完整地刻画了一次停车从入场到出场再到缴费的全生命周期信息,是历史记录模块和缴费模块的核心数据载体。
3.4 车辆信息接口 VehicleInfo
interface VehicleInfo {
id: number;
plateNumber: string;
brand: string;
color: string;
type: string;
isDefault: boolean;
}

VehicleInfo 接口描述的是用户名下登记的车辆信息。id 是车辆记录的唯一标识;plateNumber 是车牌号;brand 是车辆品牌,如"特斯拉"“奔驰”“比亚迪”;color 是车身颜色;type 是车型分类,如"轿车"“SUV”“新能源”;isDefault 是布尔值,标识该车辆是否为用户的默认车辆,默认车辆在进行停车、缴费等操作时会被优先选用。这个接口的设计考虑到了一个用户可能拥有多辆车的真实场景。
3.5 寻车记录接口 FindCarRecord
interface FindCarRecord {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
recordTime: string;
photoUri: string;
}

FindCarRecord 接口专门服务于寻车功能。当车辆停好后,系统会记录一条寻车记录,plateNumber 标识是哪辆车,zone 和 spotNumber 精确记录车辆停放的区域与车位号,recordTime 是记录生成的时间,photoUri 预留了停车照片的 URI 字段(当前 Mock 数据中为空),未来可以接入拍照功能,让车主通过照片更直观地回忆停车位置。这个接口体现了寻车功能"以记录辅助记忆"的设计理念。
3.6 辅助元数据接口 SpotMeta 与 VehicleMeta
interface SpotMeta {
zone: string;
number: string;
status: string;
type: string;
price: number;
isCharging: boolean;
}
interface VehicleMeta {
brand: string;
color: string;
type: string;
}

最后这两个接口是辅助性的元数据定义。SpotMeta 是 ParkingSpot 的子集,去掉了 id 和 duration,适用于只需要展示车位静态属性而不关心标识和时长的场景;VehicleMeta 是 VehicleInfo 的子集,只保留品牌、颜色、车型三个展示性字段。虽然在本应用的实际代码中这两个接口并未被直接使用,但它们的存在体现了开发者对数据结构的层次化思考——将"完整实体"与"展示元数据"区分开来,是一种值得提倡的接口设计习惯。
四、视觉基础:颜色常量与调色板
4.1 COLORS 常量的定义
在定义完所有接口之后,代码紧接着声明了一个全局的颜色常量对象:
const COLORS: ColorPalette = {
primary: '#1A237E',
secondary: '#3949AB',
background: '#E8EAF6',
card: '#FFFFFF',
accentFree: '#00E676',
accentOccupied: '#FF1744',
accentReserved: '#FFD600',
accentMaintenance: '#9E9E9E',
textPrimary: '#212121',
textSecondary: '#757575',
divider: '#E0E0E0',
white: '#FFFFFF'
};

这段代码将前面定义的 ColorPalette 接口落地为一个具体的常量实例 COLORS。它使用了 Material Design 风格的色值体系:主色 #1A237E 是一种深邃的靛蓝色,传递出专业、可信赖的品牌调性,被广泛用于标题栏背景、按钮背景、选中态文字等关键位置;辅助色 #3949AB 是稍浅的蓝色,用于一些次要的强调场景。
背景色 #E8EAF6 是一种极浅的蓝灰色,作为页面整体背景既能与白色卡片形成微妙的层次感,又不会喧宾夺主;卡片色 #FFFFFF 是纯白,用于承载主要内容卡片。四个状态强调色的选择极具语义性:空闲用亮绿色 #00E676(安全、可用),占用用鲜红色 #FF1744(警示、不可用),预留用明黄色 #FFD600(注意、已锁定),维修用中灰色 #9E9E9E(中性、暂停服务),这些颜色搭配符合人们对状态的本能认知。
文本色方面,主文本 #212121 是接近纯黑的深灰,保证正文的高对比度可读性;次文本 #757575 是中灰,用于标签、辅助说明等次要信息;分隔线 #E0E0E0 是浅灰,用于卡片边框和分割线。将所有颜色集中到一个常量对象中管理,是工程化 UI 开发的基本规范,它带来了三个显著好处:一是视觉一致性有保障,二是修改主题时只需改动一处,三是代码中引用 COLORS.primary 比引用 '#1A237E' 更具可读性。
五、标签页枚举:TabType
enum TabType {
PARKING = 0,
FIND_CAR = 1,
PAYMENT = 2,
HISTORY = 3,
PROFILE = 4
}

TabType 是一个数值枚举,定义了底部导航栏五个标签页的标识符。PARKING 对应停车页(索引 0),FIND_CAR 对应寻车页(索引 1),PAYMENT 对应缴费页(索引 2),HISTORY 对应记录页(索引 3),PROFILE 对应我的页(索引 4)。
使用枚举而非裸数字或字符串来标识标签页,是一个值得称道的工程实践。首先,枚举赋予了每个标签页一个语义化的名称,代码中写 TabType.PARKING 远比写 0 更容易理解其含义;其次,枚举提供了类型安全保障,编译器能够检查赋值是否合法,避免拼写错误;最后,如果未来需要新增或调整标签页顺序,只需修改枚举定义即可,所有引用处会自动适配。这种用枚举管理离散状态的思路,贯穿了整个应用的状态管理设计。
六、可观察数据模型:@Observed 装饰器
6.1 响应式数据驱动的设计哲学
ArkUI 框架采用声明式 UI 范式,其核心思想是"状态驱动视图"——开发者描述界面在某一状态下应该长什么样,当状态发生变化时,框架自动重新渲染受影响的 UI 部分。要实现这一机制,数据模型需要具备"可被观察"的能力,即当其内部属性发生变化时,能够通知框架触发更新。@Observed 装饰器正是为此而设计的:被它修饰的类,其属性变化可以被框架追踪,从而驱动依赖这些属性的 UI 组件自动刷新。
接下来代码为前面定义的四个核心接口分别创建了对应的可观察模型类。这种"接口定义形状 + @Observed 类定义实例"的双层设计,既保留了接口的纯契约价值,又通过类赋予了数据可观察的能力。
6.2 车位模型 ParkingSpotModel
@Observed
class ParkingSpotModel {
id: number;
zone: string;
number: string;
status: string;
type: string;
price: number;
isCharging: boolean;
duration: number;
constructor(id: number, zone: string, number: string, status: string,
type: string, price: number, isCharging: boolean, duration: number) {
this.id = id;
this.zone = zone;
this.number = number;
this.status = status;
this.type = type;
this.price = price;
this.isCharging = isCharging;
this.duration = duration;
}
}

ParkingSpotModel 类被 @Observed 装饰器修饰,字段与 ParkingSpot 接口完全对应。构造函数接收八个参数,依次为 id、区域、编号、状态、类型、费率、是否充电、占用时长,并在函数体内将它们逐一赋值给 this 的对应属性。
值得注意的细节是,构造函数的参数名 number 与 TypeScript/ArkTS 的保留字撞名了,但在这里由于它仅作为参数名使用并赋值给 this.number,所以不会引发问题。不过在实际工程中,更稳妥的做法是改用 spotNumber 之类的命名以避免潜在歧义。@Observed 装饰器的作用是让这个类的实例在被 @State、@Prop、@ObjectLink 等装饰器引用时,其属性变化能够被框架感知——例如当某个车位的 status 从"空闲"变为"占用"时,界面上对应的车位格颜色会自动从绿色变为红色。
6.3 停车记录模型 ParkingRecordModel
@Observed
class ParkingRecordModel {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
enterTime: string;
exitTime: string;
duration: string;
fee: number;
status: string;
paymentMethod: string;
constructor(id: number, plateNumber: string, zone: string, spotNumber: string,
enterTime: string, exitTime: string, duration: string, fee: number,
status: string, paymentMethod: string) {
this.id = id;
this.plateNumber = plateNumber;
this.zone = zone;
this.spotNumber = spotNumber;
this.enterTime = enterTime;
this.exitTime = exitTime;
this.duration = duration;
this.fee = fee;
this.status = status;
this.paymentMethod = paymentMethod;
}
}

ParkingRecordModel 的结构与 ParkingRecord 接口一一对应,构造函数接收十个参数并完成赋值。这个模型主要服务于缴费模块和记录模块——当一笔停车记录的 status 从"待支付"变更为"已支付"时,依赖该模型的 UI(如缴费列表中的状态标签颜色、记录列表中的筛选结果)都会自动刷新。
6.4 车辆模型 VehicleModel
@Observed
class VehicleModel {
id: number;
plateNumber: string;
brand: string;
color: string;
type: string;
isDefault: boolean;
constructor(id: number, plateNumber: string, brand: string, color: string,
type: string, isDefault: boolean) {
this.id = id;
this.plateNumber = plateNumber;
this.brand = brand;
this.color = color;
this.type = type;
this.isDefault = isDefault;
}
}

VehicleModel 描述用户车辆的可观察模型。当用户在"我的"页面新增、编辑或删除车辆时,这个模型的变化会驱动车辆列表 UI 实时更新。isDefault 字段的变化尤其关键——当用户将某辆车设为默认时,列表中"默认"标签的显示位置会随之切换。
6.5 寻车记录模型 FindCarRecordModel
@Observed
class FindCarRecordModel {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
recordTime: string;
photoUri: string;
constructor(id: number, plateNumber: string, zone: string, spotNumber: string,
recordTime: string, photoUri: string) {
this.id = id;
this.plateNumber = plateNumber;
this.zone = zone;
this.spotNumber = spotNumber;
this.recordTime = recordTime;
this.photoUri = photoUri;
}
}
FindCarRecordModel 是寻车功能的可观察数据模型,字段与 FindCarRecord 接口对应。每次车辆停好后新增一条寻车记录,这条记录就会被加入到寻车列表中,并通过响应式机制驱动列表刷新。
七、Mock 数据:模拟真实业务场景
7.1 车位模拟数据 MOCK_SPOTS
为了让应用在没有真实后端的情况下也能展示完整功能,代码构造了一批贴近真实的模拟数据。首先是车位数据:
const MOCK_SPOTS: ParkingSpotModel[] = [
new ParkingSpotModel(1, 'A区', 'A001', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(2, 'A区', 'A002', '占用', '普通', 5, false, 120),
new ParkingSpotModel(3, 'A区', 'A003', '空闲', 'VIP', 8, true, 0),
new ParkingSpotModel(4, 'A区', 'A004', '预留', '普通', 5, false, 0),
new ParkingSpotModel(5, 'A区', 'A005', '占用', '新能源', 5, true, 85),
new ParkingSpotModel(6, 'A区', 'A006', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(7, 'A区', 'A007', '维修', '普通', 0, false, 0),
new ParkingSpotModel(8, 'A区', 'A008', '占用', '无障碍', 5, false, 200),
new ParkingSpotModel(9, 'B区', 'B001', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(10, 'B区', 'B002', '空闲', 'VIP', 8, true, 0),
new ParkingSpotModel(11, 'B区', 'B003', '占用', '普通', 5, false, 45),
new ParkingSpotModel(12, 'B区', 'B004', '空闲', '新能源', 5, true, 0),
new ParkingSpotModel(13, 'B区', 'B005', '占用', '普通', 5, false, 90),
new ParkingSpotModel(14, 'B区', 'B006', '预留', 'VIP', 8, false, 0),
new ParkingSpotModel(15, 'B区', 'B007', '空闲', '无障碍', 5, false, 0),
new ParkingSpotModel(16, 'B区', 'B008', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(17, 'C区', 'C001', '占用', '普通', 5, false, 60),
new ParkingSpotModel(18, 'C区', 'C002', '空闲', '新能源', 5, true, 0),
new ParkingSpotModel(19, 'C区', 'C003', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(20, 'C区', 'C004', '占用', 'VIP', 8, false, 150),
new ParkingSpotModel(21, 'C区', 'C005', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(22, 'C区', 'C006', '预留', '普通', 5, false, 0),
new ParkingSpotModel(23, 'C区', 'C007', '占用', '新能源', 5, true, 75),
new ParkingSpotModel(24, 'C区', 'C008', '空闲', '无障碍', 5, false, 0),
new ParkingSpotModel(25, 'C区', 'C009', '空闲', '普通', 5, false, 0)
];
MOCK_SPOTS 是一个包含二十五个车位模型的数组,覆盖了 A、B、C 三个区域。从数据分布可以看出设计者的用心:A 区有 8 个车位(A001-A008),B 区有 8 个车位(B001-B008),C 区有 9 个车位(C001-C009),三个区域规模相近又有差异,便于测试区域筛选和统计功能。
车位状态涵盖了"空闲"“占用”“预留”“维修"全部四种类型,且各状态的数量分布合理,使得界面上的状态统计标签和柱状图能够呈现出有意义的可视化效果。车位类型则包含了"普通”“VIP”“新能源”"无障碍"四种,其中 VIP 车位费率为 8 元/小时(高于普通车位的 5 元),新能源车位多配备充电桩(isCharging: true),无障碍车位服务于特殊人群。占用状态的车位还标注了不同的占用时长(45 分钟到 200 分钟不等),让详情弹窗的"已停时长"信息有内容可展示。维修状态的车位费率设为 0,体现了"维修中不收费"的业务逻辑。
7.2 停车记录模拟数据 MOCK_RECORDS
const MOCK_RECORDS: ParkingRecordModel[] = [
new ParkingRecordModel(1, '粤B12345', 'A区', 'A002', '2024-08-01 09:00',
'2024-08-01 18:00', '9小时', 54, '已支付', '微信支付'),
new ParkingRecordModel(2, '粤B67890', 'B区', 'B003', '2024-08-02 10:00',
'2024-08-02 12:30', '2.5小时', 15, '已支付', '支付宝'),
new ParkingRecordModel(3, '京A88888', 'A区', 'A005', '2024-08-02 08:00',
'', '进行中', 0, '待支付', ''),
new ParkingRecordModel(4, '沪B66666', 'C区', 'C001', '2024-08-01 14:00',
'2024-08-01 16:00', '2小时', 12, '已支付', '微信支付'),
new ParkingRecordModel(5, '粤B99999', 'B区', 'B005', '2024-07-30 09:00',
'2024-07-30 11:00', '2小时', 0, '免费', ''),
// ... 更多记录
];
MOCK_RECORDS 包含二十条停车记录(此处为节省篇幅仅展示前五条)。这些记录的设计颇具匠心:车牌号涵盖了"粤B"“京A”“沪B”“浙C”"苏A"等多个省市前缀,模拟了来自不同地区的车辆;停车区域和车位编号与前述车位数据交叉对应;入场时间分布在七月中下旬到八月上旬,模拟了最近一段时间的历史记录。
支付状态方面,既有"已支付"(出场时间、时长、费用、支付方式齐全),也有"待支付"(车辆仍在场内,出场时间为空,时长为"进行中",费用为 0,支付方式为空),还有"免费"(特定时段或优惠导致的零费用)。支付方式覆盖了微信支付、支付宝、银联卡三种主流渠道。这种数据多样性使得缴费模块的待支付列表、记录模块的状态筛选、我的模块的费用统计都能呈现出丰富而真实的展示效果。
7.3 车辆模拟数据 MOCK_VEHICLES
const MOCK_VEHICLES: VehicleModel[] = [
new VehicleModel(1, '粤B12345', '特斯拉', '白色', '新能源', true),
new VehicleModel(2, '粤B67890', '奔驰', '黑色', '轿车', false),
new VehicleModel(3, '京A88888', '奥迪', '银灰色', 'SUV', false),
new VehicleModel(4, '沪B66666', '宝马', '蓝色', '轿车', false),
new VehicleModel(5, '粤B99999', '比亚迪', '红色', '新能源', false),
new VehicleModel(6, '粤B22222', '大众', '白色', '轿车', false),
new VehicleModel(7, '苏A12345', '本田', '黑色', 'SUV', false),
new VehicleModel(8, '粤B00001', '丰田', '银灰色', 'SUV', false),
new VehicleModel(9, '粤B77777', '小鹏', '灰色', '新能源', false),
new VehicleModel(10, '粤B66666', '蔚来', '绿色', '新能源', false)
];
MOCK_VEHICLES 包含十辆车的信息。品牌覆盖了传统豪华品牌(奔驰、奥迪、宝马)、合资品牌(大众、本田、丰田)、新能源品牌(特斯拉、比亚迪、小鹏、蔚来),体现了当前汽车市场的多元化格局。第一辆车(特斯拉)被设为默认车辆(isDefault: true),在车辆列表中会展示"默认"标签。车型涵盖了轿车、SUV、新能源三类,颜色各异。这些数据与停车记录中的车牌号形成关联,构建了一个内部自洽的数据世界。
7.4 寻车记录模拟数据 MOCK_FIND_RECORDS
const MOCK_FIND_RECORDS: FindCarRecordModel[] = [
new FindCarRecordModel(1, '粤B12345', 'A区', 'A002', '2024-08-06 18:30', ''),
new FindCarRecordModel(2, '京A88888', 'A区', 'A005', '2024-08-06 17:15', ''),
new FindCarRecordModel(3, '粤B67890', 'B区', 'B003', '2024-08-05 14:20', ''),
new FindCarRecordModel(4, '沪B66666', 'C区', 'C001', '2024-08-05 11:00', ''),
new FindCarRecordModel(5, '粤B99999', 'B区', 'B005', '2024-08-04 19:45', ''),
new FindCarRecordModel(6, '粤B22222', 'A区', 'A003', '2024-08-04 09:30', ''),
new FindCarRecordModel(7, '粤B55555', 'C区', 'C004', '2024-08-03 16:00', ''),
new FindCarRecordModel(8, '苏A12345', 'B区', 'B001', '2024-08-03 08:15', ''),
new FindCarRecordModel(9, '粤B88888', 'A区', 'A008', '2024-08-02 20:30', ''),
new FindCarRecordModel(10, '粤B44444', 'C区', 'C007', '2024-08-02 12:00', '')
];
MOCK_FIND_RECORDS 包含十条寻车记录,按时间倒序排列(最新的在最前),模拟了用户最近十次停车的位置记忆。每条记录都关联了车牌号、区域、车位编号和记录时间,photoUri 字段全部留空,为未来接入停车拍照功能预留了空间。这些数据让寻车页面能够展示一个有意义的记录列表,用户可以通过搜索车牌号快速定位某次停车记录。
八、统计与工具函数:纯函数封装业务逻辑
8.1 车位状态统计函数 getSpotStatusStats
function getSpotStatusStats(spots: ParkingSpotModel[]): Record<string, number> {
let freeCount: number = 0;
let occupiedCount: number = 0;
let reservedCount: number = 0;
let maintenanceCount: number = 0;
for (let i = 0; i < spots.length; i++) {
if (spots[i].status === '空闲') {
freeCount++;
} else if (spots[i].status === '占用') {
occupiedCount++;
} else if (spots[i].status === '预留') {
reservedCount++;
} else if (spots[i].status === '维修') {
maintenanceCount++;
}
}
let result: Record<string, number> = {};
result['free'] = freeCount;
result['occupied'] = occupiedCount;
result['reserved'] = reservedCount;
result['maintenance'] = maintenanceCount;
return result;
}
这个函数接收一个车位模型数组,返回一个以字符串为键、数字为值的 Record 对象,统计四种状态的车位数量。函数内部首先声明四个计数器变量并初始化为 0,然后通过一个 for 循环遍历所有车位,根据每个车位的 status 字段值递增对应的计数器。最后构造一个结果对象,将四个计数器以英文键名(free、occupied、reserved、maintenance)存入并返回。
这是一个典型的纯函数——输入相同则输出相同,不产生副作用。将它独立出来而非内联到组件中,既提高了代码的可测试性(可以单独编写单元测试验证统计逻辑),也提高了复用性(停车页面的状态统计标签和柱状图都需要用到统计数据)。在 UI 中,通过 getSpotStatusStats(this.spots)['free'] 这样的方式取值,就能拿到空闲车位的数量。
8.2 区域统计函数 getZoneStats
function getZoneStats(spots: ParkingSpotModel[]): Record<string, number> {
let zoneACount: number = 0;
let zoneBCount: number = 0;
let zoneCCount: number = 0;
for (let i = 0; i < spots.length; i++) {
if (spots[i].zone === 'A区') {
zoneACount++;
} else if (spots[i].zone === 'B区') {
zoneBCount++;
} else if (spots[i].zone === 'C区') {
zoneCCount++;
}
}
let result: Record<string, number> = {};
result['A区'] = zoneACount;
result['B区'] = zoneBCount;
result['C区'] = zoneCCount;
return result;
}
getZoneStats 函数的结构与 getSpotStatusStats 类似,区别在于它统计的是各区域的车位数量而非各状态的数量。函数遍历车位数组,按 zone 字段将车位归入 A 区、B 区、C 区三个计数器,最终以中文键名返回结果。这个函数为区域筛选和区域维度的可视化统计提供了数据支撑。
8.3 费用统计三件套
function getTotalFee(records: ParkingRecordModel[]): number {
let total: number = 0;
for (let i = 0; i < records.length; i++) {
total += records[i].fee;
}
return total;
}
function getPaidFee(records: ParkingRecordModel[]): number {
let total: number = 0;
for (let i = 0; i < records.length; i++) {
if (records[i].status === '已支付') {
total += records[i].fee;
}
}
return total;
}
function getPendingFee(records: ParkingRecordModel[]): number {
let total: number = 0;
for (let i = 0; i < records.length; i++) {
if (records[i].status === '待支付') {
total += records[i].fee;
}
}
return total;
}
这三个函数构成了费用统计的"三件套"。getTotalFee 计算所有记录的费用总额,不区分支付状态;getPaidFee 只累加状态为"已支付"的记录费用;getPendingFee 只累加状态为"待支付"的记录费用。三者分别对应"我的"页面费用统计卡片中的"累计费用"“已支付”"待支付"三个数字。
这种将统计逻辑拆分为多个小函数的做法,比写一个带参数的大函数更清晰——调用方无需记忆参数含义,函数名本身就表达了统计口径。需要注意的是,由于待支付记录的 fee 在 Mock 数据中为 0(车辆仍在场内,费用尚未结算),getPendingFee 实际返回值为 0,这在真实场景中会随着车辆出场计费而变化。
8.4 状态颜色映射函数 getSpotStatusColor
function getSpotStatusColor(status: string): string {
if (status === '空闲') {
return COLORS.accentFree;
} else if (status === '占用') {
return COLORS.accentOccupied;
} else if (status === '预留') {
return COLORS.accentReserved;
} else {
return COLORS.accentMaintenance;
}
}
getSpotStatusColor 函数接收一个状态字符串,返回对应的颜色值。它通过一组 if-else 链将"空闲"映射为绿色、"占用"映射为红色、“预留"映射为黄色、其余(即"维修”)映射为灰色。这个函数是连接数据层与视觉层的桥梁——车位网格中每个格子的背景色、详情弹窗中状态文字的颜色,都由它决定。将颜色映射逻辑集中在一个函数中,确保了同一状态在任何位置都呈现一致的颜色,避免了散落判断导致的颜色不一致风险。
九、应用入口:@Entry 与主框架结构
9.1 入口组件的定义
前面八个部分都是数据与逻辑的铺垫,从这一节开始,我们进入 UI 组件层。整个应用的入口由 SmartParkingApp 组件承担:
@Entry
@Component
struct SmartParkingApp {
@State currentTab: TabType = TabType.PARKING;
build() {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
if (this.currentTab === TabType.PARKING) {
ParkingContent()
} else if (this.currentTab === TabType.FIND_CAR) {
FindCarContent()
} else if (this.currentTab === TabType.PAYMENT) {
PaymentContent()
} else if (this.currentTab === TabType.HISTORY) {
HistoryContent()
} else if (this.currentTab === TabType.PROFILE) {
ProfileContent()
}
}
.layoutWeight(1)
// ... 底部导航栏代码
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.background)
}
}
@Entry 装饰器标记这个组件为应用的入口页面,应用启动后将首先渲染它;@Component 装饰器声明这是一个自定义组件。组件内部通过 @State currentTab: TabType = TabType.PARKING 定义了一个状态变量 currentTab,初始值为 TabType.PARKING,即应用打开时默认展示停车页。这个状态变量是整个标签切换机制的核心——当它的值发生变化时,build 方法中对应的条件分支会重新求值,从而切换显示的内容组件。
build 方法定义了组件的 UI 结构。最外层是一个 Column,宽高均占满父容器(100%),背景色为 COLORS.background(浅蓝灰)。内部是一个 Stack,对齐方式为 Alignment.Bottom,意味着子元素默认在底部对齐。Stack 中包含两个子元素:第一个是一个 Column,占据剩余空间(layoutWeight(1)),其内部通过 if-else 条件判断根据 currentTab 的值渲染对应的 Tab 内容组件(ParkingContent、FindCarContent、PaymentContent、HistoryContent、ProfileContent);第二个是底部导航栏 Row。
这种"Stack 叠放内容区与导航栏"的结构,让内容区可以自然占据导航栏上方的全部空间,而导航栏固定在底部,符合移动端应用的经典布局模式。
9.2 底部导航栏的构建
Row() {
Column() {
Text('\uD83C\uDD7F\uFE0F')
.fontSize(22)
Text('停车')
.fontSize(10)
.fontColor(this.currentTab === TabType.PARKING ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.PARKING;
})
Column() {
Text('\uD83D\uDD0D')
.fontSize(22)
Text('寻车')
.fontSize(10)
.fontColor(this.currentTab === TabType.FIND_CAR ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.FIND_CAR;
})
// ... 缴费、记录、我的 三个标签结构类似
}
.width('100%')
.height(58)
.padding({ top: 6, bottom: 6 })
.backgroundColor(COLORS.card)
.borderWidth({ top: 1 })
.borderColor(COLORS.divider)
底部导航栏是一个 Row,宽度占满、高度 58 像素,上下各留 6 像素内边距,背景为白色(COLORS.card),顶部有一条 1 像素的分隔线(COLORS.divider),与上方内容区形成视觉分隔。
Row 内部包含五个 Column,每个 Column 通过 layoutWeight(1) 等分导航栏宽度。每个 Column 内部有两行内容:上方是一个 emoji 图标(Text 组件,字号 22),下方是标签文字(Text 组件,字号 10)。标签文字的颜色通过三元表达式动态决定——如果当前 currentTab 等于该标签对应的枚举值,文字显示主色(COLORS.primary,深蓝),否则显示次文本色(COLORS.textSecondary,灰色),以此高亮当前选中的标签。
每个 Column 绑定了 onClick 点击事件,点击后将 this.currentTab 赋值为对应的枚举值,从而触发 UI 切换。这里五个标签分别使用了"停车"“寻车”“缴费”“记录”"我的"五个中文文案,配以相应的 emoji 图标(由于源码中使用 Unicode 转义序列表示 emoji,这里不做展开),整体风格简洁直观。这种"图标+文字"的导航栏设计是移动端应用的通用范式,用户无需阅读说明即可理解各标签的功能含义。
十、停车 Tab:车位可视化与管理
10.1 组件状态与结构概览
停车页是应用的核心模块,由 ParkingContent 组件实现:
@Component
struct ParkingContent {
@State selectedZone: string = '全部';
@State spots: ParkingSpotModel[] = MOCK_SPOTS;
@State showDetailModal: boolean = false;
@State detailSpot: ParkingSpotModel = MOCK_SPOTS[0];
private zoneOptions: string[] = ['全部', 'A区', 'B区', 'C区'];
// ...
}
组件定义了四个状态变量和一个私有属性。selectedZone 记录当前选中的区域筛选条件,初始值为"全部";spots 是车位数据数组,初始值为前面定义的 MOCK_SPOTS;showDetailModal 是一个布尔值,控制车位详情弹窗是否显示,初始为 false;detailSpot 记录当前要展示详情的那个车位对象,初始值为 MOCK_SPOTS[0](即第一个车位)。zoneOptions 是一个私有数组,包含区域筛选的可选项:“全部”“A区”“B区”“C区”。
这四个状态变量共同支撑了停车页的交互逻辑:用户点击区域筛选标签会修改 selectedZone,点击车位格子会修改 detailSpot 并将 showDetailModal 置为 true,点击弹窗关闭按钮会将 showDetailModal 置为 false。每一次状态变化都会触发 build 方法的重新执行,更新对应的 UI 部分。
10.2 @Builder 修饰的可复用 UI 片段
在停车页组件中,开发者使用 @Builder 装饰器定义了两个可复用的 UI 构建方法:modalOverlay 和 SpotDetailModal。@Builder 是 ArkUI 提供的一种机制,用于将一段 UI 结构封装为可复用的函数,类似于其他框架中的"渲染函数"或"局部模板"。通过 @Builder 封装,可以避免在 build 方法中出现大量重复的 UI 代码,提高可维护性。
modalOverlay 接收一个 onClose 回调函数作为参数,构建一个全屏遮罩层,中间显示"暂停中…“文字,点击遮罩时调用 onClose 回调。SpotDetailModal 则构建了车位详情弹窗的完整 UI 结构,包括标题、车位号、区域、类型、状态(带颜色)、费率、充电桩标识、已停时长(条件显示)以及关闭按钮。这些 @Builder 方法定义了 UI 的"模板”,在 build 方法中通过 this.SpotDetailModal() 这样的方式调用即可将其内容插入到组件树中。
10.3 车位详情弹窗的内部结构
让我们详细看看 SpotDetailModal 这个 Builder 的具体内容:
@Builder SpotDetailModal() {
Column() {
Column() {
Text('车位详情')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Row() {
Text('车位号:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.number)
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('区域:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.zone)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
// ... 类型、状态、费率等行结构类似
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showDetailModal = false;
})
}
这个 Builder 构建了一个两层嵌套的结构。外层 Column 占满全屏,背景色为半透明黑色 rgba(0,0,0,0.5)(模拟遮罩效果),内容居中对齐(justifyContent(FlexAlign.Center)),并绑定了点击事件——点击遮罩区域任意位置都会关闭弹窗(将 showDetailModal 置为 false)。
内层 Column 是弹窗主体,宽度为父容器的 90%,内边距 20 像素,白色背景,圆角 12 像素。主体内部首先是一个标题"车位详情",字号 18、粗体、主色、居中对齐,下方留 16 像素间距。随后是一系列 Row,每行展示一项车位信息:左侧是灰色标签文字(如"车位号:"“区域:”“类型:”“状态:”“费率:”),右侧是对应的值。其中车位号值用字号 16 粗体突出显示,状态值通过 getSpotStatusColor 函数获取对应颜色并以粗体显示,费率值通过模板字符串拼接"元/小时"后缀。
在费率行之后,还有两处条件渲染:如果 this.detailSpot.isCharging 为 true,则显示一个带闪电符号的"充电桩"标签(绿色文字、浅背景、圆角小标签);如果 this.detailSpot.status === '占用',则显示"已停时长"行,展示 duration 分钟数。最后是一个"关闭"按钮,宽度占满、高度 40、主色背景、白色文字、圆角 8,点击后关闭弹窗。这种条件渲染让弹窗能够根据车位实际情况动态展示内容——空闲车位不显示时长,非充电车位不显示充电桩标签,信息呈现更加精准。
10.4 停车页主体布局
build() {
Column() {
Column() {
Row() {
Text('智慧停车')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Scroll() {
Column() {
// 区域选择、车位网格、状态统计等内容
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
if (this.showDetailModal) {
// 详情弹窗
}
}
}
停车页的 build 方法构建了一个从上到下的垂直布局。最外层 Column 占满全屏,内部首先是一个内层 Column(也占满全屏),其中包含两大部分:顶部标题栏和可滚动的内容区。
标题栏是一个 Row,宽度占满、内边距 16、背景为主色(深蓝),内部一个 Text 显示"智慧停车",字号 20、粗体。这里有一个有趣的细节:标题文字颜色设为 COLORS.primary(深蓝),而背景也是 COLORS.primary(深蓝),这意味着标题文字与背景同色。在实际效果中,这可能是开发者有意为之的"色块标题栏"效果,也可能是一个需要在真实项目中修正的颜色搭配问题——通常标题栏文字应为白色以形成对比。
内容区是一个 Scroll 组件,垂直方向可滚动(ScrollDirection.Vertical),隐藏滚动条(BarState.Off),通过 layoutWeight(1) 占据标题栏以下的全部空间。Scroll 内部是一个 Column,依次包含:区域选择标签栏、车位网格俯视图、车位状态统计卡片。当 showDetailModal 为 true 时,在内容区下方还会条件渲染详情弹窗。
10.5 区域筛选标签栏
Text('选择停车场')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Row() {
ForEach(this.zoneOptions, (zone: string, index: number) => {
Text(zone)
.fontSize(13)
.fontColor(this.selectedZone === zone ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.selectedZone === zone ? COLORS.primary : COLORS.card)
.borderRadius(16)
.borderWidth(1)
.borderColor(this.selectedZone === zone ? COLORS.primary : COLORS.divider)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.selectedZone = zone;
})
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
区域筛选区域首先是一个标题"选择停车场",字号 15、粗体、深色文字,带左右上下内边距。下方是一个水平滚动的 Scroll,内部是一个 Row,通过 ForEach 遍历 zoneOptions 数组(“全部”“A区”“B区”“C区”),为每个选项生成一个胶囊形标签。
每个标签的样式通过三元表达式动态控制:如果当前 selectedZone 等于该选项值,文字为白色、背景为主色(深蓝)、边框为主色(选中态);否则文字为深色、背景为白色、边框为浅灰(未选中态)。标签圆角 16 像素(胶囊形),内边距适中,右侧间距 8 像素。点击标签时将 selectedZone 赋值为该选项值,触发样式刷新。水平滚动的设计保证了即使未来区域选项增多(如增加 D 区、E 区),也不会导致布局溢出。
10.6 车位网格俯视图
这是停车页最具视觉冲击力的部分——将所有车位以网格形式展示,模拟停车场俯视图的效果:
Row() {
Text('车位网格俯视图')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.spots.length}个车位`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
Column() {
ForEach(this.spots, (spot: ParkingSpotModel, index: number) => {
if (index % 3 === 0) {
Row() {
ForEach([0, 1, 2], (offset: number, idx: number) => {
Column() {
if (this.spots[index + offset] !== undefined) {
Text(this.spots[index + offset].number)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(this.spots[index + offset].status)
.fontSize(9)
.fontColor('rgba(255,255,255,0.85)')
Text(this.spots[index + offset].type)
.fontSize(8)
.fontColor('rgba(255,255,255,0.7)')
}
}
.width('30%')
.height(60)
.borderRadius(8)
.backgroundColor(this.spots[index + offset] !== undefined ?
getSpotStatusColor(this.spots[index + offset].status) : COLORS.divider)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ left: 4, right: 4, bottom: 8 })
.onClick(() => {
if (this.spots[index + offset] !== undefined) {
this.detailSpot = this.spots[index + offset];
this.showDetailModal = true;
}
})
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
})
}
.width('100%')
.padding({ left: 12, right: 12 })
这段代码实现了一个"每行三个车位"的网格布局,技术手法非常巧妙。首先是一个标题行,左侧"车位网格俯视图"标题,右侧显示总车位数(${this.spots.length}个车位)。
网格主体是一个 Column,内部使用 ForEach 遍历 spots 数组,但通过 if (index % 3 === 0) 条件判断,只在索引为 0、3、6、9…(即每三个车位的起始位置)时才生成一个 Row。每个 Row 内部再用一个 ForEach 遍历 [0, 1, 2] 三个偏移量,取 this.spots[index + offset] 作为该格对应的车位,从而实现"一行三列"的网格效果。这种"外层按行分组、内层按列展开"的嵌套 ForEach 模式,是 ArkUI 中实现多列网格的常见技巧。
每个车位格子是一个 Column,宽度 30%(三个格子加间距约等于 100%),高度 60 像素,圆角 8。背景色通过 getSpotStatusColor 函数根据车位状态动态获取——空闲为绿、占用为红、预留为黄、维修为灰。格子内部从上到下显示三行文字:车位编号(字号 11、粗体、白色)、状态(字号 9、半透明白色)、类型(字号 8、更淡的白色)。文字颜色采用半透明白色(rgba(255,255,255,0.85) 和 0.7),在彩色背景上呈现出层次感——编号最醒目,状态次之,类型最弱。
每个格子绑定了 onClick 事件,点击后将该车位赋值给 detailSpot 状态,并将 showDetailModal 置为 true,从而弹出车位详情。这里还做了边界检查(if (this.spots[index + offset] !== undefined)),确保最后一行不足三个车位时不会访问越界元素。这种细致的边界处理体现了开发者的严谨态度。
10.7 车位状态统计与柱状图
在车位网格下方,是一个状态统计卡片,将车位数据以更宏观的视角呈现给用户:
Column() {
Text('车位状态统计')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.padding({ top: 16, bottom: 8 })
Row() {
Text(`空闲 ${getSpotStatusStats(this.spots)['free']}个`)
.fontSize(11)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentFree)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text(`占用 ${getSpotStatusStats(this.spots)['occupied']}个`)
.fontSize(11)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
Text(`预留 ${getSpotStatusStats(this.spots)['reserved']}个`)
.fontSize(11)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.accentReserved)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
Text(`维修 ${getSpotStatusStats(this.spots)['maintenance']}个`)
.fontSize(11)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentMaintenance)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 2 })
// ... 柱状图部分
}
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ left: 16, right: 16, top: 12, bottom: 16 })
统计卡片是一个白色背景、圆角 12 的 Column,外边距使其与屏幕边缘保持 16 像素间距,顶部和底部各有 12 和 16 像素的外边距。卡片内部首先是"车位状态统计"标题,然后是一行四个状态标签,每个标签通过调用 getSpotStatusStats(this.spots) 函数获取对应状态的计数,与状态名称拼接后显示(如"空闲 13个")。标签背景色与状态颜色一致(空闲绿、占用红、预留黄、维修灰),文字颜色根据背景明暗做适配——黄色背景用深色文字,其余用白色文字,保证了可读性。
卡片下方还包含一个简化的"车位使用率柱状图",为 A 区、B 区、C 区各画了一个由两个色块(占用色和空闲色)组成的简易柱状图,下方标注区域名称。虽然这个柱状图的高度是固定的(每块 12 像素高),并未根据实际数据比例动态调整,但它以一种视觉化的方式传达了"各区域均有占用和空闲车位"的信息,为用户提供了快速概览。
十一、寻车 Tab:FindCarContent
11.1 寻车功能的设计理念
在大型商场、地下车库或机场停车场中,车主经常会遇到"停完车回来找不到车"的尴尬。寻车功能正是为解决这一问题而生——它记录每次停车的精确位置(区域+车位号),让车主可以随时回查。FindCarContent 组件实现了这一功能。
@Component
struct FindCarContent {
@State searchKeyword: string = '';
@State findRecords: FindCarRecordModel[] = MOCK_FIND_RECORDS;
// ...
}
组件定义了两个状态变量:searchKeyword 记录搜索框中输入的车牌号关键词,初始为空字符串;findRecords 是寻车记录数组,初始值为 MOCK_FIND_RECORDS。整个组件的交互逻辑比较简洁——用户在搜索框中输入车牌号,列表根据关键词筛选展示对应的寻车记录。
11.2 寻车页布局与搜索框
build() {
Column() {
Row() {
Text('寻车记录')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Row() {
Text('搜索车牌号')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(80)
TextInput({ placeholder: '请输入车牌号', text: this.searchKeyword })
.fontSize(14)
.layoutWeight(1)
.backgroundColor(COLORS.white)
.borderRadius(8)
.padding(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.onChange((value: string) => {
this.searchKeyword = value;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
// ... 记录列表
}
.width('100%')
.height('100%')
}
寻车页的布局从上到下依次是标题栏和搜索区。标题栏与停车页类似,是一个主色背景的 Row,内部"寻车记录"文字字号 20、粗体,但这里文字颜色正确地设为了白色(COLORS.white),与深蓝背景形成良好对比。
搜索区是一个 Row,左侧是"搜索车牌号"标签(字号 12、灰色、固定宽度 80),右侧是一个 TextInput 输入框。TextInput 通过 placeholder 参数设置占位提示文字"请输入车牌号",通过 text 参数绑定当前状态 this.searchKeyword,实现受控输入。输入框白色背景、圆角 8、灰色边框,占据剩余宽度(layoutWeight(1))。onChange 回调在用户输入时将新值赋给 this.searchKeyword,触发状态更新。虽然当前代码中搜索框的值变化后并未在列表渲染时做实际过滤(列表直接遍历全部 findRecords),但状态绑定已经就绪,未来只需在 ForEach 中加入过滤条件即可实现搜索功能。
11.3 寻车记录列表
Scroll() {
Column() {
ForEach(this.findRecords, (record: FindCarRecordModel, index: number) => {
Row() {
Column() {
Text(record.plateNumber)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text(`${record.zone} ${record.spotNumber}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
}
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text(record.recordTime)
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text('查看位置')
.fontSize(12)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 10 })
.borderWidth(1)
.borderColor(COLORS.divider)
})
}
.width('100%')
.padding({ top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
记录列表在一个垂直可滚动的 Scroll 中,内部 Column 通过 ForEach 遍历 findRecords 数组,为每条记录生成一个卡片。每个卡片是一个 Row,白色背景、圆角 10、灰色边框,左右外边距 16、底部外边距 10,内边距 14。
卡片左侧是一个 Column(占据剩余空间),展示车牌号(字号 16、粗体、深色)和位置信息"区域 车位号"(字号 12、灰色,上方 4 像素间距)。右侧也是一个 Column(右对齐),展示记录时间(字号 12、灰色)和"查看位置"链接文字(字号 12、主色,上方 4 像素间距)。"查看位置"以主色文字呈现,暗示这是一个可点击的操作入口,未来可以接入地图导航或室内定位功能,引导车主前往对应车位。这种"左侧主体信息+右侧时间与操作"的卡片布局,是列表项设计的经典模式,信息层次清晰,操作入口明确。
十二、缴费 Tab:PaymentContent
12.1 缴费功能与生命周期
缴费模块负责集中展示所有待支付的停车账单,并提供模拟缴费功能。PaymentContent 组件利用了 ArkUI 的生命周期回调 aboutToAppear 来初始化数据:
@Component
struct PaymentContent {
@State paymentProgress: number = 0;
@State selectedBillId: number = -1;
@State unpaidRecords: ParkingRecordModel[] = [];
aboutToAppear(): void {
let unpaid: ParkingRecordModel[] = [];
for (let i = 0; i < MOCK_RECORDS.length; i++) {
if (MOCK_RECORDS[i].status === '待支付') {
unpaid.push(MOCK_RECORDS[i]);
}
}
this.unpaidRecords = unpaid;
}
// ...
}
组件定义了三个状态变量:paymentProgress 记录缴费进度(0-100),初始为 0;selectedBillId 记录当前选中的账单 ID,初始为 -1(表示未选中);unpaidRecords 是待支付记录数组,初始为空。
aboutToAppear 是 ArkUI 组件的生命周期回调,在组件即将出现在页面上之前被调用。在这个回调中,代码遍历 MOCK_RECORDS 数组,筛选出所有 status === '待支付' 的记录,存入临时数组 unpaid,然后赋值给 this.unpaidRecords。这种在组件出现前预处理数据的做法,确保了用户看到缴费页时,待支付列表已经准备就绪。在真实项目中,这里通常会替换为异步网络请求,从后端拉取待支付账单。
12.2 缴费进度条
Column() {
Row() {
Text('缴费进度')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${this.paymentProgress}%`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 6 })
Row() {
Column()
.width(`${this.paymentProgress}%`)
.height(8)
.backgroundColor(COLORS.accentFree)
.borderRadius(4)
Column()
.layoutWeight(1)
.height(8)
.backgroundColor(COLORS.divider)
.borderRadius(4)
}
.width('100%')
.backgroundColor(COLORS.divider)
.borderRadius(4)
.height(8)
Button('模拟缴费进度')
.fontSize(12)
.height(30)
.backgroundColor(COLORS.primary)
.fontColor(COLORS.white)
.borderRadius(6)
.margin({ top: 10 })
.onClick(() => {
let newProgress: number = this.paymentProgress + 15;
if (newProgress > 100) {
newProgress = 100;
}
this.paymentProgress = newProgress;
if (this.paymentProgress === 100) {
let updated: ParkingRecordModel[] = [];
for (let i = 0; i < this.unpaidRecords.length; i++) {
let record: ParkingRecordModel = this.unpaidRecords[i];
let newRecord: ParkingRecordModel = new ParkingRecordModel(
record.id, record.plateNumber, record.zone, record.spotNumber,
record.enterTime, '2024-08-07 10:00', '2小时', 12,
'已支付', '微信支付');
updated.push(newRecord);
}
this.unpaidRecords = updated;
}
})
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ left: 16, right: 16, top: 12 })
缴费进度卡片是缴费页的亮点功能。卡片顶部是一行标题,左侧"缴费进度"(粗体),右侧显示当前进度百分比(${this.paymentProgress}%,主色),两端对齐(justifyContent(FlexAlign.SpaceBetween))。
进度条本身是一个 Row,由两个 Column 拼接而成:左侧 Column 宽度为 ${this.paymentProgress}%(动态绑定进度值),高度 8,绿色背景(COLORS.accentFree),圆角 4;右侧 Column 通过 layoutWeight(1) 占据剩余宽度,高度 8,灰色背景(COLORS.divider),圆角 4。两者拼接形成一条完整的进度条,随着 paymentProgress 的增加,绿色部分逐渐变长、灰色部分逐渐缩短,直观地反映缴费进度。
“模拟缴费进度"按钮点击后,将 paymentProgress 增加 15,但不超过 100。当进度达到 100 时,代码遍历所有待支付记录,为每条记录创建一个新的 ParkingRecordModel 实例,将状态改为"已支付”、补全出场时间、时长、费用和支付方式,然后用新数组替换 unpaidRecords。这里用"创建新对象而非修改原对象"的方式来更新数据,是响应式编程的推荐做法——它确保框架能够检测到数组引用的变化,从而触发列表的重新渲染。这一逻辑模拟了"缴费完成后账单状态更新"的真实业务流程。
12.3 待缴账单列表
Row() {
Text('待缴账单')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.unpaidRecords.length}笔`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
Scroll() {
Column() {
ForEach(this.unpaidRecords, (record: ParkingRecordModel, index: number) => {
Row() {
Column() {
Text(record.plateNumber)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${record.zone} ${record.spotNumber}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(`¥${record.fee}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
Text(record.status)
.fontSize(10)
.fontColor(record.status === '待支付' ? COLORS.accentOccupied : COLORS.accentFree)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Button(this.selectedBillId === record.id ? '✓ 已选' : '支付')
.fontSize(12)
.height(32)
.backgroundColor(this.selectedBillId === record.id ? COLORS.accentFree : COLORS.primary)
.fontColor(COLORS.white)
.borderRadius(6)
.margin({ left: 10 })
.onClick(() => {
this.selectedBillId = record.id;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 8 })
.borderWidth(1)
.borderColor(this.selectedBillId === record.id ? COLORS.primary : COLORS.divider)
})
}
.width('100%')
.padding({ top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
待缴账单区域首先是一个标题行,左侧"待缴账单",右侧显示账单笔数(${this.unpaidRecords.length}笔)。下方是一个垂直可滚动的列表,通过 ForEach 遍历 unpaidRecords,为每条待支付记录生成一个账单卡片。
每个卡片是一个 Row,分三部分:左侧 Column 展示车牌号和位置信息;中间 Column(右对齐)展示费用金额(¥ 前缀,字号 16、粗体、红色)和状态标签(待支付为红色、已支付为绿色);右侧是一个"支付"按钮。按钮文案和样式通过三元表达式动态控制——如果该账单被选中(selectedBillId === record.id),按钮显示"✓ 已选"、绿色背景;否则显示"支付"、主色背景。点击按钮将该记录的 ID 赋值给 selectedBillId,同时卡片的边框颜色也会从灰色变为选中的主色,提供清晰的选中反馈。这种"点击选中"的交互模式,为后续批量缴费或确认支付提供了操作基础。
十三、记录 Tab:HistoryContent
13.1 记录模块的状态与筛选
记录模块完整保存用户的历史停车记录,并提供按支付状态筛选的功能:
@Component
struct HistoryContent {
@State records: ParkingRecordModel[] = MOCK_RECORDS;
@State filterStatus: string = '全部';
// ...
}
组件定义了两个状态变量:records 是全部停车记录数组,初始值为 MOCK_RECORDS;filterStatus 是当前筛选状态,初始值为"全部"。用户可以通过筛选标签在"全部"“已支付”“待支付”"免费"四种状态间切换。
13.2 状态筛选标签栏
Scroll() {
Row() {
Text('全部')
.fontSize(12)
.fontColor(this.filterStatus === '全部' ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.filterStatus === '全部' ? COLORS.primary : COLORS.card)
.borderRadius(14)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.margin({ right: 8 })
.onClick(() => {
this.filterStatus = '全部';
})
Text('已支付')
// ... 选中态样式与"全部"类似
.onClick(() => {
this.filterStatus = '已支付';
})
Text('待支付')
// ...
.onClick(() => {
this.filterStatus = '待支付';
})
Text('免费')
// ...
.onClick(() => {
this.filterStatus = '免费';
})
}
.padding({ left: 16, right: 16, top: 10, bottom: 6 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
筛选标签栏是一个水平可滚动的 Row,包含四个胶囊形标签:“全部”“已支付”“待支付”“免费”。每个标签的样式与停车页的区域筛选标签逻辑一致——选中态为白字主色背景,未选中态为深字白底灰框。点击标签将 filterStatus 赋值为对应值。四个标签平铺在一行中,水平滚动以适应不同屏幕宽度。
13.3 记录列表与条件渲染
Scroll() {
Column() {
ForEach(this.records, (record: ParkingRecordModel, index: number) => {
if (this.filterStatus === '全部' || record.status === this.filterStatus) {
Column() {
Row() {
Text(record.plateNumber)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(record.status)
.fontSize(12)
.fontColor(record.status === '已支付' ? COLORS.accentFree :
record.status === '待支付' ? COLORS.accentOccupied : COLORS.accentReserved)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 6 })
Row() {
Text(`${record.zone} ${record.spotNumber}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text(record.enterTime)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ left: 12 })
}
.width('100%')
.margin({ bottom: 4 })
Row() {
if (record.exitTime !== '') {
Text(`出场: ${record.exitTime}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
if (record.duration !== '进行中') {
Text(`时长: ${record.duration}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
}
}
.width('100%')
.margin({ bottom: 6 })
Row() {
Text(`¥${record.fee}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
.layoutWeight(1)
if (record.paymentMethod !== '') {
Text(record.paymentMethod)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 10 })
.borderWidth(1)
.borderColor(COLORS.divider)
}
})
}
.width('100%')
.padding({ top: 4, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
记录列表的核心技巧在于 ForEach 内部的条件判断:if (this.filterStatus === '全部' || record.status === this.filterStatus)。这行代码实现了筛选逻辑——如果当前筛选状态为"全部",则所有记录都显示;否则只显示状态与筛选条件匹配的记录。这种在 ForEach 内部用 if 控制渲染的方式,是 ArkUI 中实现列表筛选的简洁手法。
每个记录卡片是一个 Column,内部包含四行信息。第一行是车牌号(左,粗体)和状态标签(右,颜色根据状态动态变化:已支付绿色、待支付红色、免费黄色)。第二行是位置信息和入场时间。第三行通过条件渲染展示出场时间和时长——如果出场时间不为空才显示"出场: …“,如果时长不为"进行中"才显示"时长: …”,这样"进行中"的记录就不会显示这两个字段,信息呈现更加合理。第四行是费用金额(左,大字号、粗体、红色)和支付方式(右,仅当支付方式不为空时显示)。这种多行卡片设计将一次停车的完整信息有条理地呈现出来,信息密度高但不显杂乱。
十四、我的 Tab:ProfileContent
14.1 我的页面与车辆管理概览
"我的"页面是应用的个人中心,承载了车辆管理和费用统计两大功能。ProfileContent 组件是整个应用中状态变量最多、交互逻辑最复杂的组件,涵盖了车辆的新增、编辑、删除三种操作及其对应的弹窗交互。
@Component
struct ProfileContent {
@State vehicles: VehicleModel[] = MOCK_VEHICLES;
@State showAddModal: boolean = false;
@State showEditModal: boolean = false;
@State showDeleteConfirm: boolean = false;
@State newPlateNumber: string = '';
@State newBrand: string = '';
@State newColor: string = '';
@State newType: string = '';
@State editVehicleId: number = -1;
@State editPlateNumber: string = '';
@State editBrand: string = '';
@State editColor: string = '';
@State editType: string = '';
@State deleteVehicleId: number = -1;
@State deletePlate: string = '';
private nextId: number = 11;
// ...
}
组件定义了十四个状态变量和一个私有属性,可以分为四组。第一组是车辆列表数据:vehicles 初始值为 MOCK_VEHICLES。第二组是三个弹窗显示控制:showAddModal(新增弹窗)、showEditModal(编辑弹窗)、showDeleteConfirm(删除确认弹窗),均为布尔值,初始为 false。第三组是新增车辆的四个表单字段:newPlateNumber、newBrand、newColor、newType,初始为空字符串。第四组是编辑和删除操作的上下文:editVehicleId 等四个编辑字段、deleteVehicleId 和 deletePlate 两个删除上下文。nextId 是私有属性,记录下一个新车辆的 ID,初始为 11(因为 Mock 数据有 10 辆车,ID 为 1-10)。
这么多状态变量的存在,是因为每个弹窗都需要独立的表单状态来承载用户输入,同时编辑和删除操作需要记录"正在操作哪辆车"的上下文。这种"一个操作一套状态"的设计虽然状态数量多,但逻辑清晰、互不干扰。
14.2 费用统计卡片
Column() {
Row() {
Text('费用统计')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Column() {
Text('累计费用')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`¥${getTotalFee(MOCK_RECORDS)}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('已支付')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`¥${getPaidFee(MOCK_RECORDS)}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentFree)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('待支付')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`¥${getPendingFee(MOCK_RECORDS)}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentReserved)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ left: 16, right: 16, top: 12 })
费用统计卡片位于页面顶部,是一个白色背景、圆角 12 的 Column。卡片内部首先是"费用统计"标题,然后是一个三等分的 Row,每个 Column(通过 layoutWeight(1) 等分,居中对齐)展示一项费用指标。
三列分别是:累计费用(调用 getTotalFee(MOCK_RECORDS),红色数字)、已支付(调用 getPaidFee(MOCK_RECORDS),绿色数字)、待支付(调用 getPendingFee(MOCK_RECORDS),黄色数字)。每个指标的标签用小字号灰色文字,数值用大字号粗体彩色文字,形成"标签在上、数值在下"的统计卡片范式。三种颜色(红、绿、黄)与停车状态色系一致——红色代表总额(强调)、绿色代表已支付(安全)、黄色代表待支付(提醒),用户一眼就能把握自己的费用状况。
14.3 车辆列表与操作按钮
Scroll() {
Column() {
ForEach(this.vehicles, (vehicle: VehicleModel, index: number) => {
Row() {
Column() {
Row() {
Text(vehicle.plateNumber)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
if (vehicle.isDefault) {
Text('默认')
.fontSize(9)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(3)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ left: 6 })
}
}
Text(`${vehicle.brand} | ${vehicle.color} | ${vehicle.type}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
Row() {
Text('编辑')
.fontSize(11)
.fontColor(COLORS.primary)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderWidth(1)
.borderColor(COLORS.primary)
.borderRadius(4)
.margin({ right: 6 })
.onClick(() => {
this.editVehicleId = vehicle.id;
this.editPlateNumber = vehicle.plateNumber;
this.editBrand = vehicle.brand;
this.editColor = vehicle.color;
this.editType = vehicle.type;
this.showEditModal = true;
})
Text('删除')
.fontSize(11)
.fontColor(COLORS.accentOccupied)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderWidth(1)
.borderColor(COLORS.accentOccupied)
.borderRadius(4)
.onClick(() => {
this.deleteVehicleId = vehicle.id;
this.deletePlate = vehicle.plateNumber;
this.showDeleteConfirm = true;
})
}
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 10 })
.borderWidth(1)
.borderColor(COLORS.divider)
})
}
.width('100%')
.padding({ top: 4, bottom: 80 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
车辆列表通过 ForEach 遍历 vehicles 数组,为每辆车生成一个卡片。每个卡片的左侧是一个 Column,展示车牌号和车辆详情。车牌号行中有一个条件渲染——如果 vehicle.isDefault 为 true,则在车牌号右侧显示一个"默认"小标签(白字、主色背景、圆角 3),让用户一眼识别默认车辆。下方一行用管道符分隔展示"品牌 | 颜色 | 车型",字号 12、灰色。
卡片右侧是两个操作按钮:“编辑"和"删除”。它们不是标准的 Button 组件,而是带边框的 Text——"编辑"用主色文字和主色边框,"删除"用红色文字和红色边框,都是小字号、圆角 4 的小按钮。点击"编辑"时,将当前车辆的信息赋值给编辑表单的各个状态变量,并显示编辑弹窗;点击"删除"时,记录要删除的车辆 ID 和车牌号,并显示删除确认弹窗。这种"先记录上下文再弹窗确认"的模式,确保了弹窗中的操作能够精确作用于正确的车辆。
14.4 新增车辆弹窗
if (this.showAddModal) {
Column() {
Column() {
Text('新增车辆')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Column() {
Text('车牌号')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入车牌号', text: this.newPlateNumber })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.newPlateNumber = value;
})
}
.width('100%')
.margin({ bottom: 12 })
// ... 品牌、颜色、车型 三个输入框结构类似
Row() {
Button('取消')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.background)
.borderRadius(8)
.margin({ right: 8 })
.onClick(() => {
this.showAddModal = false;
})
Button('确认添加')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
let newVehicle: VehicleModel = new VehicleModel(
this.nextId, this.newPlateNumber, this.newBrand,
this.newColor, this.newType, false);
this.vehicles.push(newVehicle);
this.nextId = this.nextId + 1;
this.showAddModal = false;
})
}
.width('100%')
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showAddModal = false;
})
}
新增车辆弹窗通过 if (this.showAddModal) 条件渲染控制显示。弹窗结构与车位详情弹窗类似——外层全屏半透明遮罩(点击关闭),内层白色圆角卡片。卡片内部首先是"新增车辆"标题,然后是四个表单字段(车牌号、品牌、颜色、车型),每个字段由一个标签 Text 和一个 TextInput 组成,输入框通过 text 参数绑定对应的状态变量,onChange 回调更新状态。
表单下方是两个按钮:“取消”(浅色背景、灰色文字,点击关闭弹窗)和"确认添加"(主色背景、白色文字)。点击"确认添加"时,使用当前四个表单状态变量和 nextId 创建一个新的 VehicleModel 实例,通过 this.vehicles.push(newVehicle) 将其添加到车辆数组,然后将 nextId 递增 1(为下一辆新车准备 ID),最后关闭弹窗。这里 push 操作会修改原数组,在 ArkUI 中需要确保框架能感知到数组变化——由于 vehicles 是 @State 变量,对它的修改会触发 UI 更新。
14.5 编辑车辆弹窗
编辑弹窗的结构与新增弹窗几乎完全一致,区别在于:标题改为"编辑车辆",四个输入框绑定的是 editPlateNumber、editBrand、editColor、editType 等编辑状态变量,确认按钮改为"保存修改"。
Button('保存修改')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
for (let i = 0; i < this.vehicles.length; i++) {
if (this.vehicles[i].id === this.editVehicleId) {
this.vehicles[i].plateNumber = this.editPlateNumber;
this.vehicles[i].brand = this.editBrand;
this.vehicles[i].color = this.editColor;
this.vehicles[i].type = this.editType;
break;
}
}
this.showEditModal = false;
})
点击"保存修改"时,代码通过一个 for 循环遍历 vehicles 数组,找到 id 等于 editVehicleId 的那辆车,将其四个字段更新为编辑表单中的值,然后 break 跳出循环(因为 ID 唯一,找到后无需继续遍历),最后关闭弹窗。这种"遍历查找再原地更新"的方式,适用于数组规模较小的场景;如果车辆数量很大,可以考虑用 Map 或 Record 建立 ID 到索引的映射以提升查找效率。
14.6 删除确认弹窗
删除操作特别设计了一个确认弹窗,防止用户误删:
if (this.showDeleteConfirm) {
Column() {
Column() {
Text('确认删除')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 12 })
Text(`确定要删除车辆 ${this.deletePlate} 吗?`)
.fontSize(14)
.fontColor(COLORS.textSecondary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 20 })
Row() {
Button('取消')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.background)
.borderRadius(8)
.margin({ right: 8 })
.onClick(() => {
this.showDeleteConfirm = false;
})
Button('确认删除')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(8)
.onClick(() => {
let newVehicles: VehicleModel[] = [];
for (let i = 0; i < this.vehicles.length; i++) {
if (this.vehicles[i].id !== this.deleteVehicleId) {
newVehicles.push(this.vehicles[i]);
}
}
this.vehicles = newVehicles;
this.showDeleteConfirm = false;
})
}
.width('100%')
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showDeleteConfirm = false;
})
}
删除确认弹窗的标题"确认删除"使用红色(COLORS.accentOccupied),从视觉上传达危险操作的警示意味。下方是确认提示文字"确定要删除车辆 xxx 吗?",其中 xxx 是之前记录的 deletePlate 车牌号,让用户明确知道要删除的是哪辆车。
两个按钮中,"取消"用浅色背景(安全操作),“确认删除"用红色背景(危险操作),色彩语义清晰。点击"确认删除"时,代码创建一个新数组 newVehicles,遍历原数组将不等于 deleteVehicleId 的车辆放入新数组,然后用新数组替换 this.vehicles。这种"过滤法删除”——创建不含目标元素的新数组——比直接 splice 修改原数组更能确保响应式框架检测到变化,是一种更可靠的删除实现方式。
14.7 新增车辆浮动按钮
Button('+ 新增车辆')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(25)
.height(44)
.padding({ left: 28, right: 28 })
.margin({ bottom: 20 })
.shadow({ radius: 8, color: 'rgba(26,35,126,0.3)', offsetY: 2 })
.onClick(() => {
this.newPlateNumber = '';
this.newBrand = '';
this.newColor = '';
this.newType = '';
this.showAddModal = true;
})
在车辆列表底部,有一个"+ 新增车辆"浮动按钮。按钮采用主色背景、白色粗体文字、圆角 25(胶囊形)、高度 44,并带有阴影效果(shadow 属性,半径 8、半透明主色阴影、Y 偏移 2),营造出悬浮于内容之上的立体感。点击按钮时,先将四个新增表单状态变量清空(确保每次打开新增弹窗时表单都是空的),然后将 showAddModal 置为 true 弹出新增弹窗。这种"打开前清空表单"的细节处理,避免了上一次新增操作的残留数据污染下一次表单。
整个"我的"页面通过 Stack({ alignContent: Alignment.Bottom }) 包裹,让内容区和浮动按钮形成叠加关系——按钮浮在内容区底部之上,是移动端 FAB(Floating Action Button)设计模式的典型实现。
十五、关键特性对比与总结
15.1 五大功能模块特性对比
下表对应用的五个功能模块进行了横向对比,帮助读者快速把握各模块的核心特征:
| 对比维度 | 停车模块 | 寻车模块 | 缴费模块 | 记录模块 | 我的模块 |
|---|---|---|---|---|---|
| 核心功能 | 车位可视化与管理 | 停车位置回查 | 待支付账单处理 | 历史记录查询筛选 | 车辆信息管理 |
| 状态变量数 | 4 个 | 2 个 | 3 个 | 2 个 | 14 个 |
| 数据来源 | MOCK_SPOTS | MOCK_FIND_RECORDS | MOCK_RECORDS(筛选待支付) | MOCK_RECORDS | MOCK_VEHICLES + MOCK_RECORDS |
| 列表渲染方式 | 嵌套 ForEach 三列网格 | 单列 ForEach | 单列 ForEach | 单列 ForEach(带条件筛选) | 单列 ForEach |
| 交互特色 | 区域筛选+车位详情弹窗 | 车牌号搜索 | 进度条+账单选中 | 状态筛选标签 | 新增/编辑/删除三种弹窗 |
| 弹窗数量 | 1 个(车位详情) | 0 个 | 0 个 | 0 个 | 3 个(新增/编辑/删除确认) |
| 统计可视化 | 状态标签+简易柱状图 | 无 | 进度条 | 无 | 三列费用统计 |
| 滚动方向 | 垂直+水平 | 垂直 | 垂直 | 垂直+水平 | 垂直 |
| 复用工具函数 | getSpotStatusStats、getSpotStatusColor | 无 | 无 | 无 | getTotalFee、getPaidFee、getPendingFee |
| 生命周期回调 | 无 | 无 | aboutToAppear | 无 | 无 |
15.2 技术架构特性对比
| 技术维度 | 实现方式 | 设计特点 |
|---|---|---|
| 数据契约 | interface 定义 7 个接口 | 接口与模型分离,契约清晰 |
| 数据模型 | @Observed 装饰 4 个模型类 | 响应式可观察,属性变化驱动 UI |
| 状态管理 | @State 装饰器 | 组件内局部状态,变化自动触发重渲染 |
| 视觉规范 | COLORS 全局调色板常量 | 颜色集中管理,主题切换成本低 |
| 标签管理 | TabType 数值枚举 | 类型安全,语义化命名 |
| 业务逻辑 | 6 个纯函数工具函数 | 无副作用,可独立测试,多组件复用 |
| UI 复用 | @Builder 装饰器封装弹窗 | 模板化 UI 片段,减少重复代码 |
| 数据初始化 | aboutToAppear 生命周期回调 | 组件出现前预处理数据 |
| 模拟数据 | 4 组 Mock 常量数组 | 数据贴近真实,覆盖多种业务场景 |
| 列表布局 | ForEach + 条件渲染 | 支持筛选、分列网格等多种列表形态 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// ==================== 1. Interface定义层 ====================
interface ColorPalette {
primary: string;
secondary: string;
background: string;
card: string;
accentFree: string;
accentOccupied: string;
accentReserved: string;
accentMaintenance: string;
textPrimary: string;
textSecondary: string;
divider: string;
white: string;
}
interface ParkingSpot {
id: number;
zone: string;
number: string;
status: string;
type: string;
price: number;
isCharging: boolean;
duration: number;
}
interface ParkingRecord {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
enterTime: string;
exitTime: string;
duration: string;
fee: number;
status: string;
paymentMethod: string;
}
interface VehicleInfo {
id: number;
plateNumber: string;
brand: string;
color: string;
type: string;
isDefault: boolean;
}
interface FindCarRecord {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
recordTime: string;
photoUri: string;
}
interface SpotMeta {
zone: string;
number: string;
status: string;
type: string;
price: number;
isCharging: boolean;
}
interface VehicleMeta {
brand: string;
color: string;
type: string;
}
// ==================== 2. 常量 ====================
const COLORS: ColorPalette = {
primary: '#1A237E',
secondary: '#3949AB',
background: '#E8EAF6',
card: '#FFFFFF',
accentFree: '#00E676',
accentOccupied: '#FF1744',
accentReserved: '#FFD600',
accentMaintenance: '#9E9E9E',
textPrimary: '#212121',
textSecondary: '#757575',
divider: '#E0E0E0',
white: '#FFFFFF'
};
// ==================== 3. 枚举 ====================
enum TabType {
PARKING = 0,
FIND_CAR = 1,
PAYMENT = 2,
HISTORY = 3,
PROFILE = 4
}
// ==================== 4. @Observed 数据模型 ====================
@Observed
class ParkingSpotModel {
id: number;
zone: string;
number: string;
status: string;
type: string;
price: number;
isCharging: boolean;
duration: number;
constructor(id: number, zone: string, number: string, status: string, type: string, price: number, isCharging: boolean, duration: number) {
this.id = id;
this.zone = zone;
this.number = number;
this.status = status;
this.type = type;
this.price = price;
this.isCharging = isCharging;
this.duration = duration;
}
}
@Observed
class ParkingRecordModel {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
enterTime: string;
exitTime: string;
duration: string;
fee: number;
status: string;
paymentMethod: string;
constructor(id: number, plateNumber: string, zone: string, spotNumber: string, enterTime: string, exitTime: string, duration: string, fee: number, status: string, paymentMethod: string) {
this.id = id;
this.plateNumber = plateNumber;
this.zone = zone;
this.spotNumber = spotNumber;
this.enterTime = enterTime;
this.exitTime = exitTime;
this.duration = duration;
this.fee = fee;
this.status = status;
this.paymentMethod = paymentMethod;
}
}
@Observed
class VehicleModel {
id: number;
plateNumber: string;
brand: string;
color: string;
type: string;
isDefault: boolean;
constructor(id: number, plateNumber: string, brand: string, color: string, type: string, isDefault: boolean) {
this.id = id;
this.plateNumber = plateNumber;
this.brand = brand;
this.color = color;
this.type = type;
this.isDefault = isDefault;
}
}
@Observed
class FindCarRecordModel {
id: number;
plateNumber: string;
zone: string;
spotNumber: string;
recordTime: string;
photoUri: string;
constructor(id: number, plateNumber: string, zone: string, spotNumber: string, recordTime: string, photoUri: string) {
this.id = id;
this.plateNumber = plateNumber;
this.zone = zone;
this.spotNumber = spotNumber;
this.recordTime = recordTime;
this.photoUri = photoUri;
}
}
// ==================== 5. Mock数据 ====================
const MOCK_SPOTS: ParkingSpotModel[] = [
new ParkingSpotModel(1, 'A区', 'A001', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(2, 'A区', 'A002', '占用', '普通', 5, false, 120),
new ParkingSpotModel(3, 'A区', 'A003', '空闲', 'VIP', 8, true, 0),
new ParkingSpotModel(4, 'A区', 'A004', '预留', '普通', 5, false, 0),
new ParkingSpotModel(5, 'A区', 'A005', '占用', '新能源', 5, true, 85),
new ParkingSpotModel(6, 'A区', 'A006', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(7, 'A区', 'A007', '维修', '普通', 0, false, 0),
new ParkingSpotModel(8, 'A区', 'A008', '占用', '无障碍', 5, false, 200),
new ParkingSpotModel(9, 'B区', 'B001', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(10, 'B区', 'B002', '空闲', 'VIP', 8, true, 0),
new ParkingSpotModel(11, 'B区', 'B003', '占用', '普通', 5, false, 45),
new ParkingSpotModel(12, 'B区', 'B004', '空闲', '新能源', 5, true, 0),
new ParkingSpotModel(13, 'B区', 'B005', '占用', '普通', 5, false, 90),
new ParkingSpotModel(14, 'B区', 'B006', '预留', 'VIP', 8, false, 0),
new ParkingSpotModel(15, 'B区', 'B007', '空闲', '无障碍', 5, false, 0),
new ParkingSpotModel(16, 'B区', 'B008', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(17, 'C区', 'C001', '占用', '普通', 5, false, 60),
new ParkingSpotModel(18, 'C区', 'C002', '空闲', '新能源', 5, true, 0),
new ParkingSpotModel(19, 'C区', 'C003', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(20, 'C区', 'C004', '占用', 'VIP', 8, false, 150),
new ParkingSpotModel(21, 'C区', 'C005', '空闲', '普通', 5, false, 0),
new ParkingSpotModel(22, 'C区', 'C006', '预留', '普通', 5, false, 0),
new ParkingSpotModel(23, 'C区', 'C007', '占用', '新能源', 5, true, 75),
new ParkingSpotModel(24, 'C区', 'C008', '空闲', '无障碍', 5, false, 0),
new ParkingSpotModel(25, 'C区', 'C009', '空闲', '普通', 5, false, 0)
];
const MOCK_RECORDS: ParkingRecordModel[] = [
new ParkingRecordModel(1, '粤B12345', 'A区', 'A002', '2024-08-01 09:00', '2024-08-01 18:00', '9小时', 54, '已支付', '微信支付'),
new ParkingRecordModel(2, '粤B67890', 'B区', 'B003', '2024-08-02 10:00', '2024-08-02 12:30', '2.5小时', 15, '已支付', '支付宝'),
new ParkingRecordModel(3, '京A88888', 'A区', 'A005', '2024-08-02 08:00', '', '进行中', 0, '待支付', ''),
new ParkingRecordModel(4, '沪B66666', 'C区', 'C001', '2024-08-01 14:00', '2024-08-01 16:00', '2小时', 12, '已支付', '微信支付'),
new ParkingRecordModel(5, '粤B99999', 'B区', 'B005', '2024-07-30 09:00', '2024-07-30 11:00', '2小时', 0, '免费', ''),
new ParkingRecordModel(6, '浙C11111', 'A区', 'A008', '2024-08-03 07:00', '', '进行中', 0, '待支付', ''),
new ParkingRecordModel(7, '粤B22222', 'B区', 'B001', '2024-07-25 08:00', '2024-07-25 20:00', '12小时', 72, '已支付', '银联卡'),
new ParkingRecordModel(8, '粤B33333', 'A区', 'A001', '2024-08-04 09:30', '2024-08-04 18:30', '9小时', 54, '已支付', '微信支付'),
new ParkingRecordModel(9, '粤B44444', 'C区', 'C004', '2024-08-02 13:00', '', '进行中', 0, '待支付', ''),
new ParkingRecordModel(10, '粤B55555', 'C区', 'C007', '2024-07-28 06:00', '2024-07-28 08:00', '2小时', 12, '已支付', '支付宝'),
new ParkingRecordModel(11, '粤B66666', 'A区', 'A003', '2024-08-05 10:00', '2024-08-05 14:00', '4小时', 32, '已支付', '微信支付'),
new ParkingRecordModel(12, '粤B77777', 'B区', 'B004', '2024-08-06 08:00', '', '进行中', 0, '待支付', ''),
new ParkingRecordModel(13, '苏A12345', 'C区', 'C003', '2024-07-20 09:00', '2024-07-20 11:30', '2.5小时', 15, '已支付', '微信支付'),
new ParkingRecordModel(14, '苏A67890', 'A区', 'A006', '2024-07-22 14:00', '2024-07-22 20:00', '6小时', 36, '已支付', '支付宝'),
new ParkingRecordModel(15, '粤B88888', 'B区', 'B006', '2024-08-01 16:00', '2024-08-01 19:00', '3小时', 18, '已支付', '微信支付'),
new ParkingRecordModel(16, '京B99999', 'A区', 'A004', '2024-07-30 07:00', '2024-07-30 10:00', '3小时', 18, '已支付', '银联卡'),
new ParkingRecordModel(17, '沪C12345', 'C区', 'C005', '2024-08-06 09:00', '', '进行中', 0, '待支付', ''),
new ParkingRecordModel(18, '粤B00001', 'B区', 'B007', '2024-07-18 08:00', '2024-07-18 17:00', '9小时', 0, '免费', ''),
new ParkingRecordModel(19, '粤B00002', 'C区', 'C002', '2024-07-15 12:00', '2024-07-15 14:00', '2小时', 12, '已支付', '微信支付'),
new ParkingRecordModel(20, '粤B00003', 'B区', 'B002', '2024-08-05 08:00', '', '进行中', 0, '待支付', '')
];
const MOCK_VEHICLES: VehicleModel[] = [
new VehicleModel(1, '粤B12345', '特斯拉', '白色', '新能源', true),
new VehicleModel(2, '粤B67890', '奔驰', '黑色', '轿车', false),
new VehicleModel(3, '京A88888', '奥迪', '银灰色', 'SUV', false),
new VehicleModel(4, '沪B66666', '宝马', '蓝色', '轿车', false),
new VehicleModel(5, '粤B99999', '比亚迪', '红色', '新能源', false),
new VehicleModel(6, '粤B22222', '大众', '白色', '轿车', false),
new VehicleModel(7, '苏A12345', '本田', '黑色', 'SUV', false),
new VehicleModel(8, '粤B00001', '丰田', '银灰色', 'SUV', false),
new VehicleModel(9, '粤B77777', '小鹏', '灰色', '新能源', false),
new VehicleModel(10, '粤B66666', '蔚来', '绿色', '新能源', false)
];
const MOCK_FIND_RECORDS: FindCarRecordModel[] = [
new FindCarRecordModel(1, '粤B12345', 'A区', 'A002', '2024-08-06 18:30', ''),
new FindCarRecordModel(2, '京A88888', 'A区', 'A005', '2024-08-06 17:15', ''),
new FindCarRecordModel(3, '粤B67890', 'B区', 'B003', '2024-08-05 14:20', ''),
new FindCarRecordModel(4, '沪B66666', 'C区', 'C001', '2024-08-05 11:00', ''),
new FindCarRecordModel(5, '粤B99999', 'B区', 'B005', '2024-08-04 19:45', ''),
new FindCarRecordModel(6, '粤B22222', 'A区', 'A003', '2024-08-04 09:30', ''),
new FindCarRecordModel(7, '粤B55555', 'C区', 'C004', '2024-08-03 16:00', ''),
new FindCarRecordModel(8, '苏A12345', 'B区', 'B001', '2024-08-03 08:15', ''),
new FindCarRecordModel(9, '粤B88888', 'A区', 'A008', '2024-08-02 20:30', ''),
new FindCarRecordModel(10, '粤B44444', 'C区', 'C007', '2024-08-02 12:00', '')
];
// ==================== 6. 统计函数 ====================
function getSpotStatusStats(spots: ParkingSpotModel[]): Record<string, number> {
let freeCount: number = 0;
let occupiedCount: number = 0;
let reservedCount: number = 0;
let maintenanceCount: number = 0;
for (let i = 0; i < spots.length; i++) {
if (spots[i].status === '空闲') {
freeCount++;
} else if (spots[i].status === '占用') {
occupiedCount++;
} else if (spots[i].status === '预留') {
reservedCount++;
} else if (spots[i].status === '维修') {
maintenanceCount++;
}
}
let result: Record<string, number> = {};
result['free'] = freeCount;
result['occupied'] = occupiedCount;
result['reserved'] = reservedCount;
result['maintenance'] = maintenanceCount;
return result;
}
function getZoneStats(spots: ParkingSpotModel[]): Record<string, number> {
let zoneACount: number = 0;
let zoneBCount: number = 0;
let zoneCCount: number = 0;
for (let i = 0; i < spots.length; i++) {
if (spots[i].zone === 'A区') {
zoneACount++;
} else if (spots[i].zone === 'B区') {
zoneBCount++;
} else if (spots[i].zone === 'C区') {
zoneCCount++;
}
}
let result: Record<string, number> = {};
result['A区'] = zoneACount;
result['B区'] = zoneBCount;
result['C区'] = zoneCCount;
return result;
}
function getTotalFee(records: ParkingRecordModel[]): number {
let total: number = 0;
for (let i = 0; i < records.length; i++) {
total += records[i].fee;
}
return total;
}
function getPaidFee(records: ParkingRecordModel[]): number {
let total: number = 0;
for (let i = 0; i < records.length; i++) {
if (records[i].status === '已支付') {
total += records[i].fee;
}
}
return total;
}
function getPendingFee(records: ParkingRecordModel[]): number {
let total: number = 0;
for (let i = 0; i < records.length; i++) {
if (records[i].status === '待支付') {
total += records[i].fee;
}
}
return total;
}
function getSpotStatusColor(status: string): string {
if (status === '空闲') {
return COLORS.accentFree;
} else if (status === '占用') {
return COLORS.accentOccupied;
} else if (status === '预留') {
return COLORS.accentReserved;
} else {
return COLORS.accentMaintenance;
}
}
// ==================== 7. @Entry 入口页面 ====================
@Entry
@Component
struct SmartParkingApp {
@State currentTab: TabType = TabType.PARKING;
build() {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
if (this.currentTab === TabType.PARKING) {
ParkingContent()
} else if (this.currentTab === TabType.FIND_CAR) {
FindCarContent()
} else if (this.currentTab === TabType.PAYMENT) {
PaymentContent()
} else if (this.currentTab === TabType.HISTORY) {
HistoryContent()
} else if (this.currentTab === TabType.PROFILE) {
ProfileContent()
}
}
.layoutWeight(1)
Row() {
Column() {
Text('\uD83C\uDD7F\uFE0F')
.fontSize(22)
Text('停车')
.fontSize(10)
.fontColor(this.currentTab === TabType.PARKING ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.PARKING;
})
Column() {
Text('\uD83D\uDD0D')
.fontSize(22)
Text('寻车')
.fontSize(10)
.fontColor(this.currentTab === TabType.FIND_CAR ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.FIND_CAR;
})
Column() {
Text('\uD83D\uDCB3')
.fontSize(22)
Text('缴费')
.fontSize(10)
.fontColor(this.currentTab === TabType.PAYMENT ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.PAYMENT;
})
Column() {
Text('\uD83D\uDCCB')
.fontSize(22)
Text('记录')
.fontSize(10)
.fontColor(this.currentTab === TabType.HISTORY ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.HISTORY;
})
Column() {
Text('\uD83D\uDC64')
.fontSize(22)
Text('我的')
.fontSize(10)
.fontColor(this.currentTab === TabType.PROFILE ? COLORS.primary : COLORS.textSecondary)
}
.layoutWeight(1)
.onClick(() => {
this.currentTab = TabType.PROFILE;
})
}
.width('100%')
.height(58)
.padding({ top: 6, bottom: 6 })
.backgroundColor(COLORS.card)
.borderWidth({ top: 1 })
.borderColor(COLORS.divider)
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.background)
}
}
// ==================== 8. 停车Tab ====================
@Component
struct ParkingContent {
@State selectedZone: string = '全部';
@State spots: ParkingSpotModel[] = MOCK_SPOTS;
@State showDetailModal: boolean = false;
@State detailSpot: ParkingSpotModel = MOCK_SPOTS[0];
private zoneOptions: string[] = ['全部', 'A区', 'B区', 'C区'];
@Builder
modalOverlay(onClose: () => void) {
Column() {
Column() {
Text('暂停中...')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Center)
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
onClose();
})
}
@Builder SpotDetailModal() {
Column() {
Column() {
Text('车位详情')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Row() {
Text('车位号:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.number)
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('区域:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.zone)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('类型:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.type)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('状态:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.status)
.fontSize(14)
.fontColor(getSpotStatusColor(this.detailSpot.status))
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('费率:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(`${this.detailSpot.price}元/小时`)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
if (this.detailSpot.isCharging) {
Row() {
Text('\u26A1充电桩')
.fontSize(12)
.fontColor(COLORS.accentFree)
.backgroundColor(COLORS.background)
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
.margin({ bottom: 10 })
}
if (this.detailSpot.status === '占用') {
Row() {
Text('已停时长:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(`${this.detailSpot.duration}分钟`)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
}
Button('关闭')
.width('100%')
.height(40)
.backgroundColor(COLORS.primary)
.fontColor(COLORS.white)
.borderRadius(8)
.onClick(() => {
this.showDetailModal = false;
})
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showDetailModal = false;
})
}
build() {
Column() {
Column() {
Row() {
Text('智慧停车')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Scroll() {
Column() {
Text('选择停车场')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Row() {
ForEach(this.zoneOptions, (zone: string, index: number) => {
Text(zone)
.fontSize(13)
.fontColor(this.selectedZone === zone ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.selectedZone === zone ? COLORS.primary : COLORS.card)
.borderRadius(16)
.borderWidth(1)
.borderColor(this.selectedZone === zone ? COLORS.primary : COLORS.divider)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.selectedZone = zone;
})
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
Row() {
Text('车位网格俯视图')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.spots.length}个车位`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
Column() {
ForEach(this.spots, (spot: ParkingSpotModel, index: number) => {
if (index % 3 === 0) {
Row() {
ForEach([0, 1, 2], (offset: number, idx: number) => {
Column() {
if (this.spots[index + offset] !== undefined) {
Text(this.spots[index + offset].number)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(this.spots[index + offset].status)
.fontSize(9)
.fontColor('rgba(255,255,255,0.85)')
Text(this.spots[index + offset].type)
.fontSize(8)
.fontColor('rgba(255,255,255,0.7)')
}
}
.width('30%')
.height(60)
.borderRadius(8)
.backgroundColor(this.spots[index + offset] !== undefined ? getSpotStatusColor(this.spots[index + offset].status) : COLORS.divider)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ left: 4, right: 4, bottom: 8 })
.onClick(() => {
if (this.spots[index + offset] !== undefined) {
this.detailSpot = this.spots[index + offset];
this.showDetailModal = true;
}
})
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
})
}
.width('100%')
.padding({ left: 12, right: 12 })
Column() {
Text('车位状态统计')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.padding({ top: 16, bottom: 8 })
Row() {
Text(`空闲 ${getSpotStatusStats(this.spots)['free']}个`)
.fontSize(11)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentFree)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text(`占用 ${getSpotStatusStats(this.spots)['occupied']}个`)
.fontSize(11)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
Text(`预留 ${getSpotStatusStats(this.spots)['reserved']}个`)
.fontSize(11)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.accentReserved)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
Text(`维修 ${getSpotStatusStats(this.spots)['maintenance']}个`)
.fontSize(11)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentMaintenance)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 2 })
Row() {
Text('车位使用率柱状图')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 16, bottom: 8 })
Row() {
Column() {
Column()
.width(30)
.height(12)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(2)
Column()
.width(30)
.height(12)
.backgroundColor(COLORS.accentFree)
.borderRadius(2)
Text('A区')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Column()
.width(30)
.height(12)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(2)
Column()
.width(30)
.height(12)
.backgroundColor(COLORS.accentFree)
.borderRadius(2)
Text('B区')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Column()
.width(30)
.height(12)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(2)
Column()
.width(30)
.height(12)
.backgroundColor(COLORS.accentFree)
.borderRadius(2)
Text('C区')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.padding({ top: 4, bottom: 12 })
}
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ left: 16, right: 16, top: 12, bottom: 16 })
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
if (this.showDetailModal) {
Column() {
Column() {
Text('车位详情')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Row() {
Text('车位号:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.number)
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('区域:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.zone)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('类型:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.type)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('状态:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(this.detailSpot.status)
.fontSize(14)
.fontColor(getSpotStatusColor(this.detailSpot.status))
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('费率:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(`${this.detailSpot.price}元/小时`)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
if (this.detailSpot.isCharging) {
Row() {
Text('\u26A1充电桩')
.fontSize(12)
.fontColor(COLORS.accentFree)
.backgroundColor(COLORS.background)
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
.margin({ bottom: 10 })
}
if (this.detailSpot.status === '占用') {
Row() {
Text('已停时长:')
.fontSize(14)
.fontColor(COLORS.textSecondary)
Text(`${this.detailSpot.duration}分钟`)
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
}
Button('关闭')
.width('100%')
.height(40)
.backgroundColor(COLORS.primary)
.fontColor(COLORS.white)
.borderRadius(8)
.onClick(() => {
this.showDetailModal = false;
})
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showDetailModal = false;
})
}
}
}
}
// ==================== 9. 寻车Tab ====================
@Component
struct FindCarContent {
@State searchKeyword: string = '';
@State findRecords: FindCarRecordModel[] = MOCK_FIND_RECORDS;
build() {
Column() {
Row() {
Text('寻车记录')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Row() {
Text('搜索车牌号')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(80)
TextInput({ placeholder: '请输入车牌号', text: this.searchKeyword })
.fontSize(14)
.layoutWeight(1)
.backgroundColor(COLORS.white)
.borderRadius(8)
.padding(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.onChange((value: string) => {
this.searchKeyword = value;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Column() {
ForEach(this.findRecords, (record: FindCarRecordModel, index: number) => {
Row() {
Column() {
Text(record.plateNumber)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text(`${record.zone} ${record.spotNumber}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
}
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text(record.recordTime)
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text('查看位置')
.fontSize(12)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 10 })
.borderWidth(1)
.borderColor(COLORS.divider)
})
}
.width('100%')
.padding({ top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
}
}
// ==================== 10. 缴费Tab ====================
@Component
struct PaymentContent {
@State paymentProgress: number = 0;
@State selectedBillId: number = -1;
@State unpaidRecords: ParkingRecordModel[] = [];
aboutToAppear(): void {
let unpaid: ParkingRecordModel[] = [];
for (let i = 0; i < MOCK_RECORDS.length; i++) {
if (MOCK_RECORDS[i].status === '待支付') {
unpaid.push(MOCK_RECORDS[i]);
}
}
this.unpaidRecords = unpaid;
}
build() {
Column() {
Row() {
Text('停车缴费')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Column() {
Row() {
Text('缴费进度')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${this.paymentProgress}%`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 6 })
Row() {
Column()
.width(`${this.paymentProgress}%`)
.height(8)
.backgroundColor(COLORS.accentFree)
.borderRadius(4)
Column()
.layoutWeight(1)
.height(8)
.backgroundColor(COLORS.divider)
.borderRadius(4)
}
.width('100%')
.backgroundColor(COLORS.divider)
.borderRadius(4)
.height(8)
Button('模拟缴费进度')
.fontSize(12)
.height(30)
.backgroundColor(COLORS.primary)
.fontColor(COLORS.white)
.borderRadius(6)
.margin({ top: 10 })
.onClick(() => {
let newProgress: number = this.paymentProgress + 15;
if (newProgress > 100) {
newProgress = 100;
}
this.paymentProgress = newProgress;
if (this.paymentProgress === 100) {
let updated: ParkingRecordModel[] = [];
for (let i = 0; i < this.unpaidRecords.length; i++) {
let record: ParkingRecordModel = this.unpaidRecords[i];
let newRecord: ParkingRecordModel = new ParkingRecordModel(record.id, record.plateNumber, record.zone, record.spotNumber, record.enterTime, '2024-08-07 10:00', '2小时', 12, '已支付', '微信支付');
updated.push(newRecord);
}
this.unpaidRecords = updated;
}
})
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ left: 16, right: 16, top: 12 })
Row() {
Text('待缴账单')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.unpaidRecords.length}笔`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
Scroll() {
Column() {
ForEach(this.unpaidRecords, (record: ParkingRecordModel, index: number) => {
Row() {
Column() {
Text(record.plateNumber)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${record.zone} ${record.spotNumber}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(`¥${record.fee}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
Text(record.status)
.fontSize(10)
.fontColor(record.status === '待支付' ? COLORS.accentOccupied : COLORS.accentFree)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Button(this.selectedBillId === record.id ? '✓ 已选' : '支付')
.fontSize(12)
.height(32)
.backgroundColor(this.selectedBillId === record.id ? COLORS.accentFree : COLORS.primary)
.fontColor(COLORS.white)
.borderRadius(6)
.margin({ left: 10 })
.onClick(() => {
this.selectedBillId = record.id;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 8 })
.borderWidth(1)
.borderColor(this.selectedBillId === record.id ? COLORS.primary : COLORS.divider)
})
}
.width('100%')
.padding({ top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
}
}
// ==================== 11. 记录Tab ====================
@Component
struct HistoryContent {
@State records: ParkingRecordModel[] = MOCK_RECORDS;
@State filterStatus: string = '全部';
build() {
Column() {
Row() {
Text('停车记录')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Scroll() {
Row() {
Text('全部')
.fontSize(12)
.fontColor(this.filterStatus === '全部' ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.filterStatus === '全部' ? COLORS.primary : COLORS.card)
.borderRadius(14)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.margin({ right: 8 })
.onClick(() => {
this.filterStatus = '全部';
})
Text('已支付')
.fontSize(12)
.fontColor(this.filterStatus === '已支付' ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.filterStatus === '已支付' ? COLORS.primary : COLORS.card)
.borderRadius(14)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.margin({ right: 8 })
.onClick(() => {
this.filterStatus = '已支付';
})
Text('待支付')
.fontSize(12)
.fontColor(this.filterStatus === '待支付' ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.filterStatus === '待支付' ? COLORS.primary : COLORS.card)
.borderRadius(14)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.margin({ right: 8 })
.onClick(() => {
this.filterStatus = '待支付';
})
Text('免费')
.fontSize(12)
.fontColor(this.filterStatus === '免费' ? COLORS.white : COLORS.textPrimary)
.backgroundColor(this.filterStatus === '免费' ? COLORS.primary : COLORS.card)
.borderRadius(14)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.onClick(() => {
this.filterStatus = '免费';
})
}
.padding({ left: 16, right: 16, top: 10, bottom: 6 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
Scroll() {
Column() {
ForEach(this.records, (record: ParkingRecordModel, index: number) => {
if (this.filterStatus === '全部' || record.status === this.filterStatus) {
Column() {
Row() {
Text(record.plateNumber)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(record.status)
.fontSize(12)
.fontColor(record.status === '已支付' ? COLORS.accentFree : record.status === '待支付' ? COLORS.accentOccupied : COLORS.accentReserved)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ bottom: 6 })
Row() {
Text(`${record.zone} ${record.spotNumber}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text(record.enterTime)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ left: 12 })
}
.width('100%')
.margin({ bottom: 4 })
Row() {
if (record.exitTime !== '') {
Text(`出场: ${record.exitTime}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
if (record.duration !== '进行中') {
Text(`时长: ${record.duration}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
}
}
.width('100%')
.margin({ bottom: 6 })
Row() {
Text(`¥${record.fee}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
.layoutWeight(1)
if (record.paymentMethod !== '') {
Text(record.paymentMethod)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 10 })
.borderWidth(1)
.borderColor(COLORS.divider)
}
})
}
.width('100%')
.padding({ top: 4, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
}
}
// ==================== 12. 我的Tab ====================
@Component
struct ProfileContent {
@State vehicles: VehicleModel[] = MOCK_VEHICLES;
@State showAddModal: boolean = false;
@State showEditModal: boolean = false;
@State showDeleteConfirm: boolean = false;
@State newPlateNumber: string = '';
@State newBrand: string = '';
@State newColor: string = '';
@State newType: string = '';
@State editVehicleId: number = -1;
@State editPlateNumber: string = '';
@State editBrand: string = '';
@State editColor: string = '';
@State editType: string = '';
@State deleteVehicleId: number = -1;
@State deletePlate: string = '';
private nextId: number = 11;
build() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Row() {
Text('我的车辆')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primary)
Column() {
Row() {
Text('费用统计')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Column() {
Text('累计费用')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`¥${getTotalFee(MOCK_RECORDS)}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('已支付')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`¥${getPaidFee(MOCK_RECORDS)}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentFree)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('待支付')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`¥${getPendingFee(MOCK_RECORDS)}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentReserved)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(12)
.margin({ left: 16, right: 16, top: 12 })
Row() {
Text('车辆列表')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
Scroll() {
Column() {
ForEach(this.vehicles, (vehicle: VehicleModel, index: number) => {
Row() {
Column() {
Row() {
Text(vehicle.plateNumber)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
if (vehicle.isDefault) {
Text('默认')
.fontSize(9)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(3)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ left: 6 })
}
}
Text(`${vehicle.brand} | ${vehicle.color} | ${vehicle.type}`)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
Row() {
Text('编辑')
.fontSize(11)
.fontColor(COLORS.primary)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderWidth(1)
.borderColor(COLORS.primary)
.borderRadius(4)
.margin({ right: 6 })
.onClick(() => {
this.editVehicleId = vehicle.id;
this.editPlateNumber = vehicle.plateNumber;
this.editBrand = vehicle.brand;
this.editColor = vehicle.color;
this.editType = vehicle.type;
this.showEditModal = true;
})
Text('删除')
.fontSize(11)
.fontColor(COLORS.accentOccupied)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderWidth(1)
.borderColor(COLORS.accentOccupied)
.borderRadius(4)
.onClick(() => {
this.deleteVehicleId = vehicle.id;
this.deletePlate = vehicle.plateNumber;
this.showDeleteConfirm = true;
})
}
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ left: 16, right: 16, bottom: 10 })
.borderWidth(1)
.borderColor(COLORS.divider)
})
}
.width('100%')
.padding({ top: 4, bottom: 80 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
Button('+ 新增车辆')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(25)
.height(44)
.padding({ left: 28, right: 28 })
.margin({ bottom: 20 })
.shadow({ radius: 8, color: 'rgba(26,35,126,0.3)', offsetY: 2 })
.onClick(() => {
this.newPlateNumber = '';
this.newBrand = '';
this.newColor = '';
this.newType = '';
this.showAddModal = true;
})
}
.width('100%')
.height('100%')
if (this.showAddModal) {
Column() {
Column() {
Text('新增车辆')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Column() {
Text('车牌号')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入车牌号', text: this.newPlateNumber })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.newPlateNumber = value;
})
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('品牌')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入品牌', text: this.newBrand })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.newBrand = value;
})
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('颜色')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入颜色', text: this.newColor })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.newColor = value;
})
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('车型')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入车型', text: this.newType })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.newType = value;
})
}
.width('100%')
.margin({ bottom: 16 })
Row() {
Button('取消')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.background)
.borderRadius(8)
.margin({ right: 8 })
.onClick(() => {
this.showAddModal = false;
})
Button('确认添加')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
let newVehicle: VehicleModel = new VehicleModel(this.nextId, this.newPlateNumber, this.newBrand, this.newColor, this.newType, false);
this.vehicles.push(newVehicle);
this.nextId = this.nextId + 1;
this.showAddModal = false;
})
}
.width('100%')
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showAddModal = false;
})
}
if (this.showEditModal) {
Column() {
Column() {
Text('编辑车辆')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Column() {
Text('车牌号')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入车牌号', text: this.editPlateNumber })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.editPlateNumber = value;
})
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('品牌')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入品牌', text: this.editBrand })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.editBrand = value;
})
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('颜色')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入颜色', text: this.editColor })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.editColor = value;
})
}
.width('100%')
.margin({ bottom: 12 })
Column() {
Text('车型')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
.margin({ bottom: 4 })
TextInput({ placeholder: '请输入车型', text: this.editType })
.fontSize(14)
.width('100%')
.borderRadius(8)
.borderWidth(1)
.borderColor(COLORS.divider)
.padding(10)
.onChange((value: string) => {
this.editType = value;
})
}
.width('100%')
.margin({ bottom: 16 })
Row() {
Button('取消')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.background)
.borderRadius(8)
.margin({ right: 8 })
.onClick(() => {
this.showEditModal = false;
})
Button('保存修改')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.white)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
for (let i = 0; i < this.vehicles.length; i++) {
if (this.vehicles[i].id === this.editVehicleId) {
this.vehicles[i].plateNumber = this.editPlateNumber;
this.vehicles[i].brand = this.editBrand;
this.vehicles[i].color = this.editColor;
this.vehicles[i].type = this.editType;
break;
}
}
this.showEditModal = false;
})
}
.width('100%')
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showEditModal = false;
})
}
if (this.showDeleteConfirm) {
Column() {
Column() {
Text('确认删除')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentOccupied)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 12 })
Text(`确定要删除车辆 ${this.deletePlate} 吗?`)
.fontSize(14)
.fontColor(COLORS.textSecondary)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 20 })
Row() {
Button('取消')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.background)
.borderRadius(8)
.margin({ right: 8 })
.onClick(() => {
this.showDeleteConfirm = false;
})
Button('确认删除')
.layoutWeight(1)
.height(40)
.fontColor(COLORS.white)
.backgroundColor(COLORS.accentOccupied)
.borderRadius(8)
.onClick(() => {
let newVehicles: VehicleModel[] = [];
for (let i = 0; i < this.vehicles.length; i++) {
if (this.vehicles[i].id !== this.deleteVehicleId) {
newVehicles.push(this.vehicles[i]);
}
}
this.vehicles = newVehicles;
this.showDeleteConfirm = false;
})
}
.width('100%')
}
.width('90%')
.padding(20)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(() => {
this.showDeleteConfirm = false;
})
}
}
.width('100%')
.height('100%')
}
}
十六、总结
通过对这份智慧停车应用代码的逐段深入剖析,我们可以清晰地看到,一个功能完整的移动端应用是如何从数据定义到 UI 呈现一步步构建起来的。这份代码虽然以 Mock 数据驱动,但其架构设计和编码规范已经具备了工程化项目的雏形,有许多值得学习和借鉴之处。

从架构层面看,应用采用了经典的分层设计:接口层定义数据契约,常量层统一视觉规范,枚举层管理离散状态,模型层提供可观察数据,Mock 层模拟业务数据,函数层封装纯逻辑,组件层构建交互界面。七层各司其职,依赖关系清晰单向,使得每一层都可以独立理解、独立测试、独立替换。特别是 Mock 数据层与工具函数层的分离,为未来接入真实后端接口留出了极为便利的替换路径——只需将 Mock 常量替换为异步请求结果,将工具函数的数据源参数从本地数组改为接口返回数据,UI 组件层几乎不需要任何改动。
从状态管理层面看,应用充分利用了 ArkUI 框架的响应式机制。@Observed 装饰器让数据模型具备可观察能力,@State 装饰器让组件拥有局部状态,两者配合实现了"数据变化驱动视图更新"的自动化流程。每个组件的状态变量设计都紧扣其交互需求——停车页用四个状态支撑区域筛选和详情弹窗,缴费页用三个状态支撑进度模拟和账单选中,我的页用十四个状态支撑三种弹窗的表单交互。状态变量的命名清晰表意,初始值设置合理,更新时机恰当,体现了对响应式编程范式的熟练掌握。
从 UI 设计层面看,应用遵循了移动端设计的多项最佳实践。统一的调色板确保了视觉风格的一致性;"标题栏+可滚动内容区+底部导航栏"的经典布局提供了熟悉的使用体验;卡片式设计让信息分组清晰、层次分明;状态颜色映射让用户通过颜色直觉就能识别车位状态;条件渲染让弹窗和列表项能够根据数据动态展示内容,避免冗余信息。车位网格俯视图的嵌套 ForEach 实现、缴费进度条的双 Column 拼接、列表筛选的 ForEach 内 if 判断等技术手法,都展现了开发者在 ArkUI 声明式 UI 编程上的巧思。
从交互体验层面看,应用覆盖了车主在停车场景下的完整操作闭环。停车模块让用户在到达前就能了解车位分布;寻车模块让用户在离场时能快速找到爱车;缴费模块让用户能集中处理待支付账单并直观看到缴费进度;记录模块让用户能按状态筛选回溯历史停车;我的模块让用户能管理名下车辆并查看费用统计。五个模块通过底部导航栏串联,切换流畅、功能互补,构成了一个完整的智慧停车服务体验。
当然,这份代码作为一个演示性项目,也存在一些可以进一步优化的空间。例如,寻车页的搜索功能尚未在列表渲染中实现实际过滤逻辑;停车页的区域筛选标签虽然切换了 selectedZone 状态,但车位网格并未根据选中区域做实际过滤展示;柱状图的高度是固定值而非根据数据比例动态计算;部分弹窗存在代码重复(如停车页中 SpotDetailModal Builder 与 build 方法中的详情弹窗代码几乎一致),可以进一步通过 Builder 调用来消除冗余。这些都是从演示项目走向生产项目时需要完善的细节。
更多推荐

所有评论(0)