导读: 本文将以一个完整的"深蓝停车"出行服务 App 为案例,逐段拆解其 HarmonyOS ArkTS 源码。该应用涵盖了接口定义、响应式数据模型、配置管理、Mock 数据、工具函数、多 Tab 页架构、弹窗交互等典型开发模式。无论你是刚入门 HarmonyOS 的开发者,还是希望深入了解 ArkTS 声明式 UI 最佳实践的工程师,本文都将提供详尽的分析与启发。

在这里插入图片描述


一、项目概览与技术架构

本项目是一个停车出行服务类 App,采用 HarmonyOS ArkTS 声明式 UI 范式开发。应用整体架构可概括为以下几个层次:

  1. 数据层(Data Layer):包括 TypeScript 接口定义(interface)、@Observed 响应式数据模型类、全局配置对象以及 Mock 数据数组,构成整个应用的数据基石。
  2. 工具层(Utility Layer):提供格式化、颜色计算、渐变构建等全局工具函数,为 UI 渲染提供辅助支持。
  3. 组件层(Component Layer):以 @Entry @Component 修饰的主入口组件为核心,下挂五个 @Component 子组件(对应五个 Tab 页),每个子组件内部通过 @Builder 装饰器拆分出多个 UI 构建函数,实现模块化渲染。
  4. 交互层(Interaction Layer):通过回调函数(callback)机制实现子组件与父组件之间的通信,弹窗(Dialog)采用条件渲染方式叠加在 Stack 容器中。

整个源码文件约 1042 行,代码组织清晰、分区明确,是学习 HarmonyOS ArkTS 声明式开发的一份优秀范例。


二、接口定义区:TypeScript 强类型约束

interface ParkingLotInfo {
  id: string; name: string; address: string; distance: number;
  availableSpaces: number; totalSpaces: number; pricePerHour: number;
  maxPrice: number; type: string; rating: number; isOpen: boolean;
  latitude: number; longitude: number; features: string[];
}

在这里插入图片描述

代码深度解析:

ParkingLotInfo 是整个应用最核心的数据接口,它描述了一个停车场实体的完整信息模型。让我们逐字段分析其设计考量:

  • id: string:唯一标识符,采用字符串类型而非数字类型,这在实际项目中非常常见。字符串 ID 可以更好地支持分布式场景下的 ID 生成策略(如 UUID、雪花算法等),同时也便于与后端 RESTful API 的资源路径进行映射。
  • name / address:停车场的名称与地址,使用 string 类型。地址字段的文本长度通常不确定,在 UI 渲染时需要配合 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 来处理文本溢出,这在后续的 UI 组件中有充分的体现。
  • distance: number:表示停车场与当前位置之间的距离,单位为公里(km)。这个数值将传入工具函数 formatDistance() 进行格式化展示——当距离小于 1km 时转换为"米"的显示方式,增强用户体验。
  • availableSpaces / totalSpaces:分别表示可用车位数与总车位数。两个数值配合可以计算出"占用率"(occupancy rate),这是停车场应用中最核心的指标之一。UI 层会通过占用率的数值区间来动态设置进度条颜色(红色表示紧张、橙色表示不足、蓝色表示充裕)。
  • pricePerHour / maxPrice:描述了停车场的计费模式——每小时单价和每日封顶价格。这个双价格模型非常贴近真实业务场景,用户可以一目了然地评估停车成本。
  • type: string:停车场类型(室内、地下、露天、机械)。虽然用字符串存储,但在 UI 渲染时会通过 getTypeColor() 函数映射为不同的颜色标签,实现视觉上的快速区分。
  • rating: number:用户评分,通常为 1.0 到 5.0 之间的浮点数。UI 中以"★"符号配合数值展示,遵循了移动端评分展示的常见设计范式。
  • isOpen: boolean:营业状态标识。在 UI 中会根据此字段渲染"营业中"(绿色)或"已关闭"(灰色)的状态标签,同时影响空位数显示的颜色逻辑。
  • latitude / longitude: number:经纬度坐标,为后续地图导航功能预留的数据接口。虽然在当前 UI 中未直接展示地图组件,但这一字段的存在体现了架构设计的前瞻性。
  • features: string[]:特色服务标签数组(充电桩、24小时、监控、洗车等)。在 UI 中以小标签的形式水平排列展示,让用户快速了解停车场的服务能力。

整体来看,ParkingLotInfo 接口的设计遵循了"面向展示"的数据建模思路——字段粒度与 UI 展示需求高度对齐,避免了过度抽象或不足抽象的问题。

interface VehicleInfo {
  id: string; plateNumber: string; brand: string; model: string; color: string;
  year: number; insuranceExpiry: string; inspectionExpiry: string;
  violationCount: number; isDefault: boolean;
}

在这里插入图片描述

代码深度解析:

VehicleInfo 接口描述了用户的车辆信息模型,包含了车辆的基本属性(品牌、型号、颜色、年份)和业务属性(保险到期日、年检到期日、违章记录数)。

特别值得注意的是 isDefault: boolean 字段的设计。在多车辆场景下,用户通常有一辆"默认车辆"——即最常用的那辆车。这个字段在 UI 层的体现是:默认车辆的车牌号旁会显示一个橙色的"默认"标签。这种设计模式在"账户管理"类应用中非常普遍(如默认收货地址、默认支付方式等),本质上是"多选一"的偏好设置。

insuranceExpiry / inspectionExpiry 两个字段采用了字符串格式的日期(如 '2027-06-15'),而非 Date 对象或时间戳。这种设计在展示型场景中非常实用——可以直接在 UI 上显示,同时由于 ISO 8601 格式的日期字符串天然支持字典序比较('2027-06-15' > '2026-07-24' 为 true),所以可以直接用于过期判断而无需额外的日期解析操作。这在后续的 VehicleModel 类中有明确的体现。

interface ParkingRecordInfo {
  id: string; lotName: string; lotAddress: string; plateNumber: string;
  entryTime: string; exitTime: string; duration: number; fee: number;
  paymentMethod: string; status: string;
}

在这里插入图片描述

代码深度解析:

ParkingRecordInfo 接口描述了停车记录的数据模型。一条停车记录关联了"哪个停车场"、“哪辆车”、“什么时间进/出”、“停了多久”、“花了多少钱”、“怎么付的"以及"当前状态”。

duration: number 字段的单位是分钟(而非小时),这是一个值得注意的设计决策。选择分钟作为基本单位的好处是:可以避免浮点数精度问题,同时在 UI 展示时可以灵活地格式化为"X小时Y分钟"的形式。例如 195 分钟可展示为"3小时15分钟",而不是 3.25 小时。

status: string 字段目前有两个取值:'已完成' 和 '停车中'。当状态为"停车中"时,exitTime 为空字符串、duration 为 0、fee 为 0、paymentMethod 为空字符串——这种"空值表示未完成"的设计简洁直观,UI 层通过条件判断来分别处理两种状态的展示逻辑。

interface FavoriteLotInfo {
  id: string; lotId: string; name: string; address: string; distance: number;
  pricePerHour: number; availableSpaces: number; addedDate: string;
}

在这里插入图片描述

代码深度解析:

FavoriteLotInfo 是用户收藏停车场的轻量级数据模型。与完整的 ParkingLotInfo 相比,它省略了评分、经纬度、特色标签等"详情页才需要"的信息,只保留了列表页展示所需的核心字段(名称、地址、距离、价格、空位数)。

这种"按需裁剪"的数据模型设计体现了 DTO(Data Transfer Object)模式 的思想:不同页面/场景使用不同粒度的数据模型,既节省了内存占用,又避免了不必要的数据传输。lotId 字段 作为与原始停车场数据的关联键,为后续"查看详情"功能提供了数据跳转的锚点。

interface MonthlyStatInfo {
  month: string; totalFee: number; totalCount: number; avgDuration: number;
}
interface ParkingTypeInfo {
  type: string; count: number; percentage: number; color: string;
}
interface DurationStatInfo { range: string; count: number; percentage: number; }
interface SettingItemInfo {
  id: string; title: string; subtitle: string; icon: string; value: string;
}
interface UserProfileInfo {
  name: string; phone: string; avatar: string; memberLevel: string;
  totalParking: number; totalSpent: number; points: number;
}
interface TabItemInfo { name: string; title: string; icon: string; }

在这里插入图片描述

代码深度解析:

剩余的六个接口分别服务于不同的业务场景:

  • MonthlyStatInfo:月度统计信息,为"记录"Tab 页的费用趋势图提供数据支撑。avgDuration 字段以分钟为单位存储平均停车时长,方便 UI 层进行格式化转换。
  • ParkingTypeInfo:停车场类型偏好统计,带有 color 字段用于直接绑定到 UI 进度条的颜色,体现了"数据驱动颜色"的设计思路——统计数据的展示颜色由数据本身决定,而非硬编码在 UI 中。
  • DurationStatInfo:停车时长分布统计,结构与 ParkingTypeInfo 类似但缺少 color 字段(因为所有时长区间共用同一颜色主题),这体现了"最小必要字段"原则。
  • SettingItemInfo:设置项数据模型,采用通用的五字段结构(id、标题、副标题、图标、当前值),可以统一描述不同类型的设置项。value 字段为空字符串时表示该设置项无需显示当前值(如"发票管理"、“关于我们”),这是一种优雅的可选值处理方式。
  • UserProfileInfo:用户档案信息,聚合了个人基本信息和累计统计数据。memberLevel 字段用于展示会员等级标签(如"蓝钻会员"),points 字段与 totalSpent 保持一致(消费1元积1分),体现了积分体系的业务规则。
  • TabItemInfo:底部 Tab 栏的配置接口,name 字段作为内部路由标识,title 用于 UI 展示,icon 使用 emoji 字符实现轻量级图标方案。这种将配置数据与 UI 组件分离的做法,使得 Tab 的增减只需修改配置对象,而无需改动 UI 代码。

三、@Observed 数据模型类:响应式状态的核心

@Observed
class ParkingModel {
  id: string; name: string; address: string; distance: number;
  availableSpaces: number; totalSpaces: number; pricePerHour: number;
  maxPrice: number; type: string; rating: number; isOpen: boolean;
  latitude: number; longitude: number; features: string[];
  constructor(info: ParkingLotInfo) {
    this.id = info.id; this.name = info.name; this.address = info.address;
    this.distance = info.distance; this.availableSpaces = info.availableSpaces;
    this.totalSpaces = info.totalSpaces; this.pricePerHour = info.pricePerHour;
    this.maxPrice = info.maxPrice; this.type = info.type; this.rating = info.rating;
    this.isOpen = info.isOpen; this.latitude = info.latitude; this.longitude = info.longitude;
    this.features = info.features;
  }
  getOccupancyRate(): number {
    if (this.totalSpaces === 0) { return 0; }
    return Math.round((1 - this.availableSpaces / this.totalSpaces) * 100);
  }
  getAvailabilityColor(): string {
    if (this.availableSpaces === 0) { return '#E53935'; }
    else if (this.availableSpaces < 20) { return '#FB8C00'; }
    else { return '#43A047'; }
  }
}

在这里插入图片描述

代码深度解析:

ParkingModel 类是 ArkTS 响应式状态管理的关键组成部分。@Observed 装饰器 是 HarmonyOS ArkUI 框架提供的核心能力之一,它使得类实例的属性变化可以被框架自动追踪,当属性值发生变化时,依赖这些属性的 UI 组件会自动重新渲染。

为什么需要 @Observed 类而不仅仅是 @State 变量? 答案在于对象属性级别的细粒度响应。@State 只能追踪基本类型或对象引用的变化——如果对象的某个深层属性发生变化(如 availableSpaces 从 156 变为 155),@State 是无法感知到的。而 @Observed 类则可以追踪到属性级别,配合 @ObjectLink 装饰器使用时,可以实现深层属性变化的自动 UI 更新。

构造函数的设计 接收一个 ParkingLotInfo 接口对象,逐字段赋值。这种"接口 -> 类"的转换模式在 ArkTS 开发中非常常见——接口用于定义数据契约(特别是从网络获取的数据),而 @Observed 类则提供响应式能力和业务计算方法。

getOccupancyRate() 方法 计算停车场的占用率百分比。关键细节在于 totalSpaces === 0 的边界保护——避免除零错误,返回 0%。计算逻辑为 (1 - 可用/总数) * 100,即"已占用比例"。使用 Math.round() 四舍五入取整,符合百分比展示的惯例。

getAvailabilityColor() 方法 根据可用车位数返回颜色值,采用三级阈值判断:

  • 0 个空位:红色 #E53935(表示"已满")
  • 1-19 个空位:橙色 #FB8C00(表示"紧张")
  • 20 个以上:绿色 #43A047(表示"充裕")

这三个颜色值对应了 Material Design 色板中的 Red 600、Orange 600 和 Green 600,体现了对 Material Design 色彩规范的遵循。阈值 20 的选择是一个业务经验值——在大型停车场(数百个车位)中,20 个以下的空位确实意味着"即将满位",需要引起用户注意。

@Observed
class VehicleModel {
  id: string; plateNumber: string; brand: string; model: string; color: string;
  year: number; insuranceExpiry: string; inspectionExpiry: string;
  violationCount: number; isDefault: boolean;
  constructor(info: VehicleInfo) {
    this.id = info.id; this.plateNumber = info.plateNumber; this.brand = info.brand;
    this.model = info.model; this.color = info.color; this.year = info.year;
    this.insuranceExpiry = info.insuranceExpiry; this.inspectionExpiry = info.inspectionExpiry;
    this.violationCount = info.violationCount; this.isDefault = info.isDefault;
  }
  isInsuranceValid(): boolean { return this.insuranceExpiry > '2026-07-24'; }
  isInspectionValid(): boolean { return this.inspectionExpiry > '2026-07-24'; }
}

代码深度解析:

VehicleModel 类同样使用 @Observed 装饰器,提供了两个业务验证方法:

isInsuranceValid() 和 isInspectionValid() 方法通过字符串比较来判断保险和年检是否在有效期内。正如前文所述,由于日期采用 ISO 8601 格式(YYYY-MM-DD),字符串的字典序比较等价于日期的先后比较。当前硬编码的基准日期 '2026-07-24' 是"今天"的模拟值;在实际项目中,应替换为动态获取的当前日期(如 new Date().toISOString().split('T')[0])。

这两个方法的设计体现了 “业务规则内聚到模型” 的面向对象设计原则——"保险是否有效"是车辆自身的属性,而非 UI 层的逻辑。这样做的好处是:如果判断规则发生变化(比如需要考虑宽限期),只需修改模型类,而无需在多个 UI 组件中逐一修改。

@Observed
class RecordModel {
  id: string; lotName: string; lotAddress: string; plateNumber: string;
  entryTime: string; exitTime: string; duration: number; fee: number;
  paymentMethod: string; status: string;
  constructor(info: ParkingRecordInfo) {
    this.id = info.id; this.lotName = info.lotName; this.lotAddress = info.lotAddress;
    this.plateNumber = info.plateNumber; this.entryTime = info.entryTime;
    this.exitTime = info.exitTime; this.duration = info.duration; this.fee = info.fee;
    this.paymentMethod = this.paymentMethod; this.status = this.status;
  }
  getDurationText(): string {
    const h = Math.floor(this.duration / 60);
    const m = this.duration % 60;
    if (h > 0) { return `${h}小时${m}分钟`; }
    return `${m}分钟`;
  }
}

