一、行业背景与应用意义

1.1 汽车后市场的数字化转型浪潮

随着中国汽车保有量突破三亿辆大关,汽车后市场规模持续扩大,汽车维修保养行业正迎来前所未有的发展机遇。然而,大量中小型汽修门店仍然依赖纸质工单、手工记账和电话沟通的传统管理模式,这不仅导致工作效率低下,更容易出现工单遗漏、配件库存混乱、客户信息丢失等问题。

在这里插入图片描述

在这样的行业背景下,一套轻量化、移动化的车辆维修保养管理系统显得尤为重要。门店管理者可以通过手机或平板随时查看维修工单状态、车辆档案、配件库存以及经营统计数据,技师可以快速录入维修项目并跟踪进度,从而实现从"接车-维修-交付"的全流程数字化管理。

1.2 移动端管理系统的核心价值

传统的汽修管理系统多部署在PC端,技师在车间工作时难以实时操作。而移动端管理系统具有天然的优势:技师可以在车间内用一部设备完成工单创建、状态更新、配件查询等操作,大幅减少了在工位和办公区之间往返的时间成本。

本系统正是面向这一需求设计的一套移动端汽修管理应用。它涵盖了维修工单管理、车辆档案管理、配件库存管理、经营统计分析以及门店信息管理五大核心模块,形成了一个完整的业务闭环。

1.3 技术选型与框架特性

本系统基于声明式UI框架开发,采用了组件化、状态驱动的架构思想。整个应用由多个独立组件构成,每个组件负责管理自己的内部状态和UI渲染。通过响应式的状态绑定机制,当数据发生变化时,UI会自动更新,无需手动操作DOM节点。

系统还引入了可观察对象(Observed Class)的数据模型设计,使得业务数据与UI视图之间的同步变得自然而高效。配合纯函数式的数据过滤和统计逻辑,代码的可测试性和可维护性得到了显著提升。


二、系统总体架构概览

2.1 架构分层设计

整个系统在代码组织上采用了清晰的分层架构,自底向上可以划分为以下几个层次:

  • 接口定义层:使用 interface 定义类型契约,约束颜色配色、应用配置、工单状态、配件分类等数据结构。
  • 数据模型层:使用 @Observed 修饰的可观察类定义核心业务实体,包括工单、车辆、配件和统计实体。
  • 静态配置层:以 const 常量形式集中管理配色方案、门店信息、状态映射和分类映射,确保配置的单一数据源。
  • Mock数据层:提供模拟的业务数据,覆盖工单、车辆、配件、月度统计、分类库存和技师信息等多个维度。
  • 工具函数层:一组纯函数,负责状态查找、数据格式化、数据过滤和统计计算,无副作用,便于复用和测试。
  • UI组件层:从主入口组件到各业务Tab组件,层层嵌套,通过 @State、@Link、@Prop 等装饰器实现组件间的状态传递与响应。

2.2 功能模块划分

系统共划分为五个主要功能模块,通过底部导航栏进行切换:

模块 标识 核心功能
维修工单 WorkOrder 工单的创建、编辑、删除、搜索与状态筛选
车辆档案 Vehicle 车辆信息的登记、编辑、删除与搜索
配件库存 Parts 配件的录入、编辑、删除、分类筛选与库存预警
经营统计 Stats 工单量趋势、营收、库存占比、技师工时等多维统计
我的门店 Profile 门店信息展示、营收概览、技师团队与快捷服务入口

三、接口定义层:类型契约的建立

3.1 颜色调色板接口

系统首先定义了一个统一的颜色调色板接口,用于规范整个应用的视觉风格。

interface ColorPalette {
  primary: string
  accent: string
  bg: string
  warning: string
  success: string
  textPrimary: string
  textSecondary: string
  textHint: string
  border: string
  cardBg: string
  white: string
  danger: string
  dark: string
}

在这里插入图片描述

逐行解析:

  • primary:主色调,用于导航栏、按钮等关键交互元素的背景色。
  • accent:强调色,用于高亮选中状态、链接文字和可交互元素的强调展示。
  • bg:全局背景色,整个应用页面的底色。
  • warning:警告色,用于低库存提醒、待处理事项等需要引起注意的场景。
  • success:成功色,用于已完成状态标识和库存充足提示。
  • textPrimary:主要文字颜色,用于标题、重要数据展示。
  • textSecondary:次要文字颜色,用于描述性文字、辅助信息。
  • textHint:提示文字颜色,用于占位符、最次要的说明文字。
  • border:边框颜色,用于卡片边框、分割线。
  • cardBg:卡片背景色,通常为白色,与全局背景形成层次。
  • white:纯白色,用于特定场景的白色背景。
  • danger:危险色,用于删除按钮、错误提示。
  • dark:深色调,用于渐变效果的深色端。

通过将所有颜色集中定义在一个接口中,后续修改配色方案时只需调整一处,所有引用该常量的组件都会自动生效,这体现了配置集中管理的最佳实践。

3.2 应用配置接口

interface AppConfig {
  appName: string
  shopName: string
  shopAddress: string
  shopPhone: string
  serviceTime: string
  techLeader: string
}

在这里插入图片描述

逐行解析:

  • appName:应用名称,在标题栏或品牌展示处使用。
  • shopName:门店全称,包含品牌和门店类型信息。
  • shopAddress:门店详细地址,用于客户导航和服务范围确认。
  • shopPhone:门店联系电话,方便客户预约和咨询。
  • serviceTime:营业时间,告知客户服务时段。
  • techLeader:技术负责人,体现门店的技术管理水平。

这个接口将门店的基础信息结构化,避免了在代码中散落硬编码的字符串。

3.3 工单状态配置接口

工单状态是整个系统最核心的业务概念之一。系统通过两个接口来定义状态的配置结构。

interface WorkStatusCfg {
  label: string
  color: string
  bg: string
}

interface WorkStatusMap {
  pending: WorkStatusCfg
  working: WorkStatusCfg
  waitingParts: WorkStatusCfg
  finished: WorkStatusCfg
  delivered: WorkStatusCfg
}

在这里插入图片描述

逐行解析:

WorkStatusCfg 接口定义了单个状态的三要素:

  • label:状态的中文显示文本,如"待检修"、“维修中”。
  • color:状态对应的文字颜色,用于状态标签的文字着色。
  • bg:状态对应的背景颜色,用于状态标签的背景着色。

WorkStatusMap 接口则将五种工单状态组织为一个映射结构:

  • pending:待检修状态,工单已创建但尚未开始维修。
  • working:维修中状态,技师正在执行维修作业。
  • waitingParts:待配件状态,维修因缺少配件而暂停。
  • finished:已完成状态,维修作业全部完成。
  • delivered:已交付状态,车辆已交还车主。

这种设计将状态语义与视觉表现绑定在一起,使用时只需通过状态名称查找即可获取完整的展示配置。

3.4 配件分类配置接口

interface CategoryCfg {
  label: string
  color: string
  bg: string
}

interface CategoryMap {
  oil: CategoryCfg
  filter: CategoryCfg
  brake: CategoryCfg
  tire: CategoryCfg
  battery: CategoryCfg
  spark: CategoryCfg
  wiper: CategoryCfg
  other: CategoryCfg
}

在这里插入图片描述

逐行解析:

CategoryCfgWorkStatusCfg 结构完全一致,同样包含标签、文字颜色和背景颜色三个字段。

CategoryMap 定义了八种配件分类:

  • oil:机油类配件,如嘉实多、美孚等品牌润滑油。
  • filter:滤芯类配件,包括机油滤芯、空气滤芯、空调滤芯。
  • brake:刹车片类配件,涉及制动系统的摩擦材料。
  • tire:轮胎类配件,涵盖各规格型号的轮胎。
  • battery:电瓶类配件,汽车蓄电池。
  • spark:火花塞类配件,点火系统的关键部件。
  • wiper:雨刮类配件,雨刮片和雨刮器总成。
  • other:其他类配件,用于无法归入以上分类的配件。

3.5 技师信息接口

interface TechInfo {
  name: string
  title: string
  years: string
  phone: string
  skill: string
}

在这里插入图片描述

逐行解析:

  • name:技师姓名或工号简称,如"李工"。
  • title:技师职称,如"高级技师"、“中级技师”。
  • years:从业年限,以字符串形式存储,如"12年"。
  • phone:联系电话,已做脱敏处理,如"139****1101"。
  • skill:擅长领域,如"发动机/底盘"、“制动/电气”。

这个接口为技师团队管理提供了标准化的数据结构。


四、数据模型层:可观察的业务实体

4.1 可观察类的核心思想

系统中所有核心业务实体都使用了 @Observed 装饰器进行修饰。这是一个关键的架构决策。被 @Observed 修饰的类,其实例的属性变化可以被框架自动追踪。当这些属性被绑定到UI组件的状态上时,属性的变化会自动触发对应UI的刷新,无需开发者手动调用更新方法。

这种响应式编程模式大幅简化了状态管理的复杂度。例如,当技师修改了某个工单的状态后,工单卡片上的状态标签颜色会自动更新;当新增一辆车辆后,车辆列表会自动增加一条记录——这一切都是框架自动完成的。

4.2 工单数据模型

@Observed class WorkOrder {
  id: number = 0
  orderNo: string = ''
  plateNo: string = ''
  model: string = ''
  project: string = ''
  mechanic: string = ''
  status: string = ''
  inTime: string = ''
  expectTime: string = ''
  cost: number = 0

  constructor(id: number, orderNo: string, plateNo: string, model: string, project: string,
    mechanic: string, status: string, inTime: string, expectTime: string, cost: number) {
    this.id = id
    this.orderNo = orderNo
    this.plateNo = plateNo
    this.model = model
    this.project = project
    this.mechanic = mechanic
    this.status = status
    this.inTime = inTime
    this.expectTime = expectTime
    this.cost = cost
  }
}

在这里插入图片描述

逐行解析:

工单模型包含了维修作业的全生命周期信息:

  • id:工单唯一标识符,自增整数,用于内部数据管理。
  • orderNo:工单编号,格式为"WX-2025-0812-001",包含日期和序号信息,便于人工识别。
  • plateNo:车牌号码,如"京A8W5J6",是车辆识别的关键字段。
  • model:车型信息,如"丰田卡罗拉",便于技师了解车辆基本情况。
  • project:维修项目描述,如"更换刹车片+机油保养",记录本次维修的具体内容。
  • mechanic:负责维修的技师姓名,如"李工"。
  • status:当前工单状态,取值为"待检修"、“维修中”、“待配件”、"已完成"或"已交付"之一。
  • inTime:车辆进厂时间,格式为"MM/DD HH:mm"。
  • expectTime:预计完成时间,格式同上。
  • cost:维修费用,以元为单位的数值。

构造函数接收全部十个参数并进行赋值初始化。每个属性都提供了默认值,确保对象在未完全初始化时不会出现 undefined 问题。

4.3 车辆数据模型

@Observed class Vehicle {
  id: number = 0
  plateNo: string = ''
  brand: string = ''
  model: string = ''
  year: number = 0
  color: string = ''
  owner: string = ''
  phone: string = ''
  mileage: number = 0
  lastMaintenance: string = ''

  constructor(id: number, plateNo: string, brand: string, model: string, year: number,
    color: string, owner: string, phone: string, mileage: number, lastMaintenance: string) {
    this.id = id
    this.plateNo = plateNo
    this.brand = brand
    this.model = model
    this.year = year
    this.color = color
    this.owner = owner
    this.phone = phone
    this.mileage = mileage
    this.lastMaintenance = lastMaintenance
  }
}

在这里插入图片描述

逐行解析:

车辆模型记录了车辆的完整档案信息:

  • id:车辆档案唯一标识。
  • plateNo:车牌号,作为车辆的主键索引。
  • brand:品牌,如"丰田"、“大众”。
  • model:具体型号,如"卡罗拉2023款"。
  • year:年份,整型数字,如2023。
  • color:车身颜色,如"珍珠白"、“曜石黑”。
  • owner:车主姓名。
  • phone:车主联系电话,已脱敏处理。
  • mileage:行驶里程,以公里为单位的整型数值。
  • lastMaintenance:上次保养日期,格式为"YYYY-MM-DD"。

车辆档案是汽修门店最重要的客户资产之一。通过完整的车辆档案,门店可以主动提醒客户进行定期保养,提升客户粘性和复购率。

4.4 配件数据模型

@Observed class PartsItem {
  id: number = 0
  name: string = ''
  code: string = ''
  category: string = ''
  stock: number = 0
  price: number = 0
  supplier: string = ''
  minStock: number = 0

  constructor(id: number, name: string, code: string, category: string, stock: number,
    price: number, supplier: string, minStock: number) {
    this.id = id
    this.name = name
    this.code = code
    this.category = category
    this.stock = stock
    this.price = price
    this.supplier = supplier
    this.minStock = minStock
  }
}

在这里插入图片描述

逐行解析:

配件模型是库存管理的核心数据结构:

  • id:配件记录唯一标识。
  • name:配件名称,如"博世刹车片前轮",包含品牌和规格信息。
  • code:配件编号,如"P-001",用于快速检索和盘点。
  • category:配件分类,取值为中文分类名,如"机油"、“刹车片”。
  • stock:当前库存数量。
  • price:单价,以元为单位。
  • supplier:供应商名称,如"博世授权店"。
  • minStock:最低库存预警阈值,当实际库存低于此值时触发预警。

特别值得注意的设计是 minStock 字段。它与 stock 配合使用,在UI层实现了动态的库存预警功能:当库存低于最低库存时,卡片会显示橙色"低库存"警示标签;否则显示绿色"库存充足"标签。

4.5 统计实体模型

系统定义了三个用于统计展示的数据模型,分别对应月度工单量、分类库存量和技师工时统计。

@Observed class MonthStat {
  month: string = ''
  count: number = 0

  constructor(month: string, count: number) {
    this.month = month
    this.count = count
  }
}

@Observed class CategoryStockStat {
  category: string = ''
  stock: number = 0

  constructor(category: string, stock: number) {
    this.category = category
    this.stock = stock
  }
}

@Observed class MechanicStat {
  name: string = ''
  hours: number = 0
  orders: number = 0
  cost: number = 0

  constructor(name: string, hours: number, orders: number, cost: number) {
    this.name = name
    this.hours = hours
    this.orders = orders
    this.cost = cost
  }
}

在这里插入图片描述

逐行解析:

MonthStat 用于柱状图展示,month 存储月份标签(如"2月"),count 存储该月的工单数量。

CategoryStockStat 用于横向进度条展示,category 存储分类名称,stock 存储该分类的库存总量。

MechanicStat 用于技师绩效展示,包含四个维度:

  • name:技师姓名。
  • hours:本月工时数。
  • orders:完成工单数。
  • cost:创造产值(元)。

这三个统计实体虽然结构简单,但它们是统计页面的数据基石,支撑了柱状图、进度条和列表三种可视化形式。


五、静态配置层:配色与业务映射的单一数据源

5.1 全局配色常量

const COLORS: ColorPalette = {
  primary: '#37474F',
  accent: '#29B6F6',
  bg: '#ECEFF1',
  warning: '#FF7043',
  success: '#66BB6A',
  textPrimary: '#37474F',
  textSecondary: '#607D8B',
  textHint: '#90A4AE',
  border: '#DDDDDD',
  cardBg: '#FFFFFF',
  white: '#FFFFFF',
  danger: '#EF5350',
  dark: '#263238'
}

在这里插入图片描述

逐行解析:

系统采用了"科技蓝灰金属色系"的视觉设计语言:

  • primary 设为 #37474F,这是一种深蓝灰色(Blue Grey 800),给人以沉稳、专业的工业感,非常适合汽修行业的科技化定位。
  • accent 设为 #29B6F6,亮蓝色(Light Blue 400),用于强调交互元素和选中状态,与深蓝灰形成明暗对比。
  • bg 设为 #ECEFF1,极浅的蓝灰色(Blue Grey 50),作为全局背景,不喧宾夺主。
  • warning 设为 #FF7043,深橙色(Deep Orange 400),用于警示信息,视觉冲击力强。
  • success 设为 #66BB6A,绿色(Green 400),传达积极、完成的语义。
  • danger 设为 #EF5350,红色(Red 400),用于危险操作如删除按钮。
  • dark 设为 #263238,更深的蓝灰色(Blue Grey 900),用于渐变效果的暗端和底部导航栏背景。

这套配色方案遵循了 Material Design 的色彩规范,通过蓝灰色系的主色调营造专业感,配合橙色和绿色作为功能性的点缀色,整体视觉统一而富有层次。

5.2 门店信息配置

const APP_CONFIG: AppConfig = {
  appName: '车管家',
  shopName: '速达汽修连锁 · 中关村旗舰店',
  shopAddress: '北京市海淀区中关村大街128号',
  shopPhone: '010-6288-5566',
  serviceTime: '08:30 - 20:30',
  techLeader: '王师傅'
}

逐行解析:

  • 应用名为"车管家",简洁易记,传达管家式服务的理念。
  • 门店全称为"速达汽修连锁 · 中关村旗舰店",体现了连锁品牌和旗舰店定位。
  • 地址位于北京海淀区中关村,科技氛围浓厚。
  • 营业时间为早八点半到晚八点半,覆盖大部分工作时间。
  • 技术负责人为"王师傅",增强了门店的信任感。

5.3 工单状态映射