代码深度解析:

RecordModel 类的 getDurationText() 方法 展示了一种常见的数值格式化模式——将原始的"分钟数"转换为用户友好的中文时间文本。

  • 使用 Math.floor(this.duration / 60) 计算小时部分,确保整数除法向下取整。
  • 使用 this.duration % 60 计算剩余的分钟数。
  • 当小时为 0 时,只显示"X分钟"(避免显示"0小时15分钟"的冗余信息),这是一种细节上的用户体验优化。
  • 使用模板字符串(template literal)${h}小时${m}分钟 实现动态拼接。

这种方法将格式化逻辑封装在模型内部,而非散落在 UI 组件的构建方法中,遵循了 DRY(Don’t Repeat Yourself)原则。在应用的多个地方(如记录列表卡片、统计摘要等)如果都需要展示时长文本,直接调用该方法即可。


四、配置对象:数据驱动的 UI 配置化

const TabConfig: Record<string, TabItemInfo> = {
  'home': { name: 'home', title: '首页', icon: '🏠' },
  'parking': { name: 'parking', title: '停车场', icon: '🅿' },
  'record': { name: 'record', title: '记录', icon: '📋' },
  'vehicle': { name: 'vehicle', title: '车辆', icon: '🚗' },
  'mine': { name: 'mine', title: '我的', icon: '👤' }
};

代码深度解析:

TabConfig 使用 Record<string, TabItemInfo> 类型定义,即一个键为字符串、值为 TabItemInfo 接口的字典对象。这种配置方式有三个显著优势:

  1. 数据与 UI 分离:Tab 的所有可变信息(标题、图标)都集中在配置对象中,UI 组件只需遍历渲染即可。如果未来要修改某个 Tab 的标题或图标,只需修改配置对象,无需触碰 UI 代码。
  2. 支持遍历渲染:在底部 Tab 栏的 @Builder 中,通过 Object.values(TabConfig) 将字典转换为数组,再使用 ForEach 进行遍历渲染,避免了硬编码五个 Tab 项。
  3. 支持路由映射:name 字段同时作为内部路由标识,在 onClick 回调中通过 if-else 链映射到对应的 BottomTab 枚举值。虽然在当前实现中使用的是简单的字符串比较,但未来可以轻松替换为路由表映射。

值得注意的是,图标使用 emoji 字符 而非图片资源。这种选择在原型开发阶段非常高效——无需准备多套分辨率的图标资源,且 emoji 在 HarmonyOS 系统中渲染效果良好。当然,在生产环境中通常会替换为矢量图标或图片资源,以获得更精确的视觉控制。

const ParkingTypeConfig: Record<string, ParkingTypeInfo> = {
  'indoor': { type: '室内停车场', count: 8, percentage: 40, color: '#0D47A1' },
  'outdoor': { type: '露天停车场', count: 6, percentage: 30, color: '#42A5F5' },
  'underground': { type: '地下停车场', count: 4, percentage: 20, color: '#1976D2' },
  'mechanical': { type: '机械车库', count: 2, percentage: 10, color: '#90CAF9' }
};
const DurationStatConfig: Record<string, DurationStatInfo> = {
  'short': { range: '0-1小时', count: 35, percentage: 35 },
  'medium': { range: '1-3小时', count: 45, percentage: 45 },
  'long': { range: '3-6小时', count: 15, percentage: 15 },
  'overnight': { range: '6小时以上', count: 5, percentage: 5 }
};

代码深度解析:

ParkingTypeConfig 和 DurationStatConfig 是两组统计图表的配置数据。

ParkingTypeConfig 的每个条目都带有 color 字段,且四个颜色值 #0D47A1、#42A5F5、#1976D2、#90CAF9 恰好取自应用主题色板中深蓝到浅蓝的四个梯度。这种"同一色系、不同明度"的配色策略使得统计图表既有多样性又保持了视觉统一性。percentage 字段直接作为水平进度条的宽度百分比使用,形成了一种"数据即布局"的声明式设计——数据值的百分比直接映射到 UI 组件的宽度比例。

DurationStatConfig 的时长分段(短途 0-1h、中途 1-3h、长途 3-6h、过夜 6h+)覆盖了城市停车的典型场景。count 和 percentage 的配对提供了"绝对数量+相对占比"的双重信息维度,UI 中两列同时展示,满足不同用户的信息偏好。

const SettingConfig: Record<string, SettingItemInfo> = {
  'payment': { id: 'payment', title: '支付管理', subtitle: '管理支付方式', icon: '💳', value: '微信支付' },
  'notify': { id: 'notify', title: '消息通知', subtitle: '推送设置', icon: '🔔', value: '已开启' },
  'plate': { id: 'plate', title: '车牌识别', subtitle: '自动识别设置', icon: '📷', value: '自动' },
  'invoice': { id: 'invoice', title: '发票管理', subtitle: '电子发票', icon: '🧾', value: '' },
  'coupon': { id: 'coupon', title: '优惠券', subtitle: '可用券3张', icon: '🎫', value: '3张' },
  'help': { id: 'help', title: '帮助中心', subtitle: '常见问题', icon: '❓', value: '' },
  'about': { id: 'about', title: '关于我们', subtitle: '版本信息', icon: 'ℹ', value: 'v2.6.0' },
  'logout': { id: 'logout', title: '退出登录', subtitle: '', icon: '🚪', value: '' }
};

代码深度解析:

SettingConfig 是"我的"Tab 页设置列表的完整数据源,包含 8 个设置项。这 8 个设置项涵盖了停车 App 的典型功能入口:支付管理、通知设置、车牌识别、发票管理、优惠券、帮助中心、关于我们、退出登录。

特别值得关注的细节:

  • subtitle 字段作为辅助说明:每个设置项都有一个简短的副标题,向用户解释该设置的功能或显示当前状态摘要。例如"支付管理"的副标题是"管理支付方式",“优惠券"的副标题是"可用券3张”——后者将动态数据直接嵌入到副标题中,提高了信息密度。
  • value 字段的可选性设计:部分设置项有 value(如"微信支付"、“已开启”、“v2.6.0”),部分为空字符串。UI 中通过 if (item.value.length > 0) 条件判断来决定是否渲染右侧的值文本。这种"空字符串表示无值"的策略比使用 undefined 或 null 更简洁,避免了空值判断的额外开销。
  • id: 'logout' 的特殊处理:在设置行的 UI 构建器中,item.id === 'logout' 时背景色设为 #FFEBEE(浅红色),视觉上与其他白色背景的设置项区分开来。这是一种"危险操作视觉预警"的设计模式——退出登录是不可逆的操作,通过颜色提示降低误触风险。

五、Mock 数据数组:构建逼真的数据场景

const ParkingLotData: ParkingLotInfo[] = [
  { id: 'p001', name: '深蓝环球中心停车场', address: '海淀区中关村大街1号', distance: 0.3, availableSpaces: 156, totalSpaces: 500, pricePerHour: 8, maxPrice: 80, type: '室内停车场', rating: 4.8, isOpen: true, latitude: 39.9847, longitude: 116.3076, features: ['充电桩', '24小时', '监控'] },
  // ... 共 20 条数据
];

代码深度解析:

ParkingLotData 数组包含 20 条停车场数据,覆盖了北京市多个城区的真实地点(海淀区、朝阳区、西城区、东城区、大兴区、昌平区、房山区),数据设计非常用心:

  1. 距离分布合理:从 0.3km 到 6.0km,模拟了"由近及远"的真实搜索结果排序。
  2. 类型分布多样:包含室内停车场(8个)、地下停车场(6个)、露天停车场(3个)、机械车库(2个),还有未开放的停车场(2个 isOpen: false),以及已满的停车场(2个 availableSpaces: 0)。
  3. 价格梯度真实:从每小时 3 元到 12 元,封顶从 30 元到 120 元,符合北京市停车场的实际价格区间。
  4. 特色标签组合丰富:充电桩、24小时、监控、电梯、洗车等服务标签的各种组合,模拟了真实停车场的服务差异化。
  5. 边界情况覆盖:数据中刻意包含了"已关闭"(p004、p014)和"已满"(p004、p014)的极端情况,确保 UI 的条件渲染逻辑(状态标签颜色、空位数显示)能够被完整测试。
const VehicleData: VehicleInfo[] = [
  { id: 'v001', plateNumber: '京A·88888', brand: '比亚迪', model: '汉EV', color: '极光蓝', year: 2024, ... },
  // ... 共 5 条数据
];

const ParkingRecordData: ParkingRecordInfo[] = [
  { id: 'r001', lotName: '深蓝环球中心停车场', ... status: '已完成' },
  // ... 共 15 条数据,其中 r013 status 为 '停车中'
];

代码深度解析:

VehicleData 包含 5 辆车,品牌涵盖比亚迪、特斯拉、蔚来、小鹏、理想等主流新能源汽车品牌,车型包括汉EV、Model Y、ET5、G9、L9。颜色使用了具有辨识度的命名(极光蓝、珍珠白、镜空粉、星爵灰、星空蓝),体现了对汽车行业命名习惯的尊重。其中只有 v001 被设为默认车辆(isDefault: true)。

ParkingRecordData 包含 15 条记录,时间跨度覆盖 7 月 10 日至 24 日(约两周),停车时长从 90 分钟到 780 分钟(13小时,模拟过夜停车),费用从 9 元到 50 元不等。特别值得注意的是 r013 记录的状态为"停车中"——这是唯一一条"未完成"的记录,exitTime、duration、fee、paymentMethod 均为空/零值,用于测试 UI 中"停车中"状态的特殊展示逻辑(显示"计费中"而非具体费用)。


六、全局工具函数:UI 渲染的辅助引擎

function formatDistance(km: number): string {
  if (km < 1) { return `${Math.round(km * 1000)}m`; }
  return `${km.toFixed(1)}km`;
}
function getOccupancyColor(rate: number): string {
  if (rate >= 90) { return '#E53935'; }
  else if (rate >= 70) { return '#FB8C00'; }
  else { return '#1976D2'; }
}
function getTypeColor(type: string): string {
  if (type === '室内停车场') { return '#0D47A1'; }
  else if (type === '地下停车场') { return '#1976D2'; }
  else if (type === '露天停车场') { return '#42A5F5'; }
  else { return '#90CAF9'; }
}
function getAvailabilityText(available: number): string {
  if (available === 0) { return '已满'; }
  else if (available < 20) { return '紧张'; }
  else if (available < 100) { return '充足'; }
  else { return '充裕'; }
}
function getStatusColor(status: string): string {
  if (status === '停车中') { return '#1976D2'; }
  else if (status === '已完成') { return '#43A047'; }
  else { return '#757575'; }
}
function buildGradient(c1: string, c2: string): LinearGradient {
  return { angle: 135, direction: GradientDirection.RightBottom, colors: [[c1, 0.0], [c2, 1.0]] };
}

代码深度解析:

这组工具函数构成了 UI 渲染的"色彩与格式化引擎",每个函数都承担一个明确的职责:

  • formatDistance():距离格式化。小于 1km 时转为米(乘以 1000 并四舍五入),大于等于 1km 时保留一位小数加"km"后缀。这使得 0.3km 显示为"300m",1.5km 显示为"1.5km",符合用户对距离的直观感知习惯。

  • getOccupancyColor():占用率颜色映射。90% 以上为红色(危急),70%-89% 为橙色(警告),70% 以下为蓝色(正常)。三级阈值的设计覆盖了停车场占用率的典型业务分级。

  • getTypeColor():停车场类型颜色映射。四种类型对应四种蓝色梯度——从最深蓝(室内)到最浅蓝(机械),实现了"类型 -> 颜色"的一对一映射。这组颜色恰好来自应用的主题色板,保证了整体视觉的一致性。

  • getAvailabilityText():空位数文本描述。四级分级(已满/紧张/充足/充裕),比颜色映射多一个层级,因为文本描述需要更细的粒度来传达信息。阈值 20 和 100 的选择是业务经验值。

  • getStatusColor():停车记录状态颜色。停车中为蓝色(进行态),已完成为绿色(终态),其他为灰色(未知态)。

  • buildGradient():线性渐变构建器。统一封装了渐变参数——135 度角、从左上到右下方向、两色渐变。整个应用中的渐变效果(顶部状态栏、费用卡片、个人中心卡片等)都通过这个函数生成,确保了渐变风格的全局一致性。如果未来需要调整渐变角度或方向,只需修改这一个函数即可。

这种将"纯函数式的 UI 辅助逻辑"抽取为全局工具函数的做法,体现了 “关注点分离”(Separation of Concerns) 的设计原则——UI 组件只负责布局和组合,数据转换和格式化逻辑交给工具函数处理。


七、底部 Tab 枚举与主入口组件

enum BottomTab { HOME = 0, PARKING = 1, RECORD = 2, VEHICLE = 3, MINE = 4 }

代码深度解析:

BottomTab 枚举定义了五个 Tab 页的索引值(0-4),使用枚举而非魔术数字(magic number)是 TypeScript 的最佳实践。枚举值与 @State currentTab: number 配合使用,在 if-else 条件判断中提供语义化的分支识别。例如 this.currentTab === BottomTab.HOME 比 this.currentTab === 0 更具可读性和可维护性。

@Entry
@Component
struct ParkingApp {
  @State currentTab: number = BottomTab.HOME;
  @State showAddVehicleDialog: boolean = false;
  @State showDeleteConfirmDialog: boolean = false;
  @State showEditPlateDialog: boolean = false;
  @State deleteTargetId: string = '';
  @State editTargetId: string = '';
  @State newPlateInput: string = '';
  @State newBrandInput: string = '';
  @State newColorInput: string = '';
  @State editPlateInput: string = '';
  // ...

代码深度解析:

ParkingApp 是整个应用的根组件,使用 @Entry 和 @Component 双装饰器标记。@Entry 表示这是应用的入口页面,@Component 表示这是一个可复用的自定义组件。

状态变量的设计体现了"集中式状态管理"的思路:所有弹窗的显示/隐藏状态(showAddVehicleDialog、showDeleteConfirmDialog、showEditPlateDialog)都由根组件统一管理。这意味着子组件不能直接打开弹窗,而是通过回调函数通知根组件来切换弹窗状态。这种"状态上提"(State Hoisting)模式确保了弹窗的显示逻辑有一个唯一的控制源,避免了状态不一致的问题。

deleteTargetId / editTargetId 两个状态变量用于在确认弹窗中记录"要操作的目标 ID"。当用户在子组件中点击"删除"或"编辑"时,子组件通过回调将目标 ID 传递给根组件,根组件将其存入状态变量,然后打开对应的确认弹窗。这种"先记录目标,再弹窗确认"的两步操作模式,是"确认型操作"的标准实现范式。

三个输入框的状态变量(newPlateInput、newBrandInput、newColorInput、editPlateInput)采用"受控输入"模式——TextInput 组件的 text 属性绑定到状态变量,onChange 回调更新状态变量,弹窗关闭时重置为空字符串。这确保了输入数据的生命周期由组件状态完全控制。

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Column() {
        this.TopStatusBar()
        Column() {
          if (this.currentTab === BottomTab.HOME) {
            HomeTabContent()
          } else if (this.currentTab === BottomTab.PARKING) {
            ParkingTabContent()
          } else if (this.currentTab === BottomTab.RECORD) {
            RecordTabContent()
          } else if (this.currentTab === BottomTab.VEHICLE) {
            VehicleTabContent({ onAddVehicle: () => { this.showAddVehicleDialog = true; } })
          } else {
            MineTabContent({
              onDeleteFavorite: (id: string) => { this.deleteTargetId = id; this.showDeleteConfirmDialog = true; },
              onEditPlate: (id: string) => { this.editTargetId = id; this.showEditPlateDialog = true; }
            })
          }
        }.layoutWeight(1).width('100%')
        this.BottomTabBar()
      }.width('100%').height('100%')
      if (this.showAddVehicleDialog) { this.AddVehicleDialog() }
      if (this.showDeleteConfirmDialog) { this.DeleteConfirmDialog() }
      if (this.showEditPlateDialog) { this.EditPlateDialog() }
    }.width('100%').height('100%').backgroundColor('#F0F4F8')
  }

代码深度解析:

build() 方法是 ArkTS 声明式 UI 的核心——它描述了组件的完整 UI 树结构。让我们逐层分析:

最外层是 Stack 容器,alignContent: Alignment.TopStart 使子元素从左上角开始堆叠。Stack 的关键作用是实现"弹窗覆盖"效果——主内容区域(Column)铺满全屏,弹窗通过 position({ x: 0, y: 0 }) 和全屏半透明背景覆盖在主内容之上。

Stack > Column 是主布局骨架,自上而下排列:

  1. TopStatusBar() —— 顶部标题栏(固定高度 56vp)
  2. Column() —— 中间内容区域(.layoutWeight(1) 占据剩余空间),根据 currentTab 条件渲染不同的 Tab 内容
  3. BottomTabBar() —— 底部导航栏(固定高度 56vp)

条件渲染(Conditional Rendering) 使用 if-else if-else 链实现 Tab 切换。当 currentTab 变化时,ArkUI 框架会销毁旧的组件树并创建新的组件树。这种方式简单直接,但需要注意:切换 Tab 时组件状态不会保留(因为组件被销毁重建了)。对于有大量用户输入的页面,可能需要配合 @Provide/@Consume 或 AppStorage 来持久化状态。

父子组件通信机制:VehicleTabContent 和 MineTabContent 在实例化时通过构造参数传入回调函数(onAddVehicle、onDeleteFavorite、onEditPlate)。这是 ArkTS 中最常用的"子 -> 父"通信方式——子组件在特定事件触发时调用回调,父组件在回调中修改自身状态,从而触发弹窗显示。这种模式等价于 React 中的"props callback"模式。

弹窗的条件渲染 三个 if 语句位于 Stack 的第二层,当对应的布尔状态为 true 时,弹窗 UI 才会被创建并叠加到主内容之上。弹窗关闭时(状态设为 false),弹窗组件被销毁,同时输入框状态被重置。

backgroundColor('#F0F4F8') 是整个应用的背景色——一种极浅的蓝灰色,与深蓝色主题形成柔和的视觉反差,避免了纯白背景的单调感。

  @Builder BottomTabBar() {
    Row() {
      ForEach(Object.values(TabConfig), (item: TabItemInfo) => {
        Column() {
          Text(item.icon).fontSize(22)
          Text(item.title).fontSize(11).margin({ top: 2 }).fontColor(this.getTabColor(item.name))
        }.layoutWeight(1).height(56).justifyContent(FlexAlign.Center)
        .onClick(() => {
          if (item.name === 'home') { this.currentTab = BottomTab.HOME; }
          // ...
        })
      })
    }.width('100%').height(56).backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 16, topRight: 16 })
    .shadow({ radius: 10, color: 'rgba(13,71,161,0.15)', offsetX: 0, offsetY: -2 })
  }

代码深度解析:

底部 Tab 栏采用 Row 横向布局,内部通过 ForEach 遍历 TabConfig 渲染 5 个等宽 Tab 项。每个 Tab 项是一个纵向排列的 Column,包含图标(emoji,22fp)和标题文字(11fp),通过 .layoutWeight(1) 实现等宽分配。

getTabColor() 方法 通过 item.name === 'home' && this.currentTab === BottomTab.HOME 的组合条件判断当前 Tab 是否被选中——选中时返回深蓝 #0D47A1,未选中时返回灰色 #9E9E9E。这种双重判断(名称匹配 + 索引匹配)虽然略显冗余,但确保了配置数据与枚举值之间的正确映射。

视觉设计细节:

  • .borderRadius({ topLeft: 16, topRight: 16 }):顶部圆角 16vp,使 Tab 栏与页面背景之间有柔和的过渡,避免了矩形边缘的生硬感。
  • .shadow({ radius: 10, color: 'rgba(13,71,161,0.15)', offsetY: -2 }):阴影向上偏移 2vp(负值),模拟 Tab 栏"悬浮"在内容区域之上的视觉效果。阴影颜色使用了主题色的低透明度版本(15% 透明度),保持了视觉上的色彩一致性。

八、弹窗 Builder:模态交互的三种形态

8.1 新增车辆弹窗

  @Builder AddVehicleDialog() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        Column() {
          Text('新增车辆').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
          Column({ space: 16 }) {
            Column({ space: 8 }) {
              Text('车牌号').fontSize(14).fontColor('#616161')
              TextInput({ placeholder: '如:京A·12345', text: this.newPlateInput })
                .fontSize(16).height(44).borderRadius(10).backgroundColor('#F5F5F5')
                .onChange((v: string) => { this.newPlateInput = v; })
            }.width('100%')
            // ... 车型输入、颜色输入
          }
          Row({ space: 12 }) {
            Button('取消').backgroundColor('#EEEEEE')
            Button('确认添加').linearGradient(buildGradient('#1565C0', '#42A5F5'))
          }
        }.width('86%').borderRadius(20).backgroundColor('#FFFFFF')
      }.width('100%')
    }.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').position({ x: 0, y: 0 })
  }

代码深度解析:

这是一个完整的自定义模态弹窗,没有使用系统提供的 CustomDialogController,而是完全通过声明式 UI 构建。这种实现方式的优势在于:弹窗的样式可以完全自定义,且与主 UI 使用相同的设计语言(颜色、圆角、阴影等)。

弹窗的层级结构:

  1. 最外层 Stack:全屏定位(position({ x: 0, y: 0 })),背景色为半透明黑色(rgba(0,0,0,0.5)),实现遮罩效果。
  2. 中间层 Column:100% 宽度的垂直居中容器,将内容推到垂直中心。
  3. 最内层 Column:86% 宽度的白色卡片,圆角 20vp,承载实际的表单内容。

表单设计 采用 Column({ space: 16 }) 垂直等间距排列三个输入组(车牌号、车型、颜色),每组包含标签(Text)和输入框(TextInput)。输入框高度统一为 44vp(符合触摸目标最小 44px 的无障碍设计标准),圆角 10vp,浅灰色背景 #F5F5F5。

按钮设计:

  • "取消"按钮:灰色背景 #EEEEEE,灰色文字。点击后关闭弹窗并重置所有输入状态。
  • "确认添加"按钮:使用主题渐变色(buildGradient('#1565C0', '#42A5F5')),白色文字,视觉上更突出,引导用户完成添加操作。

这种"取消弱化 + 确认强化"的按钮设计是移动端弹窗的通用设计范式——减少用户误操作确认的概率,同时引导用户执行主要操作。

8.2 删除确认弹窗与编辑弹窗