const WORK_STATUS: WorkStatusMap = {
  pending: { label: '待检修', color: '#FF7043', bg: '#FFF3E0' },
  working: { label: '维修中', color: '#29B6F6', bg: '#E1F5FE' },
  waitingParts: { label: '待配件', color: '#FFA726', bg: '#FFF8E1' },
  finished: { label: '已完成', color: '#66BB6A', bg: '#E8F5E9' },
  delivered: { label: '已交付', color: '#37474F', bg: '#ECEFF1' }
}

逐行解析:

每种状态都有精心设计的颜色搭配:

  • 待检修(橙色系):表示工单刚创建,尚未开始,橙色传达"需要注意"的语义。
  • 维修中(蓝色系):表示正在作业中,蓝色传达"进行中"的冷静感。
  • 待配件(琥珀色系):表示因配件短缺而等待,琥珀色传达"暂停等待"的含义。
  • 已完成(绿色系):表示维修完成,绿色传达"成功"的积极信号。
  • 已交付(蓝灰色系):表示流程终结,使用主色调表示归档状态。

每种状态的 colorbg 都是同色系的深浅搭配,确保文字在背景上有足够的对比度,同时保持视觉的和谐统一。

5.4 配件分类映射

const PART_CATEGORY: CategoryMap = {
  oil: { label: '机油', color: '#FF7043', bg: '#FFF3E0' },
  filter: { label: '滤芯', color: '#29B6F6', bg: '#E1F5FE' },
  brake: { label: '刹车片', color: '#EF5350', bg: '#FFEBEE' },
  tire: { label: '轮胎', color: '#5C6BC0', bg: '#E8EAF6' },
  battery: { label: '电瓶', color: '#66BB6A', bg: '#E8F5E9' },
  spark: { label: '火花塞', color: '#FFA726', bg: '#FFF8E1' },
  wiper: { label: '雨刮', color: '#26A69A', bg: '#E0F2F1' },
  other: { label: '其他', color: '#78909C', bg: '#ECEFF1' }
}

逐行解析:

八种配件分类各自拥有独特的颜色标识,便于用户在浏览配件列表时快速区分:

  • 机油用橙色,呼应润滑油的色泽。
  • 滤芯用蓝色,传达清洁过滤的概念。
  • 刹车片用红色,暗示制动安全的重要性。
  • 轮胎用靛蓝色,区别于其他分类。
  • 电瓶用绿色,暗示电力和能量。
  • 火花塞用琥珀色,呼应点火的火花。
  • 雨刮用青色,暗示雨水。
  • 其他用蓝灰色,中性低调。

5.5 筛选器配置

const WORK_STATUS_FILTERS: string[] = ['全部', '待检修', '维修中', '待配件', '已完成', '已交付']

const CATEGORY_FILTERS: string[] = ['全部', '机油', '滤芯', '刹车片', '轮胎', '电瓶', '火花塞', '雨刮', '其他']

逐行解析:

两个筛选器数组分别用于工单页和配件页的横向滚动筛选标签栏。它们都以"全部"开头,允许用户查看所有数据。这种设计简单直接:选中"全部"时显示所有记录,选中具体状态/分类时仅显示匹配的记录。筛选标签的渲染逻辑会根据当前选中项改变背景色和文字颜色,提供清晰的视觉反馈。


六、Mock数据层:贴近真实业务的模拟数据

6.1 工单模拟数据

系统预置了20条工单模拟数据,覆盖了五种状态和多种维修项目类型。

const mockWorkOrders: WorkOrder[] = [
  new WorkOrder(1, 'WX-2025-0812-001', '京A8W5J6', '丰田卡罗拉', '更换刹车片+机油保养', '李工', '维修中', '08/12 09:30', '08/12 16:00', 680),
  new WorkOrder(2, 'WX-2025-0812-002', '京N6K3M2', '大众朗逸', '更换轮胎四条+四轮定位', '张工', '待配件', '08/12 08:50', '08/13 11:00', 2360),
  // ... 更多工单
  new WorkOrder(20, 'WX-2025-0812-020', '京Z7H2N5', '雷克萨斯ES', '常规保养+空调滤芯', '李工', '已交付', '08/11 13:30', '08/11 15:20', 560)
]

逐行解析(以第一条为例):

  • 工单ID为1,编号"WX-2025-0812-001"表示2025年8月12日的第1号工单。
  • 车牌"京A8W5J6",车型"丰田卡罗拉"。
  • 维修项目"更换刹车片+机油保养",这是一项组合维修任务。
  • 指派技师"李工",当前状态"维修中"。
  • 进厂时间"08/12 09:30",预计完成时间"08/12 16:00"。
  • 维修费用680元。

20条工单数据涵盖了从待检修到已交付的全状态流转,维修项目包括刹车系统、机油保养、轮胎更换、电瓶更换、空调检修、变速箱保养、火花塞更换等多种常见业务场景,费用从260元到2360元不等,充分模拟了真实门店的工单分布。

6.2 车辆模拟数据

const mockVehicles: Vehicle[] = [
  new Vehicle(1, '京A8W5J6', '丰田', '卡罗拉2023款', 2023, '珍珠白', '赵磊', '139****2233', 45000, '2025-07-01'),
  // ... 更多车辆
  new Vehicle(16, '京V8R6C3', '五菱', '宏光S 2019款', 2019, '晴空银', '何军', '182****8856', 136000, '2025-01-20')
]

逐行解析(以第一条为例):

  • 车辆ID为1,车牌"京A8W5J6"。
  • 品牌"丰田",型号"卡罗拉2023款",年份2023。
  • 颜色"珍珠白",车主"赵磊",电话已脱敏。
  • 行驶里程45000公里,上次保养日期2025年7月1日。

16条车辆数据覆盖了合资品牌(丰田、大众、本田、别克、日产)、豪华品牌(奔驰、宝马、奥迪、雷克萨斯)、国产自主品牌(比亚迪、哈弗、吉利、五菱)以及新能源品牌(特斯拉),里程从1.5万到13.6万公里,年份从2019到2023,充分模拟了真实门店的客户车辆构成。

6.3 配件模拟数据

const mockParts: PartsItem[] = [
  new PartsItem(1, '博世刹车片前轮', 'P-001', '刹车片', 32, 180, '博世授权店', 10),
  new PartsItem(2, '博世刹车片后轮', 'P-002', '刹车片', 8, 168, '博世授权店', 10),
  // ... 更多配件
  new PartsItem(18, '壳牌刹车油DOT4', 'P-018', '其他', 22, 68, '壳牌授权', 10)
]

逐行解析(以第一条为例):

  • 配件ID为1,名称"博世刹车片前轮",编号"P-001"。
  • 分类"刹车片",当前库存32件,单价180元。
  • 供应商"博世授权店",最低库存预警值10件。

18条配件数据中,部分配件的库存低于最低库存阈值(如"博世刹车片后轮"库存8件,低于最低库存10件),这些配件在UI上会自动显示"低库存"预警标签。配件品牌涵盖博世、嘉实多、美孚、曼牌、马勒、米其林、普利司通、瓦尔塔、风帆、NGK、3M、长城、菲罗多、壳牌等主流汽配品牌,真实还原了汽修门店的配件库。

6.4 统计模拟数据

const mockMonthStats: MonthStat[] = [
  new MonthStat('2月', 112),
  new MonthStat('3月', 138),
  new MonthStat('4月', 126),
  new MonthStat('5月', 152),
  new MonthStat('6月', 145),
  new MonthStat('7月', 168)
]

const mockCategoryStocks: CategoryStockStat[] = [
  new CategoryStockStat('机油', 69),
  new CategoryStockStat('滤芯', 113),
  new CategoryStockStat('刹车片', 51),
  new CategoryStockStat('轮胎', 19),
  new CategoryStockStat('电瓶', 15),
  new CategoryStockStat('火花塞', 60),
  new CategoryStockStat('雨刮', 33),
  new CategoryStockStat('其他', 22)
]

const mockMechanicStats: MechanicStat[] = [
  new MechanicStat('李工', 186, 42, 28600),
  new MechanicStat('张工', 172, 38, 34200),
  new MechanicStat('王工', 168, 35, 29800),
  new MechanicStat('赵工', 145, 30, 21500)
]

const mockTechnicians: TechInfo[] = [
  { name: '李工', title: '高级技师', years: '12年', phone: '139****1101', skill: '发动机/底盘' },
  { name: '张工', title: '高级技师', years: '10年', phone: '139****1102', skill: '制动/电气' },
  { name: '王工', title: '中级技师', years: '7年', phone: '139****1103', skill: '变速箱/空调' },
  { name: '赵工', title: '中级技师', years: '5年', phone: '139****1104', skill: '轮胎/钣金' }
]

逐行解析:

月度工单统计展示了2月至7月共6个月的数据,工单量从112到168不等,呈整体上升趋势,体现了门店业务的增长态势。

分类库存统计展示了8个配件分类的库存总量,滤芯库存最多(113件),电瓶库存最少(15件)。

技师统计展示了4位技师的工时、完成工单数和产值。张工虽然工单数不是最多,但产值最高(34200元),说明他接手的可能是高价值维修项目。

技师信息列表补充了职称、年限和擅长领域,李工和张工为高级技师,王工和赵工为中级技师,形成了合理的技术梯队。


七、工具函数层:纯函数式的业务逻辑

7.1 状态与分类查找函数

function getStatusCfg(status: string): WorkStatusCfg {
  if (status === '待检修') {
    return WORK_STATUS.pending
  }
  if (status === '维修中') {
    return WORK_STATUS.working
  }
  if (status === '待配件') {
    return WORK_STATUS.waitingParts
  }
  if (status === '已完成') {
    return WORK_STATUS.finished
  }
  return WORK_STATUS.delivered
}

function getCategoryCfg(category: string): CategoryCfg {
  if (category === '机油') {
    return PART_CATEGORY.oil
  }
  if (category === '滤芯') {
    return PART_CATEGORY.filter
  }
  if (category === '刹车片') {
    return PART_CATEGORY.brake
  }
  if (category === '轮胎') {
    return PART_CATEGORY.tire
  }
  if (category === '电瓶') {
    return PART_CATEGORY.battery
  }
  if (category === '火花塞') {
    return PART_CATEGORY.spark
  }
  if (category === '雨刮') {
    return PART_CATEGORY.wiper
  }
  return PART_CATEGORY.other
}

逐行解析:

这两个函数是连接业务字符串与视觉配置的桥梁。getStatusCfg 接收中文状态字符串,返回对应的 WorkStatusCfg 配置对象;getCategoryCfg 接收中文分类字符串,返回对应的 CategoryCfg 配置对象。

函数采用 if 判断的逐级匹配方式,最后一个分支作为默认返回值。这种写法虽然不如 switch 或 Map 查找简洁,但在不支持某些高级语法的环境中具有良好的兼容性,且逻辑清晰易读。

这两个函数在UI渲染时被频繁调用——每张工单卡片的状态标签颜色、每张配件卡片的分类标签颜色,都通过它们动态获取。

7.2 编号生成函数

function genOrderNo(len: number): string {
  const n = len + 1
  if (n < 100) {
    return 'WX-2025-0812-0' + n.toString()
  }
  return 'WX-2025-0812-' + n.toString()
}

function genPartCode(len: number): string {
  const n = len + 1
  if (n < 100) {
    return 'P-0' + n.toString()
  }
  return 'P-' + n.toString()
}

逐行解析:

genOrderNo 根据当前工单列表长度生成下一个工单编号。参数 len 是当前数组的长度,加1后得到新工单的序号。当序号小于100时,前面补零(如"001"),否则直接拼接。生成的编号格式为"WX-2025-0812-021"。

genPartCode 的逻辑类似,生成格式为"P-019"的配件编号。

这两个函数在新增工单/配件时被调用,自动填充编号字段,减轻用户的手工输入负担。

7.3 格式化函数

function formatMoney(cost: number): string {
  return '¥' + cost.toFixed(0)
}

function formatMileage(mileage: number): string {
  return (mileage / 10000).toFixed(1) + '万km'
}

function todayStr(): string {
  return '08/12'
}

function nowTimeStr(): string {
  return '08/12 09:00'
}

逐行解析:

  • formatMoney:将数值格式化为带人民币符号的字符串,如"¥680"。使用 toFixed(0) 确保不显示小数位。
  • formatMileage:将公里数转换为"万km"单位的字符串,如45000变为"4.5万km"。除以10000后保留一位小数。
  • todayStr:返回当前日期字符串,这里简化为固定值"08/12"。
  • nowTimeStr:返回当前时间字符串,简化为"08/12 09:00"。

7.4 数据过滤函数

function filterOrders(orders: WorkOrder[], statusFilter: string, keyword: string): WorkOrder[] {
  const result: WorkOrder[] = []
  for (let i = 0; i < orders.length; i++) {
    const o = orders[i]
    const matchStatus = statusFilter === '全部' || o.status === statusFilter
    const matchKw = keyword === '' || o.plateNo.indexOf(keyword) >= 0 ||
      o.orderNo.indexOf(keyword) >= 0 || o.model.indexOf(keyword) >= 0
    if (matchStatus && matchKw) {
      result.push(o)
    }
  }
  return result
}

function filterVehicles(vehicles: Vehicle[], keyword: string): Vehicle[] {
  const result: Vehicle[] = []
  for (let i = 0; i < vehicles.length; i++) {
    const v = vehicles[i]
    const matchKw = keyword === '' || v.plateNo.indexOf(keyword) >= 0 ||
      v.brand.indexOf(keyword) >= 0 || v.owner.indexOf(keyword) >= 0
    if (matchKw) {
      result.push(v)
    }
  }
  return result
}

function filterParts(parts: PartsItem[], categoryFilter: string, keyword: string): PartsItem[] {
  const result: PartsItem[] = []
  for (let i = 0; i < parts.length; i++) {
    const p = parts[i]
    const matchCat = categoryFilter === '全部' || p.category === categoryFilter
    const matchKw = keyword === '' || p.name.indexOf(keyword) >= 0 || p.code.indexOf(keyword) >= 0
    if (matchCat && matchKw) {
      result.push(p)
    }
  }
  return result
}

逐行解析:

三个过滤函数分别处理工单、车辆和配件的筛选逻辑。它们的核心设计模式一致:

  1. 创建空结果数组。
  2. 遍历原始数组,对每个元素进行条件判断。
  3. 条件判断采用"短路或"模式:当筛选条件为"全部"或关键字为空时,自动匹配通过;否则进行精确匹配或包含匹配。
  4. 同时满足所有条件的元素才被加入结果数组。

filterOrders 支持状态筛选和关键字搜索(车牌、工单号、车型三个字段)。
filterVehicles 仅支持关键字搜索(车牌、品牌、车主三个字段)。
filterParts 支持分类筛选和关键字搜索(配件名称、编号两个字段)。

使用 indexOf 进行模糊匹配,当关键字是目标字段的子串时即视为匹配,用户体验友好。

7.5 统计计算函数

function getMaxCount(stats: MonthStat[]): number {
  let max = 1
  for (let i = 0; i < stats.length; i++) {
    if (stats[i].count > max) {
      max = stats[i].count
    }
  }
  return max
}

function getMaxStock(stats: CategoryStockStat[]): number {
  let max = 1
  for (let i = 0; i < stats.length; i++) {
    if (stats[i].stock > max) {
      max = stats[i].stock
    }
  }
  return max
}

function getStockPct(stock: number, stats: CategoryStockStat[]): number {
  const max = getMaxStock(stats)
  let pct = Math.floor(stock / max * 100)
  if (pct < 8) {
    pct = 8
  }
  return pct
}

function getBarHeight(count: number, stats: MonthStat[]): number {
  const max = getMaxCount(stats)
  const h = Math.floor(count / max * 120)
  return 24 + h
}

逐行解析:

getMaxCountgetMaxStock 是辅助函数,分别找出月度工单量和分类库存量的最大值。初始值设为1而非0,是为了避免后续除法运算中出现除以零的情况。

getStockPct 计算某分类库存占最大库存的百分比,用于横向进度条的宽度。关键设计是当百分比低于8%时,强制设为8%,确保即使库存量最小的分类,其进度条也有最小可见宽度,避免视觉上的"消失"。

getBarHeight 计算柱状图中某月柱子的高度。以最大值对应120像素为基准进行等比缩放,再加上24像素的基础高度,确保最矮的柱子也有可见高度。

7.6 汇总统计函数

function getTotalCost(orders: WorkOrder[]): number {
  let total = 0
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === '已完成' || orders[i].status === '已交付') {
      total = total + orders[i].cost
    }
  }
  return total
}

function countFinishedOrders(orders: WorkOrder[]): number {
  let c = 0
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === '已完成' || orders[i].status === '已交付') {
      c = c + 1
    }
  }
  return c
}

function countWaitingOrders(orders: WorkOrder[]): number {
  let c = 0
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === '待配件') {
      c = c + 1
    }
  }
  return c
}

逐行解析:

  • getTotalCost:累加所有"已完成"和"已交付"状态工单的费用,得到今日营收总额。只统计已完成工单的收入是合理的业务逻辑——未完成的工单收入尚未确认。
  • countFinishedOrders:统计"已完成"和"已交付"状态的工单数量。
  • countWaitingOrders:统计"待配件"状态的工单数量,帮助管理者快速了解因配件短缺而阻塞的工单数量。

7.7 配件图标函数

function getCategoryIcon(category: string): string {
  if (category === '机油') {
    return '🛢️'
  }
  if (category === '滤芯') {
    return '🌀'
  }
  if (category === '刹车片') {
    return '🛑'
  }
  if (category === '轮胎') {
    return '⭕'
  }
  if (category === '电瓶') {
    return '🔋'
  }
  if (category === '火花塞') {
    return '⚡'
  }
  if (category === '雨刮') {
    return '💧'
  }
  return '📦'
}