删除确认弹窗和编辑车牌弹窗的结构与新增弹窗基本一致,核心区别在于:

  • 删除弹窗的标题为红色(#E53935),"确认删除"按钮也为红色,传达危险操作的警示感。
  • 编辑弹窗只有一个输入框,结构更简洁,宽度为 80%(比新增弹窗的 86% 更窄,因为内容更少)。

三个弹窗共享相同的设计骨架(遮罩层 + 居中卡片 + 表单/内容 + 操作按钮),形成了统一的视觉语言。


九、Tab1 首页:信息聚合与附近推荐

@Component
struct HomeTabContent {
  @State nearbyList: ParkingLotInfo[] = ParkingLotData.slice(0, 15);

代码深度解析:

首页组件使用 .slice(0, 15) 从 20 条停车场数据中截取前 15 条作为"附近停车场"的数据源。这模拟了"按距离排序取前 N 条"的搜索逻辑——实际项目中应通过排序算法实现,但 Mock 数据已按距离升序排列,所以直接截取即可。

  @Builder LocationCard() {
    Column() {
      Row() {
        Column() {
          Row() { Text('📍').fontSize(18); Text('当前位置').fontSize(13).fontColor('#90CAF9') }
          Text('海淀区中关村大街1号').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        }.layoutWeight(1)
        Column() { Text('晴 28°'); Text('适宜停车') }
      }
      Divider().color('rgba(255,255,255,0.2)')
      Row() {
        Column() { Text('附近'); Text('20'); Text('停车场') }
        Column() { Text('可用'); Text('3,478'); Text('车位') }
        Column() { Text('均价'); Text('¥6.5'); Text('每小时') }
      }
    }.linearGradient(buildGradient('#0D47A1', '#1976D2'))
  }

代码深度解析:

LocationCard 是首页的核心信息卡片,采用了"大卡片 + 渐变背景"的设计模式。

第一行:左侧显示定位图标 + "当前位置"标签 + 详细地址;右侧显示天气信息和停车适宜度提示。这种"左信息 + 右辅助"的布局是信息卡片的常见范式。

分割线:使用 Divider 组件,颜色为白色 20% 透明度(rgba(255,255,255,0.2)),在深色渐变背景上形成微妙的分隔效果,比完全不透明的分割线更加柔和优雅。

第二行:三列等宽统计信息(附近停车场数 20、可用车位数 3,478、均价 6.5 元),每列采用"小标签 + 大数字 + 小单位"的三行纵向布局。数字使用 24fp Bold,形成强烈的视觉锚点,让用户一眼获取关键数据。

整个卡片使用与顶部状态栏相同的渐变配色(#0D47A1 -> #1976D2),保持了"首屏信息区"的色彩连贯性。

  @Builder QuickActionsRow() {
    Row() {
      Column() { Text('🧭').fontSize(28); Text('导航') }.backgroundColor('#FFFFFF').borderRadius(14)
      Column() { Text('⚡').fontSize(28); Text('充电') }.backgroundColor('#FFFFFF').borderRadius(14)
      Column() { Text('🅿').fontSize(28); Text('预约') }.backgroundColor('#FFFFFF').borderRadius(14)
      Column() { Text('🎫').fontSize(28); Text('优惠券') }.backgroundColor('#FFFFFF').borderRadius(14)
    }
  }

代码深度解析:

快捷操作栏提供了四个高频功能的入口:导航、充电、预约、优惠券。采用等宽 Row 布局,每个入口是一个白色圆角卡片(borderRadius(14)),内部纵向排列 emoji 图标(28fp,大尺寸便于点击)和文字标签。

卡片之间通过 margin({ left: 8 }) 设置间距(第一个除外),保持统一的视觉节奏。每张卡片带有轻微阴影(radius: 4, color: 'rgba(0,0,0,0.06)'),营造出"悬浮"的层次感。

  @Builder NearbyParkingCard(lot: ParkingLotInfo) {
    Column() {
      Row() {
        Column() { Text('🅿').fontSize(24) }.width(48).height(48).borderRadius(12).backgroundColor('#E3F2FD')
        Column({ space: 4 }) {
          Text(lot.name).fontWeight(FontWeight.Bold).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Row() {
            Text(formatDistance(lot.distance)); Text(' · '); Text(lot.type); Text(' · '); Text('★' + lot.rating.toFixed(1))
          }
          Text(lot.address).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }.layoutWeight(1)
        Column() { Text('🧭').fontSize(20) }.width(40).height(40).borderRadius(20).backgroundColor('#1976D2')
      }
      Row() {
        Row() { Text('空位'); Text(lot.availableSpaces.toString()).fontWeight(FontWeight.Bold); Text('/' + lot.totalSpaces) }
        Row() { Text('¥' + lot.pricePerHour); Text('/小时') }
      }
      Row() {
        Column() {
          Row().width(`${(1 - lot.availableSpaces / lot.totalSpaces) * 100}%`).height(4)
            .backgroundColor(getOccupancyColor(...))
        }.width('100%').height(4).backgroundColor('#E0E0E0')
      }
    }.backgroundColor('#FFFFFF').borderRadius(16).shadow({ radius: 6, color: 'rgba(0,0,0,0.06)' })
  }

代码深度解析:

NearbyParkingCard 是停车场列表的核心卡片组件,信息密度高、层次分明。

第一行(头部):左侧蓝色图标区域(48x48vp,圆角 12vp,浅蓝背景)+ 中间信息区(名称 + 距离/类型/评分 + 地址)+ 右侧导航按钮(40x40vp 圆形,主题蓝背景)。.textOverflow({ overflow: TextOverflow.Ellipsis }) 配合 .maxLines(1) 实现了文本溢出时的省略号截断,这是列表卡片中文本展示的标准处理方式。

第二行(数据行):左侧显示空位数(可用/总数),右侧显示价格。可用数字体加粗且根据营业状态着色(营业中为蓝色,已关闭为红色)。

第三行(进度条):使用"外层背景 + 内层前景"的双层 Row 实现水平进度条。外层高度 4vp、浅灰色背景 #E0E0E0;内层宽度为占用率百分比(使用模板字符串动态计算),颜色通过 getOccupancyColor() 函数根据占用率动态设置。这种内联进度条实现方式无需额外的图表组件库,非常轻量高效。


十、Tab2 停车场:筛选、排序与详情展示

@Component
struct ParkingTabContent {
  @State allParkingList: ParkingLotInfo[] = ParkingLotData;
  @State filterType: string = '全部';
  @State sortBy: string = '距离';

代码深度解析:

停车场 Tab 组件维护三个状态变量:完整停车场列表、当前筛选类型、当前排序方式。filterType 和 sortBy 的组合构成了这个页面的"查询条件"——虽然当前代码中筛选和排序的条件渲染逻辑尚未完全实现(filterType 和 sortBy 的变化没有触发列表的重新过滤/排序),但状态变量的声明已经为后续完善预留了接口。

  @Builder FilterBar() {
    Scroll() {
      Row() {
        this.FilterChip('全部'); this.FilterChip('室内'); this.FilterChip('地下');
        this.FilterChip('露天'); this.FilterChip('机械'); this.FilterChip('充电桩'); this.FilterChip('24小时')
      }.padding({ left: 16, right: 16 })
    }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(48)
  }

  @Builder FilterChip(label: string) {
    Text(label).fontColor(this.filterType === label ? '#FFFFFF' : '#1976D2')
      .backgroundColor(this.filterType === label ? '#0D47A1' : '#E3F2FD')
      .onClick(() => { this.filterType = label; })
  }

代码深度解析:

筛选栏采用水平滚动的 Scroll + Row 布局,内含 7 个筛选标签。每个标签(FilterChip)根据是否被选中显示不同的颜色方案:

  • 选中态:深蓝背景 #0D47A1 + 白色文字 —— "填充式选中"效果
  • 未选中态:浅蓝背景 #E3F2FD + 蓝色文字 —— "描边式未选中"效果

这种"填充 vs 描边"的选中/未选中视觉对比是移动端筛选标签的经典设计范式,用户可以快速识别当前激活的筛选条件。.scrollable(ScrollDirection.Horizontal) 使标签栏在标签过多时可以水平滚动,.scrollBar(BarState.Off) 隐藏滚动条以保持界面整洁。

  @Builder ParkingDetailCard(lot: ParkingLotInfo) {
    // 完整的停车场详情卡片,包含:
    // 1. 名称 + 营业状态标签
    // 2. 地址 + 距离
    // 3. 类型标签 + 评分 + 特色服务标签(ForEach 渲染)
    // 4. 空位信息 + 占用率进度条
    // 5. 价格信息 + 操作按钮
  }

代码深度解析:

ParkingDetailCard 是整个应用中信息量最大的卡片组件,五层信息纵向堆叠。与首页的 NearbyParkingCard 相比,它提供了更丰富的细节:

  1. 营业状态标签:lot.isOpen ? '营业中' : '已关闭',绿色或灰色胶囊标签,是用户选择停车场的第一决策依据。
  2. 类型标签:使用 getTypeColor() 函数为不同类型的停车场分配不同的背景色,形成快速视觉分类。
  3. 特色服务标签:通过 ForEach(lot.features, ...) 动态渲染,每个标签采用浅蓝背景的药丸样式。features 数组的不同长度意味着每张卡片的服务标签数量可能不同,ForEach 的动态渲染能力恰好应对了这种数据驱动的不确定性。
  4. 占用率信息:不仅显示空位数量,还显示"可用性文本"(getAvailabilityText)和"使用率百分比",为用户提供多维度的车位信息。
  5. 操作按钮:底部左侧"详情"按钮(浅蓝描边样式)和右侧"导航"按钮(主题渐变填充样式),"弱化次要操作 + 强化主要操作"的设计延续了弹窗中的按钮设计策略。

十一、Tab3 记录:费用统计与历史查询

@Component
struct RecordTabContent {
  @State recordList: ParkingRecordInfo[] = ParkingRecordData;
  @State totalFee: number = 265;
  @State totalCount: number = 15;

代码深度解析:

记录 Tab 维护三个状态变量:停车记录列表、本月总费用、本月停车次数。虽然 totalFee 和 totalCount 可以通过 recordList 动态计算得出,但这里选择了硬编码的方式。在实际项目中,这两个值应当通过 aboutToAppear() 生命周期回调中的计算逻辑来初始化,并随着记录的增删而更新。

  @Builder FeeSummaryCard() {
    Column() {
      Row() {
        Text('本月停车'); Text('7月').padding(...).backgroundColor('rgba(255,255,255,0.2)')
      }
      Row() {
        Text('¥').fontSize(18); Text(this.totalFee.toString()).fontSize(40).fontWeight(FontWeight.Bold)
      }
      Row() {
        Column() { Text('停车次数'); Text(this.totalCount + '次') }
        Column() { Text('平均费用'); Text('¥' + (this.totalFee / this.totalCount).toFixed(1)) }
        Column() { Text('较上月'); Text('-36.9%').fontColor('#43A047') }
      }
    }.linearGradient(buildGradient('#0D47A1', '#1565C0'))
  }

代码深度解析:

费用摘要卡片是记录 Tab 的"首屏视觉焦点",采用了大数字 + 渐变背景的强视觉设计:

  • 核心数字:¥265 使用 40fp Bold 的超大字号,配合 ¥ 符号的 18fp 字号形成大小对比,引导用户第一眼关注本月消费总额。
  • 三项辅助指标:停车次数(15次)、平均费用(¥17.7)、较上月变化(-36.9%)。平均费用通过 totalFee / totalCount 实时计算,较上月变化使用绿色文字(#43A047)表示"消费下降"的好消息。
  • 月份标签:使用白色 20% 透明度的背景色(rgba(255,255,255,0.2)),在深蓝渐变上形成微妙的"磨砂玻璃"效果。
  @Builder MonthlyFeeChart() {
    Column() {
      ForEach(MonthlyStatData, (stat: MonthlyStatInfo) => {
        Column() {
          Text('¥' + stat.totalFee); Column().height(stat.totalFee / 6).backgroundColor(stat.month === '7月' ? '#0D47A1' : '#42A5F5')
          Text(stat.month)
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      })
    }.alignItems(VerticalAlign.Bottom)
  }

代码深度解析:

月度费用柱状图是一个纯声明式实现的轻量级图表组件。每根柱子的高度为 stat.totalFee / 6(将最大值约 480 映射到约 80vp 的高度),当月(7月)使用深蓝色,其他月份使用浅蓝色,通过颜色对比突出当前月份。

.alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,这是柱状图的标准对齐方式——底部代表零值基线,柱子向上延伸表示数值大小。整个图表容器高度固定为 120vp,通过 alignItems(VerticalAlign.Bottom) 约束柱子的增长方向。

  @Builder RecordCard(record: ParkingRecordInfo) {
    // 记录卡片包含:
    // 1. 停车场名称 + 状态标签
    // 2. 入场/出场时间 + 停车时长
    // 3. 车牌号 + 支付方式 + 费用
  }

代码深度解析:

记录卡片的信息层次非常清晰:标题行(名称+状态)-> 时间行(入/出+时长)-> 底部行(车牌+支付+费用)。

状态条件渲染是这张卡片的关键逻辑:当 record.exitTime.length > 0 时显示实际出场时间,否则显示"—“;当 record.paymentMethod.length > 0 时显示支付方式标签,否则不渲染;当 record.fee > 0 时显示具体费用,否则显示"计费中”。这种多层条件判断确保了"已完成"和"停车中"两种状态的记录都能正确展示。

费用区域的对齐方式:使用 .layoutWeight(1).justifyContent(FlexAlign.End) 将费用推到右侧,与左侧的车牌号和支付方式标签形成视觉平衡,体现了"信息密度均匀分布"的布局原则。


十二、Tab4 车辆:车辆管理与类型偏好

@Component
struct VehicleTabContent {
  @State vehicleList: VehicleInfo[] = VehicleData;
  onAddVehicle: (() => void) | null = null;

代码深度解析:

车辆 Tab 组件通过 onAddVehicle 回调属性与父组件通信。(() => void) | null 类型声明表示这是一个可选的回调函数(可能为 null),使用前需要通过 if (this.onAddVehicle) 进行空值检查。这种"可选回调"模式在 ArkTS 中是处理子组件事件的推荐方式。

  @Builder VehicleCard(vehicle: VehicleInfo) {
    // 车辆卡片包含:
    // 1. 车辆图标 + 车牌号(含"默认"标签)+ 品牌型号 + 年款颜色
    // 2. 保险到期 + 年检到期 + 违章记录(三项均带颜色状态指示)
    // 3. 操作按钮:设为默认 / 编辑车牌 / 删除
  }

代码深度解析:

车辆卡片是信息密度最高的卡片之一,包含了车辆的完整画像。

"默认"标签:通过 if (vehicle.isDefault) 条件渲染,只在默认车辆上显示橙色标签。这种"视觉标识 + 条件渲染"的组合让用户无需阅读即可识别默认车辆。

三项日期/数量指标的颜色逻辑:

  • 保险/年检到期日 > 基准日期 -> 绿色(有效)
  • 保险/年检到期日 <= 基准日期 -> 红色(即将过期或已过期)
  • 违章数 > 0 -> 红色(有违章)
  • 违章数 = 0 -> 绿色(无违章)

这种"红绿二值颜色"的方案让用户快速扫描所有车辆的健康状态,无需逐一阅读具体日期。

操作按钮行:三个等宽按钮分别对应"设为默认"、“编辑车牌”、"删除"三个操作。"删除"按钮使用红色文字 + 浅红背景(#FFEBEE),延续了"危险操作红色预警"的设计语言。


十三、Tab5 我的:个人中心与收藏管理

@Component
struct MineTabContent {
  @State favoriteList: FavoriteLotInfo[] = FavoriteLotData;
  @State settings: SettingItemInfo[] = Object.values(SettingConfig);
  onDeleteFavorite: ((id: string) => void) | null = null;
  onEditPlate: ((id: string) => void) | null = null;

代码深度解析:

"我的"Tab 是功能最丰富的页面,包含四个主要区块:个人档案卡片、停车统计图表、收藏停车场列表、设置项列表。组件通过两个回调(onDeleteFavorite 和 onEditPlate)与父组件通信,分别处理收藏删除和车牌编辑的弹窗触发。

settings 状态变量的初始化 使用 Object.values(SettingConfig) 将字典对象转换为数组,便于 ForEach 遍历渲染。这种"字典定义 + 转数组渲染"的模式在配置化 UI 开发中非常实用。

  @Builder ProfileCard() {
    Column() {
      Row() {
        Column() { Text(UserProfile.avatar).fontSize(36) }.width(64).height(64).borderRadius(32)
        Column({ space: 6 }) {
          Row() {
            Text(UserProfile.name).fontSize(20).fontWeight(FontWeight.Bold)
            Text(UserProfile.memberLevel).padding(...).backgroundColor('#90CAF9')
          }
          Text(UserProfile.phone).fontSize(14).fontColor('#90CAF9')
        }
        Text('>').fontSize(20).fontColor('#90CAF9')
      }
      Row() {
        Column() { Text(UserProfile.totalParking); Text('总停车') }
        Column() { Text('¥' + UserProfile.totalSpent); Text('总消费') }
        Column() { Text(UserProfile.points); Text('积分') }
      }
    }.linearGradient(buildGradient('#0D47A1', '#1976D2'))
  }

代码深度解析:

个人档案卡片使用了与首页 LocationCard、记录页 FeeSummaryCard 相同的"渐变大卡片"设计模式,形成了跨 Tab 页的视觉统一性。

头像区域:64x64vp 的正方形,圆角 32vp(即完全圆形),使用半透明白色背景(rgba(255,255,255,0.3))。emoji 头像在圆形浅色背景上的显示效果类似于"字母头像"(Letter Avatar),是一种无需真实头像图片的轻量化方案。

会员等级标签:使用浅蓝背景 #90CAF9 + 深蓝文字 #0D47A1 的配色,与白色文字形成对比,突出会员身份。

三项统计数据:总停车次数(137)、总消费金额(2525元)、积分(2525),与记录页 FeeSummaryCard 的"本月统计"形成"全局 vs 当月"的互补视角。

  @Builder FavoriteCard(fav: FavoriteLotInfo) {
    Column() {
      Row() {
        Column() { Text('🅿').fontSize(20) }.width(40).height(40).borderRadius(10).backgroundColor('#E3F2FD')
        Column({ space: 3 }) {
          Text(fav.name).fontWeight(FontWeight.Medium)
          Text(fav.address).fontColor('#9E9E9E')
          Row() { Text(formatDistance(fav.distance)); Text(' · 空位' + fav.availableSpaces); Text(' · ¥' + fav.pricePerHour + '/h') }
        }.layoutWeight(1)
        Column() { Text('🗑').fontSize(18).padding(8).onClick(() => { if (this.onDeleteFavorite) { this.onDeleteFavorite(fav.id); } }) }
      }
    }
  }

代码深度解析:

收藏卡片相比首页的停车场卡片更加紧凑——省略了进度条和价格大字展示,只保留核心信息(名称、地址、距离、空位数、单价),适合列表场景下的快速浏览。

删除操作 通过垃圾桶 emoji 图标(18fp,带 8vp 内边距增大点击区域)触发,点击后调用 onDeleteFavorite 回调通知父组件弹出确认弹窗。这种"列表内触发 + 弹窗确认"的两步操作模式既提供了操作入口,又防止了误删。

  @Builder SettingRow(item: SettingItemInfo) {
    Row() {
      Text(item.icon).fontSize(20).width(36)
      Column({ space: 2 }) {
        Text(item.title)
        if (item.subtitle.length > 0) { Text(item.subtitle).fontSize(12).fontColor('#9E9E9E') }
      }.layoutWeight(1)
      if (item.value.length > 0) { Text(item.value).fontColor('#9E9E9E') }
      Text('>').fontSize(18).fontColor('#BDBDBD')
    }.backgroundColor(item.id === 'logout' ? '#FFEBEE' : '#FFFFFF')
  }

代码深度解析:

设置行组件是一个高度通用的列表项构建器——通过一个 SettingItemInfo 对象驱动全部 UI 渲染,无需任何硬编码的逻辑分支。

布局结构:图标(固定 36vp 宽)+ 标题/副标题(自适应宽度)+ 右侧值(可选)+ 箭头指示符。这是移动端设置列表的"标准四段式"布局。

副标题的条件渲染 if (item.subtitle.length > 0) 确保了没有副标题的设置项(如"退出登录"的 subtitle 为空字符串)不会留下多余的空白区域。

退出登录的特殊背景:通过 item.id === 'logout' 的判断为退出登录行设置浅红色背景(#FFEBEE),与其他白色行形成视觉隔离,强化"危险操作"的警示。

箭头指示符 >:使用灰色(#BDBDBD)的右箭头,暗示该设置项可以点击进入二级页面。这是 iOS 和 Android 设置页面的通用设计惯例。


十四、设计模式与架构总结

通过以上逐段分析,我们可以提炼出本项目采用的若干关键设计模式和最佳实践:

  1. 配置驱动 UI:Tab 栏、设置列表、统计图表等 UI 元素都通过配置对象驱动渲染,实现了数据与 UI 的分离。
  2. @Observed + @State 双层响应式:模型类使用 @Observed 支持深层属性响应式,组件使用 @State 管理视图状态。
  3. 回调通信模式:子组件通过可选回调函数((() => void) | null)向父组件传递事件,实现了单向数据流。
  4. 条件渲染策略:通过 if-else 实现状态相关的 UI 变化(如弹窗显隐、Tab 切换、空值处理)。
  5. Builder 拆分:将复杂组件拆分为多个 @Builder 函数,每个函数负责一个独立的 UI 区块,提高可读性和可维护性。
  6. 统一设计语言:通过 buildGradient()、getOccupancyColor() 等工具函数统一管理颜色和视觉效果。
  7. 防御性编程:除零保护、空值检查、可选回调判断等防御性代码确保了边界情况下的安全运行。

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 深蓝停车出行服务App - 鸿蒙ArkTS
// 深蓝色系(#0D47A1, #1565C0, #1976D2, #42A5F5, #90CAF9)地图卡片布局

// ==================== 1. 接口定义区 ====================

interface ParkingLotInfo {
  id: string; name: string; address: string; distance: number;
  availableSpaces: number; totalSpaces: number; pricePerHour: number;
  maxPrice: number; type: string; rating: number; isOpen: boolean;
  latitude: number; longitude: number; features: string[];
}
interface VehicleInfo {
  id: string; plateNumber: string; brand: string; model: string; color: string;
  year: number; insuranceExpiry: string; inspectionExpiry: string;
  violationCount: number; isDefault: boolean;
}
interface ParkingRecordInfo {
  id: string; lotName: string; lotAddress: string; plateNumber: string;
  entryTime: string; exitTime: string; duration: number; fee: number;
  paymentMethod: string; status: string;
}
interface FavoriteLotInfo {
  id: string; lotId: string; name: string; address: string; distance: number;
  pricePerHour: number; availableSpaces: number; addedDate: string;
}
interface MonthlyStatInfo {
  month: string; totalFee: number; totalCount: number; avgDuration: number;
}
interface ParkingTypeInfo {
  type: string; count: number; percentage: number; color: string;
}
interface DurationStatInfo { range: string; count: number; percentage: number; }
interface SettingItemInfo {
  id: string; title: string; subtitle: string; icon: string; value: string;
}
interface UserProfileInfo {
  name: string; phone: string; avatar: string; memberLevel: string;
  totalParking: number; totalSpent: number; points: number;
}
interface TabItemInfo { name: string; title: string; icon: string; }

// ==================== 2. @Observed 数据模型类 ====================

@Observed
class ParkingModel {
  id: string; name: string; address: string; distance: number;
  availableSpaces: number; totalSpaces: number; pricePerHour: number;
  maxPrice: number; type: string; rating: number; isOpen: boolean;
  latitude: number; longitude: number; features: string[];
  constructor(info: ParkingLotInfo) {
    this.id = info.id; this.name = info.name; this.address = info.address;
    this.distance = info.distance; this.availableSpaces = info.availableSpaces;
    this.totalSpaces = info.totalSpaces; this.pricePerHour = info.pricePerHour;
    this.maxPrice = info.maxPrice; this.type = info.type; this.rating = info.rating;
    this.isOpen = info.isOpen; this.latitude = info.latitude; this.longitude = info.longitude;
    this.features = info.features;
  }
  getOccupancyRate(): number {
    if (this.totalSpaces === 0) { return 0; }
    return Math.round((1 - this.availableSpaces / this.totalSpaces) * 100);
  }
  getAvailabilityColor(): string {
    if (this.availableSpaces === 0) { return '#E53935'; }
    else if (this.availableSpaces < 20) { return '#FB8C00'; }
    else { return '#43A047'; }
  }
}

@Observed
class VehicleModel {
  id: string; plateNumber: string; brand: string; model: string; color: string;
  year: number; insuranceExpiry: string; inspectionExpiry: string;
  violationCount: number; isDefault: boolean;
  constructor(info: VehicleInfo) {
    this.id = info.id; this.plateNumber = info.plateNumber; this.brand = info.brand;
    this.model = info.model; this.color = info.color; this.year = info.year;
    this.insuranceExpiry = info.insuranceExpiry; this.inspectionExpiry = info.inspectionExpiry;
    this.violationCount = info.violationCount; this.isDefault = info.isDefault;
  }
  isInsuranceValid(): boolean { return this.insuranceExpiry > '2026-07-24'; }
  isInspectionValid(): boolean { return this.inspectionExpiry > '2026-07-24'; }
}

@Observed
class RecordModel {
  id: string; lotName: string; lotAddress: string; plateNumber: string;
  entryTime: string; exitTime: string; duration: number; fee: number;
  paymentMethod: string; status: string;
  constructor(info: ParkingRecordInfo) {
    this.id = info.id; this.lotName = info.lotName; this.lotAddress = info.lotAddress;
    this.plateNumber = info.plateNumber; this.entryTime = info.entryTime;
    this.exitTime = info.exitTime; this.duration = info.duration; this.fee = info.fee;
    this.paymentMethod = info.paymentMethod; this.status = info.status;
  }
  getDurationText(): string {
    const h = Math.floor(this.duration / 60);
    const m = this.duration % 60;
    if (h > 0) { return `${h}小时${m}分钟`; }
    return `${m}分钟`;
  }
}

// ==================== 3. 配置对象 ====================

const TabConfig: Record<string, TabItemInfo> = {
  'home': { name: 'home', title: '首页', icon: '🏠' },
  'parking': { name: 'parking', title: '停车场', icon: '🅿' },
  'record': { name: 'record', title: '记录', icon: '📋' },
  'vehicle': { name: 'vehicle', title: '车辆', icon: '🚗' },
  'mine': { name: 'mine', title: '我的', icon: '👤' }
};
const ParkingTypeConfig: Record<string, ParkingTypeInfo> = {
  'indoor': { type: '室内停车场', count: 8, percentage: 40, color: '#0D47A1' },
  'outdoor': { type: '露天停车场', count: 6, percentage: 30, color: '#42A5F5' },
  'underground': { type: '地下停车场', count: 4, percentage: 20, color: '#1976D2' },
  'mechanical': { type: '机械车库', count: 2, percentage: 10, color: '#90CAF9' }
};
const DurationStatConfig: Record<string, DurationStatInfo> = {
  'short': { range: '0-1小时', count: 35, percentage: 35 },
  'medium': { range: '1-3小时', count: 45, percentage: 45 },
  'long': { range: '3-6小时', count: 15, percentage: 15 },
  'overnight': { range: '6小时以上', count: 5, percentage: 5 }
};
const SettingConfig: Record<string, SettingItemInfo> = {
  'payment': { id: 'payment', title: '支付管理', subtitle: '管理支付方式', icon: '💳', value: '微信支付' },
  'notify': { id: 'notify', title: '消息通知', subtitle: '推送设置', icon: '🔔', value: '已开启' },
  'plate': { id: 'plate', title: '车牌识别', subtitle: '自动识别设置', icon: '📷', value: '自动' },
  'invoice': { id: 'invoice', title: '发票管理', subtitle: '电子发票', icon: '🧾', value: '' },
  'coupon': { id: 'coupon', title: '优惠券', subtitle: '可用券3张', icon: '🎫', value: '3张' },
  'help': { id: 'help', title: '帮助中心', subtitle: '常见问题', icon: '❓', value: '' },
  'about': { id: 'about', title: '关于我们', subtitle: '版本信息', icon: 'ℹ', value: 'v2.6.0' },
  'logout': { id: 'logout', title: '退出登录', subtitle: '', icon: '🚪', value: '' }
};

// ==================== 4. Mock 数据数组 ====================

const ParkingLotData: ParkingLotInfo[] = [
  { id: 'p001', name: '深蓝环球中心停车场', address: '海淀区中关村大街1号', distance: 0.3, availableSpaces: 156, totalSpaces: 500, pricePerHour: 8, maxPrice: 80, type: '室内停车场', rating: 4.8, isOpen: true, latitude: 39.9847, longitude: 116.3076, features: ['充电桩', '24小时', '监控'] },
  { id: 'p002', name: '万达广场地下车库', address: '朝阳区建国路93号', distance: 0.8, availableSpaces: 89, totalSpaces: 800, pricePerHour: 6, maxPrice: 60, type: '地下停车场', rating: 4.5, isOpen: true, latitude: 39.9087, longitude: 116.4635, features: ['充电桩', '电梯', '监控'] },
  { id: 'p003', name: '金融街停车楼', address: '西城区金融大街7号', distance: 1.2, availableSpaces: 45, totalSpaces: 300, pricePerHour: 12, maxPrice: 120, type: '室内停车场', rating: 4.7, isOpen: true, latitude: 39.9156, longitude: 116.3632, features: ['充电桩', '24小时', '洗车'] },
  { id: 'p004', name: '国贸中心停车场', address: '朝阳区东三环中路', distance: 1.5, availableSpaces: 0, totalSpaces: 600, pricePerHour: 10, maxPrice: 100, type: '室内停车场', rating: 4.6, isOpen: false, latitude: 39.9089, longitude: 116.4587, features: ['充电桩', '电梯'] },
  { id: 'p005', name: '西单大悦城车库', address: '西城区西单北大街131号', distance: 1.8, availableSpaces: 234, totalSpaces: 700, pricePerHour: 7, maxPrice: 70, type: '地下停车场', rating: 4.4, isOpen: true, latitude: 39.9098, longitude: 116.3723, features: ['充电桩', '电梯', '监控'] },
  { id: 'p006', name: '望京SOHO停车场', address: '朝阳区阜通东大街1号', distance: 2.1, availableSpaces: 178, totalSpaces: 900, pricePerHour: 5, maxPrice: 50, type: '地下停车场', rating: 4.5, isOpen: true, latitude: 39.9931, longitude: 116.4735, features: ['充电桩', '24小时', '洗车'] },
  { id: 'p007', name: '三里屯太古里车库', address: '朝阳区三里屯路19号', distance: 2.5, availableSpaces: 12, totalSpaces: 400, pricePerHour: 10, maxPrice: 100, type: '室内停车场', rating: 4.7, isOpen: true, latitude: 39.9361, longitude: 116.4551, features: ['充电桩', '电梯', '监控'] },
  { id: 'p008', name: '王府井大街停车场', address: '东城区王府井大街', distance: 2.8, availableSpaces: 67, totalSpaces: 250, pricePerHour: 8, maxPrice: 80, type: '室内停车场', rating: 4.3, isOpen: true, latitude: 39.9145, longitude: 116.4103, features: ['监控', '24小时'] },
  { id: 'p009', name: '奥林匹克公园停车场', address: '朝阳区北辰东路', distance: 3.0, availableSpaces: 345, totalSpaces: 1000, pricePerHour: 4, maxPrice: 40, type: '露天停车场', rating: 4.2, isOpen: true, latitude: 39.9928, longitude: 116.3974, features: ['充电桩', '24小时'] },
  { id: 'p010', name: '蓝色港湾停车场', address: '朝阳区朝阳公园路6号', distance: 3.2, availableSpaces: 89, totalSpaces: 600, pricePerHour: 6, maxPrice: 60, type: '室内停车场', rating: 4.5, isOpen: true, latitude: 39.9447, longitude: 116.4752, features: ['充电桩', '电梯', '洗车'] },
  { id: 'p011', name: '五棵松华熙LIVE车库', address: '海淀区复兴路69号', distance: 3.5, availableSpaces: 256, totalSpaces: 800, pricePerHour: 5, maxPrice: 50, type: '地下停车场', rating: 4.4, isOpen: true, latitude: 39.9067, longitude: 116.2845, features: ['充电桩', '24小时', '监控'] },
  { id: 'p012', name: '合生汇停车场', address: '朝阳区西大望路', distance: 3.8, availableSpaces: 34, totalSpaces: 500, pricePerHour: 7, maxPrice: 70, type: '室内停车场', rating: 4.6, isOpen: true, latitude: 39.9123, longitude: 116.4856, features: ['充电桩', '电梯'] },
  { id: 'p013', name: '颐堤港停车场', address: '朝阳区酒仙桥路18号', distance: 4.0, availableSpaces: 412, totalSpaces: 1200, pricePerHour: 6, maxPrice: 60, type: '地下停车场', rating: 4.7, isOpen: true, latitude: 39.9689, longitude: 116.4923, features: ['充电桩', '电梯', '监控', '洗车'] },
  { id: 'p014', name: '朝阳大悦城车库', address: '朝阳区朝阳北路101号', distance: 4.2, availableSpaces: 0, totalSpaces: 700, pricePerHour: 6, maxPrice: 60, type: '地下停车场', rating: 4.5, isOpen: false, latitude: 39.9234, longitude: 116.5067, features: ['充电桩', '电梯'] },
  { id: 'p015', name: '世纪金源停车场', address: '海淀区远大路1号', distance: 4.5, availableSpaces: 567, totalSpaces: 1500, pricePerHour: 4, maxPrice: 40, type: '室内停车场', rating: 4.3, isOpen: true, latitude: 39.9567, longitude: 116.2890, features: ['充电桩', '24小时'] },
  { id: 'p016', name: '荟聚中心停车场', address: '大兴区欣宁街15号', distance: 5.0, availableSpaces: 678, totalSpaces: 2000, pricePerHour: 3, maxPrice: 30, type: '室内停车场', rating: 4.4, isOpen: true, latitude: 39.8056, longitude: 116.3423, features: ['充电桩', '电梯', '监控'] },
  { id: 'p017', name: '龙德广场停车场', address: '昌平区立汤路186号', distance: 5.5, availableSpaces: 123, totalSpaces: 600, pricePerHour: 5, maxPrice: 50, type: '露天停车场', rating: 4.2, isOpen: true, latitude: 40.0567, longitude: 116.4123, features: ['充电桩', '24小时'] },
  { id: 'p018', name: '首创奥特莱斯车库', address: '房山区长阳镇', distance: 6.0, availableSpaces: 345, totalSpaces: 800, pricePerHour: 3, maxPrice: 30, type: '露天停车场', rating: 4.1, isOpen: true, latitude: 39.7456, longitude: 116.2234, features: ['充电桩', '监控'] },
  { id: 'p019', name: '中关村e世界停车场', address: '海淀区中关村大街11号', distance: 0.5, availableSpaces: 23, totalSpaces: 200, pricePerHour: 10, maxPrice: 100, type: '机械车库', rating: 4.0, isOpen: true, latitude: 39.9812, longitude: 116.3112, features: ['监控', '24小时'] },
  { id: 'p020', name: '数码大厦停车场', address: '海淀区南大街9号', distance: 1.0, availableSpaces: 45, totalSpaces: 180, pricePerHour: 8, maxPrice: 80, type: '机械车库', rating: 3.9, isOpen: true, latitude: 39.9756, longitude: 116.3201, features: ['监控'] }
];

const VehicleData: VehicleInfo[] = [
  { id: 'v001', plateNumber: '京A·88888', brand: '比亚迪', model: '汉EV', color: '极光蓝', year: 2024, insuranceExpiry: '2027-06-15', inspectionExpiry: '2028-06-15', violationCount: 0, isDefault: true },
  { id: 'v002', plateNumber: '京B·66666', brand: '特斯拉', model: 'Model Y', color: '珍珠白', year: 2023, insuranceExpiry: '2026-12-20', inspectionExpiry: '2027-12-20', violationCount: 2, isDefault: false },
  { id: 'v003', plateNumber: '京C·12345', brand: '蔚来', model: 'ET5', color: '镜空粉', year: 2024, insuranceExpiry: '2027-03-10', inspectionExpiry: '2028-03-10', violationCount: 0, isDefault: false },
  { id: 'v004', plateNumber: '京D·77777', brand: '小鹏', model: 'G9', color: '星爵灰', year: 2023, insuranceExpiry: '2026-09-25', inspectionExpiry: '2027-09-25', violationCount: 1, isDefault: false },
  { id: 'v005', plateNumber: '京E·55555', brand: '理想', model: 'L9', color: '星空蓝', year: 2024, insuranceExpiry: '2027-08-30', inspectionExpiry: '2028-08-30', violationCount: 0, isDefault: false }
];

const ParkingRecordData: ParkingRecordInfo[] = [
  { id: 'r001', lotName: '深蓝环球中心停车场', lotAddress: '海淀区中关村大街1号', plateNumber: '京A·88888', entryTime: '2026-07-24 09:15', exitTime: '2026-07-24 12:30', duration: 195, fee: 24, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r002', lotName: '万达广场地下车库', lotAddress: '朝阳区建国路93号', plateNumber: '京A·88888', entryTime: '2026-07-23 14:00', exitTime: '2026-07-23 18:45', duration: 285, fee: 18, paymentMethod: '支付宝', status: '已完成' },
  { id: 'r003', lotName: '金融街停车楼', lotAddress: '西城区金融大街7号', plateNumber: '京B·66666', entryTime: '2026-07-22 08:30', exitTime: '2026-07-22 10:00', duration: 90, fee: 12, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r004', lotName: '望京SOHO停车场', lotAddress: '朝阳区阜通东大街1号', plateNumber: '京A·88888', entryTime: '2026-07-21 19:00', exitTime: '2026-07-22 08:00', duration: 780, fee: 50, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r005', lotName: '西单大悦城车库', lotAddress: '西城区西单北大街131号', plateNumber: '京C·12345', entryTime: '2026-07-20 13:20', exitTime: '2026-07-20 16:50', duration: 210, fee: 14, paymentMethod: '会员卡', status: '已完成' },
  { id: 'r006', lotName: '三里屯太古里车库', lotAddress: '朝阳区三里屯路19号', plateNumber: '京A·88888', entryTime: '2026-07-19 20:00', exitTime: '2026-07-19 23:30', duration: 210, fee: 20, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r007', lotName: '奥林匹克公园停车场', lotAddress: '朝阳区北辰东路', plateNumber: '京D·77777', entryTime: '2026-07-18 15:00', exitTime: '2026-07-18 19:00', duration: 240, fee: 16, paymentMethod: '支付宝', status: '已完成' },
  { id: 'r008', lotName: '蓝色港湾停车场', lotAddress: '朝阳区朝阳公园路6号', plateNumber: '京A·88888', entryTime: '2026-07-17 11:00', exitTime: '2026-07-17 14:15', duration: 195, fee: 18, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r009', lotName: '五棵松华熙LIVE车库', lotAddress: '海淀区复兴路69号', plateNumber: '京E·55555', entryTime: '2026-07-16 18:30', exitTime: '2026-07-16 22:00', duration: 210, fee: 20, paymentMethod: '会员卡', status: '已完成' },
  { id: 'r010', lotName: '合生汇停车场', lotAddress: '朝阳区西大望路', plateNumber: '京A·88888', entryTime: '2026-07-15 10:00', exitTime: '2026-07-15 13:00', duration: 180, fee: 21, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r011', lotName: '颐堤港停车场', lotAddress: '朝阳区酒仙桥路18号', plateNumber: '京B·66666', entryTime: '2026-07-14 16:00', exitTime: '2026-07-14 20:30', duration: 270, fee: 18, paymentMethod: '支付宝', status: '已完成' },
  { id: 'r012', lotName: '世纪金源停车场', lotAddress: '海淀区远大路1号', plateNumber: '京A·88888', entryTime: '2026-07-13 09:00', exitTime: '2026-07-13 12:00', duration: 180, fee: 12, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r013', lotName: '深蓝环球中心停车场', lotAddress: '海淀区中关村大街1号', plateNumber: '京C·12345', entryTime: '2026-07-24 08:00', exitTime: '', duration: 0, fee: 0, paymentMethod: '', status: '停车中' },
  { id: 'r014', lotName: '荟聚中心停车场', lotAddress: '大兴区欣宁街15号', plateNumber: '京D·77777', entryTime: '2026-07-11 14:00', exitTime: '2026-07-11 17:45', duration: 225, fee: 9, paymentMethod: '微信支付', status: '已完成' },
  { id: 'r015', lotName: '中关村e世界停车场', lotAddress: '海淀区中关村大街11号', plateNumber: '京A·88888', entryTime: '2026-07-10 13:00', exitTime: '2026-07-10 15:30', duration: 150, fee: 20, paymentMethod: '微信支付', status: '已完成' }
];

const FavoriteLotData: FavoriteLotInfo[] = [
  { id: 'f001', lotId: 'p001', name: '深蓝环球中心停车场', address: '海淀区中关村大街1号', distance: 0.3, pricePerHour: 8, availableSpaces: 156, addedDate: '2026-06-01' },
  { id: 'f002', lotId: 'p002', name: '万达广场地下车库', address: '朝阳区建国路93号', distance: 0.8, pricePerHour: 6, availableSpaces: 89, addedDate: '2026-06-05' },
  { id: 'f003', lotId: 'p003', name: '金融街停车楼', address: '西城区金融大街7号', distance: 1.2, pricePerHour: 12, availableSpaces: 45, addedDate: '2026-06-10' },
  { id: 'f004', lotId: 'p005', name: '西单大悦城车库', address: '西城区西单北大街131号', distance: 1.8, pricePerHour: 7, availableSpaces: 234, addedDate: '2026-06-15' },
  { id: 'f005', lotId: 'p006', name: '望京SOHO停车场', address: '朝阳区阜通东大街1号', distance: 2.1, pricePerHour: 5, availableSpaces: 178, addedDate: '2026-06-20' },
  { id: 'f006', lotId: 'p009', name: '奥林匹克公园停车场', address: '朝阳区北辰东路', distance: 3.0, pricePerHour: 4, availableSpaces: 345, addedDate: '2026-06-25' },
  { id: 'f007', lotId: 'p010', name: '蓝色港湾停车场', address: '朝阳区朝阳公园路6号', distance: 3.2, pricePerHour: 6, availableSpaces: 89, addedDate: '2026-07-01' },
  { id: 'f008', lotId: 'p013', name: '颐堤港停车场', address: '朝阳区酒仙桥路18号', distance: 4.0, pricePerHour: 6, availableSpaces: 412, addedDate: '2026-07-05' },
  { id: 'f009', lotId: 'p015', name: '世纪金源停车场', address: '海淀区远大路1号', distance: 4.5, pricePerHour: 4, availableSpaces: 567, addedDate: '2026-07-10' },
  { id: 'f010', lotId: 'p016', name: '荟聚中心停车场', address: '大兴区欣宁街15号', distance: 5.0, pricePerHour: 3, availableSpaces: 678, addedDate: '2026-07-15' }
];

const MonthlyStatData: MonthlyStatInfo[] = [
  { month: '1月', totalFee: 320, totalCount: 18, avgDuration: 165 },
  { month: '2月', totalFee: 280, totalCount: 15, avgDuration: 180 },
  { month: '3月', totalFee: 410, totalCount: 22, avgDuration: 155 },
  { month: '4月', totalFee: 350, totalCount: 19, avgDuration: 170 },
  { month: '5月', totalFee: 480, totalCount: 25, avgDuration: 160 },
  { month: '6月', totalFee: 420, totalCount: 23, avgDuration: 175 },
  { month: '7月', totalFee: 265, totalCount: 15, avgDuration: 190 }
];

const UserProfile: UserProfileInfo = {
  name: '张伟', phone: '138****8888', avatar: '👤', memberLevel: '蓝钻会员',
  totalParking: 137, totalSpent: 2525, points: 2525
};

// ==================== 5. 全局工具函数 ====================

function formatDistance(km: number): string {
  if (km < 1) { return `${Math.round(km * 1000)}m`; }
  return `${km.toFixed(1)}km`;
}
function getOccupancyColor(rate: number): string {
  if (rate >= 90) { return '#E53935'; }
  else if (rate >= 70) { return '#FB8C00'; }
  else { return '#1976D2'; }
}
function getTypeColor(type: string): string {
  if (type === '室内停车场') { return '#0D47A1'; }
  else if (type === '地下停车场') { return '#1976D2'; }
  else if (type === '露天停车场') { return '#42A5F5'; }
  else { return '#90CAF9'; }
}
function getAvailabilityText(available: number): string {
  if (available === 0) { return '已满'; }
  else if (available < 20) { return '紧张'; }
  else if (available < 100) { return '充足'; }
  else { return '充裕'; }
}
function getStatusColor(status: string): string {
  if (status === '停车中') { return '#1976D2'; }
  else if (status === '已完成') { return '#43A047'; }
  else { return '#757575'; }
}
function buildGradient(c1: string, c2: string): LinearGradient {
  return { angle: 135, direction: GradientDirection.RightBottom, colors: [[c1, 0.0], [c2, 1.0]] };
}

// ==================== 6. 底部Tab枚举 ====================
enum BottomTab { HOME = 0, PARKING = 1, RECORD = 2, VEHICLE = 3, MINE = 4 }

// ==================== 7. @Entry 主入口组件 ====================

@Entry
@Component
struct ParkingApp {
  @State currentTab: number = BottomTab.HOME;
  @State showAddVehicleDialog: boolean = false;
  @State showDeleteConfirmDialog: boolean = false;
  @State showEditPlateDialog: boolean = false;
  @State deleteTargetId: string = '';
  @State editTargetId: string = '';
  @State newPlateInput: string = '';
  @State newBrandInput: string = '';
  @State newColorInput: string = '';
  @State editPlateInput: string = '';

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Column() {
        this.TopStatusBar()
        Column() {
          if (this.currentTab === BottomTab.HOME) {
            HomeTabContent()
          } else if (this.currentTab === BottomTab.PARKING) {
            ParkingTabContent()
          } else if (this.currentTab === BottomTab.RECORD) {
            RecordTabContent()
          } else if (this.currentTab === BottomTab.VEHICLE) {
            VehicleTabContent({ onAddVehicle: () => { this.showAddVehicleDialog = true; } })
          } else {
            MineTabContent({
              onDeleteFavorite: (id: string) => { this.deleteTargetId = id; this.showDeleteConfirmDialog = true; },
              onEditPlate: (id: string) => { this.editTargetId = id; this.showEditPlateDialog = true; }
            })
          }
        }.layoutWeight(1).width('100%')
        this.BottomTabBar()
      }.width('100%').height('100%')

      if (this.showAddVehicleDialog) { this.AddVehicleDialog() }
      if (this.showDeleteConfirmDialog) { this.DeleteConfirmDialog() }
      if (this.showEditPlateDialog) { this.EditPlateDialog() }
    }.width('100%').height('100%').backgroundColor('#F0F4F8')
  }

  getTabColor(name: string): string {
    if ((name === 'home' && this.currentTab === BottomTab.HOME) ||
      (name === 'parking' && this.currentTab === BottomTab.PARKING) ||
      (name === 'record' && this.currentTab === BottomTab.RECORD) ||
      (name === 'vehicle' && this.currentTab === BottomTab.VEHICLE) ||
      (name === 'mine' && this.currentTab === BottomTab.MINE)) {
      return '#0D47A1';
    }
    return '#9E9E9E';
  }

  @Builder TopStatusBar() {
    Row() {
      Row() {
        Text('深蓝停车').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text('BluePark').fontSize(12).fontColor('#90CAF9').margin({ left: 8 })
      }.layoutWeight(1)
      Row() {
        Text('🔍').fontSize(20).margin({ right: 16 })
        Text('🔔').fontSize(20)
      }
    }.width('100%').height(56).padding({ left: 16, right: 16 })
    .linearGradient(buildGradient('#0D47A1', '#1976D2'))
  }

  @Builder BottomTabBar() {
    Row() {
      ForEach(Object.values(TabConfig), (item: TabItemInfo) => {
        Column() {
          Text(item.icon).fontSize(22)
          Text(item.title).fontSize(11).margin({ top: 2 }).fontColor(this.getTabColor(item.name))
        }.layoutWeight(1).height(56).justifyContent(FlexAlign.Center)
        .onClick(() => {
          if (item.name === 'home') { this.currentTab = BottomTab.HOME; }
          else if (item.name === 'parking') { this.currentTab = BottomTab.PARKING; }
          else if (item.name === 'record') { this.currentTab = BottomTab.RECORD; }
          else if (item.name === 'vehicle') { this.currentTab = BottomTab.VEHICLE; }
          else { this.currentTab = BottomTab.MINE; }
        })
      })
    }.width('100%').height(56).backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 16, topRight: 16 })
    .shadow({ radius: 10, color: 'rgba(13,71,161,0.15)', offsetX: 0, offsetY: -2 })
  }

  @Builder AddVehicleDialog() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        Column() {
          Text('新增车辆').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#0D47A1').margin({ top: 24, bottom: 20 })
          Column({ space: 16 }) {
            Column({ space: 8 }) {
              Text('车牌号').fontSize(14).fontColor('#616161').alignSelf(ItemAlign.Start)
              TextInput({ placeholder: '如:京A·12345', text: this.newPlateInput })
                .fontSize(16).height(44).borderRadius(10).backgroundColor('#F5F5F5')
                .padding({ left: 12, right: 12 }).onChange((v: string) => { this.newPlateInput = v; })
            }.width('100%')
            Column({ space: 8 }) {
              Text('车型').fontSize(14).fontColor('#616161').alignSelf(ItemAlign.Start)
              TextInput({ placeholder: '如:比亚迪汉EV', text: this.newBrandInput })
                .fontSize(16).height(44).borderRadius(10).backgroundColor('#F5F5F5')
                .padding({ left: 12, right: 12 }).onChange((v: string) => { this.newBrandInput = v; })
            }.width('100%')
            Column({ space: 8 }) {
              Text('颜色').fontSize(14).fontColor('#616161').alignSelf(ItemAlign.Start)
              TextInput({ placeholder: '如:极光蓝', text: this.newColorInput })
                .fontSize(16).height(44).borderRadius(10).backgroundColor('#F5F5F5')
                .padding({ left: 12, right: 12 }).onChange((v: string) => { this.newColorInput = v; })
            }.width('100%')
          }.width('100%').padding({ left: 20, right: 20 })
          Row({ space: 12 }) {
            Button('取消').fontSize(16).fontColor('#616161').layoutWeight(1).height(44).borderRadius(22)
              .backgroundColor('#EEEEEE').onClick(() => {
              this.showAddVehicleDialog = false;
              this.newPlateInput = ''; this.newBrandInput = ''; this.newColorInput = '';
            })
            Button('确认添加').fontSize(16).fontColor('#FFFFFF').layoutWeight(1).height(44).borderRadius(22)
              .linearGradient(buildGradient('#1565C0', '#42A5F5')).onClick(() => {
              this.showAddVehicleDialog = false;
              this.newPlateInput = ''; this.newBrandInput = ''; this.newColorInput = '';
            })
          }.width('100%').padding({ left: 20, right: 20, top: 28, bottom: 24 })
        }.width('86%').borderRadius(20).backgroundColor('#FFFFFF')
      }.width('100%')
    }.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').position({ x: 0, y: 0 })
  }

  @Builder DeleteConfirmDialog() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        Column() {
          Text('确认删除').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E53935').margin({ top: 24, bottom: 16 })
          Text('确定要删除这个收藏的停车场吗?删除后无法恢复。')
            .fontSize(14).fontColor('#616161').textAlign(TextAlign.Center).lineHeight(22)
            .padding({ left: 20, right: 20 }).margin({ bottom: 24 })
          Row({ space: 12 }) {
            Button('取消').fontSize(16).fontColor('#616161').layoutWeight(1).height(44).borderRadius(22)
              .backgroundColor('#EEEEEE').onClick(() => { this.showDeleteConfirmDialog = false; this.deleteTargetId = ''; })
            Button('确认删除').fontSize(16).fontColor('#FFFFFF').layoutWeight(1).height(44).borderRadius(22)
              .backgroundColor('#E53935').onClick(() => { this.showDeleteConfirmDialog = false; this.deleteTargetId = ''; })
          }.width('100%').padding({ left: 20, right: 20, bottom: 24 })
        }.width('80%').borderRadius(20).backgroundColor('#FFFFFF')
      }.width('100%')
    }.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').position({ x: 0, y: 0 })
  }

  @Builder EditPlateDialog() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        Column() {
          Text('编辑车牌号').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#0D47A1').margin({ top: 24, bottom: 20 })
          Column({ space: 8 }) {
            Text('新车牌号').fontSize(14).fontColor('#616161').alignSelf(ItemAlign.Start)
            TextInput({ placeholder: '请输入新车牌号', text: this.editPlateInput })
              .fontSize(16).height(44).borderRadius(10).backgroundColor('#F5F5F5')
              .padding({ left: 12, right: 12 }).onChange((v: string) => { this.editPlateInput = v; })
          }.width('100%').padding({ left: 20, right: 20 })
          Row({ space: 12 }) {
            Button('取消').fontSize(16).fontColor('#616161').layoutWeight(1).height(44).borderRadius(22)
              .backgroundColor('#EEEEEE').onClick(() => {
              this.showEditPlateDialog = false; this.editTargetId = ''; this.editPlateInput = '';
            })
            Button('保存').fontSize(16).fontColor('#FFFFFF').layoutWeight(1).height(44).borderRadius(22)
              .linearGradient(buildGradient('#1565C0', '#42A5F5')).onClick(() => {
              this.showEditPlateDialog = false; this.editTargetId = ''; this.editPlateInput = '';
            })
          }.width('100%').padding({ left: 20, right: 20, top: 28, bottom: 24 })
        }.width('80%').borderRadius(20).backgroundColor('#FFFFFF')
      }.width('100%')
    }.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').position({ x: 0, y: 0 })
  }
}

// ==================== 8. Tab1: 首页 ====================

@Component
struct HomeTabContent {
  @State nearbyList: ParkingLotInfo[] = ParkingLotData.slice(0, 15);

  build() {
    Scroll() {
      Column() {
        this.LocationCard()
        this.QuickActionsRow()
        Row() {
          Text('附近停车场').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0D47A1').layoutWeight(1)
          Text('查看全部 >').fontSize(13).fontColor('#1976D2')
        }.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
        ForEach(this.nearbyList, (lot: ParkingLotInfo) => { this.NearbyParkingCard(lot) })
        Column().width('100%').height(20)
      }.width('100%')
    }.width('100%').height('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
  }

  @Builder LocationCard() {
    Column() {
      Row() {
        Column() {
          Row() {
            Text('📍').fontSize(18)
            Text('当前位置').fontSize(13).fontColor('#90CAF9').margin({ left: 4 })
          }
          Text('海淀区中关村大街1号').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 6 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Column() {
          Text('晴 28°').fontSize(14).fontColor('#90CAF9')
          Text('适宜停车').fontSize(12).fontColor('#42A5F5').margin({ top: 4 })
        }.alignItems(HorizontalAlign.End)
      }.width('100%')
      Divider().color('rgba(255,255,255,0.2)').margin({ top: 16, bottom: 16 })
      Row() {
        Column() {
          Text('附近').fontSize(12).fontColor('#90CAF9')
          Text('20').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 4 })
          Text('停车场').fontSize(11).fontColor('#42A5F5').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('可用').fontSize(12).fontColor('#90CAF9')
          Text('3,478').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 4 })
          Text('车位').fontSize(11).fontColor('#42A5F5').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('均价').fontSize(12).fontColor('#90CAF9')
          Text('¥6.5').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 4 })
          Text('每小时').fontSize(11).fontColor('#42A5F5').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }.width('100%')
    }.width('100%').padding(20).borderRadius(20)
    .linearGradient(buildGradient('#0D47A1', '#1976D2'))
    .shadow({ radius: 12, color: 'rgba(13,71,161,0.3)', offsetX: 0, offsetY: 4 })
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder QuickActionsRow() {
    Row() {
      Column() {
        Text('🧭').fontSize(28)
        Text('导航').fontSize(12).fontColor('#0D47A1').margin({ top: 4 })
      }.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor('#FFFFFF').borderRadius(14)
      .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
      Column() {
        Text('⚡').fontSize(28)
        Text('充电').fontSize(12).fontColor('#0D47A1').margin({ top: 4 })
      }.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor('#FFFFFF').borderRadius(14)
      .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 }).margin({ left: 8 })
      Column() {
        Text('🅿').fontSize(28)
        Text('预约').fontSize(12).fontColor('#0D47A1').margin({ top: 4 })
      }.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor('#FFFFFF').borderRadius(14)
      .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 }).margin({ left: 8 })
      Column() {
        Text('🎫').fontSize(28)
        Text('优惠券').fontSize(12).fontColor('#0D47A1').margin({ top: 4 })
      }.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor('#FFFFFF').borderRadius(14)
      .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 }).margin({ left: 8 })
    }.width('100%').padding({ left: 16, right: 16, top: 16 })
  }

  @Builder NearbyParkingCard(lot: ParkingLotInfo) {
    Column() {
      Row() {
        Column() { Text('🅿').fontSize(24) }
        .width(48).height(48).borderRadius(12).backgroundColor('#E3F2FD')
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text(lot.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Row() {
            Text(formatDistance(lot.distance)).fontSize(12).fontColor('#1976D2')
            Text(' · ').fontSize(12).fontColor('#9E9E9E')
            Text(lot.type).fontSize(12).fontColor('#757575')
            Text(' · ').fontSize(12).fontColor('#9E9E9E')
            Text('★' + lot.rating.toFixed(1)).fontSize(12).fontColor('#FB8C00')
          }
          Text(lot.address).fontSize(12).fontColor('#9E9E9E')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)
        Column() { Text('🧭').fontSize(20) }
        .width(40).height(40).borderRadius(20).backgroundColor('#1976D2')
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
      }.width('100%')
      Row() {
        Row() {
          Text('空位').fontSize(11).fontColor('#757575')
          Text(lot.availableSpaces.toString()).fontSize(14).fontWeight(FontWeight.Bold)
            .fontColor(lot.isOpen ? '#1976D2' : '#E53935').margin({ left: 4 })
          Text('/' + lot.totalSpaces).fontSize(11).fontColor('#9E9E9E')
        }.layoutWeight(1)
        Row() {
          Text('¥' + lot.pricePerHour).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
          Text('/小时').fontSize(11).fontColor('#9E9E9E')
        }
      }.width('100%').margin({ top: 12 })
      Row() {
        Column() {
          Row().width(lot.totalSpaces > 0 ? `${(1 - lot.availableSpaces / lot.totalSpaces) * 100}%` : '100%')
            .height(4).borderRadius(2)
            .backgroundColor(getOccupancyColor(lot.totalSpaces > 0 ? Math.round((1 - lot.availableSpaces / lot.totalSpaces) * 100) : 100))
        }.width('100%').height(4).borderRadius(2).backgroundColor('#E0E0E0')
      }.width('100%').margin({ top: 8 })
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 8 })
  }
}

// ==================== 9. Tab2: 停车场 ====================

@Component
struct ParkingTabContent {
  @State allParkingList: ParkingLotInfo[] = ParkingLotData;
  @State filterType: string = '全部';
  @State sortBy: string = '距离';

  build() {
    Scroll() {
      Column() {
        this.FilterBar()
        this.SortBar()
        ForEach(this.allParkingList, (lot: ParkingLotInfo) => { this.ParkingDetailCard(lot) })
        Column().width('100%').height(20)
      }.width('100%')
    }.width('100%').height('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
  }

  @Builder FilterBar() {
    Scroll() {
      Row() {
        this.FilterChip('全部')
        this.FilterChip('室内')
        this.FilterChip('地下')
        this.FilterChip('露天')
        this.FilterChip('机械')
        this.FilterChip('充电桩')
        this.FilterChip('24小时')
      }.padding({ left: 16, right: 16 })
    }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(48).width('100%')
  }

  @Builder FilterChip(label: string) {
    Text(label).fontSize(13).fontColor(this.filterType === label ? '#FFFFFF' : '#1976D2')
      .padding({ left: 16, right: 16, top: 8, bottom: 8 }).margin({ right: 8 }).borderRadius(20)
      .backgroundColor(this.filterType === label ? '#0D47A1' : '#E3F2FD')
      .onClick(() => { this.filterType = label; })
  }

  @Builder SortBar() {
    Row() {
      Text('共' + this.allParkingList.length.toString() + '个停车场').fontSize(13).fontColor('#757575').layoutWeight(1)
      Row({ space: 4 }) {
        Text(this.sortBy).fontSize(13).fontColor('#1976D2')
        Text('⇅').fontSize(14).fontColor('#1976D2')
      }.onClick(() => {
        if (this.sortBy === '距离') { this.sortBy = '价格'; }
        else if (this.sortBy === '价格') { this.sortBy = '评分'; }
        else { this.sortBy = '距离'; }
      })
    }.width('100%').padding({ left: 16, right: 16, top: 4, bottom: 8 })
  }

  @Builder ParkingDetailCard(lot: ParkingLotInfo) {
    Column() {
      Row() {
        Text(lot.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121').layoutWeight(1)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(lot.isOpen ? '营业中' : '已关闭').fontSize(11).fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
          .backgroundColor(lot.isOpen ? '#43A047' : '#9E9E9E')
      }.width('100%')
      Row() {
        Text('📍 ' + lot.address).fontSize(13).fontColor('#757575').layoutWeight(1)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(formatDistance(lot.distance)).fontSize(13).fontColor('#1976D2').fontWeight(FontWeight.Medium)
      }.width('100%').margin({ top: 8 })
      Row() {
        Text(lot.type).fontSize(12).fontColor('#FFFFFF').padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8).backgroundColor(getTypeColor(lot.type))
        Text('★ ' + lot.rating.toFixed(1)).fontSize(12).fontColor('#FB8C00').margin({ left: 8 })
        Row() {
          ForEach(lot.features, (feat: string) => {
            Text(feat).fontSize(11).fontColor('#1976D2').padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .margin({ right: 4 }).borderRadius(6).backgroundColor('#E3F2FD')
          })
        }.margin({ left: 8 }).layoutWeight(1)
      }.width('100%').margin({ top: 10 })
      Column({ space: 6 }) {
        Row() {
          Text('空位').fontSize(12).fontColor('#616161')
          Text(lot.availableSpaces.toString()).fontSize(13).fontWeight(FontWeight.Bold)
            .fontColor(lot.availableSpaces > 0 ? '#1976D2' : '#E53935').margin({ left: 4 })
          Text(' / ' + lot.totalSpaces.toString()).fontSize(12).fontColor('#9E9E9E')
          Text('  (' + getAvailabilityText(lot.availableSpaces) + ')').fontSize(12)
            .fontColor(lot.availableSpaces > 0 ? '#43A047' : '#E53935')
          Text(lot.totalSpaces > 0 ? '使用率' + Math.round((1 - lot.availableSpaces / lot.totalSpaces) * 100).toString() + '%' : '已满')
            .fontSize(12).fontColor('#9E9E9E').layoutWeight(1).textAlign(TextAlign.End)
        }.width('100%')
        Column() {
          Row().width(lot.totalSpaces > 0 ? `${(1 - lot.availableSpaces / lot.totalSpaces) * 100}%` : '100%')
            .height(6).borderRadius(3)
            .backgroundColor(getOccupancyColor(lot.totalSpaces > 0 ? Math.round((1 - lot.availableSpaces / lot.totalSpaces) * 100) : 100))
        }.width('100%').height(6).borderRadius(3).backgroundColor('#E0E0E0')
      }.width('100%').margin({ top: 12 })
      Row() {
        Column() {
          Text('¥' + lot.pricePerHour + '/小时').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
          Text('封顶 ¥' + lot.maxPrice + '/天').fontSize(11).fontColor('#9E9E9E').margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Row({ space: 8 }) {
          Button('详情').fontSize(13).fontColor('#1976D2').height(36).borderRadius(18).backgroundColor('#E3F2FD')
          Button('导航').fontSize(13).fontColor('#FFFFFF').height(36).borderRadius(18)
            .linearGradient(buildGradient('#1565C0', '#42A5F5'))
        }
      }.width('100%').margin({ top: 14 })
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 8 })
  }
}

// ==================== 10. Tab3: 记录 ====================

@Component
struct RecordTabContent {
  @State recordList: ParkingRecordInfo[] = ParkingRecordData;
  @State totalFee: number = 265;
  @State totalCount: number = 15;