逐行解析:

这个函数为每种配件分类返回一个对应的 Emoji 图标。机油用油桶、滤芯用旋涡、刹车片用停止标志、轮胎用圆圈、电瓶用电池、火花塞用闪电、雨刮用水滴、其他用包裹。这些图标在配件卡片的左侧以圆形背景展示,增强了视觉辨识度,让用户一眼就能区分不同分类的配件。


八、主入口组件:应用的骨架与导航

8.1 Tab索引枚举

enum TabIndex {
  WorkOrder = 0,
  Vehicle = 1,
  Parts = 2,
  Stats = 3,
  Profile = 4
}

逐行解析:

枚举类型定义了五个Tab页的索引值。使用枚举而非魔法数字(如直接写0、1、2…)提升了代码的可读性和可维护性。在组件中通过 TabIndex.WorkOrder 而非 0 来引用,含义一目了然。

8.2 主入口组件状态声明

@Entry
@Component
struct GarageApp {
  @State currentTab: number = TabIndex.WorkOrder
  @State workOrders: WorkOrder[] = mockWorkOrders
  @State vehicles: Vehicle[] = mockVehicles
  @State parts: PartsItem[] = mockParts

逐行解析:

  • @Entry 装饰器标记此组件为应用的入口组件,框架会从这里开始渲染。
  • @Component 装饰器声明这是一个自定义组件。
  • @State currentTab:当前选中的Tab索引,初始值为工单页。这个状态的变化会驱动整个页面内容的切换。
  • @State workOrders:工单数据数组,初始化为Mock数据。
  • @State vehicles:车辆数据数组,初始化为Mock数据。
  • @State parts:配件数据数组,初始化为Mock数据。

三个数据数组作为应用级状态在主组件中管理,并通过 @Link 向下传递给各业务Tab组件,实现了数据的单向流动。

8.3 遮罩层构建器

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

逐行解析:

@Builder 是一个UI构建器装饰器,用于定义可复用的UI片段。modalOverlay 构建器接收一个 onClose 回调函数参数,生成一个半透明黑色遮罩层。

  • 外层 Column 占满全屏,使用绝对定位(position)覆盖在页面之上。
  • 内层 Column 同样占满全屏,背景色为半透明黑色(rgba(0,0,0,0.5))。
  • 点击遮罩层时触发 onClose 回调,关闭弹窗。

这个构建器在主组件和各业务Tab组件中都有定义,用于新增、编辑、删除确认弹窗的遮罩效果。

8.4 头部导航栏构建器

@Builder headerBar(title: string) {
  Row() {
    Text(title)
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
    Column().layoutWeight(1)
    Text('🛠️')
      .fontSize(24)
  }
  .width('100%')
  .padding({ left: 20, right: 20, top: 50, bottom: 16 })
  .alignItems(VerticalAlign.Center)
  .linearGradient({
    direction: GradientDirection.RightBottom,
    colors: [[0x37474F, 0.0], [0x263238, 1.0]]
  })
  .borderRadius({ bottomLeft: 24, bottomRight: 24 })
}

逐行解析:

headerBar 构建器接收一个标题字符串参数,生成应用顶部的导航栏:

  • 使用 Row 水平布局,左侧显示标题文字(22号粗体白色),右侧显示工具图标。
  • 中间的 Column().layoutWeight(1) 作为弹性占位符,将标题推向左侧,图标推向右侧。
  • padding 设置了较大的顶部内边距(50),为状态栏留出空间。
  • linearGradient 设置从左上到右下的渐变背景,从 #37474F(主色)渐变到 #263238(深色),营造金属质感。
  • borderRadius 仅设置左下和右下圆角,使导航栏底部呈现弧形过渡。

8.5 底部导航栏构建器

@Builder bottomTabBar() {
  Row() {
    Column() {
      Text('🔧')
        .fontSize(22)
        .margin({ bottom: 2 })
      Text('工单')
        .fontSize(11)
        .fontColor(this.currentTab === TabIndex.WorkOrder ? '#29B6F6' : '#90A4AE')
      if (this.currentTab === TabIndex.WorkOrder) {
        Column()
          .width(20)
          .height(3)
          .borderRadius(2)
          .backgroundColor('#29B6F6')
          .margin({ top: 2 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => { this.currentTab = TabIndex.WorkOrder })

    // ... 其他四个Tab项结构相同
  }
  .width('100%')
  .height(64)
  .backgroundColor('#263238')
  .border({ width: 1, color: '#37474F' })
}

逐行解析:

底部导航栏包含五个Tab项,每个Tab项的结构完全一致:

  • 上方是 Emoji 图标(22号字体),下方是中文标签(11号字体)。
  • 标签颜色根据当前选中状态动态变化:选中时为亮蓝色 #29B6F6,未选中时为灰色 #90A4AE
  • 当Tab被选中时,标签下方显示一个小的亮蓝色指示条(20x3像素的圆角矩形),提供额外的视觉反馈。
  • 每个 Column 使用 layoutWeight(1) 均分导航栏宽度。
  • onClick 事件将 currentTab 切换到对应的Tab索引。

整个导航栏背景为深色 #263238,高度64像素,顶部有一像素的边框线与内容区分隔。

8.6 主构建方法

build() {
  Column() {
    Stack() {
      Column() {
        if (this.currentTab === TabIndex.WorkOrder) {
          this.headerBar('维修工单')
          WorkOrderTab({ orders: this.workOrders })
        }
        if (this.currentTab === TabIndex.Vehicle) {
          this.headerBar('车辆档案')
          VehicleTab({ vehicles: this.vehicles })
        }
        if (this.currentTab === TabIndex.Parts) {
          this.headerBar('配件库存')
          PartsTab({ parts: this.parts })
        }
        if (this.currentTab === TabIndex.Stats) {
          this.headerBar('经营统计')
          StatsTab()
        }
        if (this.currentTab === TabIndex.Profile) {
          this.headerBar('我的门店')
          ProfileTab({ orders: this.workOrders })
        }
        this.bottomTabBar()
      }
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

逐行解析:

build 方法是组件的核心渲染方法。通过一系列 if 条件判断,根据 currentTab 的值决定渲染哪个业务Tab组件:

  • 每个分支都先调用 headerBar 渲染标题栏,然后渲染对应的业务组件。
  • 业务组件通过参数接收数据:WorkOrderTab 接收工单数组,VehicleTab 接收车辆数组,PartsTab 接收配件数组,ProfileTab 接收工单数组(用于统计展示),StatsTab 不需要参数(直接引用Mock数据)。
  • 最后调用 bottomTabBar 渲染底部导航栏。
  • 最外层设置全局背景色为 COLORS.bg

使用 Stack 作为容器,为后续可能叠加的遮罩层和弹窗提供了层级基础。


九、维修工单Tab:全流程CRUD管理

9.1 组件状态声明

@Component
struct WorkOrderTab {
  @Link orders: WorkOrder[]
  @State statusFilter: string = '全部'
  @State keyword: string = ''
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State editItem: WorkOrder | null = null
  @State deleteItem: WorkOrder | null = null
  @State formOrderNo: string = ''
  @State formPlateNo: string = ''
  @State formModel: string = ''
  @State formProject: string = ''
  @State formMechanic: string = ''
  @State formStatus: string = '待检修'
  @State formInTime: string = ''
  @State formExpectTime: string = ''
  @State formCost: string = ''

逐行解析:

  • @Link orders:与父组件建立双向绑定,工单数据的增删改会同步到父组件状态。
  • statusFilter:当前选中的状态筛选条件。
  • keyword:搜索关键字。
  • showAddModalshowEditModalshowDeleteConfirm:三个布尔值控制三种弹窗的显隐。
  • editItemdeleteItem:记录当前正在编辑或删除的工单对象,类型为可空(| null)。
  • form* 系列字段:表单输入的临时存储,所有值以字符串形式保存(包括费用等数值字段),在提交时再进行类型转换。

9.2 新增工单逻辑

openAdd() {
  this.formOrderNo = genOrderNo(this.orders.length)
  this.formPlateNo = ''
  this.formModel = ''
  this.formProject = ''
  this.formMechanic = ''
  this.formStatus = '待检修'
  this.formInTime = nowTimeStr()
  this.formExpectTime = '08/12 18:00'
  this.formCost = ''
  this.showAddModal = true
}

confirmAdd() {
  const plate = this.formPlateNo === '' ? '京A8W5J6' : this.formPlateNo
  const model = this.formModel === '' ? '未知车型' : this.formModel
  const project = this.formProject === '' ? '常规保养' : this.formProject
  const mech = this.formMechanic === '' ? '李工' : this.formMechanic
  const cost = this.formCost === '' ? 0 : Number(this.formCost)
  this.orders.push(new WorkOrder(this.orders.length + 1, this.formOrderNo, plate, model,
    project, mech, this.formStatus, this.formInTime, this.formExpectTime, cost))
  this.showAddModal = false
}

逐行解析:

openAdd 方法在打开新增弹窗前进行表单初始化:

  • 工单编号通过 genOrderNo 自动生成。
  • 进厂时间设为当前时间,预计完成时间设为当天18:00。
  • 状态默认为"待检修"。
  • 其他字段留空,等待用户输入。

confirmAdd 方法在用户确认新增时执行:

  • 对每个字段进行空值兜底处理:车牌为空则使用默认值"京A8W5J6",车型为空则用"未知车型",项目为空则用"常规保养",技师为空则默认"李工",费用为空则为0。
  • 使用 Number() 将费用字符串转换为数值。
  • 创建新的 WorkOrder 实例并 push 到数组中。
  • 关闭弹窗。

这种"空值兜底"的设计确保即使用户不填写某些字段,系统也能生成有效数据,不会因空值导致渲染异常。

9.3 编辑与删除逻辑

openEdit(item: WorkOrder) {
  this.editItem = item
  this.formOrderNo = item.orderNo
  this.formPlateNo = item.plateNo
  this.formModel = item.model
  this.formProject = item.project
  this.formMechanic = item.mechanic
  this.formStatus = item.status
  this.formInTime = item.inTime
  this.formExpectTime = item.expectTime
  this.formCost = item.cost.toString()
  this.showEditModal = true
}

confirmEdit() {
  const item = this.editItem
  if (item !== null) {
    item.plateNo = this.formPlateNo
    item.model = this.formModel
    item.project = this.formProject
    item.mechanic = this.formMechanic
    item.status = this.formStatus
    item.cost = this.formCost === '' ? 0 : Number(this.formCost)
  }
  this.editItem = null
  this.showEditModal = false
}

confirmDelete() {
  const item = this.deleteItem
  if (item !== null) {
    const idx = this.orders.indexOf(item)
    if (idx >= 0) {
      this.orders.splice(idx, 1)
    }
  }
  this.deleteItem = null
  this.showDeleteConfirm = false
}

逐行解析:

openEdit 将目标工单的数据回填到表单字段中,然后显示编辑弹窗。注意费用字段通过 toString() 转换为字符串以适配表单输入框。

confirmEdit 直接修改 editItem 对象的属性。由于 WorkOrder@Observed 类,属性修改会自动触发UI更新。编辑时不允许修改工单编号和进厂时间,这是合理的业务约束——编号是唯一标识不应变更,进厂时间是历史记录不应篡改。

confirmDelete 通过 indexOf 找到目标工单在数组中的索引,然后使用 splice 删除。删除前先进行 null 检查和索引有效性检查,确保操作安全。

9.4 工单卡片构建器

@Builder workOrderCard(item: WorkOrder) {
  Row() {
    Column()
      .width(4)
      .borderRadius(2)
      .backgroundColor(getStatusCfg(item.status).color)
      .alignSelf(ItemAlign.Stretch)
    Column() {
      Row() {
        Text(item.orderNo)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#37474F')
        Column().layoutWeight(1)
        Text(item.status)
          .fontSize(11)
          .fontColor(getStatusCfg(item.status).color)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(10)
          .backgroundColor(getStatusCfg(item.status).bg)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

逐行解析:

工单卡片是信息密度最高的UI元素之一:

  • 最左侧是一个4像素宽的彩色竖条,颜色由工单状态决定(通过 getStatusCfg 获取),使用 alignSelf(ItemAlign.Stretch) 使其高度撑满整个卡片。这个设计让用户在浏览列表时能通过颜色快速识别工单状态。
  • 卡片主体分为多个 Row 层级:
    • 第一行:左侧是工单编号(加粗),右侧是状态标签(带背景色的圆角小标签)。
    • 第二行:车牌号(加粗大字号)、车型(灰色小字号)、技师姓名(蓝色)。
    • 第三行:维修项目描述(灰色)、费用金额(橙色加粗大字号)。
    • 第四行:进厂/预计时间(灰色小字)、编辑按钮、删除按钮。
      Row() {
        Text('🚗 ' + item.plateNo)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#37474F')
        Column().width(10)
        Text(item.model)
          .fontSize(13)
          .fontColor('#888888')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Column().layoutWeight(1)
        Text(item.mechanic)
          .fontSize(12)
          .fontColor('#29B6F6')
      }

逐行解析:

车牌号前加了汽车 Emoji 图标增强视觉辨识度。车型文字设置了 maxLines(1)textOverflow 省略号,确保长文本不会破坏布局。技师姓名使用蓝色 #29B6F6 突出显示。

      Row() {
        Text('项目: ' + item.project)
          .fontSize(12)
          .fontColor('#607D8B')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Column().layoutWeight(1)
        Text(formatMoney(item.cost))
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF7043')
      }

逐行解析:

维修项目同样设置了单行省略号。费用金额通过 formatMoney 格式化为"¥680"的形式,使用橙色 #FF7043 加粗16号字体,是卡片上视觉权重最高的元素——费用是用户最关心的信息。

      Row() {
        Text('进厂 ' + item.inTime + '  预计 ' + item.expectTime)
          .fontSize(11)
          .fontColor('#90A4AE')
        Column().layoutWeight(1)
        Button('编辑')
          .height(26)
          .borderRadius(13)
          .fontSize(11)
          .backgroundColor('#E1F5FE')
          .fontColor('#0288D1')
          .onClick(() => { this.openEdit(item) })
        Column().width(8)
        Button('删除')
          .height(26)
          .borderRadius(13)
          .fontSize(11)
          .backgroundColor('#FFEBEE')
          .fontColor('#EF5350')
          .onClick(() => { this.openDelete(item) })
      }

逐行解析:

卡片底部一行展示了时间信息和操作按钮:

  • 时间文字使用最小字号(11号)和最浅颜色(#90A4AE),作为辅助信息。
  • 编辑按钮使用浅蓝色背景和深蓝色文字,视觉上温和。
  • 删除按钮使用浅红色背景和红色文字,视觉上警示。
  • 两个按钮都采用胶囊形状(高度26,圆角13),紧凑而不拥挤。

9.5 状态筛选标签与表单状态选择器

@Builder statusTag(label: string) {
  Text(label)
    .fontSize(13)
    .fontColor(this.statusFilter === label ? '#FFFFFF' : '#607D8B')
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .borderRadius(16)
    .backgroundColor(this.statusFilter === label ? '#37474F' : '#FFFFFF')
    .border({ width: 1, color: this.statusFilter === label ? '#37474F' : '#DDDDDD' })
    .margin({ right: 8 })
    .onClick(() => { this.statusFilter = label })
}

@Builder formStatusChip(s: string) {
  Text(s)
    .fontSize(12)
    .fontColor(this.formStatus === s ? '#FFFFFF' : '#607D8B')
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .borderRadius(14)
    .backgroundColor(this.formStatus === s ? '#29B6F6' : '#F5F5F5')
    .border({ width: 1, color: this.formStatus === s ? '#29B6F6' : '#E0E0E0' })
    .margin({ right: 8, bottom: 8 })
    .onClick(() => { this.formStatus = s })
}

逐行解析:

statusTag 是列表页的状态筛选标签,formStatusChip 是表单弹窗中的状态选择器。两者结构相似但配色不同:

  • 筛选标签选中时使用深蓝灰色(主色调)背景白色文字,未选中时白底灰字带灰边框。
  • 表单选择器选中时使用亮蓝色(强调色)背景白色文字,未选中时浅灰底灰字带浅灰边框。

这种配色差异帮助用户区分"筛选"和"选择"两种不同的交互场景。点击事件分别修改 statusFilterformStatus 状态。

9.6 表单字段构建器

@Builder modalFormFields() {
  Column() {
    Column() {
      Text('工单号')
        .fontSize(13)
        .fontColor('#666666')
        .margin({ bottom: 6 })
      TextInput({ text: this.formOrderNo, placeholder: '系统自动生成' })
        .height(40)
        .borderRadius(10)
        .backgroundColor('#F5F5F5')
        .padding({ left: 12, right: 12 })
        .onChange((value: string) => { this.formOrderNo = value })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .margin({ bottom: 12 })
    // ... 更多表单字段
  }
}

逐行解析:

表单字段构建器将所有输入项封装在一个 Column 中。每个字段遵循统一的结构:

  • 外层 Column 占满宽度,左对齐,底部留12像素间距。
  • 字段标签使用13号灰色字体,底部留6像素间距。
  • TextInput 输入框高度40像素,圆角10,浅灰背景,左右内边距12像素。
  • onChange 回调将输入值同步到对应的 form* 状态字段。

工单号字段的 placeholder 为"系统自动生成",因为该值在打开弹窗时已由 genOrderNo 自动填充,用户通常无需手动修改。

9.7 弹窗内容构建器

系统为新增、编辑、删除三种操作分别设计了弹窗内容构建器。

@Builder addModalContent() {
  Column() {
    Text('新增维修工单')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#37474F')
      .margin({ bottom: 4 })
    Text('填写车辆维修信息')
      .fontSize(13)
      .fontColor('#90A4AE')
      .margin({ bottom: 16 })
    Scroll() {
      Column() {
        this.modalFormFields()
      }
      .width('100%')
    }
    .layoutWeight(1)
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    Row() {
      Button('取消')
        .layoutWeight(1)
        .height(42)
        .borderRadius(21)
        .backgroundColor('#F5F5F5')
        .fontColor('#666666')
        .fontSize(14)
        .onClick(() => { this.showAddModal = false })
      Column().width(12)
      Button('确认新增')
        .layoutWeight(1)
        .height(42)
        .borderRadius(21)
        .backgroundColor('#37474F')
        .fontColor('#FFFFFF')
        .fontSize(14)
        .onClick(() => { this.confirmAdd() })
    }
    .width('100%')
    .margin({ top: 12 })
  }
  .width('88%')
  .padding(24)
  .backgroundColor('#FFFFFF')
  .borderRadius(24)
  .constraintSize({ maxHeight: '80%' })
}

逐行解析:

新增弹窗的结构自上而下分为三个部分:

  • 标题区:主标题"新增维修工单"(20号粗体),副标题"填写车辆维修信息"(13号灰色)。
  • 表单滚动区:使用 Scroll 包裹表单字段,支持垂直滚动。scrollBar(BarState.Off) 隐藏滚动条保持视觉整洁。layoutWeight(1) 让滚动区占据弹窗的弹性空间。constraintSize({ maxHeight: '80%' }) 限制弹窗最大高度为屏幕的80%,防止表单过长时溢出屏幕。
  • 操作按钮区:取消按钮(灰色背景)和确认新增按钮(深蓝灰背景),各占一半宽度(layoutWeight(1)),中间留12像素间距。按钮高度42像素,圆角21,呈胶囊形。

编辑弹窗 editModalContent 结构几乎相同,区别在于标题改为"编辑维修工单",确认按钮文字改为"保存修改"且背景色改为亮蓝色 #29B6F6

删除确认弹窗 deleteModalContent 则更加简洁,没有表单字段,只显示警告图标、确认文字和工单编号,底部是取消和红色"确认删除"按钮。

9.8 工单Tab主构建方法

build() {
  Stack() {
    Column() {
      Row() {
        Text('🔍')
          .fontSize(14)
          .margin({ right: 8 })
        TextInput({ placeholder: '搜索工单号 / 车牌 / 车型' })
          .height(40)
          .layoutWeight(1)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .border({ width: 1, color: '#E0E0E0' })
          .padding({ left: 14, right: 14 })
          .onChange((value: string) => { this.keyword = value })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14 })
      .alignItems(VerticalAlign.Center)

      Scroll() {
        Row() {
          ForEach(WORK_STATUS_FILTERS, (label: string) => {
            this.statusTag(label)
          })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 12, bottom: 4 })

      Scroll() {
        Column() {
          ForEach(filterOrders(this.orders, this.statusFilter, this.keyword), (item: WorkOrder) => {
            this.workOrderCard(item)
          })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 8, bottom: 20 })
      }
      .layoutWeight(1)
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .width('100%')

      Row() {
        Button('+ 新增工单')
          .height(44)
          .width('100%')
          .borderRadius(22)
          .backgroundColor('#37474F')
          .fontColor('#FFFFFF')
          .fontSize(15)
          .onClick(() => { this.openAdd() })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8, bottom: 12 })
    }
    .layoutWeight(1)
    .width('100%')

逐行解析:

工单Tab页面自上而下由四个部分组成:

  1. 搜索栏:放大镜图标加输入框,圆角20像素的白色搜索框,placeholder 提示可搜索的字段。输入变化实时更新 keyword 状态。

  2. 状态筛选栏:横向滚动的 Scroll 容器,使用 ForEach 遍历 WORK_STATUS_FILTERS 数组生成筛选标签。横向滚动确保所有筛选标签都能展示,不被屏幕宽度限制。

  3. 工单列表:垂直滚动的 Scroll 容器,使用 ForEach 遍历 filterOrders 的返回结果生成工单卡片。注意这里每次渲染都会调用 filterOrders 进行实时过滤,当 statusFilterkeyword 变化时,列表内容自动更新。layoutWeight(1) 让列表占据剩余空间。

  4. 新增按钮:底部固定的全宽按钮,深蓝灰背景白色文字,点击调用 openAdd 打开新增弹窗。

    if (this.showAddModal) {
      this.modalOverlay(() => { this.showAddModal = false })
      Column() {
        this.addModalContent()
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
    }
    // ... 编辑和删除弹窗结构相同
  }
  .layoutWeight(1)
  .width('100%')
}

逐行解析:

Stack 容器中,弹窗层叠加在内容层之上。当 showAddModal 为 true 时,先渲染遮罩层 modalOverlay,再渲染弹窗内容 addModalContent。弹窗外层 Column 占满全屏,通过 justifyContent(FlexAlign.Center)alignItems(HorizontalAlign.Center) 实现居中布局。

编辑和删除弹窗的渲染逻辑完全相同,只是条件和内容构建器不同。


十、车辆档案Tab:客户资产的全息管理

10.1 组件结构与CRUD逻辑

车辆Tab的组件结构与工单Tab高度一致,同样包含 @Link 数据绑定、三个弹窗控制状态、表单字段状态以及完整的CRUD方法。

@Component
struct VehicleTab {
  @Link vehicles: Vehicle[]
  @State keyword: string = ''
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State editItem: Vehicle | null = null
  @State deleteItem: Vehicle | null = null
  @State formPlateNo: string = ''
  @State formBrand: string = ''
  @State formVModel: string = ''
  @State formYear: string = ''
  @State formColor: string = ''
  @State formOwner: string = ''
  @State formPhone: string = ''
  @State formMileage: string = ''
  @State formLastMaint: string = ''

逐行解析:

车辆表单字段比工单更多,包含车牌、品牌、型号、年份、颜色、车主、电话、里程和上次保养日期共九个字段。注意型号字段命名为 formVModel 而非 formModel,这是为了避免与组件内部可能的命名冲突。

confirmAdd() {
  const plate = this.formPlateNo === '' ? '京A8W5J6' : this.formPlateNo
  const brand = this.formBrand === '' ? '丰田' : this.formBrand
  const model = this.formVModel === '' ? '卡罗拉' : this.formVModel
  const year = this.formYear === '' ? 2024 : Number(this.formYear)
  const color = this.formColor === '' ? '珍珠白' : this.formColor
  const owner = this.formOwner === '' ? '未知车主' : this.formOwner
  const phone = this.formPhone === '' ? '139****0000' : this.formPhone
  const mileage = this.formMileage === '' ? 0 : Number(this.formMileage)
  const lastMaint = this.formLastMaint === '' ? todayStr() : this.formLastMaint
  this.vehicles.push(new Vehicle(this.vehicles.length + 1, plate, brand, model, year,
    color, owner, phone, mileage, lastMaint))
  this.showAddModal = false
}

逐行解析:

新增车辆时的空值兜底逻辑同样完善:每个字段都有合理的默认值。年份和里程通过 Number() 转换为数值类型。上次保养日期为空时使用 todayStr() 填充当前日期。

10.2 车辆卡片设计

@Builder vehicleCard(item: Vehicle) {
  Row() {
    Column() {
      Text('🚗')
        .fontSize(30)
    }
    .width(64)
    .height(64)
    .borderRadius(32)
    .backgroundColor('#E1F5FE')
    .justifyContent(FlexAlign.Center)

    Column() {
      Row() {
        Text(item.plateNo)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#37474F')
        Column().layoutWeight(1)
        Text(item.owner)
          .fontSize(12)
          .fontColor('#29B6F6')
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

逐行解析:

车辆卡片采用了与工单卡片不同的布局风格:

  • 左侧是一个64x64的圆形头像区域,浅蓝色背景配汽车Emoji图标,视觉上更加醒目。
  • 第一行:车牌号(加粗16号)和车主姓名(蓝色12号)。
  • 后续行采用三列网格布局,每列包含标签(10号灰色)和值(12号深色),展示品牌型号、年份、颜色、电话、里程、上次保养等信息。
      Row() {
        Column() {
          Text('品牌型号')
            .fontSize(10)
            .fontColor('#90A4AE')
          Text(item.brand + ' ' + item.model)
            .fontSize(12)
            .fontColor('#37474F')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Column() {
          Text('年份')
            .fontSize(10)
            .fontColor('#90A4AE')
          Text(item.year + '款')
            .fontSize(12)
            .fontColor('#37474F')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Column() {
          Text('颜色')
            .fontSize(10)
            .fontColor('#90A4AE')
          Text(item.color)
            .fontSize(12)
            .fontColor('#37474F')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .margin({ top: 8 })

逐行解析:

三列等宽的信息展示行使用 layoutWeight(1) 实现均分。每列上方是10号灰色标签,下方是12号深色值。这种"标签+值"的布局方式信息密度高,层次清晰。品牌型号字段设置了单行省略号,防止长型号名称破坏布局。

      Row() {
        Column() {
          Text('行驶里程')
            .fontSize(10)
            .fontColor('#90A4AE')
          Text(formatMileage(item.mileage))
            .fontSize(12)
            .fontColor('#FF7043')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

逐行解析:

行驶里程使用橙色 #FF7043 突出显示,并通过 formatMileage 函数将原始公里数转换为"4.5万km"的可读格式。里程是判断车辆保养需求的重要指标,因此用醒目的颜色标识。

10.3 车辆表单的双列布局

Row() {
  Column() {
    Text('品牌')
      .fontSize(13)
      .fontColor('#666666')
      .margin({ bottom: 6 })
    TextInput({ text: this.formBrand, placeholder: '如 丰田' })
      .height(40)
      .borderRadius(10)
      .backgroundColor('#F5F5F5')
      .padding({ left: 12, right: 12 })
      .onChange((value: string) => { this.formBrand = value })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Start)
  Column().width(10)
  Column() {
    Text('型号')
      .fontSize(13)
      .fontColor('#666666')
      .margin({ bottom: 6 })
    TextInput({ text: this.formVModel, placeholder: '如 卡罗拉2023款' })
      .height(40)
      .borderRadius(10)
      .backgroundColor('#F5F5F5')
      .padding({ left: 12, right: 12 })
      .onChange((value: string) => { this.formVModel = value })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Start)
}
.width('100%')
.margin({ bottom: 12 })
.alignItems(VerticalAlign.Top)

逐行解析:

由于车辆表单字段较多,系统采用了双列布局来优化空间利用。品牌和型号、年份和颜色、车主和电话、里程和上次保养分别成对排列。每对字段使用 Row 容器,两个 Column 各占 layoutWeight(1) 宽度,中间用10像素宽的间隔 Column 分隔。这种设计使表单更加紧凑,减少了弹窗的垂直高度。

10.4 车辆Tab主构建方法

车辆Tab的主构建方法与工单Tab结构相似,包含搜索栏、车辆列表和新增按钮。区别在于没有状态筛选栏(车辆档案不需要状态筛选),搜索框的 placeholder 改为"搜索车牌 / 品牌 / 车主",列表数据通过 filterVehicles 函数过滤。


十一、配件库存Tab:精细化的库存预警管理

11.1 组件状态与CRUD逻辑

@Component
struct PartsTab {
  @Link parts: PartsItem[]
  @State categoryFilter: string = '全部'
  @State keyword: string = ''
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State editItem: PartsItem | null = null
  @State deleteItem: PartsItem | null = null
  @State formName: string = ''
  @State formCode: string = ''
  @State formCategory: string = '机油'
  @State formStock: string = ''
  @State formPrice: string = ''
  @State formSupplier: string = ''
  @State formMinStock: string = ''

逐行解析:

配件Tab使用 categoryFilter 替代了工单Tab的 statusFilter,用于配件分类筛选。表单默认分类设为"机油"。其他状态字段与工单Tab和车辆Tab保持一致的设计模式。

confirmAdd() {
  const name = this.formName === '' ? '通用配件' : this.formName
  const code = this.formCode === '' ? genPartCode(this.parts.length) : this.formCode
  const stock = this.formStock === '' ? 0 : Number(this.formStock)
  const price = this.formPrice === '' ? 0 : Number(this.formPrice)
  const supplier = this.formSupplier === '' ? '未登记供应商' : this.formSupplier
  const minStock = this.formMinStock === '' ? 10 : Number(this.formMinStock)
  this.parts.push(new PartsItem(this.parts.length + 1, name, code, this.formCategory,
    stock, price, supplier, minStock))
  this.showAddModal = false
}

逐行解析:

新增配件时空值兜底逻辑:名称默认"通用配件",编号自动生成,供应商默认"未登记供应商",最低库存默认10件。库存和价格转为数值类型。

11.2 分类筛选标签

@Builder categoryTag(label: string) {
  Text(label)
    .fontSize(13)
    .fontColor(this.categoryFilter === label ? '#FFFFFF' : '#607D8B')
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .borderRadius(16)
    .backgroundColor(this.categoryFilter === label ? '#37474F' : '#FFFFFF')
    .border({ width: 1, color: this.categoryFilter === label ? '#37474F' : '#DDDDDD' })
    .margin({ right: 8 })
    .onClick(() => { this.categoryFilter = label })
}

逐行解析:

分类筛选标签与工单状态筛选标签结构完全一致,但绑定的状态是 categoryFilter。由于配件分类有8种(加上"全部"共9个标签),横向滚动的设计尤为重要。

11.3 配件卡片与库存预警

@Builder partsCard(item: PartsItem) {
  Row() {
    Column() {
      Text(getCategoryIcon(item.category))
        .fontSize(22)
    }
    .width(48)
    .height(48)
    .borderRadius(12)
    .backgroundColor(getCategoryCfg(item.category).bg)
    .justifyContent(FlexAlign.Center)

逐行解析:

配件卡片左侧是一个48x48的圆角方形图标区域,背景色由配件分类决定(通过 getCategoryCfg 获取),图标由分类对应的 Emoji 决定(通过 getCategoryIcon 获取)。这种设计让每张配件卡片在视觉上都有独特的分类标识。

        Row() {
          Text('库存')
            .fontSize(12)
            .fontColor('#607D8B')
          Column().width(6)
          Text(item.stock + '件')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(item.stock < item.minStock ? '#FF7043' : '#37474F')
          Column().width(8)
          if (item.stock < item.minStock) {
            Text('⚠ 低库存')
              .fontSize(10)
              .fontColor('#FF7043')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(8)
              .backgroundColor('#FFF3E0')
          } else {
            Text('库存充足')
              .fontSize(10)
              .fontColor('#66BB6A')
              .padding({ left: 6, right: 2, top: 2, bottom: 2 })
              .borderRadius(8)
              .backgroundColor('#E8F5E9')
          }
          Column().layoutWeight(1)
          Text('¥' + item.price)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF7043')
        }

逐行解析:

这是配件卡片最核心的设计——库存预警机制:

  • 库存数量文字的颜色是动态的:当库存低于最低库存阈值时(item.stock < item.minStock),文字变为橙色 #FF7043;否则为正常的深色 #37474F
  • 通过 if...else 条件渲染,低库存时显示橙色"低库存"警示标签,库存充足时显示绿色"库存充足"标签。
  • 右侧始终显示单价(橙色加粗)。

这种动态的视觉反馈让管理者一眼就能识别哪些配件需要补货,是库存管理中最实用的功能之一。

        Row() {
          Text('最低库存 ' + item.minStock + '件')
            .fontSize(10)
            .fontColor('#B0BEC5')
          Column().layoutWeight(1)
          Button('编辑')
            .height(26)
            .borderRadius(13)
            .fontSize(11)
            .backgroundColor('#E1F5FE')
            .fontColor('#0288D1')
            .onClick(() => { this.openEdit(item) })
          Column().width(8)
          Button('删除')
            .height(26)
            .borderRadius(13)
            .fontSize(11)
            .backgroundColor('#FFEBEE')
            .fontColor('#EF5350')
            .onClick(() => { this.openDelete(item) })
        }

逐行解析:

卡片底部显示最低库存阈值(浅灰色小字)和操作按钮。最低库存信息的展示让用户了解预警的判断依据,增加了系统的透明度。

11.4 配件表单与分类选择器

@Builder formCategoryChip(s: string) {
  Text(s)
    .fontSize(12)
    .fontColor(this.formCategory === s ? '#FFFFFF' : '#607D8B')
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .borderRadius(14)
    .backgroundColor(this.formCategory === s ? '#29B6F6' : '#F5F5F5')
    .border({ width: 1, color: this.formCategory === s ? '#29B6F6' : '#E0E0E0' })
    .margin({ right: 8, bottom: 8 })
    .onClick(() => { this.formCategory = s })
}

逐行解析:

配件表单中的分类选择器使用 ForEach(CATEGORY_FILTERS.slice(1), ...) 遍历分类数组(去掉"全部"选项),生成8个可选的分类标签。slice(1) 跳过数组的第一个元素"全部",因为"全部"是筛选用的,不应出现在表单的分类选择中。

11.5 配件Tab主构建方法

配件Tab的主构建方法与工单Tab结构高度一致,包含搜索栏、分类筛选栏(横向滚动)、配件列表(垂直滚动)和新增按钮。列表数据通过 filterParts 函数进行分类和关键字双重过滤。


十二、经营统计Tab:数据可视化的实践

12.1 区块标题构建器

@Builder sectionTitle(title: string, sub: string) {
  Row() {
    Column() {
      Text(title)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
      Text(sub)
        .fontSize(11)
        .fontColor('#90A4AE')
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Start)
    Column().layoutWeight(1)
    Column()
      .width(36)
      .height(4)
      .borderRadius(2)
      .linearGradient({
        direction: GradientDirection.Right,
        colors: [[0x37474F, 0.0], [0x29B6F6, 1.0]]
      })
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
}

逐行解析:

统计页面的每个数据区块都使用统一的标题样式:左侧是主标题(16号粗体)和副标题(11号灰色),右侧是一个36x4像素的渐变装饰条,从深蓝灰渐变到亮蓝色,作为视觉点缀。

12.2 概览数据卡片

Row() {
  Column() {
    Text(mockWorkOrders.length + '')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#37474F')
    Text('今日工单')
      .fontSize(10)
      .fontColor('#90A4AE')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  Column() {
    Text(countFinishedOrders(mockWorkOrders) + '')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#66BB6A')
    Text('已完成')
      .fontSize(10)
      .fontColor('#90A4AE')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  // ... 待配件、在修车辆
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: '#E8E8E8' })
.shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })

逐行解析:

页面顶部的概览卡片采用四列等宽布局,分别展示今日工单总数(深色)、已完成数(绿色)、待配件数(橙色)和在修车辆数(蓝色)。每个数字使用20号粗体,下方是10号灰色标签。四个数字使用不同颜色,帮助用户快速区分各项指标的健康状况。

12.3 营收卡片

Column() {
  Row() {
    Column() {
      Text('今日营收')
        .fontSize(12)
        .fontColor('#90A4AE')
      Text('¥' + getTotalCost(mockWorkOrders).toFixed(0))
        .fontSize(30)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ top: 6 })
      Text('已完成/已交付 工单收入合计')
        .fontSize(10)
        .fontColor('#90A4AE')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    Column() {
      Text('📈')
        .fontSize(36)
    }
    .width(64)
    .height(64)
    .borderRadius(32)
    .backgroundColor('#E1F5FE')
    .justifyContent(FlexAlign.Center)
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.border({ width: 1, color: '#E8E8E8' })
.shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
.margin({ top: 12 })
.linearGradient({
  direction: GradientDirection.Right,
  colors: [[0xFFFFFF, 0.0], [0xE1F5FE, 1.0]]
})

逐行解析:

营收卡片是统计页面视觉权重最高的元素:

  • 营收金额使用30号粗体深色字体,是整个页面最大的文字,突出了营收数据的重要性。
  • 金额通过 getTotalCost(mockWorkOrders).toFixed(0) 计算并格式化,只统计已完成和已交付工单的收入。
  • 右侧是圆形浅蓝色背景配趋势图标的装饰元素。
  • 卡片背景使用从白色到浅蓝色的渐变,增强了视觉层次感。

12.4 月度工单量柱状图

Column() {
  this.sectionTitle('月维修工单量', '近6个月维修业务走势')
  Row() {
    ForEach(mockMonthStats, (item: MonthStat) => {
      Column() {
        Column()
          .width(24)
          .height(getBarHeight(item.count, mockMonthStats))
          .borderRadius({ topLeft: 8, topRight: 8 })
          .linearGradient({
            direction: GradientDirection.Bottom,
            colors: [[0x37474F, 0.0], [0x29B6F6, 1.0]]
          })
        Text(item.count.toString())
          .fontSize(10)
          .fontColor('#607D8B')
          .margin({ top: 4 })
        Text(item.month)
          .fontSize(10)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.End)
    })
  }
  .width('100%')
  .height(190)
  .alignItems(VerticalAlign.Bottom)
  .margin({ top: 14 })
}

逐行解析:

柱状图是统计页面最具技术含量的可视化组件:

  • 使用 ForEach 遍历 mockMonthStats 数组,每个月份生成一个柱子。
  • 每个柱子由三部分组成:渐变色柱体(24像素宽,高度由 getBarHeight 动态计算)、数值文字(10号灰色)、月份标签(10号浅灰色)。
  • 柱体使用从底部到顶部的渐变色:底部为深蓝灰 #37474F,顶部为亮蓝色 #29B6F6,形成金属光泽效果。
  • 柱体顶部设置了8像素的圆角,视觉更加柔和。
  • 外层 Row 高度190像素,alignItems(VerticalAlign.Bottom) 确保所有柱子底部对齐。
  • 每个月份的 Column 使用 justifyContent(FlexAlign.End) 确保内容从底部开始排列。

这是一个纯代码绘制的柱状图,没有依赖任何第三方图表库,充分展示了声明式UI的灵活性。

12.5 分类库存进度条

Column() {
  this.sectionTitle('配件分类库存占比', '各类配件当前库存量')
  Column() {
    ForEach(mockCategoryStocks, (item: CategoryStockStat) => {
      Row() {
        Text(item.category)
          .fontSize(12)
          .fontColor('#607D8B')
          .width(48)
        Row() {
          Row()
            .width(getStockPct(item.stock, mockCategoryStocks) + '%')
            .height(12)
            .borderRadius(6)
            .backgroundColor(getCategoryCfg(item.category).color)
        }
        .layoutWeight(1)
        .height(12)
        .borderRadius(6)
        .backgroundColor('#ECEFF1')
        .justifyContent(FlexAlign.Start)
        Text(item.stock + '件')
          .fontSize(11)
          .fontColor('#90A4AE')
          .width(48)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .margin({ bottom: 10 })
      .alignItems(VerticalAlign.Center)
    })
  }
  .width('100%')
  .margin({ top: 16 })
}

逐行解析:

横向进度条展示了各配件分类的库存占比:

  • 每行三列布局:左侧48像素宽的分类名称、中间弹性的进度条、右侧48像素宽的库存数量。
  • 进度条采用嵌套 Row 设计:外层 Row 是灰色背景的轨道(#ECEFF1),内层 Row 是彩色填充条,宽度由 getStockPct 计算的百分比决定。
  • 填充条颜色由分类配置决定(通过 getCategoryCfg 获取),每种分类使用不同颜色。
  • 进度条高度12像素,圆角6像素,视觉精致。

12.6 技师工时统计

Column() {
  this.sectionTitle('维修师傅工时统计', '本月工时 · 完成工单 · 产值')
  Column() {
    ForEach(mockMechanicStats, (item: MechanicStat, index: number) => {
      Column() {
        Row() {
          Column() {
            Text(item.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#37474F')
            Text('完成 ' + item.orders + ' 单')
              .fontSize(10)
              .fontColor('#90A4AE')
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          Column().layoutWeight(1)
          Column() {
            Text(item.hours + 'h')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#29B6F6')
            Text(formatMoney(item.cost))
              .fontSize(10)
              .fontColor('#FF7043')
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        if (index < mockMechanicStats.length - 1) {
          Column()
            .width('100%')
            .height(1)
            .backgroundColor('#F0F0F0')
            .margin({ top: 10, bottom: 10 })
        }
      }
      .width('100%')
    })
  }
  .width('100%')
  .margin({ top: 12 })
}

逐行解析:

技师工时统计采用列表形式展示,每位技师一行:

  • 左侧:技师姓名(15号粗体)和完成工单数(10号灰色)。
  • 右侧:工时数(14号粗体蓝色,带"h"单位)和产值(10号橙色,带"¥"符号)。
  • 技师之间用1像素的浅灰色分割线分隔,最后一个技师下方不显示分割线(通过 index < mockMechanicStats.length - 1 判断)。

这种布局简洁明了,管理者可以快速对比各技师的工时和产值表现。


十三、我的门店Tab:品牌形象与综合信息

13.1 门店信息卡片

@Builder shopCard() {
  Column() {
    Row() {
      Column() {
        Text('🔧')
          .fontSize(28)
      }
      .width(56)
      .height(56)
      .borderRadius(28)
      .backgroundColor('#22FFFFFF')
      .justifyContent(FlexAlign.Center)

      Column() {
        Text(APP_CONFIG.shopName)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('📞 ' + APP_CONFIG.shopPhone + '   ' + APP_CONFIG.serviceTime)
          .fontSize(11)
          .fontColor('#B0BEC5')
          .margin({ top: 4 })
      }
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Column()
      .width('100%')
      .height(1)
      .backgroundColor('#22FFFFFF')
      .margin({ top: 14, bottom: 14 })

    Row() {
      Text('📍 ' + APP_CONFIG.shopAddress)
        .fontSize(11)
        .fontColor('#CFD8DC')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .borderRadius(12)
  .linearGradient({
    direction: GradientDirection.RightBottom,
    colors: [[0x37474F, 0.0], [0x263238, 1.0]]
  })
  .shadow({ radius: 8, color: '#33000000', offsetX: 0, offsetY: 4 })
}

逐行解析:

门店信息卡片是整个应用中视觉最独特的卡片,采用了深色渐变背景:

  • 顶部行:左侧是半透明白色圆形背景的工具图标,右侧是门店名称(白色粗体)和电话+营业时间(浅灰色)。
  • 中间是半透明白色的分割线(#22FFFFFF 表示22%透明度的白色)。
  • 底部是门店地址,使用定位图标前缀,浅灰色文字。
  • 卡片背景使用与导航栏相同的深色渐变(从 #37474F#263238),配合较大的阴影(radius 8,offsetY 4),营造出悬浮卡片的效果。

13.2 概览网格与营收卡片

@Builder overviewGrid() {
  Column() {
    Row() {
      Column() {
        Text(this.orders.length + '')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#37474F')
        Text('今日工单')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      // ... 已完成、待配件、在修车辆
    }
    .width('100%')
    .padding({ top: 14, bottom: 14 })
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .border({ width: 1, color: '#E8E8E8' })
  .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
}

逐行解析:

概览网格与统计页面的概览卡片类似,但数字字号更大(22号),并且数据来源于通过 @Prop 传入的 orders 数组而非直接引用Mock数据。

@Builder revenueCard() {
  Row() {
    Column() {
      Text('今日营收')
        .fontSize(12)
        .fontColor('#90A4AE')
      Text('¥' + getTotalCost(this.orders).toFixed(0))
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FF7043')
        .margin({ top: 6 })
      Text('本月累计 ' + formatMoney(getTotalCost(this.orders) * 9))
        .fontSize(10)
        .fontColor('#90A4AE')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    Column() {
      Text('💰')
        .fontSize(32)
    }
    .width(60)
    .height(60)
    .borderRadius(30)
    .backgroundColor('#FFF3E0')
    .justifyContent(FlexAlign.Center)
  }
  .width('100%')
  .padding(16)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .border({ width: 1, color: '#E8E8E8' })
  .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
  .alignItems(VerticalAlign.Center)
}

逐行解析:

营收卡片与统计页面的营收卡片设计思路相似,但配色不同:营收金额使用橙色 #FF7043(而非深色),右上角是橙色圆形背景的钱袋图标。底部增加了"本月累计"的估算值(今日营收乘以9作为月度估算),为管理者提供更长周期的营收参考。

13.3 技师团队卡片

@Builder techCard() {
  Column() {
    Row() {
      Column() {
        Text('维修师傅')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#37474F')
        Text('团队共 ' + mockTechnicians.length + ' 人')
          .fontSize(11)
          .fontColor('#90A4AE')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      Column().layoutWeight(1)
      Text('技术负责人: ' + APP_CONFIG.techLeader)
        .fontSize(10)
        .fontColor('#29B6F6')
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Column() {
      ForEach(mockTechnicians, (item: TechInfo, index: number) => {
        Column() {
          Row() {
            Column() {
              Text(item.name.substring(0, 1))
                .fontSize(15)
                .fontColor('#FFFFFF')
            }
            .width(40)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#37474F')
            .justifyContent(FlexAlign.Center)

            Column() {
              Row() {
                Text(item.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#37474F')
                Column().width(8)
                Text(item.title)
                  .fontSize(10)
                  .fontColor('#0288D1')
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor('#E1F5FE')
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)
              Text(item.skill + ' · ' + item.years + '经验')
                .fontSize(11)
                .fontColor('#90A4AE')
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .margin({ left: 12 })
            .alignItems(HorizontalAlign.Start)

            Text(item.phone)
              .fontSize(10)
              .fontColor('#B0BEC5')
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          if (index < mockTechnicians.length - 1) {
            Column()
              .width('100%')
              .height(1)
              .backgroundColor('#F0F0F0')
              .margin({ top: 10, bottom: 10 })
          }
        }
        .width('100%')
      })
    }
    .width('100%')
    .margin({ top: 12 })
  }
  .width('100%')
  .padding(16)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .border({ width: 1, color: '#E8E8E8' })
  .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
}

逐行解析:

技师团队卡片展示了门店的技术力量:

  • 卡片标题行:左侧是"维修师傅"标题和团队人数,右侧是技术负责人信息(蓝色)。
  • 技师列表:每位技师一行,左侧是40x40的圆形头像(深蓝灰背景,白色姓氏首字),中间是姓名+职称标签+擅长领域和经验,右侧是电话号码。
  • 职称标签使用浅蓝色背景深蓝色文字的圆角小标签,视觉上类似徽章效果。
  • 技师之间用分割线分隔。

item.name.substring(0, 1) 提取技师姓名的第一个字作为头像文字,这是一种简单但有效的头像生成方式。

13.4 快捷服务入口

@Builder serviceRow(icon: string, label: string, value: string) {
  Row() {
    Text(icon)
      .fontSize(16)
      .width(32)
    Text(label)
      .fontSize(13)
      .fontColor('#37474F')
    Column().layoutWeight(1)
    Text(value)
      .fontSize(12)
      .fontColor('#90A4AE')
    Text('›')
      .fontSize(14)
      .fontColor('#B0BEC5')
      .margin({ left: 6 })
  }
  .width('100%')
  .padding({ top: 12, bottom: 12 })
  .alignItems(VerticalAlign.Center)
}

逐行解析:

serviceRow 是一个通用的列表行构建器,用于生成快捷服务入口:

  • 左侧是图标(16号,32像素宽)和标签(13号深色)。
  • 右侧是值(12号灰色)和右箭头"›"(14号浅灰色)。
  • 整行上下各12像素内边距。

在主构建方法中,通过四次调用生成了四个服务入口:

this.serviceRow('📋', '门店经营报表', '月报 / 年报')
this.serviceRow('🛠️', '维修预约管理', '待处理 3 条')
this.serviceRow('⭐', '客户评价管理', '4.9 分')
this.serviceRow('⚙️', '系统设置', 'v2.4.0')

每个入口右侧显示了对应的状态或版本信息,如"待处理 3 条"暗示有预约等待处理,"4.9 分"展示了客户满意度,"v2.4.0"显示系统版本号。这些入口为未来功能扩展预留了空间。

13.5 我的门店Tab主构建方法

build() {
  Scroll() {
    Column() {
      this.shopCard()
      this.overviewGrid()
      this.revenueCard()
      this.techCard()

      Column() {
        this.serviceRow('📋', '门店经营报表', '月报 / 年报')
        this.serviceRow('🛠️', '维修预约管理', '待处理 3 条')
        this.serviceRow('⭐', '客户评价管理', '4.9 分')
        this.serviceRow('⚙️', '系统设置', 'v2.4.0')
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
      .border({ width: 1, color: '#E8E8E8' })
      .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
      .margin({ top: 12, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12 })
  }
  .layoutWeight(1)
  .scrollable(ScrollDirection.Vertical)
  .scrollBar(BarState.Off)
  .width('100%')
}

逐行解析:

我的门店Tab将所有信息卡片垂直排列在一个可滚动容器中:门店信息卡片、概览网格、营收卡片、技师团队卡片和快捷服务入口。整体布局从品牌形象到经营数据再到团队介绍,最后是功能入口,形成了一个完整的"门店主页"。


十四、五大功能模块关键特性对比

特性维度 维修工单 车辆档案 配件库存 经营统计 我的门店
核心功能 工单全生命周期管理 车辆信息登记与维护 配件录入与库存预警 多维数据可视化 门店品牌与综合信息
数据模型 WorkOrder Vehicle PartsItem MonthStat/CategoryStockStat/MechanicStat 复用WorkOrder+TechInfo
CRUD操作 支持新增/编辑/删除 支持新增/编辑/删除 支持新增/编辑/删除 只读展示 只读展示
搜索功能 工单号/车牌/车型 车牌/品牌/车主 配件名称/编号
筛选功能 5种状态筛选 8种分类筛选
弹窗类型 新增/编辑/删除确认 新增/编辑/删除确认 新增/编辑/删除确认
数据传递 @Link双向绑定 @Link双向绑定 @Link双向绑定 直接引用Mock @Prop单向只读
可视化形式 卡片列表 卡片列表 卡片列表+预警标签 柱状图+进度条+列表 深色渐变卡片+列表
动态视觉 状态色条+状态标签 圆形头像+三列网格 分类图标+库存预警色 渐变柱体+动态进度条 渐变背景+职称徽章
特殊设计 编号自动生成 里程格式化显示 低库存智能预警 纯代码绘制图表 品牌形象展示
表单布局 单列纵向 双列网格 双列网格 无表单 无表单
业务价值 维修流程管控 客户资产管理 库存成本控制 经营决策支持 品牌与团队展示

十五、技术架构总结

15.1 架构模式评析

本系统采用了典型的"状态驱动UI"架构模式。整个应用的数据流可以概括为:状态定义 -> 状态变更 -> UI自动刷新。每个组件通过 @State 管理自身内部状态,通过 @Link 与父组件建立双向绑定,通过 @Prop 接收只读数据。这种单向数据流的设计使得状态变更的来源清晰可追溯,降低了调试难度。

@Observed 装饰器的使用是另一个架构亮点。通过将业务实体类标记为可观察,对象属性的修改可以自动触发依赖该属性的UI组件刷新。这使得编辑操作变得极其简单——直接修改对象属性即可,无需手动触发更新或操作DOM。

15.2 代码组织特点

系统的代码组织遵循了"自底向上"的构建顺序:先定义接口类型契约,再实现数据模型,然后提供静态配置和Mock数据,接着编写工具函数,最后构建UI组件。每一层都只依赖其下方的层,不存在循环依赖。这种分层设计使得代码的可读性和可维护性都得到了保障。

工具函数全部采用纯函数实现,没有副作用,不依赖外部状态,仅通过参数接收输入并返回输出。这使得这些函数可以被任意复用和独立测试,也方便后续替换为真实的后端API调用。

15.3 UI设计亮点

系统在UI设计上有多个值得称道的细节:

色彩体系的统一性。整套配色方案以蓝灰色系为主色调,橙色作为警示色,绿色作为成功色,蓝色作为强调色。所有组件严格遵循这一色彩体系,视觉风格高度统一。

信息层级的清晰性。通过字号大小(22/20/16/15/13/12/11/10号)、字重粗细、颜色深浅三个维度的组合,建立了明确的信息层级。重要数据用大字号粗体,次要信息用小字号浅色,辅助说明用最小字号最浅色。

条件渲染的巧妙运用。从底部导航栏的选中指示条、配件卡片的低库存预警标签,到技师列表的分割线,系统大量使用条件渲染来实现动态UI效果,代码简洁而表达力强。

纯代码绘制的图表。统计页面的柱状图和进度条完全使用基础UI组件(Column、Row)和属性设置(渐变、圆角、动态宽高)实现,无需引入任何第三方图表库,展现了声明式UI的强大表现力。

15.4 业务逻辑完整性

从业务角度看,系统覆盖了汽修门店运营的核心环节:

  • 接车环节:通过工单新增功能记录车辆信息和维修需求。
  • 维修环节:通过工单状态流转(待检修->维修中->待配件->已完成)跟踪维修进度。
  • 配件环节:通过配件库存管理和低库存预警确保维修所需配件的供应。
  • 交付环节:通过"已交付"状态标记完成交付的工单。
  • 复盘环节:通过经营统计页面分析工单量趋势、营收情况和技师绩效。

五个功能模块形成了一个完整的业务闭环,从接车到交付再到经营分析,覆盖了汽修门店日常运营的全流程。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 464.ets - 车辆维修保养管理系统
// 科技蓝灰金属色系:钢蓝#37474F / 亮蓝#29B6F6 / 浅灰蓝#ECEFF1
// 橙色#FF7043警示 / 绿色#66BB6A完成
// ============================================================

// ---- 接口定义 ----
interface ColorPalette {
  primary: string
  accent: string
  bg: string
  warning: string
  success: string
  textPrimary: string
  textSecondary: string
  textHint: string
  border: string
  cardBg: string
  white: string
  danger: string
  dark: string
}

interface AppConfig {
  appName: string
  shopName: string
  shopAddress: string
  shopPhone: string
  serviceTime: string
  techLeader: string
}

interface WorkStatusCfg {
  label: string
  color: string
  bg: string
}

interface WorkStatusMap {
  pending: WorkStatusCfg
  working: WorkStatusCfg
  waitingParts: WorkStatusCfg
  finished: WorkStatusCfg
  delivered: WorkStatusCfg
}

interface CategoryCfg {
  label: string
  color: string
  bg: string
}

interface CategoryMap {
  oil: CategoryCfg
  filter: CategoryCfg
  brake: CategoryCfg
  tire: CategoryCfg
  battery: CategoryCfg
  spark: CategoryCfg
  wiper: CategoryCfg
  other: CategoryCfg
}

interface TechInfo {
  name: string
  title: string
  years: string
  phone: string
  skill: string
}

// ---- 数据模型 ----
@Observed class WorkOrder {
  id: number = 0
  orderNo: string = ''
  plateNo: string = ''
  model: string = ''
  project: string = ''
  mechanic: string = ''
  status: string = ''
  inTime: string = ''
  expectTime: string = ''
  cost: number = 0

  constructor(id: number, orderNo: string, plateNo: string, model: string, project: string,
    mechanic: string, status: string, inTime: string, expectTime: string, cost: number) {
    this.id = id
    this.orderNo = orderNo
    this.plateNo = plateNo
    this.model = model
    this.project = project
    this.mechanic = mechanic
    this.status = status
    this.inTime = inTime
    this.expectTime = expectTime
    this.cost = cost
  }
}

@Observed class Vehicle {
  id: number = 0
  plateNo: string = ''
  brand: string = ''
  model: string = ''
  year: number = 0
  color: string = ''
  owner: string = ''
  phone: string = ''
  mileage: number = 0
  lastMaintenance: string = ''

  constructor(id: number, plateNo: string, brand: string, model: string, year: number,
    color: string, owner: string, phone: string, mileage: number, lastMaintenance: string) {
    this.id = id
    this.plateNo = plateNo
    this.brand = brand
    this.model = model
    this.year = year
    this.color = color
    this.owner = owner
    this.phone = phone
    this.mileage = mileage
    this.lastMaintenance = lastMaintenance
  }
}

@Observed class PartsItem {
  id: number = 0
  name: string = ''
  code: string = ''
  category: string = ''
  stock: number = 0
  price: number = 0
  supplier: string = ''
  minStock: number = 0

  constructor(id: number, name: string, code: string, category: string, stock: number,
    price: number, supplier: string, minStock: number) {
    this.id = id
    this.name = name
    this.code = code
    this.category = category
    this.stock = stock
    this.price = price
    this.supplier = supplier
    this.minStock = minStock
  }
}

@Observed class MonthStat {
  month: string = ''
  count: number = 0

  constructor(month: string, count: number) {
    this.month = month
    this.count = count
  }
}

@Observed class CategoryStockStat {
  category: string = ''
  stock: number = 0

  constructor(category: string, stock: number) {
    this.category = category
    this.stock = stock
  }
}

@Observed class MechanicStat {
  name: string = ''
  hours: number = 0
  orders: number = 0
  cost: number = 0

  constructor(name: string, hours: number, orders: number, cost: number) {
    this.name = name
    this.hours = hours
    this.orders = orders
    this.cost = cost
  }
}

// ---- 静态配置 ----
const COLORS: ColorPalette = {
  primary: '#37474F',
  accent: '#29B6F6',
  bg: '#ECEFF1',
  warning: '#FF7043',
  success: '#66BB6A',
  textPrimary: '#37474F',
  textSecondary: '#607D8B',
  textHint: '#90A4AE',
  border: '#DDDDDD',
  cardBg: '#FFFFFF',
  white: '#FFFFFF',
  danger: '#EF5350',
  dark: '#263238'
}

const APP_CONFIG: AppConfig = {
  appName: '车管家',
  shopName: '速达汽修连锁 · 中关村旗舰店',
  shopAddress: '北京市海淀区中关村大街128号',
  shopPhone: '010-6288-5566',
  serviceTime: '08:30 - 20:30',
  techLeader: '王师傅'
}

const WORK_STATUS: WorkStatusMap = {
  pending: { label: '待检修', color: '#FF7043', bg: '#FFF3E0' },
  working: { label: '维修中', color: '#29B6F6', bg: '#E1F5FE' },
  waitingParts: { label: '待配件', color: '#FFA726', bg: '#FFF8E1' },
  finished: { label: '已完成', color: '#66BB6A', bg: '#E8F5E9' },
  delivered: { label: '已交付', color: '#37474F', bg: '#ECEFF1' }
}

const PART_CATEGORY: CategoryMap = {
  oil: { label: '机油', color: '#FF7043', bg: '#FFF3E0' },
  filter: { label: '滤芯', color: '#29B6F6', bg: '#E1F5FE' },
  brake: { label: '刹车片', color: '#EF5350', bg: '#FFEBEE' },
  tire: { label: '轮胎', color: '#5C6BC0', bg: '#E8EAF6' },
  battery: { label: '电瓶', color: '#66BB6A', bg: '#E8F5E9' },
  spark: { label: '火花塞', color: '#FFA726', bg: '#FFF8E1' },
  wiper: { label: '雨刮', color: '#26A69A', bg: '#E0F2F1' },
  other: { label: '其他', color: '#78909C', bg: '#ECEFF1' }
}

const WORK_STATUS_FILTERS: string[] = ['全部', '待检修', '维修中', '待配件', '已完成', '已交付']

const CATEGORY_FILTERS: string[] = ['全部', '机油', '滤芯', '刹车片', '轮胎', '电瓶', '火花塞', '雨刮', '其他']

// ---- Mock数据 ----
const mockWorkOrders: WorkOrder[] = [
  new WorkOrder(1, 'WX-2025-0812-001', '京A8W5J6', '丰田卡罗拉', '更换刹车片+机油保养', '李工', '维修中', '08/12 09:30', '08/12 16:00', 680),
  new WorkOrder(2, 'WX-2025-0812-002', '京N6K3M2', '大众朗逸', '更换轮胎四条+四轮定位', '张工', '待配件', '08/12 08:50', '08/13 11:00', 2360),
  new WorkOrder(3, 'WX-2025-0812-003', '京Q7A8P5', '本田雅阁', '发动机大保养', '王工', '维修中', '08/12 09:10', '08/12 18:00', 1250),
  new WorkOrder(4, 'WX-2025-0812-004', '京B3C9T1', '比亚迪汉EV', '更换电瓶', '李工', '已完成', '08/11 14:20', '08/11 15:40', 850),
  new WorkOrder(5, 'WX-2025-0812-005', '京H5F2V8', '别克GL8', '空调系统检修+加氟', '赵工', '待检修', '08/12 10:00', '08/12 17:30', 520),
  new WorkOrder(6, 'WX-2025-0812-006', '京M1D6R3', '奔驰C200', '更换火花塞+清洗节气门', '张工', '维修中', '08/12 09:40', '08/12 15:00', 980),
  new WorkOrder(7, 'WX-2025-0812-007', '京C2E7S9', '奥迪A6L', '变速箱油更换', '王工', '待配件', '08/11 16:30', '08/13 10:00', 1480),
  new WorkOrder(8, 'WX-2025-0812-008', '京K8G4N6', '哈弗H6', '更换雨刮+检查刹车', '赵工', '已完成', '08/11 11:00', '08/11 12:30', 260),
  new WorkOrder(9, 'WX-2025-0812-009', '京F4H9J7', '日产轩逸', '机油保养+机滤空滤', '李工', '待检修', '08/12 11:00', '08/12 18:30', 450),
  new WorkOrder(10, 'WX-2025-0812-010', '京J1M5W2', '宝马X5', '刹车盘打磨+刹车油更换', '张工', '维修中', '08/12 08:30', '08/12 19:00', 1650),
  new WorkOrder(11, 'WX-2025-0812-011', '京P9N3Q8', '特斯拉Model3', '轮胎换位+动平衡', '赵工', '已完成', '08/10 15:00', '08/10 16:20', 380),
  new WorkOrder(12, 'WX-2025-0812-012', '京R2T7Y4', '大众迈腾', '底盘异响检查', '王工', '维修中', '08/12 10:30', '08/12 16:30', 420),
  new WorkOrder(13, 'WX-2025-0812-013', '京S5K8U1', '丰田RAV4', '更换前轮刹车片', '李工', '待配件', '08/12 09:00', '08/13 09:00', 720),
  new WorkOrder(14, 'WX-2025-0812-014', '京T6L9E2', '本田CR-V', '全车漆面抛光打蜡', '赵工', '待检修', '08/12 13:00', '08/13 12:00', 880),
  new WorkOrder(15, 'WX-2025-0812-015', '京U3P4F6', '奥迪Q5', '更换电瓶+全车检测', '张工', '已完成', '08/11 09:20', '08/11 11:40', 1050),
  new WorkOrder(16, 'WX-2025-0812-016', '京V8R6C3', '五菱宏光', '机油保养', '李工', '已交付', '08/11 10:10', '08/11 12:00', 320),
  new WorkOrder(17, 'WX-2025-0812-017', '京W4N7J9', '宝马3系', '更换后轮刹车片+刹车油', '王工', '维修中', '08/12 11:30', '08/12 17:00', 960),
  new WorkOrder(18, 'WX-2025-0812-018', '京X1M2K8', '吉利星越L', '更换火花塞+点火线圈', '赵工', '已完成', '08/10 14:00', '08/10 17:30', 760),
  new WorkOrder(19, 'WX-2025-0812-019', '京Y5J6L4', '别克君威', '变速箱油+滤网更换', '张工', '待配件', '08/12 08:00', '08/13 15:00', 1560),
  new WorkOrder(20, 'WX-2025-0812-020', '京Z7H2N5', '雷克萨斯ES', '常规保养+空调滤芯', '李工', '已交付', '08/11 13:30', '08/11 15:20', 560)
]

const mockVehicles: Vehicle[] = [
  new Vehicle(1, '京A8W5J6', '丰田', '卡罗拉2023款', 2023, '珍珠白', '赵磊', '139****2233', 45000, '2025-07-01'),
  new Vehicle(2, '京N6K3M2', '大众', '朗逸2022款', 2022, '曜石黑', '孙丽', '138****7765', 62000, '2025-06-15'),
  new Vehicle(3, '京Q7A8P5', '本田', '雅阁2021款', 2021, '极光蓝', '周强', '137****9981', 83000, '2025-05-20'),
  new Vehicle(4, '京B3C9T1', '比亚迪', '汉EV 2023款', 2023, '赤帝红', '吴敏', '136****4521', 28000, '2025-07-22'),
  new Vehicle(5, '京H5F2V8', '别克', 'GL8 2022款', 2022, '星夜灰', '郑浩', '135****6634', 96000, '2025-04-18'),
  new Vehicle(6, '京M1D6R3', '奔驰', 'C200L 2023款', 2023, '曜岩黑', '王芳', '134****8812', 31000, '2025-07-10'),
  new Vehicle(7, '京C2E7S9', '奥迪', 'A6L 2021款', 2021, '深海蓝', '李娜', '133****5567', 89000, '2025-03-30'),
  new Vehicle(8, '京K8G4N6', '哈弗', 'H6 2020款', 2020, '炫晶白', '陈勇', '132****4478', 118000, '2025-02-14'),
  new Vehicle(9, '京F4H9J7', '日产', '轩逸2023款', 2023, '珠光白', '刘洋', '131****6639', 15000, '2025-07-28'),
  new Vehicle(10, '京J1M5W2', '宝马', 'X5 2022款', 2022, '碳黑', '徐鹏', '130****2245', 52000, '2025-06-08'),
  new Vehicle(11, '京P9N3Q8', '特斯拉', 'Model3 2022款', 2022, '珍珠白', '杨雪', '188****7741', 47000, '2025-06-25'),
  new Vehicle(12, '京R2T7Y4', '大众', '迈腾2021款', 2021, '幻影黑', '高翔', '186****8890', 74000, '2025-04-02'),
  new Vehicle(13, '京S5K8U1', '丰田', 'RAV4 2023款', 2023, '铂金白', '马超', '185****3309', 26000, '2025-07-18'),
  new Vehicle(14, '京T6L9E2', '本田', 'CR-V 2022款', 2022, '炫雅银', '朱琳', '184****6672', 68000, '2025-05-12'),
  new Vehicle(15, '京U3P4F6', '奥迪', 'Q5L 2023款', 2023, '天云灰', '罗刚', '183****2298', 33000, '2025-07-05'),
  new Vehicle(16, '京V8R6C3', '五菱', '宏光S 2019款', 2019, '晴空银', '何军', '182****8856', 136000, '2025-01-20')
]

const mockParts: PartsItem[] = [
  new PartsItem(1, '博世刹车片前轮', 'P-001', '刹车片', 32, 180, '博世授权店', 10),
  new PartsItem(2, '博世刹车片后轮', 'P-002', '刹车片', 8, 168, '博世授权店', 10),
  new PartsItem(3, '嘉实多极护5W-30', 'P-003', '机油', 56, 320, '嘉实多华北总代', 20),
  new PartsItem(4, '美孚速霸1000', 'P-004', '机油', 9, 268, '美孚直营', 15),
  new PartsItem(5, '曼牌机油滤芯', 'P-005', '滤芯', 48, 45, '曼牌经销', 20),
  new PartsItem(6, '马勒空气滤芯', 'P-006', '滤芯', 36, 52, '马勒代理', 15),
  new PartsItem(7, '马勒空调滤芯', 'P-007', '滤芯', 29, 68, '马勒代理', 15),
  new PartsItem(8, '米其林轮胎205/55R16', 'P-008', '轮胎', 14, 620, '米其林授权店', 6),
  new PartsItem(9, '普利司通轮胎225/45R18', 'P-009', '轮胎', 5, 780, '普利司通总代', 6),
  new PartsItem(10, '瓦尔塔AGM电瓶70Ah', 'P-010', '电瓶', 12, 1280, '瓦尔塔授权', 5),
  new PartsItem(11, '风帆电瓶60Ah', 'P-011', '电瓶', 3, 560, '风帆代理', 6),
  new PartsItem(12, 'NGK铱金火花塞', 'P-012', '火花塞', 42, 88, 'NGK经销', 16),
  new PartsItem(13, '博世双铱金火花塞', 'P-013', '火花塞', 18, 120, '博世授权店', 12),
  new PartsItem(14, '3M无骨雨刮24寸', 'P-014', '雨刮', 26, 75, '3M经销商', 12),
  new PartsItem(15, '博世雨刮器总成', 'P-015', '雨刮', 7, 96, '博世授权店', 10),
  new PartsItem(16, '长城润滑油变速箱油', 'P-016', '机油', 4, 260, '长城直营', 10),
  new PartsItem(17, '菲罗多刹车盘', 'P-017', '刹车片', 11, 320, '菲罗多代理', 5),
  new PartsItem(18, '壳牌刹车油DOT4', 'P-018', '其他', 22, 68, '壳牌授权', 10)
]

const mockMonthStats: MonthStat[] = [
  new MonthStat('2月', 112),
  new MonthStat('3月', 138),
  new MonthStat('4月', 126),
  new MonthStat('5月', 152),
  new MonthStat('6月', 145),
  new MonthStat('7月', 168)
]

const mockCategoryStocks: CategoryStockStat[] = [
  new CategoryStockStat('机油', 69),
  new CategoryStockStat('滤芯', 113),
  new CategoryStockStat('刹车片', 51),
  new CategoryStockStat('轮胎', 19),
  new CategoryStockStat('电瓶', 15),
  new CategoryStockStat('火花塞', 60),
  new CategoryStockStat('雨刮', 33),
  new CategoryStockStat('其他', 22)
]

const mockMechanicStats: MechanicStat[] = [
  new MechanicStat('李工', 186, 42, 28600),
  new MechanicStat('张工', 172, 38, 34200),
  new MechanicStat('王工', 168, 35, 29800),
  new MechanicStat('赵工', 145, 30, 21500)
]

const mockTechnicians: TechInfo[] = [
  { name: '李工', title: '高级技师', years: '12年', phone: '139****1101', skill: '发动机/底盘' },
  { name: '张工', title: '高级技师', years: '10年', phone: '139****1102', skill: '制动/电气' },
  { name: '王工', title: '中级技师', years: '7年', phone: '139****1103', skill: '变速箱/空调' },
  { name: '赵工', title: '中级技师', years: '5年', phone: '139****1104', skill: '轮胎/钣金' }
]

// ---- 全局纯函数 ----
function getStatusCfg(status: string): WorkStatusCfg {
  if (status === '待检修') {
    return WORK_STATUS.pending
  }
  if (status === '维修中') {
    return WORK_STATUS.working
  }
  if (status === '待配件') {
    return WORK_STATUS.waitingParts
  }
  if (status === '已完成') {
    return WORK_STATUS.finished
  }
  return WORK_STATUS.delivered
}

function getCategoryCfg(category: string): CategoryCfg {
  if (category === '机油') {
    return PART_CATEGORY.oil
  }
  if (category === '滤芯') {
    return PART_CATEGORY.filter
  }
  if (category === '刹车片') {
    return PART_CATEGORY.brake
  }
  if (category === '轮胎') {
    return PART_CATEGORY.tire
  }
  if (category === '电瓶') {
    return PART_CATEGORY.battery
  }
  if (category === '火花塞') {
    return PART_CATEGORY.spark
  }
  if (category === '雨刮') {
    return PART_CATEGORY.wiper
  }
  return PART_CATEGORY.other
}

function genOrderNo(len: number): string {
  const n = len + 1
  if (n < 100) {
    return 'WX-2025-0812-0' + n.toString()
  }
  return 'WX-2025-0812-' + n.toString()
}

function genPartCode(len: number): string {
  const n = len + 1
  if (n < 100) {
    return 'P-0' + n.toString()
  }
  return 'P-' + n.toString()
}

function formatMoney(cost: number): string {
  return '¥' + cost.toFixed(0)
}

function formatMileage(mileage: number): string {
  return (mileage / 10000).toFixed(1) + '万km'
}

function todayStr(): string {
  return '08/12'
}

function nowTimeStr(): string {
  return '08/12 09:00'
}

function filterOrders(orders: WorkOrder[], statusFilter: string, keyword: string): WorkOrder[] {
  const result: WorkOrder[] = []
  for (let i = 0; i < orders.length; i++) {
    const o = orders[i]
    const matchStatus = statusFilter === '全部' || o.status === statusFilter
    const matchKw = keyword === '' || o.plateNo.indexOf(keyword) >= 0 ||
      o.orderNo.indexOf(keyword) >= 0 || o.model.indexOf(keyword) >= 0
    if (matchStatus && matchKw) {
      result.push(o)
    }
  }
  return result
}

function filterVehicles(vehicles: Vehicle[], keyword: string): Vehicle[] {
  const result: Vehicle[] = []
  for (let i = 0; i < vehicles.length; i++) {
    const v = vehicles[i]
    const matchKw = keyword === '' || v.plateNo.indexOf(keyword) >= 0 ||
      v.brand.indexOf(keyword) >= 0 || v.owner.indexOf(keyword) >= 0
    if (matchKw) {
      result.push(v)
    }
  }
  return result
}

function filterParts(parts: PartsItem[], categoryFilter: string, keyword: string): PartsItem[] {
  const result: PartsItem[] = []
  for (let i = 0; i < parts.length; i++) {
    const p = parts[i]
    const matchCat = categoryFilter === '全部' || p.category === categoryFilter
    const matchKw = keyword === '' || p.name.indexOf(keyword) >= 0 || p.code.indexOf(keyword) >= 0
    if (matchCat && matchKw) {
      result.push(p)
    }
  }
  return result
}

function getMaxCount(stats: MonthStat[]): number {
  let max = 1
  for (let i = 0; i < stats.length; i++) {
    if (stats[i].count > max) {
      max = stats[i].count
    }
  }
  return max
}

function getMaxStock(stats: CategoryStockStat[]): number {
  let max = 1
  for (let i = 0; i < stats.length; i++) {
    if (stats[i].stock > max) {
      max = stats[i].stock
    }
  }
  return max
}

function getStockPct(stock: number, stats: CategoryStockStat[]): number {
  const max = getMaxStock(stats)
  let pct = Math.floor(stock / max * 100)
  if (pct < 8) {
    pct = 8
  }
  return pct
}

function getBarHeight(count: number, stats: MonthStat[]): number {
  const max = getMaxCount(stats)
  const h = Math.floor(count / max * 120)
  return 24 + h
}

function getTotalCost(orders: WorkOrder[]): number {
  let total = 0
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === '已完成' || orders[i].status === '已交付') {
      total = total + orders[i].cost
    }
  }
  return total
}

function countFinishedOrders(orders: WorkOrder[]): number {
  let c = 0
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === '已完成' || orders[i].status === '已交付') {
      c = c + 1
    }
  }
  return c
}

function countWaitingOrders(orders: WorkOrder[]): number {
  let c = 0
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === '待配件') {
      c = c + 1
    }
  }
  return c
}

// ---- Tab索引枚举 ----
enum TabIndex {
  WorkOrder = 0,
  Vehicle = 1,
  Parts = 2,
  Stats = 3,
  Profile = 4
}

// ---- 主入口 ----
@Entry
@Component
struct GarageApp {
  @State currentTab: number = TabIndex.WorkOrder
  @State workOrders: WorkOrder[] = mockWorkOrders
  @State vehicles: Vehicle[] = mockVehicles
  @State parts: PartsItem[] = mockParts

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

  @Builder headerBar(title: string) {
    Row() {
      Text(title)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Column().layoutWeight(1)
      Text('🛠️')
        .fontSize(24)
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 50, bottom: 16 })
    .alignItems(VerticalAlign.Center)
    .linearGradient({
      direction: GradientDirection.RightBottom,
      colors: [[0x37474F, 0.0], [0x263238, 1.0]]
    })
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
  }

  @Builder bottomTabBar() {
    Row() {
      Column() {
        Text('🔧')
          .fontSize(22)
          .margin({ bottom: 2 })
        Text('工单')
          .fontSize(11)
          .fontColor(this.currentTab === TabIndex.WorkOrder ? '#29B6F6' : '#90A4AE')
        if (this.currentTab === TabIndex.WorkOrder) {
          Column()
            .width(20)
            .height(3)
            .borderRadius(2)
            .backgroundColor('#29B6F6')
            .margin({ top: 2 })
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.currentTab = TabIndex.WorkOrder })

      Column() {
        Text('🚗')
          .fontSize(22)
          .margin({ bottom: 2 })
        Text('车辆')
          .fontSize(11)
          .fontColor(this.currentTab === TabIndex.Vehicle ? '#29B6F6' : '#90A4AE')
        if (this.currentTab === TabIndex.Vehicle) {
          Column()
            .width(20)
            .height(3)
            .borderRadius(2)
            .backgroundColor('#29B6F6')
            .margin({ top: 2 })
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.currentTab = TabIndex.Vehicle })

      Column() {
        Text('📦')
          .fontSize(22)
          .margin({ bottom: 2 })
        Text('配件')
          .fontSize(11)
          .fontColor(this.currentTab === TabIndex.Parts ? '#29B6F6' : '#90A4AE')
        if (this.currentTab === TabIndex.Parts) {
          Column()
            .width(20)
            .height(3)
            .borderRadius(2)
            .backgroundColor('#29B6F6')
            .margin({ top: 2 })
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.currentTab = TabIndex.Parts })

      Column() {
        Text('📊')
          .fontSize(22)
          .margin({ bottom: 2 })
        Text('统计')
          .fontSize(11)
          .fontColor(this.currentTab === TabIndex.Stats ? '#29B6F6' : '#90A4AE')
        if (this.currentTab === TabIndex.Stats) {
          Column()
            .width(20)
            .height(3)
            .borderRadius(2)
            .backgroundColor('#29B6F6')
            .margin({ top: 2 })
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.currentTab = TabIndex.Stats })

      Column() {
        Text('👤')
          .fontSize(22)
          .margin({ bottom: 2 })
        Text('我的')
          .fontSize(11)
          .fontColor(this.currentTab === TabIndex.Profile ? '#29B6F6' : '#90A4AE')
        if (this.currentTab === TabIndex.Profile) {
          Column()
            .width(20)
            .height(3)
            .borderRadius(2)
            .backgroundColor('#29B6F6')
            .margin({ top: 2 })
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.currentTab = TabIndex.Profile })
    }
    .width('100%')
    .height(64)
    .backgroundColor('#263238')
    .border({ width: 1, color: '#37474F' })
  }

  build() {
    Column() {
      Stack() {
        Column() {
          if (this.currentTab === TabIndex.WorkOrder) {
            this.headerBar('维修工单')
            WorkOrderTab({ orders: this.workOrders })
          }
          if (this.currentTab === TabIndex.Vehicle) {
            this.headerBar('车辆档案')
            VehicleTab({ vehicles: this.vehicles })
          }
          if (this.currentTab === TabIndex.Parts) {
            this.headerBar('配件库存')
            PartsTab({ parts: this.parts })
          }
          if (this.currentTab === TabIndex.Stats) {
            this.headerBar('经营统计')
            StatsTab()
          }
          if (this.currentTab === TabIndex.Profile) {
            this.headerBar('我的门店')
            ProfileTab({ orders: this.workOrders })
          }
          this.bottomTabBar()
        }
        .width('100%')
        .height('100%')
      }
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

// ---- 工单Tab ----
@Component
struct WorkOrderTab {
  @Link orders: WorkOrder[]
  @State statusFilter: string = '全部'
  @State keyword: string = ''
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State editItem: WorkOrder | null = null
  @State deleteItem: WorkOrder | null = null
  @State formOrderNo: string = ''
  @State formPlateNo: string = ''
  @State formModel: string = ''
  @State formProject: string = ''
  @State formMechanic: string = ''
  @State formStatus: string = '待检修'
  @State formInTime: string = ''
  @State formExpectTime: string = ''
  @State formCost: string = ''

  openAdd() {
    this.formOrderNo = genOrderNo(this.orders.length)
    this.formPlateNo = ''
    this.formModel = ''
    this.formProject = ''
    this.formMechanic = ''
    this.formStatus = '待检修'
    this.formInTime = nowTimeStr()
    this.formExpectTime = '08/12 18:00'
    this.formCost = ''
    this.showAddModal = true
  }

  openEdit(item: WorkOrder) {
    this.editItem = item
    this.formOrderNo = item.orderNo
    this.formPlateNo = item.plateNo
    this.formModel = item.model
    this.formProject = item.project
    this.formMechanic = item.mechanic
    this.formStatus = item.status
    this.formInTime = item.inTime
    this.formExpectTime = item.expectTime
    this.formCost = item.cost.toString()
    this.showEditModal = true
  }

  openDelete(item: WorkOrder) {
    this.deleteItem = item
    this.showDeleteConfirm = true
  }

  confirmAdd() {
    const plate = this.formPlateNo === '' ? '京A8W5J6' : this.formPlateNo
    const model = this.formModel === '' ? '未知车型' : this.formModel
    const project = this.formProject === '' ? '常规保养' : this.formProject
    const mech = this.formMechanic === '' ? '李工' : this.formMechanic
    const cost = this.formCost === '' ? 0 : Number(this.formCost)
    this.orders.push(new WorkOrder(this.orders.length + 1, this.formOrderNo, plate, model,
      project, mech, this.formStatus, this.formInTime, this.formExpectTime, cost))
    this.showAddModal = false
  }

  confirmEdit() {
    const item = this.editItem
    if (item !== null) {
      item.plateNo = this.formPlateNo
      item.model = this.formModel
      item.project = this.formProject
      item.mechanic = this.formMechanic
      item.status = this.formStatus
      item.cost = this.formCost === '' ? 0 : Number(this.formCost)
    }
    this.editItem = null
    this.showEditModal = false
  }

  confirmDelete() {
    const item = this.deleteItem
    if (item !== null) {
      const idx = this.orders.indexOf(item)
      if (idx >= 0) {
        this.orders.splice(idx, 1)
      }
    }
    this.deleteItem = null
    this.showDeleteConfirm = false
  }

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

  @Builder statusTag(label: string) {
    Text(label)
      .fontSize(13)
      .fontColor(this.statusFilter === label ? '#FFFFFF' : '#607D8B')
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .borderRadius(16)
      .backgroundColor(this.statusFilter === label ? '#37474F' : '#FFFFFF')
      .border({ width: 1, color: this.statusFilter === label ? '#37474F' : '#DDDDDD' })
      .margin({ right: 8 })
      .onClick(() => { this.statusFilter = label })
  }

  @Builder formStatusChip(s: string) {
    Text(s)
      .fontSize(12)
      .fontColor(this.formStatus === s ? '#FFFFFF' : '#607D8B')
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(14)
      .backgroundColor(this.formStatus === s ? '#29B6F6' : '#F5F5F5')
      .border({ width: 1, color: this.formStatus === s ? '#29B6F6' : '#E0E0E0' })
      .margin({ right: 8, bottom: 8 })
      .onClick(() => { this.formStatus = s })
  }

  @Builder workOrderCard(item: WorkOrder) {
    Row() {
      Column()
        .width(4)
        .borderRadius(2)
        .backgroundColor(getStatusCfg(item.status).color)
        .alignSelf(ItemAlign.Stretch)
      Column() {
        Row() {
          Text(item.orderNo)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#37474F')
          Column().layoutWeight(1)
          Text(item.status)
            .fontSize(11)
            .fontColor(getStatusCfg(item.status).color)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(10)
            .backgroundColor(getStatusCfg(item.status).bg)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('🚗 ' + item.plateNo)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#37474F')
          Column().width(10)
          Text(item.model)
            .fontSize(13)
            .fontColor('#888888')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Column().layoutWeight(1)
          Text(item.mechanic)
            .fontSize(12)
            .fontColor('#29B6F6')
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('项目: ' + item.project)
            .fontSize(12)
            .fontColor('#607D8B')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Column().layoutWeight(1)
          Text(formatMoney(item.cost))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF7043')
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('进厂 ' + item.inTime + '  预计 ' + item.expectTime)
            .fontSize(11)
            .fontColor('#90A4AE')
          Column().layoutWeight(1)
          Button('编辑')
            .height(26)
            .borderRadius(13)
            .fontSize(11)
            .backgroundColor('#E1F5FE')
            .fontColor('#0288D1')
            .onClick(() => { this.openEdit(item) })
          Column().width(8)
          Button('删除')
            .height(26)
            .borderRadius(13)
            .fontSize(11)
            .backgroundColor('#FFEBEE')
            .fontColor('#EF5350')
            .onClick(() => { this.openDelete(item) })
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)
      }
      .layoutWeight(1)
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .border({ width: 1, color: '#E8E8E8' })
    .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
    .margin({ bottom: 12 })
  }

  @Builder modalFormFields() {
    Column() {
      Column() {
        Text('工单号')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formOrderNo, placeholder: '系统自动生成' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formOrderNo = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Column() {
        Text('车牌号')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formPlateNo, placeholder: '如 京A8W5J6' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formPlateNo = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Column() {
        Text('车型')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formModel, placeholder: '如 丰田卡罗拉' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formModel = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Column() {
        Text('维修项目')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formProject, placeholder: '如 更换刹车片' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formProject = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Column() {
        Text('维修师傅')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formMechanic, placeholder: '如 李工' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formMechanic = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Column() {
        Text('费用(元)')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formCost, placeholder: '如 680' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formCost = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Column() {
        Text('维修状态')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 8 })
        Row() {
          ForEach(WORK_STATUS_FILTERS.slice(1), (s: string) => {
            this.formStatusChip(s)
          })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
  }

  @Builder addModalContent() {
    Column() {
      Text('新增维修工单')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ bottom: 4 })
      Text('填写车辆维修信息')
        .fontSize(13)
        .fontColor('#90A4AE')
        .margin({ bottom: 16 })
      Scroll() {
        Column() {
          this.modalFormFields()
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .width('100%')
      Row() {
        Button('取消')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .fontSize(14)
          .onClick(() => { this.showAddModal = false })
        Column().width(12)
        Button('确认新增')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#37474F')
          .fontColor('#FFFFFF')
          .fontSize(14)
          .onClick(() => { this.confirmAdd() })
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('88%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder editModalContent() {
    Column() {
      Text('编辑维修工单')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ bottom: 4 })
      Text('修改工单 ' + this.formOrderNo)
        .fontSize(13)
        .fontColor('#90A4AE')
        .margin({ bottom: 16 })
      Scroll() {
        Column() {
          this.modalFormFields()
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .width('100%')
      Row() {
        Button('取消')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .fontSize(14)
          .onClick(() => { this.showEditModal = false })
        Column().width(12)
        Button('保存修改')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#29B6F6')
          .fontColor('#FFFFFF')
          .fontSize(14)
          .onClick(() => { this.confirmEdit() })
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('88%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder deleteModalContent() {
    Column() {
      Text('⚠️')
        .fontSize(40)
        .margin({ bottom: 10 })
      Text('确认删除该工单?')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ bottom: 8 })
      Text((this.deleteItem !== null ? this.deleteItem.orderNo : '') + ' 删除后不可恢复')
        .fontSize(13)
        .fontColor('#90A4AE')
        .margin({ bottom: 20 })
      Row() {
        Button('取消')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .fontSize(14)
          .onClick(() => { this.showDeleteConfirm = false })
        Column().width(12)
        Button('确认删除')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#EF5350')
          .fontColor('#FFFFFF')
          .fontSize(14)
          .onClick(() => { this.confirmDelete() })
      }
      .width('100%')
    }
    .width('82%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Text('🔍')
            .fontSize(14)
            .margin({ right: 8 })
          TextInput({ placeholder: '搜索工单号 / 车牌 / 车型' })
            .height(40)
            .layoutWeight(1)
            .borderRadius(20)
            .backgroundColor('#FFFFFF')
            .border({ width: 1, color: '#E0E0E0' })
            .padding({ left: 14, right: 14 })
            .onChange((value: string) => { this.keyword = value })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })
        .alignItems(VerticalAlign.Center)

        Scroll() {
          Row() {
            ForEach(WORK_STATUS_FILTERS, (label: string) => {
              this.statusTag(label)
            })
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ top: 12, bottom: 4 })

        Scroll() {
          Column() {
            ForEach(filterOrders(this.orders, this.statusFilter, this.keyword), (item: WorkOrder) => {
              this.workOrderCard(item)
            })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 8, bottom: 20 })
        }
        .layoutWeight(1)
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .width('100%')

        Row() {
          Button('+ 新增工单')
            .height(44)
            .width('100%')
            .borderRadius(22)
            .backgroundColor('#37474F')
            .fontColor('#FFFFFF')
            .fontSize(15)
            .onClick(() => { this.openAdd() })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 8, bottom: 12 })
      }
      .layoutWeight(1)
      .width('100%')

      if (this.showAddModal) {
        this.modalOverlay(() => { this.showAddModal = false })
        Column() {
          this.addModalContent()
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
      if (this.showEditModal) {
        this.modalOverlay(() => { this.showEditModal = false })
        Column() {
          this.editModalContent()
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
      if (this.showDeleteConfirm) {
        this.modalOverlay(() => { this.showDeleteConfirm = false })
        Column() {
          this.deleteModalContent()
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
    }
    .layoutWeight(1)
    .width('100%')
  }
}

// ---- 车辆Tab ----
@Component
struct VehicleTab {
  @Link vehicles: Vehicle[]
  @State keyword: string = ''
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State editItem: Vehicle | null = null
  @State deleteItem: Vehicle | null = null
  @State formPlateNo: string = ''
  @State formBrand: string = ''
  @State formVModel: string = ''
  @State formYear: string = ''
  @State formColor: string = ''
  @State formOwner: string = ''
  @State formPhone: string = ''
  @State formMileage: string = ''
  @State formLastMaint: string = ''

  openAdd() {
    this.formPlateNo = ''
    this.formBrand = ''
    this.formVModel = ''
    this.formYear = '2024'
    this.formColor = ''
    this.formOwner = ''
    this.formPhone = ''
    this.formMileage = ''
    this.formLastMaint = todayStr()
    this.showAddModal = true
  }

  openEdit(item: Vehicle) {
    this.editItem = item
    this.formPlateNo = item.plateNo
    this.formBrand = item.brand
    this.formVModel = item.model
    this.formYear = item.year.toString()
    this.formColor = item.color
    this.formOwner = item.owner
    this.formPhone = item.phone
    this.formMileage = item.mileage.toString()
    this.formLastMaint = item.lastMaintenance
    this.showEditModal = true
  }

  openDelete(item: Vehicle) {
    this.deleteItem = item
    this.showDeleteConfirm = true
  }

  confirmAdd() {
    const plate = this.formPlateNo === '' ? '京A8W5J6' : this.formPlateNo
    const brand = this.formBrand === '' ? '丰田' : this.formBrand
    const model = this.formVModel === '' ? '卡罗拉' : this.formVModel
    const year = this.formYear === '' ? 2024 : Number(this.formYear)
    const color = this.formColor === '' ? '珍珠白' : this.formColor
    const owner = this.formOwner === '' ? '未知车主' : this.formOwner
    const phone = this.formPhone === '' ? '139****0000' : this.formPhone
    const mileage = this.formMileage === '' ? 0 : Number(this.formMileage)
    const lastMaint = this.formLastMaint === '' ? todayStr() : this.formLastMaint
    this.vehicles.push(new Vehicle(this.vehicles.length + 1, plate, brand, model, year,
      color, owner, phone, mileage, lastMaint))
    this.showAddModal = false
  }

  confirmEdit() {
    const item = this.editItem
    if (item !== null) {
      item.plateNo = this.formPlateNo
      item.brand = this.formBrand
      item.model = this.formVModel
      item.year = this.formYear === '' ? 2024 : Number(this.formYear)
      item.color = this.formColor
      item.owner = this.formOwner
      item.phone = this.formPhone
      item.mileage = this.formMileage === '' ? 0 : Number(this.formMileage)
      item.lastMaintenance = this.formLastMaint
    }
    this.editItem = null
    this.showEditModal = false
  }

  confirmDelete() {
    const item = this.deleteItem
    if (item !== null) {
      const idx = this.vehicles.indexOf(item)
      if (idx >= 0) {
        this.vehicles.splice(idx, 1)
      }
    }
    this.deleteItem = null
    this.showDeleteConfirm = false
  }

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

  @Builder vehicleCard(item: Vehicle) {
    Row() {
      Column() {
        Text('🚗')
          .fontSize(30)
      }
      .width(64)
      .height(64)
      .borderRadius(32)
      .backgroundColor('#E1F5FE')
      .justifyContent(FlexAlign.Center)

      Column() {
        Row() {
          Text(item.plateNo)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#37474F')
          Column().layoutWeight(1)
          Text(item.owner)
            .fontSize(12)
            .fontColor('#29B6F6')
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Row() {
          Column() {
            Text('品牌型号')
              .fontSize(10)
              .fontColor('#90A4AE')
            Text(item.brand + ' ' + item.model)
              .fontSize(12)
              .fontColor('#37474F')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Column() {
            Text('年份')
              .fontSize(10)
              .fontColor('#90A4AE')
            Text(item.year + '款')
              .fontSize(12)
              .fontColor('#37474F')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Column() {
            Text('颜色')
              .fontSize(10)
              .fontColor('#90A4AE')
            Text(item.color)
              .fontSize(12)
              .fontColor('#37474F')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Column() {
            Text('车主电话')
              .fontSize(10)
              .fontColor('#90A4AE')
            Text(item.phone)
              .fontSize(12)
              .fontColor('#37474F')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Column() {
            Text('行驶里程')
              .fontSize(10)
              .fontColor('#90A4AE')
            Text(formatMileage(item.mileage))
              .fontSize(12)
              .fontColor('#FF7043')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Column() {
            Text('上次保养')
              .fontSize(10)
              .fontColor('#90A4AE')
            Text(item.lastMaintenance)
              .fontSize(12)
              .fontColor('#37474F')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Column().layoutWeight(1)
          Button('编辑')
            .height(26)
            .borderRadius(13)
            .fontSize(11)
            .backgroundColor('#E1F5FE')
            .fontColor('#0288D1')
            .onClick(() => { this.openEdit(item) })
          Column().width(8)
          Button('删除')
            .height(26)
            .borderRadius(13)
            .fontSize(11)
            .backgroundColor('#FFEBEE')
            .fontColor('#EF5350')
            .onClick(() => { this.openDelete(item) })
        }
        .width('100%')
        .margin({ top: 10 })
        .alignItems(VerticalAlign.Center)
      }
      .layoutWeight(1)
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .border({ width: 1, color: '#E8E8E8' })
    .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
    .margin({ bottom: 12 })
  }

  @Builder modalFormFields() {
    Column() {
      Column() {
        Text('车牌号')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ bottom: 6 })
        TextInput({ text: this.formPlateNo, placeholder: '如 京A8W5J6' })
          .height(40)
          .borderRadius(10)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12 })
          .onChange((value: string) => { this.formPlateNo = value })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ bottom: 12 })

      Row() {
        Column() {
          Text('品牌')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formBrand, placeholder: '如 丰田' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formBrand = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Column().width(10)
        Column() {
          Text('型号')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formVModel, placeholder: '如 卡罗拉2023款' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formVModel = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .margin({ bottom: 12 })
      .alignItems(VerticalAlign.Top)

      Row() {
        Column() {
          Text('年份')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formYear, placeholder: '如 2023' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formYear = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Column().width(10)
        Column() {
          Text('颜色')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formColor, placeholder: '如 珍珠白' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formColor = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .margin({ bottom: 12 })
      .alignItems(VerticalAlign.Top)

      Row() {
        Column() {
          Text('车主')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formOwner, placeholder: '如 赵磊' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formOwner = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Column().width(10)
        Column() {
          Text('电话')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formPhone, placeholder: '如 139****2233' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formPhone = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .margin({ bottom: 12 })
      .alignItems(VerticalAlign.Top)

      Row() {
        Column() {
          Text('里程(km)')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formMileage, placeholder: '如 45000' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formMileage = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Column().width(10)
        Column() {
          Text('上次保养')
            .fontSize(13)
            .fontColor('#666666')
            .margin({ bottom: 6 })
          TextInput({ text: this.formLastMaint, placeholder: '如 2025-07-01' })
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12 })
            .onChange((value: string) => { this.formLastMaint = value })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
    }
    .width('100%')
  }

  @Builder addModalContent() {
    Column() {
      Text('新增车辆档案')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ bottom: 4 })
      Text('登记车辆基本信息')
        .fontSize(13)
        .fontColor('#90A4AE')
        .margin({ bottom: 16 })
      Scroll() {
        Column() {
          this.modalFormFields()
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .width('100%')
      Row() {
        Button('取消')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .fontSize(14)
          .onClick(() => { this.showAddModal = false })
        Column().width(12)
        Button('确认新增')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#37474F')
          .fontColor('#FFFFFF')
          .fontSize(14)
          .onClick(() => { this.confirmAdd() })
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('88%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder editModalContent() {
    Column() {
      Text('编辑车辆档案')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ bottom: 4 })
      Text('修改车辆 ' + this.formPlateNo)
        .fontSize(13)
        .fontColor('#90A4AE')
        .margin({ bottom: 16 })
      Scroll() {
        Column() {
          this.modalFormFields()
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .width('100%')
      Row() {
        Button('取消')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .fontSize(14)
          .onClick(() => { this.showEditModal = false })
        Column().width(12)
        Button('保存修改')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#29B6F6')
          .fontColor('#FFFFFF')
          .fontSize(14)
          .onClick(() => { this.confirmEdit() })
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('88%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder deleteModalContent() {
    Column() {
      Text('⚠️')
        .fontSize(40)
        .margin({ bottom: 10 })
      Text('确认删除该车辆?')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#37474F')
        .margin({ bottom: 8 })
      Text((this.deleteItem !== null ? this.deleteItem.plateNo : '') + ' 删除后不可恢复')
        .fontSize(13)
        .fontColor('#90A4AE')
        .margin({ bottom: 20 })
      Row() {
        Button('取消')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#F5F5F5')
          .fontColor('#666666')
          .fontSize(14)
          .onClick(() => { this.showDeleteConfirm = false })
        Column().width(12)
        Button('确认删除')
          .layoutWeight(1)
          .height(42)
          .borderRadius(21)
          .backgroundColor('#EF5350')
          .fontColor('#FFFFFF')
          .fontSize(14)
          .onClick(() => { this.confirmDelete() })
      }
      .width('100%')
    }
    .width('82%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Text('🔍')
            .fontSize(14)
            .margin({ right: 8 })
          TextInput({ placeholder: '搜索车牌 / 品牌 / 车主' })
            .height(40)
            .layoutWeight(1)
            .borderRadius(20)
            .backgroundColor('#FFFFFF')
            .border({ width: 1, color: '#E0E0E0' })
            .padding({ left: 14, right: 14 })
            .onChange((value: string) => { this.keyword = value })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })
        .alignItems(VerticalAlign.Center)

        Scroll() {
          Column() {
            ForEach(filterVehicles(this.vehicles, this.keyword), (item: Vehicle) => {
              this.vehicleCard(item)
            })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 20 })
        }
        .layoutWeight(1)
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .width('100%')

        Row() {
          Button('+ 新增车辆')
            .height(44)
            .width('100%')
            .borderRadius(22)
            .backgroundColor('#37474F')
            .fontColor('#FFFFFF')
            .fontSize(15)
            .onClick(() => { this.openAdd() })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 8, bottom: 12 })
      }
      .layoutWeight(1)
      .width('100%')

      if (this.showAddModal) {
        this.modalOverlay(() => { this.showAddModal = false })
        Column() {
          this.addModalContent()
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
      if (this.showEditModal) {
        this.modalOverlay(() => { this.showEditModal = false })
        Column() {
          this.editModalContent()
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
      if (this.showDeleteConfirm) {
        this.modalOverlay(() => { this.showDeleteConfirm = false })
        Column() {
          this.deleteModalContent()
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      }
    }
    .layoutWeight(1)
    .width('100%')
  }
}

function getCategoryIcon(category: string): string {
  if (category === '机油') {
    return '🛢️'
  }
  if (category === '滤芯') {
    return '🌀'
  }
  if (category === '刹车片') {
    return '🛑'
  }
  if (category === '轮胎') {
    return '⭕'
  }
  if (category === '电瓶') {
    return '🔋'
  }
  if (category === '火花塞') {
    return '⚡'
  }
  if (category === '雨刮') {
    return '💧'
  }
  return '📦'
}


          this.serviceRow('⭐', '客户评价管理', '4.9 分')
          this.serviceRow('⚙️', '系统设置', 'v2.4.0')
        }
        .width('100%')
        .padding({ left: 16, right: 16 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .border({ width: 1, color: '#E8E8E8' })
        .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
        .margin({ top: 12, bottom: 20 })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })
    }
    .layoutWeight(1)
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
  }
}


15.5 可扩展性分析

系统在多个方面预留了扩展空间:

  • 数据层扩展:当前使用Mock数据,后续可替换为后端API调用,工具函数的纯函数设计使这一替换几乎无成本。
  • 功能扩展:我的门店Tab中的快捷服务入口(报表、预约、评价、设置)为未来功能迭代提供了入口。
  • 状态扩展:工单状态映射和配件分类映射采用配置化设计,新增状态或分类只需修改配置常量,不影响业务逻辑。
  • UI主题扩展:颜色配置集中管理,未来可实现主题切换功能(如深色模式)只需替换COLORS常量。

15.6 总结

本系统是一个结构完整、设计精细的车辆维修保养管理移动端应用。它以五大功能模块构建了汽修门店的数字化管理闭环,通过响应式状态驱动和可观察数据模型实现了高效的状态管理,通过统一的色彩体系和精细的信息层级营造了专业的视觉体验,通过纯代码绘制的图表展现了声明式UI的表现力。

在这里插入图片描述

从代码层面看,系统展现了良好的工程实践:接口先行、配置集中、函数纯净、组件自治。这些设计原则使得系统具备了较强的可读性、可测试性和可维护性,为后续的功能迭代和规模化开发奠定了坚实的基础。

对于开发者而言,本系统的代码是一份优秀的声明式UI开发学习材料——它涵盖了接口定义、可观察类、状态装饰器、构建器复用、条件渲染、列表渲染、弹窗管理、表单处理、数据过滤、数据可视化等声明式UI开发的核心知识点,每个知识点都有完整的代码示例和真实的业务场景支撑。

Logo

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

更多推荐