  build() {
    Scroll() {
      Column() {
        this.FeeSummaryCard()
        this.MonthlyFeeChart()
        this.DurationStatCard()
        Row() {
          Text('停车记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0D47A1').layoutWeight(1)
          Text('共' + this.recordList.length.toString() + '条').fontSize(13).fontColor('#757575')
        }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
        ForEach(this.recordList, (record: ParkingRecordInfo) => { this.RecordCard(record) })
        Column().width('100%').height(20)
      }.width('100%')
    }.width('100%').height('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
  }

  @Builder FeeSummaryCard() {
    Column() {
      Row() {
        Text('本月停车').fontSize(14).fontColor('#90CAF9')
        Text('7月').fontSize(12).fontColor('#42A5F5').padding({ left: 8, right: 8, top: 2, bottom: 2 })
          .borderRadius(8).backgroundColor('rgba(255,255,255,0.2)').margin({ left: 8 })
      }.width('100%')
      Row() {
        Text('¥').fontSize(18).fontColor('#FFFFFF').margin({ bottom: 8 })
        Text(this.totalFee.toString()).fontSize(40).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      }.margin({ top: 8 }).alignItems(VerticalAlign.Bottom)
      Row() {
        Column() {
          Text('停车次数').fontSize(12).fontColor('#90CAF9')
          Text(this.totalCount.toString() + '次').fontSize(16).fontWeight(FontWeight.Medium).fontColor('#FFFFFF').margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('平均费用').fontSize(12).fontColor('#90CAF9')
          Text('¥' + (this.totalFee / this.totalCount).toFixed(1)).fontSize(16).fontWeight(FontWeight.Medium).fontColor('#FFFFFF').margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('较上月').fontSize(12).fontColor('#90CAF9')
          Text('-36.9%').fontSize(16).fontWeight(FontWeight.Medium).fontColor('#43A047').margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      }.width('100%').margin({ top: 20 })
    }.width('100%').padding(20).borderRadius(20)
    .linearGradient(buildGradient('#0D47A1', '#1565C0'))
    .shadow({ radius: 12, color: 'rgba(13,71,161,0.3)', offsetX: 0, offsetY: 4 })
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder MonthlyFeeChart() {
    Column() {
      Text('月度停车费用').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
      Row() {
        ForEach(MonthlyStatData, (stat: MonthlyStatInfo) => {
          Column() {
            Text('¥' + stat.totalFee.toString()).fontSize(10).fontColor('#1976D2').margin({ bottom: 4 })
            Column().width(20).height(stat.totalFee / 6).borderRadius(4)
              .backgroundColor(stat.month === '7月' ? '#0D47A1' : '#42A5F5')
            Text(stat.month).fontSize(10).fontColor('#757575').margin({ top: 4 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        })
      }.width('100%').height(120).margin({ top: 16 }).alignItems(VerticalAlign.Bottom)
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder DurationStatCard() {
    Column() {
      Text('停车时长分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
      ForEach(Object.values(DurationStatConfig), (stat: DurationStatInfo) => {
        Row() {
          Text(stat.range).fontSize(13).fontColor('#616161').width(80)
          Column() {
            Row().width(`${stat.percentage}%`).height(10).borderRadius(5).backgroundColor('#1976D2')
          }.layoutWeight(1).height(10).borderRadius(5).backgroundColor('#E3F2FD').margin({ left: 8, right: 8 })
          Text(stat.count.toString() + '次').fontSize(12).fontColor('#1976D2').width(40).textAlign(TextAlign.End)
          Text(stat.percentage.toString() + '%').fontSize(12).fontColor('#9E9E9E').width(36).textAlign(TextAlign.End)
        }.width('100%').margin({ top: 10 })
      })
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder RecordCard(record: ParkingRecordInfo) {
    Column() {
      Row() {
        Column() {
          Text(record.lotName).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(record.lotAddress).fontSize(12).fontColor('#9E9E9E').margin({ top: 4 })
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ right: 8 })
        Text(record.status).fontSize(11).fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
          .backgroundColor(getStatusColor(record.status))
      }.width('100%')
      Row() {
        Column() {
          Text('入场时间').fontSize(11).fontColor('#9E9E9E')
          Text(record.entryTime).fontSize(13).fontColor('#212121').margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Column() {
          Text('出场时间').fontSize(11).fontColor('#9E9E9E')
          Text(record.exitTime.length > 0 ? record.exitTime : '—').fontSize(13).fontColor('#212121').margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Column() {
          Text('时长').fontSize(11).fontColor('#9E9E9E')
          Text(record.duration > 0 ? `${Math.floor(record.duration / 60)}h${record.duration % 60}m` : '—')
            .fontSize(13).fontColor('#1976D2').fontWeight(FontWeight.Medium).margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start)
      }.width('100%').margin({ top: 12 })
      Row() {
        Text('🚗 ' + record.plateNumber).fontSize(12).fontColor('#616161')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).backgroundColor('#F5F5F5')
        if (record.paymentMethod.length > 0) {
          Text('💳 ' + record.paymentMethod).fontSize(12).fontColor('#616161')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).backgroundColor('#F5F5F5').margin({ left: 8 })
        }
        Row() {
          if (record.fee > 0) {
            Text('¥').fontSize(13).fontColor('#0D47A1')
            Text(record.fee.toString()).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
          } else {
            Text('计费中').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1976D2')
          }
        }.layoutWeight(1).justifyContent(FlexAlign.End)
      }.width('100%').margin({ top: 12 }).alignItems(VerticalAlign.Center)
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 8 })
  }
}

// ==================== 11. Tab4: 车辆 ====================

@Component
struct VehicleTabContent {
  @State vehicleList: VehicleInfo[] = VehicleData;
  onAddVehicle: (() => void) | null = null;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('我的车辆').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0D47A1').layoutWeight(1)
          Row() {
            Text('+ 新增').fontSize(14).fontColor('#FFFFFF')
          }.padding({ left: 16, right: 16, top: 8, bottom: 8 }).borderRadius(20)
          .linearGradient(buildGradient('#1565C0', '#42A5F5'))
          .onClick(() => { if (this.onAddVehicle) { this.onAddVehicle(); } })
        }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
        ForEach(this.vehicleList, (vehicle: VehicleInfo) => { this.VehicleCard(vehicle) })
        this.ParkingTypeChart()
        Column().width('100%').height(20)
      }.width('100%')
    }.width('100%').height('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
  }

  @Builder VehicleCard(vehicle: VehicleInfo) {
    Column() {
      Row() {
        Column() { Text('🚗').fontSize(28) }
        .width(56).height(56).borderRadius(14).backgroundColor('#E3F2FD')
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Row() {
            Text(vehicle.plateNumber).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
            if (vehicle.isDefault) {
              Text('默认').fontSize(10).fontColor('#FFFFFF').padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(6).backgroundColor('#FB8C00').margin({ left: 8 })
            }
          }
          Text(vehicle.brand + ' · ' + vehicle.model).fontSize(13).fontColor('#616161')
          Text(vehicle.year.toString() + '款 · ' + vehicle.color).fontSize(12).fontColor('#9E9E9E')
        }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)
      }.width('100%')
      Row() {
        Column() {
          Text('保险到期').fontSize(11).fontColor('#9E9E9E')
          Text(vehicle.insuranceExpiry).fontSize(13)
            .fontColor(vehicle.insuranceExpiry > '2026-07-24' ? '#43A047' : '#E53935').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('年检到期').fontSize(11).fontColor('#9E9E9E')
          Text(vehicle.inspectionExpiry).fontSize(13)
            .fontColor(vehicle.inspectionExpiry > '2026-07-24' ? '#43A047' : '#E53935').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('违章记录').fontSize(11).fontColor('#9E9E9E')
          Text(vehicle.violationCount.toString() + '条').fontSize(13)
            .fontColor(vehicle.violationCount > 0 ? '#E53935' : '#43A047').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      }.width('100%').margin({ top: 16 })
      Row({ space: 8 }) {
        Button('设为默认').fontSize(12).fontColor('#1976D2').layoutWeight(1).height(34).borderRadius(17).backgroundColor('#E3F2FD')
        Button('编辑车牌').fontSize(12).fontColor('#1976D2').layoutWeight(1).height(34).borderRadius(17).backgroundColor('#E3F2FD')
        Button('删除').fontSize(12).fontColor('#E53935').layoutWeight(1).height(34).borderRadius(17).backgroundColor('#FFEBEE')
      }.width('100%').margin({ top: 14 })
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 8 })
  }

  @Builder ParkingTypeChart() {
    Column() {
      Text('停车场类型偏好').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0D47A1').margin({ bottom: 12 })
      ForEach(Object.values(ParkingTypeConfig), (item: ParkingTypeInfo) => {
        Row() {
          Text(item.type).fontSize(13).fontColor('#616161').width(100)
          Column() {
            Row().width(`${item.percentage}%`).height(14).borderRadius(7).backgroundColor(item.color)
          }.layoutWeight(1).height(14).borderRadius(7).backgroundColor('#F0F0F0').margin({ left: 8, right: 8 })
          Text(item.count.toString() + '次').fontSize(12).fontColor('#1976D2').width(40).textAlign(TextAlign.End)
          Text(item.percentage.toString() + '%').fontSize(12).fontColor('#9E9E9E').width(36).textAlign(TextAlign.End)
        }.width('100%').margin({ top: 10 }).alignItems(VerticalAlign.Center)
      })
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 12 })
  }
}

// ==================== 12. Tab5: 我的 ====================

@Component
struct MineTabContent {
  @State favoriteList: FavoriteLotInfo[] = FavoriteLotData;
  @State settings: SettingItemInfo[] = Object.values(SettingConfig);
  onDeleteFavorite: ((id: string) => void) | null = null;
  onEditPlate: ((id: string) => void) | null = null;

  build() {
    Scroll() {
      Column() {
        this.ProfileCard()
        this.ParkingStatsCard()
        Row() {
          Text('收藏停车场').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0D47A1').layoutWeight(1)
          Text(this.favoriteList.length.toString() + '个').fontSize(13).fontColor('#757575')
        }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })
        ForEach(this.favoriteList, (fav: FavoriteLotInfo) => { this.FavoriteCard(fav) })
        Text('设置').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
          .width('100%').padding({ left: 16, top: 16, bottom: 8 })
        ForEach(this.settings, (item: SettingItemInfo) => { this.SettingRow(item) })
        Column().width('100%').height(20)
      }.width('100%')
    }.width('100%').height('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
  }

  @Builder ProfileCard() {
    Column() {
      Row() {
        Column() { Text(UserProfile.avatar).fontSize(36) }
        .width(64).height(64).borderRadius(32).backgroundColor('rgba(255,255,255,0.3)')
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        Column({ space: 6 }) {
          Row() {
            Text(UserProfile.name).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text(UserProfile.memberLevel).fontSize(11).fontColor('#0D47A1')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8).backgroundColor('#90CAF9').margin({ left: 8 })
          }
          Text(UserProfile.phone).fontSize(14).fontColor('#90CAF9')
        }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
        Text('>').fontSize(20).fontColor('#90CAF9')
      }.width('100%')
      Row() {
        Column() {
          Text(UserProfile.totalParking.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('总停车').fontSize(12).fontColor('#90CAF9').margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('¥' + UserProfile.totalSpent.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('总消费').fontSize(12).fontColor('#90CAF9').margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text(UserProfile.points.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('积分').fontSize(12).fontColor('#90CAF9').margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }.width('100%').margin({ top: 20 })
    }.width('100%').padding(20).borderRadius(20)
    .linearGradient(buildGradient('#0D47A1', '#1976D2'))
    .shadow({ radius: 12, color: 'rgba(13,71,161,0.3)', offsetX: 0, offsetY: 4 })
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder ParkingStatsCard() {
    Column() {
      Text('停车统计').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0D47A1')
      Row() {
        ForEach(MonthlyStatData, (stat: MonthlyStatInfo) => {
          Column() {
            Text(stat.totalFee.toString()).fontSize(9).fontColor('#757575').margin({ bottom: 2 })
            Column().width(18).height(stat.totalFee / 5).borderRadius(3)
              .backgroundColor(stat.month === '7月' ? '#0D47A1' : '#90CAF9')
            Text(stat.month).fontSize(9).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        })
      }.width('100%').height(100).margin({ top: 14 }).alignItems(VerticalAlign.Bottom)
      Row() {
        Column() {
          Text('本月停车').fontSize(11).fontColor('#9E9E9E')
          Text('15次').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1976D2').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('本月消费').fontSize(11).fontColor('#9E9E9E')
          Text('¥265').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#0D47A1').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('平均时长').fontSize(11).fontColor('#9E9E9E')
          Text('3.2h').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1976D2').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }.width('100%').margin({ top: 16 })
    }.width('100%').padding(16).borderRadius(16).backgroundColor('#FFFFFF')
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder FavoriteCard(fav: FavoriteLotInfo) {
    Column() {
      Row() {
        Column() { Text('🅿').fontSize(20) }
        .width(40).height(40).borderRadius(10).backgroundColor('#E3F2FD')
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        Column({ space: 3 }) {
          Text(fav.name).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#212121')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(fav.address).fontSize(12).fontColor('#9E9E9E')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Row() {
            Text(formatDistance(fav.distance)).fontSize(11).fontColor('#1976D2')
            Text(' · 空位' + fav.availableSpaces.toString()).fontSize(11).fontColor('#43A047')
            Text(' · ¥' + fav.pricePerHour.toString() + '/h').fontSize(11).fontColor('#757575')
          }
        }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)
        Column() {
          Text('🗑').fontSize(18).padding(8)
            .onClick(() => { if (this.onDeleteFavorite) { this.onDeleteFavorite(fav.id); } })
        }
      }.width('100%').alignItems(VerticalAlign.Center)
    }.width('100%').padding(14).borderRadius(14).backgroundColor('#FFFFFF')
    .shadow({ radius: 4, color: 'rgba(0,0,0,0.05)', offsetX: 0, offsetY: 2 })
    .margin({ left: 16, right: 16, top: 6 })
  }

  @Builder SettingRow(item: SettingItemInfo) {
    Row() {
      Text(item.icon).fontSize(20).width(36)
      Column({ space: 2 }) {
        Text(item.title).fontSize(15).fontColor('#212121')
        if (item.subtitle.length > 0) {
          Text(item.subtitle).fontSize(12).fontColor('#9E9E9E')
        }
      }.layoutWeight(1).margin({ left: 8 }).alignItems(HorizontalAlign.Start)
      if (item.value.length > 0) {
        Text(item.value).fontSize(13).fontColor('#9E9E9E')
      }
      Text('>').fontSize(18).fontColor('#BDBDBD').margin({ left: 4 })
    }.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor(item.id === 'logout' ? '#FFEBEE' : '#FFFFFF')
    .margin({ left: 16, right: 16, top: 1 })
  }
}


十五、各模块关键技术点对比总结

在这里插入图片描述

维度首页(HomeTabContent)停车场(ParkingTabContent)记录(RecordTabContent)车辆(VehicleTabContent)我的(MineTabContent)
核心功能位置展示 + 附近停车场推荐 + 快捷入口全部停车场列表 + 类型筛选 + 多维排序月度费用统计 + 时长分布 + 历史记录查询车辆信息管理 + 保险/年检状态监控个人档案 + 收藏管理 + 应用设置
状态管理要点nearbyList(@State,15条截取数据)allParkingList + filterType + sortBy(三个 @State 协同)recordList + totalFee + totalCount(统计值与列表分离)vehicleList(@State)favoriteList + settings(两个 @State 分别管理)
关键组件LocationCard、QuickActionsRow、NearbyParkingCardFilterBar、SortBar、ParkingDetailCardFeeSummaryCard、MonthlyFeeChart、DurationStatCard、RecordCardVehicleCard、ParkingTypeChartProfileCard、ParkingStatsCard、FavoriteCard、SettingRow
数据可视化三列统计数据(数字卡片)水平进度条(占用率)柱状图(月度费用)、水平进度条(时长分布)、大数字摘要水平进度条(类型偏好)柱状图(月度统计)、三列统计数据
交互设计纯展示,无用户操作筛选切换、排序切换、详情/导航按钮纯展示,无用户操作新增车辆回调、设为默认、编辑车牌、删除删除收藏回调(触发确认弹窗)、设置行点击
父子通信无(无回调参数)无(无回调参数)无(无回调参数)onAddVehicle 回调 -> 父组件弹窗onDeleteFavorite + onEditPlate 双回调
条件渲染无特殊条件逻辑isOpen 营业状态、features 动态标签、空位数颜色status 区分"已完成/停车中"、exitTime/fee 空值处理isDefault 默认标签、insuranceExpiry/inspectionExpiry 日期颜色、violationCount 数量颜色subtitle/value 空值判断、logout 特殊背景色
设计模式数据截取展示、渐变信息卡片配置驱动筛选、动态进度条颜色数据驱动图表高度、多层条件展示可选回调模式、多指标颜色预警通用列表项构建器、字典转数组遍历
使用的全局工具函数formatDistance、getOccupancyColor、buildGradientformatDistance、getTypeColor、getAvailabilityText、getOccupancyColor、buildGradientgetStatusColor、buildGradientbuildGradientformatDistance、buildGradient
布局特征Scroll 纵向滚动 + ForEach 列表Scroll 纵向滚动 + 水平 Scroll 筛选栏 + ForEach 列表Scroll 纵向滚动 + ForEach 图表/列表Scroll 纵向滚动 + ForEach 列表 + 图表Scroll 纵向滚动 + ForEach 列表 + 静态配置列表

总结: 实现了一个功能完备的停车出行服务 App,涵盖了 HarmonyOS ArkTS 声明式 UI 开发的核心模式——接口定义、响应式状态管理、配置驱动渲染、条件分支逻辑、父子组件通信、自定义弹窗等。通过统一的设计语言(深蓝色系)、一致的布局范式(卡片 + 阴影 + 圆角)和模块化的代码组织(@Builder 拆分 + @Component 封装),展现了高质量 ArkTS 工程代码的典型范式。希望这篇逐段解析能够帮助读者深入理解 HarmonyOS ArkTS 的设计理念与实践技巧。

更多推荐