基于HarmonyOS API 24实战解析:构建高性能外卖骑手工作台应用build 方法的根容器选择了 Stack 组件,这是一个层叠布局容器,允许其子组件在 Z 轴方向上堆叠
一、应用概述与设计理念
在当今快节奏的即时配送行业中,外卖骑手作为连接商家与消费者的关键纽带,其工作效率直接决定了用户体验和平台口碑。为了满足骑手在移动端高效接单、实时追踪配送进度、查看收入统计以及管理客户评价等核心需求,我们基于 HarmonyOS 的 ArkTS 声明式 UI 框架,打造了一款功能完备、界面精美的骑手工作台应用。这款应用不仅仅是一个简单的订单展示工具,更是一个集成了数据可视化、状态管理、交互反馈于一体的综合性业务平台,旨在通过优秀的交互设计和高效的技术实现,帮助骑手在繁忙的工作中快速获取关键信息、做出精准决策。

从设计理念上来看,本应用充分遵循了 HarmonyOS 的 Design 设计规范,同时结合了外卖配送行业的实际业务场景。在色彩体系方面,我们采用了闪电黄(#FFC107)与深灰色(#37474F)的搭配方案。闪电黄象征着速度与效率,能够在视觉上迅速吸引骑手的注意力,用于突出核心操作按钮、收入金额以及重要状态标识;而深灰色则赋予界面稳重专业的气质,广泛应用于顶部导航栏、文字主体以及背景层次的分割。这种高对比度的配色方案确保了骑手在户外强光环境下依然能够清晰辨识界面内容。在信息架构层面,应用采用经典的底部 Tab 导航模式,将核心业务模块划分为接单大厅、配送追踪、收入统计、客户评价和个人中心五大板块,使得功能入口清晰明了,符合移动应用用户的操作习惯。
技术架构方面,本应用完全基于 ArkTS 语言进行开发,充分利用了其声明式 UI 编程模型的优势。通过 @Component 装饰器定义组件,利用 @State 实现响应式状态管理,借助 @Builder 封装可复用的 UI 构建逻辑,整个代码结构清晰、维护便捷。在布局实现上,综合运用了 Column、Row、Stack、Scroll 等基础容器组件,配合 layoutWeight、padding、margin 等布局属性,构建出灵活且自适应的界面结构。特别是在收入统计模块中,我们通过纯 ArkTS 代码实现了动态柱状图效果,避免了引入额外的图表库,从而有效控制了应用包体积并提升了渲染性能。此外,应用还精心设计了多种弹窗交互,包括接单确认、备注编辑和取消订单等场景,确保每一个关键操作都有明确的用户确认环节,提升了操作的安全性和准确性。
从应用场景的角度分析,这款骑手工作台覆盖了配送业务的完整生命周期。在接单大厅中,骑手可以浏览附近待接订单,通过卡片化的设计直观获取商家信息、配送距离、预计时效和配送费用等关键数据,从而快速判断是否接单。进入配送模块后,时间轴式的配送进度展示让骑手对当前订单状态一目了然,从到达商家、取餐完成到配送中、即将送达,每个节点都有清晰的时间记录和状态标识。收入统计模块则以周为维度,通过柱状图和明细列表相结合的方式,帮助骑手全面掌握自己的劳动成果。客户评价模块不仅展示了综合评分和星级分布,还通过标签云的形式提炼出骑手的服务亮点。个人中心则集中了账户信息、车辆管理和系统设置等辅助功能。可以说,这款应用真正做到了业务全覆盖、数据可视化、操作便捷化,是 HarmonyOS 生态下即时配送类应用的典型技术范例。
二、类型定义与数据建模
在任何一个健壮的 ArkTS 应用中,合理的数据建模都是确保代码可维护性和类型安全性的基石。本应用在开发初期就投入了大量精力进行接口设计,通过 TypeScript 的接口(Interface)机制,为每一个业务实体建立了清晰的数据契约。这种做法不仅能够在编译阶段捕获潜在的类型错误,还能为后续的数据流转、组件传参提供完善的智能提示和代码补全支持。
2.1 订单元数据接口定义
interface OrderMeta {
id: number
shopName: string
shopColor: string
customerName: string
customerPhone: string
address: string
distance: string
items: string
amount: number
deliveryFee: number
estimatedTime: string
status: string
orderTime: string
}

OrderMeta 接口是整个应用中最为核心的数据模型之一,它完整描述了一个外卖订单所包含的全部关键信息。该接口定义了十二个字段,涵盖了从订单标识到配送细节的各个方面。其中,id 字段作为数字类型的唯一标识符,用于在系统中精准定位每一个订单;shopName 和 shopColor 字段分别记录了商家名称和对应的主题色,这使得在 UI 渲染时可以为不同商家的订单卡片赋予差异化的视觉标识,帮助骑手快速区分订单来源;customerName 和 customerPhone 字段保存了客户的基本联系信息,当然在实际生产环境中,手机号通常会经过脱敏处理以保护用户隐私;address 字段详细记录了配送目的地,distance 字段则以字符串形式表达了骑手当前位置到目的地的距离,是骑手判断是否接单的重要参考依据;items 字段以字符串形式汇总了订单中的所有商品,便于骑手在取餐时进行核对;amount 和 deliveryFee 两个数值型字段分别代表订单总金额和骑手可获得的配送费用,是骑手关注的核心经济指标;estimatedTime、status 和 orderTime 三个字段则共同描述了订单的时间线和当前状态,为配送调度提供了时间维度上的决策支持。通过这样一套完整的字段设计,OrderMeta 接口成功地将复杂的业务实体转化为结构化的数据对象,为后续的数据处理和 UI 渲染奠定了坚实的基础。
2.2 配送步骤与收入数据接口
interface DeliveryStepMeta {
step: number
title: string
desc: string
time: string
done: boolean
}
interface IncomeMeta {
date: string
orders: number
income: number
hours: number
}

DeliveryStepMeta 接口专门用于描述配送流程中的每一个节点状态,是实现时间轴式进度展示的数据基础。该接口包含五个字段:step 字段作为数字类型的步骤序号,决定了各节点在时间轴上的排列顺序;title 字段提供了每个步骤的简短标题,例如"到达商家"、"取餐完成"等,这些标题会直接展示在用户界面上;desc 字段则给出了更为详细的步骤描述信息,如具体的取餐内容或配送地址,为骑手提供更丰富的上下文;time 字段记录了每个步骤发生或预计发生的时间点,是时间轴组件的重要展示内容;done 字段是一个布尔值,用于标识该步骤是否已经完成,UI 层会根据这个字段的值来切换不同的颜色、图标和样式,从而向用户传递清晰的进度信息。这种结构化的步骤定义方式,使得配送进度的管理和展示变得异常灵活,未来如果需要增加新的配送节点或调整现有流程,只需修改数据配置即可,无需改动核心渲染逻辑。
IncomeMeta 接口则聚焦于骑手的收入统计维度,是数据可视化模块的核心数据模型。它包含四个字段:date 字段以字符串形式记录日期或星期信息,作为柱状图和列表的横轴标签;orders 字段是数字类型,记录了对应时间段的完成订单数量,反映了骑手的工作强度;income 字段记录了该时间段的实际收入金额,是骑手最为关心的核心数据,也是柱状图高度计算的直接依据;hours 字段则统计了骑手在线工作的时长,可用于后续计算单位时间收益等更深层次的运营指标。这四个字段的组合,使得收入数据不仅可以以总金额的形式呈现,还可以从订单量、工作时长等多个维度进行交叉分析,为骑手优化自己的工作节奏提供了数据支撑。
2.3 评价与颜色令牌接口
interface ReviewMeta {
id: number
customerName: string
rating: number
content: string
date: string
tags: string[]
}
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
dark: string;
darkLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
}

ReviewMeta 接口定义了客户评价数据的结构规范。id 字段作为评价的唯一标识;customerName 字段记录评价客户的称呼;rating 字段是一个数字类型的评分值,通常为 1 到 5 之间的整数,直接决定了界面上显示的星星数量;content 字段保存了客户撰写的详细评价文字内容,是展示服务质量口碑的核心载体;date 字段记录了评价发布的时间;tags 字段则是一个字符串数组,用于存储系统或客户为该评价打上的标签,如"速度快"、"态度好"等,这些标签在 UI 上会以小型标签云的形式呈现,让用户能够快速捕捉到评价的核心要点,而无需阅读大段文字。
ColorPalette 接口则体现了本应用在工程化方面的高度自律,它将整个应用使用到的所有颜色值统一抽象为一个调色板对象。这种设计令牌(Design Token)的思路来源于现代前端工程的最佳实践,通过将颜色从具体的色值(如 #FFC107)抽象为语义化的名称(如 primary、success、danger),实现了设计系统与代码实现的深度解耦。该接口定义了十五个颜色字段,覆盖了主色调的三种明暗层次、深色系的两种层次、背景色、卡片背景色、三种文字层级色、边框色以及成功、警告、危险三种状态色。这种精细化的颜色分层确保了界面视觉的一致性和专业感,同时也极大地方便了后续的主题切换或颜色调整工作——开发者只需修改一处常量定义,整个应用的色彩风格就会同步更新,彻底避免了在传统开发中常见的"散弹式修改"问题。
三、设计令牌与常量配置
在设计令牌接口定义完成后,接下来的关键步骤就是将这些抽象的色彩和配置概念转化为具体的常量值。本应用通过集中式的常量定义,确保了所有模块在视觉表现和交互行为上保持高度一致。
3.1 全局颜色常量定义
const COLORS: ColorPalette = {
primary: '#FFC107',
primaryLight: '#FFE082',
primaryDark: '#FF8F00',
dark: '#37474F',
darkLight: '#546E7A',
bg: '#FAFAFA',
cardBg: '#FFFFFF',
textPrimary: '#263238',
textSecondary: '#78909C',
textHint: '#B0BEC5',
border: '#ECEFF1',
success: '#66BB6A',
warning: '#FFA726',
danger: '#EF5350',
white: '#FFFFFF'
};

COLORS 常量对象是整个应用视觉系统的核心枢纽,它将 ColorPalette 接口中定义的十五个语义化颜色名称映射为具体的十六进制色值。主色调采用了从浅到深的三级黄色系:primaryLight(#FFE082)通常用于标签背景、轻量级高亮区域或悬停状态的底色;primary(#FFC107)作为品牌主色,承担着按钮背景、收入金额高亮、重要图标着色等核心视觉职责;primaryDark(#FF8F00)则用于需要更强对比度的场景,如 Tab 栏选中态的文字颜色、配送状态标签背景等。深色系中的 dark(#37474F)被大量运用于顶部状态栏的渐变背景,营造出沉稳专业的头部氛围;darkLight(#546E7A)则作为渐变终点色或辅助文字色使用。在背景和层级方面,bg(#FAFAFA)设定了整个页面的浅灰底色,这种微妙的灰色能够有效降低长时间使用时的视觉疲劳,同时与白色的卡片背景 cardBg(#FFFFFF)形成自然的层级分割。文字颜色采用了三级灰度体系:textPrimary(#263238)用于标题和核心内容,textSecondary(#78909C)用于辅助说明文字,textHint(#B0BEC5)则用于占位符、时间戳等最低优先级的信息。状态色方面,success(#66BB6A)专用于已完成步骤的标识,warning(#FFA726)用于低评分或需要注意的提示,danger(#EF5350)则与取消订单、警告弹窗等危险操作绑定。通过这种系统化的颜色管理,整个应用呈现出高度协调的视觉语言。
3.2 底部导航与数据结构常量
const TAB_CONFIG: Record<string, string> = {
'hall': '接单',
'delivery': '配送',
'income': '收入',
'review': '评价',
'mine': '我的'
}

TAB_CONFIG 常量使用 TypeScript 的 Record<string, string> 类型定义了一个字典对象,用于集中管理底部导航栏的配置信息。这种字典结构的优势在于它以 Tab 的英文标识符作为键(如 hall、delivery),以对应的中文显示文案作为值。这种设计实现了标识符与显示文案的解耦,使得代码中可以通过稳定的英文键值进行逻辑判断和状态切换,而界面渲染时则可以灵活地展示对应的中文标签。例如,在 build 方法中判断当前应该渲染哪个页面内容时,使用的是简洁的 this.currentTab === 'hall' 这样的条件表达式;而在显示 Tab 名称时,只需通过 TAB_CONFIG[key] 即可获取到对应的中文文案。此外,这种集中式配置也为后续的多语言国际化改造预留了便利——当需要将应用切换为英文或其他语言时,只需修改这一处常量定义即可,所有的导航标签都会同步更新,无需在分散的代码中逐一查找替换。
3.3 业务数据集定义
const ORDER_LIST: OrderMeta[] = [
{ id: 1001, shopName: '兰州拉面馆', shopColor: '#FFAB91', customerName: '张先生', customerPhone: '138****8888', address: '阳光花园3栋502室', distance: '1.2km', items: '牛肉拉面x2, 凉拌黄瓜x1', amount: 38.5, deliveryFee: 5.5, estimatedTime: '25分钟', status: '待接单', orderTime: '12:05' },
{ id: 1002, shopName: '黄焖鸡米饭', shopColor: '#FFE082', customerName: '李女士', customerPhone: '139****6666', address: '翠湖天地B座1803', distance: '0.8km', items: '黄焖鸡大份x1, 米饭x1', amount: 25.0, deliveryFee: 4.0, estimatedTime: '20分钟', status: '待接单', orderTime: '12:08' },
{ id: 1003, shopName: '肯德基', shopColor: '#FFCC80', customerName: '王先生', customerPhone: '137****5555', address: '万达广场5楼', distance: '2.5km', items: '全家桶x1, 可乐x2', amount: 89.0, deliveryFee: 7.0, estimatedTime: '35分钟', status: '待接单', orderTime: '12:10' },
{ id: 1004, shopName: '沙县小吃', shopColor: '#A5D6A7', customerName: '赵女士', customerPhone: '136****4444', address: '金域蓝湾7栋301', distance: '1.5km', items: '蒸饺x2, 鸭腿饭x1', amount: 22.0, deliveryFee: 4.5, estimatedTime: '22分钟', status: '待接单', orderTime: '12:12' },
{ id: 1005, shopName: '蜜雪冰城', shopColor: '#F48FB1', customerName: '陈先生', customerPhone: '135****3333', address: '大学城学生公寓6栋', distance: '3.0km', items: '柠檬水x3, 冰淇淋x2', amount: 18.0, deliveryFee: 6.0, estimatedTime: '30分钟', status: '待接单', orderTime: '12:15' },
{ id: 1006, shopName: '麻辣烫', shopColor: '#CE93D8', customerName: '孙女士', customerPhone: '134****2222', address: '锦绣华庭12栋805', distance: '1.8km', items: '麻辣烫大份x1', amount: 32.0, deliveryFee: 5.0, estimatedTime: '25分钟', status: '待接单', orderTime: '12:18' }
]

ORDER_LIST 常量是一个硬编码的订单数组,用于在开发和演示阶段模拟真实的接单大厅数据。数组中包含六个订单对象,每个对象都严格遵循 OrderMeta 接口的字段定义。这六个订单覆盖了多种典型的外卖场景:从传统中式餐饮如兰州拉面、黄焖鸡米饭、沙县小吃,到连锁快餐品牌肯德基,再到饮品店蜜雪冰城和夜宵类麻辣烫,充分体现了测试数据的多样性。每个订单都设置了不同的 shopColor,这使得在 UI 渲染时,六个订单卡片会呈现出六种不同的商家标识色,增加了界面的视觉丰富度。订单的距离从 0.8km 到 3.0km 不等,配送费也从 4.0 元到 7.0 元阶梯分布,订单金额更是覆盖了从 18 元到 89 元的广泛区间,这些数据差异为骑手提供了丰富的决策维度。值得注意的是,所有客户手机号都采用了 138****8888 这样的脱敏格式,这既符合真实业务场景中的隐私保护要求,也展示了开发者在数据安全方面的意识。在实际生产环境中,这个常量数组会被替换为从后端 API 异步获取的动态数据,但现有的硬编码数据为前端 UI 的独立开发和快速迭代提供了极大的便利。
const DELIVERY_STEPS: DeliveryStepMeta[] = [
{ step: 1, title: '到达商家', desc: '已到达兰州拉面馆', time: '12:08', done: true },
{ step: 2, title: '取餐完成', desc: '确认取餐:牛肉拉面x2, 凉拌黄瓜x1', time: '12:15', done: true },
{ step: 3, title: '配送中', desc: '前往阳光花园3栋502室', time: '12:16', done: true },
{ step: 4, title: '即将送达', desc: '距离目的地200米', time: '12:25', done: false },
{ step: 5, title: '已送达', desc: '等待客户确认收货', time: '--:--', done: false }
]
const INCOME_LIST: IncomeMeta[] = [
{ date: '周一', orders: 18, income: 126.5, hours: 8 },
{ date: '周二', orders: 22, income: 154.0, hours: 9 },
{ date: '周三', orders: 15, income: 98.5, hours: 7 },
{ date: '周四', orders: 25, income: 178.5, hours: 10 },
{ date: '周五', orders: 30, income: 215.0, hours: 11 },
{ date: '周六', orders: 28, income: 196.0, hours: 10 },
{ date: '周日', orders: 20, income: 140.0, hours: 8 }
]
const REVIEW_LIST: ReviewMeta[] = [
{ id: 1, customerName: '张先生', rating: 5, content: '送餐速度很快,态度也很好,骑手小哥辛苦了!', date: '2024-03-20', tags: ['速度快', '态度好'] },
{ id: 2, customerName: '李女士', rating: 5, content: '准时送达,包装完好,非常满意的一次外卖体验', date: '2024-03-19', tags: ['准时', '包装好'] },
{ id: 3, customerName: '王先生', rating: 4, content: '整体不错,就是路上稍微绕了一点路', date: '2024-03-18', tags: ['整体不错'] },
{ id: 4, customerName: '赵女士', rating: 5, content: '下雨天也准时送到,太感谢了!给五星好评', date: '2024-03-17', tags: ['风雨无阻', '准时'] },
{ id: 5, customerName: '陈先生', rating: 5, content: '小哥特别有耐心,帮忙送到门口,点赞!', date: '2024-03-16', tags: ['有耐心', '服务好'] },
{ id: 6, customerName: '孙女士', rating: 4, content: '速度还行,汤洒了一点点但整体OK', date: '2024-03-15', tags: ['速度还行'] }
]
const WEEKLY_STATS: Record<string, number> = {
'totalOrders': 158,
'totalIncome': 1108,
'avgRating': 4.8,
'onlineHours': 63
}

DELIVERY_STEPS 数组定义了配送流程的五个标准节点,构成了时间轴组件的数据源。前三个节点的 done 字段为 true,表示这些步骤已经完成,对应的 UI 会显示绿色的勾选图标和实线连接;后两个节点的 done 为 false,表示尚未完成,UI 会显示灰色序号和虚线或灰色连接线。INCOME_LIST 数组包含了周一至周日共七天的收入记录,数据呈现出典型的周内波动规律——工作日订单量相对平稳,周五达到峰值(30 单、215 元收入),周末则有所回落。这种真实感极强的数据分布,使得柱状图的展示效果更加具有说服力。REVIEW_LIST 数组包含了六条客户评价,评分以五星好评为主(四条五星、两条四星),评价内容覆盖了速度、态度、准时性、包装等多个服务维度,tags 数组的存在让好评的核心亮点得以结构化提取。WEEKLY_STATS 则是一个汇总性的统计字典,记录了本周总订单量 158 单、总收入 1108 元、平均评分 4.8 分、在线时长 63 小时等关键运营指标,这些数据被广泛用于各个页面的顶部概览卡片中。
四、工具函数的实现与应用
在业务数据准备就绪之后,应用还需要一系列工具函数来对数据进行加工、转换和格式化,以满足不同场景下的展示需求。这些函数虽然代码量不大,却在提升代码复用性和界面表现力方面发挥着不可或缺的作用。
4.1 收入极值计算函数
function getMaxIncome(): number {
let maxVal: number = 0
for (const item of INCOME_LIST) {
if (item.income > maxVal) {
maxVal = item.income
}
}
return maxVal
}

getMaxIncome 函数的作用是从 INCOME_LIST 数组中找出单日的最高收入金额,这个值是收入柱状图实现动态高度计算的关键基准。函数内部声明了一个局部变量 maxVal 并初始化为 0,然后使用 for...of 循环遍历收入数组中的每一项。在每次迭代中,函数会将当前项的 income 值与 maxVal 进行比较,如果当前值更大,则更新 maxVal。这种简单的线性搜索算法的时间复杂度为 O(n),对于只有七个元素的数据集来说完全足够高效。该函数返回的最大值(在本例中为 215.0,即周五的收入)被用于柱状图渲染时的比例计算:每一根柱子的高度等于 100 * (item.income / getMaxIncome()),这意味着收入最高的那一天会显示为满高度(100%),而其他日期的柱子高度则按比例缩放。这种基于最大值的归一化处理方式,确保了柱状图能够充分利用可用的垂直空间,同时保持各数据点之间的相对比例关系,是一种非常经典且有效的数据可视化技巧。
4.2 星级评分转换函数
function getRatingStars(rating: number): string {
let stars: string = ''
for (let i = 0; i < 5; i++) {
if (i < rating) {
stars += '★'
} else {
stars += '☆'
}
}
return stars
}

getRatingStars 函数负责将数字类型的评分值转换为可视化的星星字符串。该函数接收一个 rating 参数,预期值为 1 到 5 之间的整数。函数内部初始化了一个空字符串 stars,然后通过一个执行五次的 for 循环,依次判断当前索引 i 是否小于传入的评分值。如果条件成立,则向字符串追加实心星号 ★;否则追加空心星号 ☆。这种算法的巧妙之处在于,它利用字符串拼接的方式,一次性生成了完整的五星展示文本。例如,当传入的评分为 4 时,函数会返回 ★★★★☆,这是一个非常直观的可视化表达。在 ArkTS 的 Text 组件中直接渲染这个字符串,配合适当的字体颜色和大小,就能实现简洁美观的星级评分展示。相较于引入图片资源或自定义绘制来实现星星图标,这种纯文本方案具有零资源依赖、渲染性能高、代码体积小的显著优势,充分体现了 ArkTS 开发中"轻量优先"的工程哲学。
4.3 评分颜色映射函数
function getRatingColor(rating: number): string {
if (rating >= 5) {
return COLORS.success
}
if (rating >= 4) {
return COLORS.primaryDark
}
return COLORS.warning
}

getRatingColor 函数实现了评分值到颜色值的语义化映射,它的核心目标是通过颜色向用户传递评分的"质量等级"信息。函数接收一个评分参数,并按照三个区间进行判断:如果评分大于等于 5(满分),则返回 COLORS.success(绿色),绿色在心理学上通常与优秀、通过、安全等积极含义相关联,用于满分评价能够强化用户的好感;如果评分大于等于 4(但未达到 5),则返回 COLORS.primaryDark(深黄色),这是一种中性的偏积极色彩,用于表示"良好"而非"完美"的评级;如果评分低于 4,则返回 COLORS.warning(橙色),橙色通常用于警示或提醒,暗示这个评价可能存在一些问题需要关注。这种分层的颜色策略使得用户在浏览评价列表时,即使不仔细阅读具体的评分数字,仅凭颜色就能快速识别出好评、中评和差评的分布情况,大大提升了信息获取的效率。同时,函数内部直接引用了 COLORS 常量对象中的颜色定义,确保了视觉表现与全局设计系统的一致性。
五、入口组件与状态管理
RiderApp 作为应用的入口组件,是整个 UI 树的根节点,承担着页面路由、状态管理、全局布局等核心职责。通过 ArkTS 提供的装饰器机制,该组件实现了声明式的 UI 编程范式。
5.1 组件装饰器与状态定义
@Entry
@Component
struct RiderApp {
@State currentTab: string = 'hall'
@State showAcceptDialog: boolean = false
@State showEditDialog: boolean = false
@State showCancelDialog: boolean = false
@State targetOrder: OrderMeta = ORDER_LIST[0]
@Entry 装饰器标记了 RiderApp 作为应用的入口点,这意味着它是程序启动时第一个被实例化和渲染的组件。@Component 装饰器则表明这是一个自定义的可复用组件单元,它包含了独立的状态和构建逻辑。在组件内部,我们使用 @State 装饰器定义了五个响应式状态变量。currentTab 是一个字符串类型的状态,默认值为 'hall',它控制着当前显示的是哪个 Tab 页面的内容,是整个应用页面切换的核心驱动变量。showAcceptDialog、showEditDialog 和 showCancelDialog 是三个布尔类型的状态变量,分别控制接单确认弹窗、编辑备注弹窗和取消订单确认弹窗的显示与隐藏,默认均为 false,即初始状态下所有弹窗均不可见。targetOrder 是一个 OrderMeta 类型的状态变量,默认初始化为 ORDER_LIST 数组的第一个元素,它用于保存当前被选中或正在操作的订单对象,当用户点击某个订单的"立即接单"按钮时,该变量会被更新为对应的订单数据,随后弹窗会根据这个变量的值来渲染具体的订单信息。这些被 @State 装饰的变量具有自动的依赖追踪能力,当它们的值发生变化时,ArkTS 运行时会自动识别出依赖这些状态的所有 UI 节点,并精准地触发重新渲染,而无需开发者手动操作 DOM 或调用刷新方法。
5.2 构建函数与全局布局架构
build() {
Stack() {
Column() {
// 顶部状态栏
Row() { ... }
.width('100%')
.height(56)
.padding({ left: 20, right: 20 })
.linearGradient({ angle: 135, colors: [[COLORS.dark, 0.0], [COLORS.darkLight, 1.0]] })
// 内容区域
Column() {
if (this.currentTab === 'hall') {
this.hallContent()
} else if (this.currentTab === 'delivery') {
this.deliveryContent()
} else if (this.currentTab === 'income') {
this.incomeContent()
} else if (this.currentTab === 'review') {
this.reviewContent()
} else {
this.mineContent()
}
}
.layoutWeight(1)
.backgroundColor(COLORS.bg)
// 底部Tab栏
this.bottomTabBar()
}
.width('100%')
.height('100%')
if (this.showAcceptDialog) {
this.acceptDialog()
}
if (this.showEditDialog) {
this.editDialog()
}
if (this.showCancelDialog) {
this.cancelDialog()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build 方法是每个 ArkTS 组件必须实现的核心方法,它描述了组件的 UI 结构。在 RiderApp 中,build 方法的根容器选择了 Stack 组件,这是一个层叠布局容器,允许其子组件在 Z 轴方向上堆叠。选择 Stack 作为根容器的战略意义在于,它天然地支持了"主内容 + 浮层弹窗"这种常见的移动端布局模式——底部的 Column 承载了应用的正常页面内容,而顶部的三个条件渲染弹窗则作为覆盖层悬浮在所有内容之上。在 Stack 内部,第一层是一个 Column 组件,它采用垂直方向的三段式布局:顶部是固定的状态栏 Row,中间是可变的页面内容区域 Column,底部是固定的 Tab 导航栏。这种布局模式与原生 iOS 和 Android 应用的常见结构高度一致,能够给用户带来熟悉的操作体验。
顶部状态栏是一个高度为 56 逻辑像素的 Row 容器,内部左侧放置了应用标题"骑手工作台"和副标题"在线中 · 今日已完成 12 单",右侧则通过一个小圆点加"在线"文字的形式展示了骑手的实时工作状态。整个状态栏应用了从 COLORS.dark 到 COLORS.darkLight 的 135 度线性渐变背景,这种斜向渐变比纯色背景更具视觉层次感,同时也与底部内容区域形成了鲜明的色彩对比,起到了很好的视觉分隔作用。中间的内容区域设置了 layoutWeight(1),这意味着它会占据 Column 中除顶部和底部固定高度区域外的所有剩余垂直空间,实现了经典的"头尾固定、中间自适应"布局效果。该区域通过一系列 if-else if 条件表达式,根据 currentTab 的值动态渲染对应的页面内容 Builder 方法。底部的 Tab 栏通过 this.bottomTabBar() 调用独立的 Builder 方法进行渲染。最后,Stack 的顶层通过三个条件判断来渲染三个全屏弹窗,只有当对应的状态变量为 true 时,弹窗才会被创建并显示,这种条件渲染机制确保了弹窗在不显示时不会消耗任何渲染资源。
六、底部导航栏的实现
底部 Tab 导航栏是移动端应用中最为核心的导航组件之一,它决定了用户在不同功能模块之间切换的效率和体验。
6.1 Tab 栏 Builder 方法
@Builder
bottomTabBar() {
Row() {
ForEach(Object.keys(TAB_CONFIG), (key: string) => {
Column() {
Text(this.getTabIcon(key))
.fontSize(20)
Text(TAB_CONFIG[key])
.fontSize(11)
.margin({ top: 2 })
.fontColor(this.currentTab === key ? COLORS.primaryDark : COLORS.textHint)
}
.layoutWeight(1)
.height(56)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = key
})
})
}
.width('100%')
.height(56)
.backgroundColor(COLORS.white)
.border({ width: { top: 1 }, color: COLORS.border })
}
bottomTabBar 方法使用了 @Builder 装饰器,这是 ArkTS 中用于封装可复用 UI 构建逻辑的重要机制。被 @Builder 装饰的方法可以在同一个组件的其他 Builder 方法或 build 方法中通过 this.methodName() 的形式直接调用,就像使用一个自定义的 UI 组件一样。在实现上,bottomTabBar 首先创建了一个横向的 Row 容器,然后通过 ForEach 循环遍历 TAB_CONFIG 字典的所有键值。Object.keys(TAB_CONFIG) 会返回一个包含 ['hall', 'delivery', 'income', 'review', 'mine'] 的字符串数组,ForEach 则为数组中的每一个元素创建对应的 Tab 项 UI。每一个 Tab 项都是一个 Column 容器,内部垂直排列着图标 Text 和标签 Text 两个子组件。图标通过调用 this.getTabIcon(key) 动态获取,而标签文字则直接通过 TAB_CONFIG[key] 从配置字典中读取。标签的文字颜色采用了三元表达式进行动态绑定:当 currentTab 等于当前 Tab 的 key 时,文字显示为 COLORS.primaryDark(深黄色),表示选中状态;否则显示为 COLORS.textHint(浅灰色),表示未选中状态。这种视觉反馈让用户能够立即识别出自己当前所在的页面位置。
每个 Tab 项的 Column 都设置了 layoutWeight(1),这意味着五个 Tab 项会在 Row 的可用宽度内平均分配空间,实现了经典的等分底部导航效果。justifyContent(FlexAlign.Center) 确保了图标和文字在垂直方向上的居中对齐。最关键的是 onClick 事件绑定,当用户点击某个 Tab 项时,会触发回调函数将 currentTab 状态更新为被点击项的 key 值。由于 currentTab 被 @State 装饰,这个赋值操作会自动触发依赖 currentTab 的所有 UI 节点的重新渲染——具体来说,中间的页面内容区域会切换到新 Tab 对应的 Builder 方法,同时底部 Tab 栏自身的文字颜色也会同步更新,整个切换过程流畅且响应迅速。整个 Tab 栏设置了白色背景和顶部 1 像素的分隔线,分隔线颜色使用 COLORS.border,这种细微的分割在白色 Tab 栏与灰色内容背景之间建立了一个柔和的边界,避免了界面元素的粘连感。
6.2 动态图标切换函数
getTabIcon(key: string): string {
if (key === 'hall') {
return this.currentTab === key ? '📦' : '📭'
}
if (key === 'delivery') {
return this.currentTab === key ? '🛵' : '🚲'
}
if (key === 'income') {
return this.currentTab === key ? '💰' : '🪙'
}
if (key === 'review') {
return this.currentTab === key ? '⭐' : '☆'
}
return this.currentTab === key ? '👤' : '🙆'
}
getTabIcon 是一个普通的类成员方法,它根据传入的 Tab 键值和当前的选中状态,返回对应的 Unicode Emoji 图标字符串。这个函数的设计体现了对微交互体验的深入思考——每一个 Tab 项在选中和未选中状态下都使用了不同的 Emoji 图标,而非仅仅改变颜色。例如,“接单” Tab 在选中时显示包裹图标 📦,暗示有活跃的订单等待处理;未选中时则显示信箱图标 📭,表示一种相对静态的状态。“配送” Tab 在选中时显示电动车图标 🛵,象征着正在进行的配送行动;未选中时则显示自行车图标 🚲,传达出一种更为平和的状态。“收入” Tab 在选中时显示钱袋图标 💰,强调"财富积累"的积极意象;未选中时则显示硬币图标 🪙。“评价” Tab 使用了实心星 ⭐ 和空心星 ☆ 的经典对比组合。“我的” Tab 则使用了人像图标 👤 和举手图标 🙆。这种图标级别的差异化设计,配合颜色的变化,形成了双重视觉反馈机制,使得 Tab 切换的体验更加丰富和有趣。Emoji 图标的使用也避免了引入额外的图标字体或图片资源,进一步保持了项目的轻量化和简洁性。
七、接单大厅模块详解
接单大厅是骑手使用频率最高的核心页面,它的设计目标是在有限的屏幕空间内,以最高效的信息密度展示附近待接订单,帮助骑手快速做出接单决策。
7.1 接单大厅内容构建器
@Builder
hallContent() {
Scroll() {
Column() {
// 今日数据概览
Row() {
this.statBox('今日订单', '12', COLORS.primary)
this.statBox('今日收入', '¥86', COLORS.success)
this.statBox('平均时效', '26min', COLORS.primaryDark)
}
.width('92%')
.margin({ top: 12, bottom: 12 })
Text('附近订单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
.alignSelf(ItemAlign.Start)
ForEach(ORDER_LIST, (order: OrderMeta) => {
this.orderCard(order)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
hallContent Builder 方法构建了接单大厅的完整页面结构。它的根容器是一个 Scroll 组件,这非常重要,因为订单列表可能会很长,超出屏幕高度,需要通过滚动来查看所有内容。Scroll 组件内部是一个垂直的 Column,包含三个主要部分。第一部分是一个横向的 Row,内部调用了三次 this.statBox() Builder 方法,分别展示了"今日订单 12"、“今日收入 ¥86"和"平均时效 26min"三个核心日维度指标。这三个统计卡片共享同一行,通过 statBox 内部的 layoutWeight(1) 实现均分宽度。第二部分是一个标题文字"附近订单”,它使用了 alignSelf(ItemAlign.Start) 来在 Column 内左对齐,为下方的订单列表提供了明确的区域标识。第三部分通过 ForEach 循环遍历 ORDER_LIST 数组,为每一个订单对象调用 this.orderCard(order) 来渲染对应的订单卡片。Scroll 组件的 scrollBar(BarState.Off) 属性设置为隐藏滚动条,这符合现代移动应用的设计趋势——通过内容截断和弹性回弹来暗示可滚动性,而非显示传统的滚动条,从而获得更加清爽的视觉效果。
7.2 统计卡片构建器
@Builder
statBox(title: string, value: string, color: string) {
Column() {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(title)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.margin({ right: 8 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
statBox 是一个高度可复用的 Builder 方法,它接收三个参数:title(标题文字,如"今日订单")、value(数值文字,如"12")和 color(数值的颜色值)。这种参数化的设计使得 statBox 可以灵活地应用于任何需要展示"标题 + 数值"组合的场景。在内部实现上,statBox 使用了一个 Column 容器垂直排列两个 Text 组件,上方的 value 使用了较大的字号(20)和粗体,并应用了传入的 color 参数作为字体颜色,确保数值信息足够醒目;下方的 title 则使用了较小的字号(11)和次要文字颜色,作为数值的说明标签。Column 设置了 layoutWeight(1),这意味着当多个 statBox 并排放在同一个 Row 中时,它们会自动均分父容器的可用宽度。白色的卡片背景、12 的圆角半径以及微妙的底部阴影(shadow({ radius: 2, color: '#0D000000', offsetY: 1 }))共同营造出一种轻量级的卡片悬浮效果,使得统计区域从浅灰色的页面背景中适度突出,但又不至于过于抢眼而分散用户对下方订单列表的注意力。
7.3 订单卡片构建器
@Builder
orderCard(order: OrderMeta) {
Column() {
// 商家信息
Row() {
Stack() {
Text('🏪')
.fontSize(20)
}
.width(40)
.height(40)
.borderRadius(10)
.backgroundColor(order.shopColor)
Column() {
Text(order.shopName)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(order.orderTime + ' 下单 · ' + order.distance)
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('¥' + order.deliveryFee.toFixed(1))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Divider().color(COLORS.border).margin({ top: 10, bottom: 10 })
// 订单详情
Row() {
Text('📋')
.fontSize(14)
.margin({ right: 6 })
Text(order.items)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
Row() {
Text('📍')
.fontSize(14)
.margin({ right: 6 })
Text(order.address)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 6 })
Row() {
Text('⏱')
.fontSize(14)
.margin({ right: 6 })
Text('预计 ' + order.estimatedTime + ' 送达')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 6 })
// 接单按钮
Button('立即接单')
.fontSize(14)
.fontColor(COLORS.dark)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(38)
.width('100%')
.margin({ top: 12 })
.onClick(() => {
this.targetOrder = order
this.showAcceptDialog = true
})
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
}
orderCard Builder 方法是接单大厅中最复杂的 UI 构建单元,它接收一个 OrderMeta 类型的参数,并据此渲染出一张信息丰富且视觉层次分明的订单卡片。卡片的整体结构采用垂直 Column 布局,内部可以进一步划分为四个信息区域。第一个区域是顶部的商家信息行,采用横向 Row 布局:最左侧是一个 40x40 的 Stack 容器,内部放置了商店 Emoji 图标,背景色使用了从订单数据传入的 order.shopColor,这使得不同商家的订单卡片在视觉上具有独特的标识色块;中间是一个 Column,内部垂直排列商家名称和下单时间加距离信息,其中商家名称使用了粗体和主文字色以突出重点,时间和距离则使用了更小的字号和提示色;最右侧是配送费用,以大号粗体和 COLORS.primaryDark 颜色展示,由于这个数值是骑手最关心的经济收益,所以被放置在视觉的终端位置以吸引注意力。layoutWeight(1) 的应用确保了中间的文字区域会自动占据商店图标和配送费用之间的所有剩余空间。
第二个区域是一条水平分割线 Divider,它在视觉上将商家概览信息与下方的订单详情分隔开来,颜色的微弱对比(COLORS.border)确保了分割的存在感适中,不会过于突兀。第三个区域包含三行订单详情信息,分别通过 📋、📍、⏱ 三个 Emoji 图标作为视觉锚点,配合对应的文字内容展示商品清单、配送地址和预计送达时间。每一行都是一个 Row,图标固定在左侧,文字内容通过 layoutWeight(1) 占据剩余空间。这种图标加文字的组合形式,极大地提升了信息的扫描效率——骑手可以在几秒钟内通过图标快速定位到自己关心的信息类别。第四个区域是底部的"立即接单"按钮,它使用了 COLORS.primary 作为背景色,COLORS.dark 作为文字色,这种黄底深色字的组合在白色卡片背景上形成了强烈的对比,使得按钮成为整个卡片中最具行动号召力的元素。按钮的 onClick 事件绑定了两个操作:先将当前订单赋值给 targetOrder 状态变量,然后将 showAcceptDialog 状态设为 true,这两个操作的组合触发了接单确认弹窗的显示,同时弹窗内部会基于 targetOrder 的数据渲染出对应的订单详情,实现了从列表到详情确认的无缝衔接。
八、配送追踪模块详解
配送模块是骑手在接单后使用最为频繁的功能页面,它的核心任务是以最直观的方式展示当前订单的配送进度,并提供必要的操作入口。
8.1 配送页面内容构建器
@Builder
deliveryContent() {
Scroll() {
Column() {
Text('当前配送')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 16, left: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
// 配送中订单卡片
Column() {
Row() {
Stack() {
Text('🏪')
.fontSize(20)
}
.width(40)
.height(40)
.borderRadius(10)
.backgroundColor('#FFAB91')
Column() {
Text('兰州拉面馆')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('订单 #1001')
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('配送中')
.fontSize(12)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primaryDark)
.borderRadius(10)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 配送路线时间轴
ForEach(DELIVERY_STEPS, (step: DeliveryStepMeta) => {
this.deliveryStepItem(step)
})
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 12 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 操作按钮
Row() {
Button('编辑备注')
.fontSize(14)
.fontColor(COLORS.primaryDark)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.primary })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.targetOrder = ORDER_LIST[0]
this.showEditDialog = true
})
Button('取消订单')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 10 })
.onClick(() => {
this.targetOrder = ORDER_LIST[0]
this.showCancelDialog = true
})
}
.width('92%')
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
deliveryContent Builder 方法构建了配送追踪页面的完整结构。页面的整体布局同样以 Scroll 作为根容器,内部是垂直的 Column。页面开头是一个标题文字"当前配送",使用 alignSelf(ItemAlign.Start) 实现左对齐,明确了当前页面的功能定位。接下来是一个白色的卡片容器,内部包含了两个部分:顶部的订单概要信息和底部的配送时间轴。订单概要信息行展示了商家图标(背景色硬编码为 #FFAB91,对应兰州拉面馆的标识色)、商家名称和订单编号,最右侧是一个"配送中"的状态标签,该标签使用了 COLORS.primaryDark 作为背景色和白色文字,以圆角胶囊形状呈现,在视觉上非常醒目,能够让骑手一眼识别出当前订单的处理状态。
时间轴部分通过 ForEach 循环遍历 DELIVERY_STEPS 数组,为每一个步骤调用 this.deliveryStepItem(step) 进行渲染。卡片下方是操作按钮区域,采用横向 Row 布局放置了两个按钮:左侧的"编辑备注"按钮使用了白色背景和黄色边框的幽灵按钮样式,这种较轻量的视觉处理表明这是一个次要操作;右侧的"取消订单"按钮则使用了 COLORS.danger(红色)作为背景色和白色文字,以强烈的色彩警示用户这是一个需要谨慎操作的危险动作。两个按钮都设置了 layoutWeight(1) 并带有 10 的左间距,实现了等宽并排布局。两个按钮的点击事件分别设置了 targetOrder 并打开了对应的弹窗,与接单大厅的交互逻辑保持一致。
8.2 时间轴步骤项构建器
@Builder
deliveryStepItem(step: DeliveryStepMeta) {
Row() {
// 时间轴
Column() {
Stack() {
if (step.done) {
Text('✓')
.fontSize(14)
.fontColor(COLORS.white)
.fontWeight(FontWeight.Bold)
} else {
Text(step.step.toString())
.fontSize(12)
.fontColor(step.done ? COLORS.white : COLORS.textHint)
.fontWeight(FontWeight.Bold)
}
}
.width(28)
.height(28)
.borderRadius(14)
.backgroundColor(step.done ? COLORS.success : COLORS.border)
if (step.step < 5) {
Column()
.width(2)
.height(32)
.backgroundColor(step.done ? COLORS.success : COLORS.border)
}
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(step.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(step.done ? COLORS.textPrimary : COLORS.textSecondary)
Text(step.desc)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
Text(step.time)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
deliveryStepItem Builder 方法是实现时间轴视觉效果的核心单元,它的设计精妙地平衡了信息密度与视觉清晰度。每个步骤项采用横向 Row 布局,分为左侧的时间轴标识区和右侧的内容信息区。左侧的 Column 容器中,首先是一个圆形的 Stack,其背景色根据 step.done 的值在 COLORS.success(绿色,已完成)和 COLORS.border(灰色,未完成)之间切换。圆形内部通过条件渲染展示不同的内容:已完成的步骤显示白色的勾选符号 ✓,未完成的步骤则显示步骤序号。圆形的尺寸为 28x28,配合 14 的圆角半径,形成一个完美的小圆点。在圆形下方,只有当 step.step < 5 时才会渲染一条垂直的连接线,这是为了避免在最后一个步骤下方出现多余的线条。连接线的颜色同样根据完成状态在绿色和灰色之间切换,这种色彩一致性使得已完成的路径在视觉上形成一条连续的绿色通道,引导用户直观地感知到当前的整体进度。
右侧的内容区域是一个 Column,内部垂直排列了三个 Text 组件:步骤标题 step.title 使用了 14 号粗体,颜色根据完成状态在主文字色和次要文字色之间切换;步骤描述 step.desc 提供了更详细的上下文信息,如"确认取餐:牛肉拉面x2, 凉拌黄瓜x1";时间戳 step.time 使用最小的字号和最浅的颜色,处于信息层级的最底层。整个右侧区域通过 layoutWeight(1) 占据了 Row 中圆形标识右侧的所有剩余空间,并通过 alignItems(HorizontalAlign.Start) 确保文字内容左对齐。alignItems(VerticalAlign.Top) 的设置则确保当右侧文字内容较多时,整个步骤项仍然从顶部对齐,保持时间轴的整齐感。
九、收入统计模块详解
收入统计模块是骑手最为关心的功能之一,它通过数据可视化的方式,将抽象的收入数字转化为直观的图表和列表,帮助骑手全面掌握自己的劳动成果。
9.1 收入页面内容构建器
@Builder
incomeContent() {
Scroll() {
Column() {
// 本周收入概览
Column() {
Text('本周总收入')
.fontSize(13)
.fontColor('#FFFFFFCC')
Text('¥' + WEEKLY_STATS.totalIncome)
.fontSize(32)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.margin({ top: 4 })
Row() {
Column() {
Text('总单量')
.fontSize(11)
.fontColor('#FFFFFFCC')
Text(WEEKLY_STATS.totalOrders.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('在线时长')
.fontSize(11)
.fontColor('#FFFFFFCC')
Text(WEEKLY_STATS.onlineHours + 'h')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('平均评分')
.fontSize(11)
.fontColor('#FFFFFFCC')
Text(WEEKLY_STATS.avgRating.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.margin({ top: 16 })
}
.width('92%')
.padding(20)
.borderRadius(16)
.linearGradient({ angle: 135, colors: [[COLORS.dark, 0.0], [COLORS.darkLight, 1.0]] })
.alignItems(HorizontalAlign.Center)
.margin({ top: 12, bottom: 16 })
.shadow({ radius: 8, color: '#1A37474F', offsetY: 4 })
// 收入柱状图
Column() {
Text('每日收入')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
ForEach(INCOME_LIST, (item: IncomeMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Text('¥' + item.income)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ bottom: 2 })
}
.width('100%')
.height(100 * (item.income / getMaxIncome()))
.linearGradient({ angle: 180, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ topLeft: 4, topRight: 4 })
Text(item.date)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
Text(item.orders + '单')
.fontSize(9)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 12 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 收入明细
Text('收入明细')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
.alignSelf(ItemAlign.Start)
ForEach(INCOME_LIST, (item: IncomeMeta) => {
Row() {
Column() {
Text(item.date)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(item.orders + '单 · ' + item.hours + '小时')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥' + item.income.toFixed(1))
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
}
.width('92%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(10)
.margin({ bottom: 6 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
incomeContent Builder 方法构建了收入统计页面的三层信息架构。第一层是本周收入概览卡片,这是一个深色背景的视觉焦点区域,使用了与顶部状态栏相同的从 COLORS.dark 到 COLORS.darkLight 的 135 度线性渐变。卡片顶部展示了"本周总收入"标签和金额 ¥1108,其中金额使用了 32 号超大字体和 COLORS.primary(黄色),在深色背景上形成了极为强烈的视觉冲击力,确保骑手打开页面时第一眼就能捕捉到最关心的总收入信息。金额下方是一个横向的三列布局,分别展示总单量(158)、在线时长(63h)和平均评分(4.8),这三项指标的标题使用了半透明白色 #FFFFFFCC,数值则使用纯白色或黄色,形成了清晰的信息层级。这张概览卡片的阴影参数(radius: 8, color: '#1A37474F', offsetY: 4)比其他卡片的阴影更为明显,进一步强化了它作为页面视觉中心的地位。
第二层是每日收入柱状图,这是整个应用中最具技术含量和视觉吸引力的数据可视化组件。柱状图的标题"每日收入"使用左对齐的粗体文字。图表主体是一个横向的 Row,内部通过 ForEach 遍历 INCOME_LIST 数组,为每一天生成一个垂直的 Column 柱形。每个柱形内部从上到下依次是:顶部的收入金额标签(如 ¥126.5)、实际的柱子图形、日期标签(如"周一")和订单数量标签(如"18单")。柱子的核心高度计算表达式 100 * (item.income / getMaxIncome()) 实现了基于最大值的动态比例缩放,确保最高的柱子高度为 100 逻辑像素,其他柱子按比例缩小。柱子的背景使用了从 COLORS.primary 到 COLORS.primaryDark 的 180 度垂直渐变,顶部左右 4 像素的圆角使得柱子顶部呈现圆润的视觉效果。Stack({ alignContent: Alignment.Bottom }) 的使用确保了收入金额标签始终显示在柱子的顶部位置。整个 Row 的 alignItems(VerticalAlign.Bottom) 属性则确保所有柱子都从底部对齐,这是标准柱状图的基本特征。
第三层是收入明细列表,以卡片行的形式逐日展示详细的收入数据。每一行包含左侧的日期、订单数和工作时长信息,以及右侧的收入金额。这种列表形式补充了柱状图在精确数值阅读上的不足,用户可以在图表中快速感知趋势和对比关系,然后在列表中查看精确的数字。每一行都使用了白色的卡片背景、轻微的圆角和阴影,与柱状图区域的白色卡片容器形成了视觉上的呼应和统一。
十、客户评价模块详解
客户评价模块不仅是骑手服务质量的反馈窗口,也是平台激励骑手提升服务的重要工具。该模块通过综合评分展示、星级分布可视化和评价列表三种形式,全面呈现骑手的服务口碑。
10.1 评价页面内容构建器
@Builder
reviewContent() {
Scroll() {
Column() {
Row() {
Column() {
Text(WEEKLY_STATS.avgRating.toString())
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text(getRatingStars(5))
.fontSize(14)
.fontColor(COLORS.primary)
.margin({ top: 4 })
Text('综合评分')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
this.ratingBar('5星', 80)
this.ratingBar('4星', 15)
this.ratingBar('3星', 3)
this.ratingBar('2星', 1)
this.ratingBar('1星', 1)
}
.layoutWeight(1.2)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ top: 12, bottom: 16 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
ForEach(REVIEW_LIST, (review: ReviewMeta) => {
this.reviewCard(review)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
reviewContent Builder 方法构建了评价页面的结构。页面顶部是一个白色的概览卡片,内部采用横向 Row 布局分为左右两个区域。左侧区域垂直居中展示了综合评分 4.8,使用了 36 号的超大粗体字体和 COLORS.primary 颜色,下方紧跟五星图标和"综合评分"标签,形成了从核心数字到辅助说明的完整信息链。右侧区域则通过五次调用 this.ratingBar() Builder 方法,展示了从五星到一星的比例分布。值得注意的是,右侧 Column 的 layoutWeight 设置为 1.2,略大于左侧的 1,这意味着右侧的星级分布条会获得略多的水平空间,因为分布条需要足够的宽度才能清晰展示比例差异。这种非对称的权重分配体现了布局设计中的灵活性原则——不是一味地均等分割,而是根据内容的实际需求进行合理的空间分配。
概览卡片下方是通过 ForEach 循环渲染的评价列表,每个评价项通过 this.reviewCard(review) 进行渲染。整个页面的布局逻辑与接单大厅非常相似,都是"概览统计 + 详情列表"的经典模式,这种一致性的信息架构降低了用户的学习成本,使得骑手在切换不同模块时能够快速适应页面的组织方式。
10.2 星级分布条构建器
@Builder
ratingBar(label: string, percent: number) {
Row() {
Text(label)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(28)
Column()
.layoutWeight(1)
.height(6)
.backgroundColor(COLORS.border)
.borderRadius(3)
Column()
.width(percent + '%')
.height(6)
.backgroundColor(COLORS.primary)
.borderRadius(3)
.position({ x: 28, y: 0 })
Text(percent + '%')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(28)
.margin({ left: 4 })
}
.width('100%')
.height(16)
.alignItems(VerticalAlign.Center)
.margin({ bottom: 2 })
}
ratingBar Builder 方法实现了一个精巧的比例进度条组件,用于展示各星级评价的占比分布。该方法接收两个参数:label(如"5星")和 percent(如 80)。在实现上,ratingBar 使用了一个横向 Row 容器,内部包含四个子元素,从左到右依次是:固定宽度为 28 的星级标签文字、作为背景轨道的 Column、作为前景填充的 Column 和固定宽度的百分比文字。背景轨道和前景填充都是高度为 6、圆角为 3 的 Column 组件,它们共同构成了圆角进度条的视觉效果。其中,背景轨道设置了 layoutWeight(1),占据了标签和百分比文字之间的所有可用宽度;前景填充则通过 width(percent + '%') 将其宽度设置为传入的百分比值。
这里有一个非常巧妙的布局技巧:前景填充 Column 使用了 .position({ x: 28, y: 0 }) 进行绝对定位,将其起点固定在距离父容器左边缘 28 像素的位置,这个位置恰好是左侧标签文字的宽度。通过这种绝对定位叠加的方式,前景填充条能够精确地覆盖在背景轨道之上,从轨道的左端开始向右延伸,形成标准的进度条填充效果。如果没有这个绝对定位,前景填充条会被放置在背景轨道的右侧,而不是覆盖在轨道上。百分比文字固定在右侧,宽度 28 像素,左边距 4 像素,与左侧标签形成了对称的视觉平衡。整个组件的高度设置为 16,并通过 alignItems(VerticalAlign.Center) 确保所有子元素在垂直方向上居中对齐。底部 2 的间距使得五星到一星的五个分布条之间保持了适度的呼吸感。
10.3 评价卡片构建器
@Builder
reviewCard(review: ReviewMeta) {
Column() {
Row() {
Stack() {
Text('👤')
.fontSize(20)
}
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor(COLORS.primaryLight)
Column() {
Text(review.customerName)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(review.date)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text(getRatingStars(review.rating))
.fontSize(14)
.fontColor(getRatingColor(review.rating))
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(review.content)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.lineHeight(20)
.margin({ top: 10 })
// 标签
Row() {
ForEach(review.tags, (tag: string) => {
Text(tag)
.fontSize(10)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primaryLight)
.fontColor(COLORS.primaryDark)
.borderRadius(4)
.margin({ right: 6 })
})
}
.margin({ top: 8 })
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 10 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
reviewCard Builder 方法渲染了每一条客户评价的详细内容。卡片的顶部是一个横向的 Row,左侧是一个圆形头像区域,内部放置了人物 Emoji 图标 👤,背景色使用了浅黄色 COLORS.primaryLight,圆角半径 18 配合 36 的宽高形成了正圆形。中间区域展示了客户姓名和评价日期,右侧则是通过 getRatingStars(review.rating) 动态生成的星级字符串,其颜色通过 getRatingColor(review.rating) 根据评分值映射为绿色、深黄色或橙色。这种将评分值同时映射为图标和颜色的双重编码方式,使得评分信息在视觉上传达得更为充分。
卡片中部是评价的正文内容 review.content,设置了 20 的行高,确保多行文字具有良好的阅读体验。正文下方是标签区域,通过 ForEach 循环遍历 review.tags 数组,为每一个标签渲染一个带有浅黄色背景和深黄色文字的圆角小标签。每个标签都有左右 8、上下 3 的内边距,以及 4 的圆角半径和 6 的右外边距,这种紧凑而精致的样式处理使得标签看起来像是一个个小型的徽章,既起到了信息分类的作用,又丰富了界面的视觉细节。整个评价卡片的阴影比其他卡片更为轻微(radius: 2, offsetY: 1),这种差异化的处理使得评价列表在视觉上更加轻盈,不会因为过多的卡片堆叠而产生压抑感。
十一、个人中心模块详解
"我的"模块作为应用的个人中心页面,集中展示了骑手的基本信息、本月核心数据以及各类辅助功能的入口。
11.1 个人中心内容构建器
@Builder
mineContent() {
Scroll() {
Column() {
// 骑手信息卡片
Row() {
Stack() {
Text('🛵')
.fontSize(32)
}
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor(COLORS.primary)
Column() {
Text('金牌骑手')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('工号:R20240315')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('本月已配送 358 单')
.fontSize(12)
.fontColor(COLORS.primaryDark)
.margin({ top: 2 })
}
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ top: 16, bottom: 16 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 本月数据
Row() {
this.statBox('本月订单', '358', COLORS.primary)
this.statBox('本月收入', '¥2512', COLORS.success)
this.statBox('好评率', '98%', COLORS.primaryDark)
}
.width('92%')
.margin({ bottom: 16 })
ForEach(['我的钱包', '车辆信息', '保险服务', '在线客服', '骑手规则', '设置'], (item: string) => {
Row() {
Text(item)
.fontSize(14)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('›')
.fontSize(20)
.fontColor(COLORS.textHint)
}
.width('92%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
mineContent Builder 方法构建了个人中心页面的三层结构。第一层是骑手信息卡片,采用横向 Row 布局:左侧是一个 60x60 的大型圆形头像,内部放置了 32 号的电动车 Emoji 图标,背景色使用了品牌主色 COLORS.primary,这个尺寸远大于其他页面的图标,形成了个人中心页面特有的视觉焦点;右侧是一个 Column,垂直排列着骑手等级(“金牌骑手”)、工号和本月配送单量三项信息,其中等级使用了 18 号粗体,工号使用次要文字色,而本月单量则使用了 COLORS.primaryDark 以突出其动态变化的属性。layoutWeight(1) 确保了文字区域占据头像右侧的所有可用空间。
第二层是本月数据概览,直接复用了之前在接单大厅中定义的 statBox Builder 方法,展示了本月订单(358)、本月收入(¥2512)和好评率(98%)三项指标。这种对已有 Builder 方法的复用,充分体现了组件化开发的优势——相同的视觉元素只需要编写一次,就可以在不同页面中多次使用,既保证了视觉一致性,又避免了代码的冗余。
第三层是功能菜单列表,通过 ForEach 遍历一个硬编码的字符串数组 ['我的钱包', '车辆信息', '保险服务', '在线客服', '骑手规则', '设置'],为每一项渲染一个菜单行。每一行都是一个横向的 Row,左侧是功能名称,中间通过 layoutWeight(1) 的空白 Column 将文字推向左侧并占据所有剩余空间,右侧是一个大于号符号 ›,使用了较大的字号(20)和提示色,这是移动端列表导航的经典设计模式,暗示该行是可点击的,点击后会进入下一级页面。每一行都有白色的背景、12 的圆角和 8 的底部间距,整体呈现出整洁的列表形态。这种将数组常量直接内联在 ForEach 中的写法虽然简洁,但在实际生产环境中,通常会将菜单配置提取为一个包含图标、标题、跳转路由等更丰富信息的数组,以便实现更复杂的菜单渲染和导航逻辑。
十二、弹窗交互组件详解
弹窗是现代移动应用中不可或缺的交互元素,它能够在不打断用户当前操作流程的前提下,提供重要的信息确认、数据输入或警告提示。本应用精心设计了三种弹窗,分别对应不同的业务场景。
12.1 接单确认弹窗
@Builder
acceptDialog() {
Column() {
Column() {
Text('确认接单')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
// 订单信息
Row() {
Stack() {
Text('🏪')
.fontSize(24)
}
.width(48)
.height(48)
.borderRadius(12)
.backgroundColor(this.targetOrder.shopColor)
Column() {
Text(this.targetOrder.shopName)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(this.targetOrder.distance + ' · 预计' + this.targetOrder.estimatedTime)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥' + this.targetOrder.deliveryFee.toFixed(1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
}
.width('100%')
.margin({ top: 16, bottom: 16 })
.alignItems(VerticalAlign.Center)
Row() {
Text('配送费')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + this.targetOrder.deliveryFee.toFixed(1))
.fontSize(13)
.fontColor(COLORS.primaryDark)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('订单金额')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + this.targetOrder.amount.toFixed(2))
.fontSize(13)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
Row() {
Button('放弃')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showAcceptDialog = false
})
Button('接单')
.fontSize(14)
.fontColor(COLORS.dark)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showAcceptDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
acceptDialog Builder 方法实现了接单确认弹窗。该弹窗的整体结构是一个全屏的 Column,背景色设置为半透明黑色 rgba(0,0,0,0.5),这是移动端弹窗的经典遮罩层处理方式,既能阻断用户对底层页面的操作,又能通过半透明效果保持上下文的可见性。遮罩层内部通过 justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 将弹窗内容在水平和垂直两个方向上居中对齐。
弹窗的内容区域是一个宽度为屏幕 80% 的白色圆角卡片,圆角半径达到 20,比页面中的普通卡片(圆角 16)更大,这种差异化的圆角处理使得弹窗在视觉上更加柔和和突出。卡片内部从上到下依次包含:标题"确认接单"、订单概要信息行、配送费明细行、订单金额明细行和底部的操作按钮行。订单概要信息行复用了与订单卡片类似的布局结构——商家图标、名称和距离信息在左,配送费用在右,确保骑手在确认接单时能够再次核对自己最关心的关键信息。配送费和订单金额两行采用了标签在左、数值在右的经典表单布局,中间通过 layoutWeight(1) 的空白列实现两端对齐,底部边框线 border({ width: { bottom: 1 }, color: COLORS.border }) 将配送费行与订单金额行进行了优雅的分隔。
底部的操作按钮行包含"放弃"和"接单"两个按钮,它们并排布局,各占一半的宽度。"放弃"按钮使用了白色背景和灰色边框的幽灵样式,视觉上较轻量,降低了用户误操作的心理压力;"接单"按钮则使用了黄色的主按钮样式,并以粗体文字强化行动号召力。两个按钮的点击事件目前都只是简单地将 showAcceptDialog 设为 false,在实际生产环境中,"接单"按钮的回调中会调用后端 API 来真正执行接单操作。内容卡片上的 .onClick(() => {}) 是一个空的事件处理函数,它的作用是阻止点击事件向遮罩层冒泡,避免用户点击弹窗内容时意外关闭弹窗——只有点击弹窗外的半透明区域时,弹窗才会关闭(虽然当前实现中点击遮罩层关闭的逻辑未显式写出,但这是 Stack 层叠布局中常见的交互模式)。
12.2 编辑备注弹窗
@Builder
editDialog() {
Column() {
Column() {
Text('编辑配送备注')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
Text('订单编号')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('#' + this.targetOrder.id)
.fontSize(13)
.fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('商家名称')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetOrder.shopName)
.fontSize(13)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('配送地址')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetOrder.address)
.fontSize(12)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showEditDialog = false
})
Button('保存')
.fontSize(14)
.fontColor(COLORS.dark)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showEditDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
editDialog Builder 方法实现了编辑配送备注的弹窗。该弹窗的整体布局与接单确认弹窗高度一致,都采用了"遮罩层 + 居中白色卡片"的经典模态对话框结构。弹窗标题为"编辑配送备注",下方展示了三条只读的订单信息:订单编号、商家名称和配送地址。每条信息都采用了一致的表单行布局——左侧是灰色的标签文字,右侧是对应的值文字,中间通过 layoutWeight(1) 实现两端对齐,底部通过 1 像素的边框线进行分隔。这种布局方式在视觉上非常规整,便于用户快速扫描和核对信息。
需要注意的是,当前实现中这个弹窗仅展示了订单信息的预览,尚未包含真正的"编辑"功能,如文本输入框等。在实际生产版本中,这里应该添加一个 TextArea 或 TextInput 组件供骑手输入备注内容,同时"保存"按钮的点击事件中应该将输入的内容提交到后端。当前版本中"保存"按钮仅关闭了弹窗,这是为了演示交互流程而做的简化处理。底部按钮的布局逻辑与接单确认弹窗完全一致:左侧的"取消"按钮采用幽灵样式,右侧的"保存"按钮采用主按钮样式,两者通过 margin({ left: 12 }) 保持适度的间距。
12.3 取消订单确认弹窗
@Builder
cancelDialog() {
Column() {
Column() {
Stack() {
Text('⚠')
.fontSize(40)
.fontColor(COLORS.danger)
}
.width(64)
.height(64)
.borderRadius(32)
.backgroundColor('#FFEBEE')
.margin({ bottom: 16 })
Text('取消订单确认')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定要取消订单 #' + this.targetOrder.id + ' 吗?取消后可能影响您的接单评分,且需重新等待派单。')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.lineHeight(20)
.margin({ top: 12 })
Row() {
Button('继续配送')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showCancelDialog = false
})
Button('确认取消')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showCancelDialog = false
})
}
.width('100%')
.margin({ top: 20 })
}
.width('76%')
.padding(24)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
cancelDialog Builder 方法实现了取消订单的确认弹窗,这是三种弹窗中警示性最强的一个,因此在视觉设计上采用了与其他两种弹窗不同的策略。弹窗内容卡片的顶部不再直接是文字标题,而是一个醒目的警告图标区域:一个 64x64 的圆形 Stack,背景色使用了浅红色 #FFEBEE,内部放置了 40 号大小的警告符号 ⚠,图标颜色使用了 COLORS.danger(红色)。这种大尺寸的彩色图标能够在瞬间抓住用户的注意力,传递出"危险操作"的强烈信号。
图标下方是标题"取消订单确认"和警告说明文字。说明文字的内容经过了精心设计:"确定要取消订单 #1001 吗?取消后可能影响您的接单评分,且需重新等待派单。"这段文字不仅询问了用户的确认意向,更重要的是明确告知了取消订单的两个负面后果——影响评分和需要重新等待派单。这种透明化的风险提示策略,能够有效地劝阻用户的冲动取消行为,减少不必要的订单流失。文字使用了 textAlign(TextAlign.Center) 进行居中对齐,并设置了 20 的行高,确保多行文字在居中对齐时仍然保持良好的阅读体验。
底部按钮的文案也经过了仔细推敲:左侧的按钮不是简单的"取消",而是"继续配送",这种正向的文案引导暗示了推荐的操作方向;右侧的按钮明确标注为"确认取消",并使用了 COLORS.danger 红色背景,与用户点击"取消订单"按钮时的原始意图在色彩上保持一致。内容卡片的宽度设置为 76%,略小于其他两种弹窗的 80%,同时内边距设置为 24,大于其他弹窗的 20,这种微妙的尺寸差异使得警告弹窗在视觉上更加紧凑和聚焦,强化了其作为特殊状态提示的角色定位。
十三、模块特性对比分析
为了更清晰地理解本应用中各个模块的设计差异和技术特点,下表从多个维度对五大核心模块进行了系统性的对比分析。
| 对比维度 | 接单大厅 | 配送追踪 | 收入统计 | 客户评价 | 个人中心 |
|---|---|---|---|---|---|
| 核心数据模型 | OrderMeta 数组 |
DeliveryStepMeta 数组 |
IncomeMeta 数组 + WEEKLY_STATS |
ReviewMeta 数组 |
字符串数组 + 硬编码信息 |
| 布局复杂度 | 中高(卡片列表 + 统计行) | 中等(时间轴 + 操作区) | 高(图表 + 列表 + 概览) | 中等(分布图 + 列表) | 低(信息卡 + 菜单列表) |
| 关键 UI 组件 | orderCard、statBox |
deliveryStepItem |
动态柱状图、statBox |
ratingBar、reviewCard |
statBox(复用) |
| 数据可视化方式 | 纯文本数值展示 | 时间轴进度条 | 归一化柱状图 | 比例进度条 + 星级图标 | 纯文本展示 |
| 状态交互 | 触发接单确认弹窗 | 触发编辑/取消弹窗 | 无交互(纯展示) | 无交互(纯展示) | 无交互(菜单列表) |
| 视觉焦点元素 | 黄色"立即接单"按钮 | 绿色已完成时间轴节点 | 深色概览卡片 + 柱状图 | 综合评分大数字 | 大型圆形头像 |
| 复用组件数量 | 2(statBox、orderCard) |
1(deliveryStepItem) |
1(statBox) |
2(ratingBar、reviewCard) |
1(statBox) |
| 主要技术难点 | 卡片信息层级设计 | 时间轴节点与连接线对齐 | 柱状图动态高度计算 | 星级分布条叠加定位 | 菜单列表统一样式 |
| 色彩策略 | 多色商家标识 + 黄色主按钮 | 绿色已完成 / 灰色未完成 | 深色概览 + 黄色图表 | 黄色评分 + 彩色标签 | 黄色头像 + 灰色菜单 |
| 用户操作频率 | 极高(高频接单) | 高(配送中查看) | 中(日终/周终查看) | 低(被动查看反馈) | 低(偶尔查看/设置) |
从上表可以看出,五个模块虽然同处于一个应用中,但各自承担着不同的业务职责和用户使用场景,因此在数据模型选择、布局复杂度、交互设计和视觉策略上都呈现出明显的差异化特征。接单大厅和配送追踪属于高频操作的"生产工具"型页面,因此在交互设计上更加注重操作效率和反馈明确性;收入统计和客户评价属于中低频的"数据看板"型页面,因此在信息可视化方面投入了更多的设计精力;个人中心则是一个典型的"入口聚合"型页面,其设计重点在于信息的清晰分类和层级的合理组织。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// 242.ets - 外卖骑手APP
// 主题色:闪电黄 #FFC107 + 深灰 #37474F
// 布局:接单大厅卡片流 + 配送路线时间轴 + 收入统计柱状图 + 评价列表
// ==================== 类型定义 ====================
interface OrderMeta {
id: number
shopName: string
shopColor: string
customerName: string
customerPhone: string
address: string
distance: string
items: string
amount: number
deliveryFee: number
estimatedTime: string
status: string
orderTime: string
}
interface DeliveryStepMeta {
step: number
title: string
desc: string
time: string
done: boolean
}
interface IncomeMeta {
date: string
orders: number
income: number
hours: number
}
interface ReviewMeta {
id: number
customerName: string
rating: number
content: string
date: string
tags: string[]
}
// ==================== 设计令牌 ====================
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
dark: string;
darkLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
}
const COLORS: ColorPalette = {
primary: '#FFC107',
primaryLight: '#FFE082',
primaryDark: '#FF8F00',
dark: '#37474F',
darkLight: '#546E7A',
bg: '#FAFAFA',
cardBg: '#FFFFFF',
textPrimary: '#263238',
textSecondary: '#78909C',
textHint: '#B0BEC5',
border: '#ECEFF1',
success: '#66BB6A',
warning: '#FFA726',
danger: '#EF5350',
white: '#FFFFFF'
};
const TAB_CONFIG: Record<string, string> = {
'hall': '接单',
'delivery': '配送',
'income': '收入',
'review': '评价',
'mine': '我的'
}
// ==================== 硬编码数据 ====================
const ORDER_LIST: OrderMeta[] = [
{ id: 1001, shopName: '兰州拉面馆', shopColor: '#FFAB91', customerName: '张先生', customerPhone: '138****8888', address: '阳光花园3栋502室', distance: '1.2km', items: '牛肉拉面x2, 凉拌黄瓜x1', amount: 38.5, deliveryFee: 5.5, estimatedTime: '25分钟', status: '待接单', orderTime: '12:05' },
{ id: 1002, shopName: '黄焖鸡米饭', shopColor: '#FFE082', customerName: '李女士', customerPhone: '139****6666', address: '翠湖天地B座1803', distance: '0.8km', items: '黄焖鸡大份x1, 米饭x1', amount: 25.0, deliveryFee: 4.0, estimatedTime: '20分钟', status: '待接单', orderTime: '12:08' },
{ id: 1003, shopName: '肯德基', shopColor: '#FFCC80', customerName: '王先生', customerPhone: '137****5555', address: '万达广场5楼', distance: '2.5km', items: '全家桶x1, 可乐x2', amount: 89.0, deliveryFee: 7.0, estimatedTime: '35分钟', status: '待接单', orderTime: '12:10' },
{ id: 1004, shopName: '沙县小吃', shopColor: '#A5D6A7', customerName: '赵女士', customerPhone: '136****4444', address: '金域蓝湾7栋301', distance: '1.5km', items: '蒸饺x2, 鸭腿饭x1', amount: 22.0, deliveryFee: 4.5, estimatedTime: '22分钟', status: '待接单', orderTime: '12:12' },
{ id: 1005, shopName: '蜜雪冰城', shopColor: '#F48FB1', customerName: '陈先生', customerPhone: '135****3333', address: '大学城学生公寓6栋', distance: '3.0km', items: '柠檬水x3, 冰淇淋x2', amount: 18.0, deliveryFee: 6.0, estimatedTime: '30分钟', status: '待接单', orderTime: '12:15' },
{ id: 1006, shopName: '麻辣烫', shopColor: '#CE93D8', customerName: '孙女士', customerPhone: '134****2222', address: '锦绣华庭12栋805', distance: '1.8km', items: '麻辣烫大份x1', amount: 32.0, deliveryFee: 5.0, estimatedTime: '25分钟', status: '待接单', orderTime: '12:18' }
]
const DELIVERY_STEPS: DeliveryStepMeta[] = [
{ step: 1, title: '到达商家', desc: '已到达兰州拉面馆', time: '12:08', done: true },
{ step: 2, title: '取餐完成', desc: '确认取餐:牛肉拉面x2, 凉拌黄瓜x1', time: '12:15', done: true },
{ step: 3, title: '配送中', desc: '前往阳光花园3栋502室', time: '12:16', done: true },
{ step: 4, title: '即将送达', desc: '距离目的地200米', time: '12:25', done: false },
{ step: 5, title: '已送达', desc: '等待客户确认收货', time: '--:--', done: false }
]
const INCOME_LIST: IncomeMeta[] = [
{ date: '周一', orders: 18, income: 126.5, hours: 8 },
{ date: '周二', orders: 22, income: 154.0, hours: 9 },
{ date: '周三', orders: 15, income: 98.5, hours: 7 },
{ date: '周四', orders: 25, income: 178.5, hours: 10 },
{ date: '周五', orders: 30, income: 215.0, hours: 11 },
{ date: '周六', orders: 28, income: 196.0, hours: 10 },
{ date: '周日', orders: 20, income: 140.0, hours: 8 }
]
const REVIEW_LIST: ReviewMeta[] = [
{ id: 1, customerName: '张先生', rating: 5, content: '送餐速度很快,态度也很好,骑手小哥辛苦了!', date: '2024-03-20', tags: ['速度快', '态度好'] },
{ id: 2, customerName: '李女士', rating: 5, content: '准时送达,包装完好,非常满意的一次外卖体验', date: '2024-03-19', tags: ['准时', '包装好'] },
{ id: 3, customerName: '王先生', rating: 4, content: '整体不错,就是路上稍微绕了一点路', date: '2024-03-18', tags: ['整体不错'] },
{ id: 4, customerName: '赵女士', rating: 5, content: '下雨天也准时送到,太感谢了!给五星好评', date: '2024-03-17', tags: ['风雨无阻', '准时'] },
{ id: 5, customerName: '陈先生', rating: 5, content: '小哥特别有耐心,帮忙送到门口,点赞!', date: '2024-03-16', tags: ['有耐心', '服务好'] },
{ id: 6, customerName: '孙女士', rating: 4, content: '速度还行,汤洒了一点点但整体OK', date: '2024-03-15', tags: ['速度还行'] }
]
const WEEKLY_STATS: Record<string, number> = {
'totalOrders': 158,
'totalIncome': 1108,
'avgRating': 4.8,
'onlineHours': 63
}
function getMaxIncome(): number {
let maxVal: number = 0
for (const item of INCOME_LIST) {
if (item.income > maxVal) {
maxVal = item.income
}
}
return maxVal
}
function getRatingStars(rating: number): string {
let stars: string = ''
for (let i = 0; i < 5; i++) {
if (i < rating) {
stars += '★'
} else {
stars += '☆'
}
}
return stars
}
function getRatingColor(rating: number): string {
if (rating >= 5) {
return COLORS.success
}
if (rating >= 4) {
return COLORS.primaryDark
}
return COLORS.warning
}
// ==================== 入口组件 ====================
@Entry
@Component
struct RiderApp {
@State currentTab: string = 'hall'
@State showAcceptDialog: boolean = false
@State showEditDialog: boolean = false
@State showCancelDialog: boolean = false
@State targetOrder: OrderMeta = ORDER_LIST[0]
build() {
Stack() {
Column() {
// 顶部状态栏
Row() {
Column() {
Text('骑手工作台')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('在线中 · 今日已完成 12 单')
.fontSize(11)
.fontColor('#FFFFFFCC')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Row() {
Stack() {
Text('●')
.fontSize(10)
.fontColor(COLORS.success)
}
.width(24)
.height(24)
Text('在线')
.fontSize(12)
.fontColor(COLORS.white)
}
}
.width('100%')
.height(56)
.padding({ left: 20, right: 20 })
.linearGradient({ angle: 135, colors: [[COLORS.dark, 0.0], [COLORS.darkLight, 1.0]] })
// 内容区域
Column() {
if (this.currentTab === 'hall') {
this.hallContent()
} else if (this.currentTab === 'delivery') {
this.deliveryContent()
} else if (this.currentTab === 'income') {
this.incomeContent()
} else if (this.currentTab === 'review') {
this.reviewContent()
} else {
this.mineContent()
}
}
.layoutWeight(1)
.backgroundColor(COLORS.bg)
// 底部Tab栏
this.bottomTabBar()
}
.width('100%')
.height('100%')
if (this.showAcceptDialog) {
this.acceptDialog()
}
if (this.showEditDialog) {
this.editDialog()
}
if (this.showCancelDialog) {
this.cancelDialog()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
@Builder
bottomTabBar() {
Row() {
ForEach(Object.keys(TAB_CONFIG), (key: string) => {
Column() {
Text(this.getTabIcon(key))
.fontSize(20)
Text(TAB_CONFIG[key])
.fontSize(11)
.margin({ top: 2 })
.fontColor(this.currentTab === key ? COLORS.primaryDark : COLORS.textHint)
}
.layoutWeight(1)
.height(56)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = key
})
})
}
.width('100%')
.height(56)
.backgroundColor(COLORS.white)
.border({ width: { top: 1 }, color: COLORS.border })
}
getTabIcon(key: string): string {
if (key === 'hall') {
return this.currentTab === key ? '📦' : '📭'
}
if (key === 'delivery') {
return this.currentTab === key ? '🛵' : '🚲'
}
if (key === 'income') {
return this.currentTab === key ? '💰' : '🪙'
}
if (key === 'review') {
return this.currentTab === key ? '⭐' : '☆'
}
return this.currentTab === key ? '👤' : '🙆'
}
// ========== 接单大厅 ==========
@Builder
hallContent() {
Scroll() {
Column() {
// 今日数据概览
Row() {
this.statBox('今日订单', '12', COLORS.primary)
this.statBox('今日收入', '¥86', COLORS.success)
this.statBox('平均时效', '26min', COLORS.primaryDark)
}
.width('92%')
.margin({ top: 12, bottom: 12 })
Text('附近订单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
.alignSelf(ItemAlign.Start)
ForEach(ORDER_LIST, (order: OrderMeta) => {
this.orderCard(order)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
@Builder
statBox(title: string, value: string, color: string) {
Column() {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(title)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.margin({ right: 8 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
@Builder
orderCard(order: OrderMeta) {
Column() {
// 商家信息
Row() {
Stack() {
Text('🏪')
.fontSize(20)
}
.width(40)
.height(40)
.borderRadius(10)
.backgroundColor(order.shopColor)
Column() {
Text(order.shopName)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(order.orderTime + ' 下单 · ' + order.distance)
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('¥' + order.deliveryFee.toFixed(1))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Divider().color(COLORS.border).margin({ top: 10, bottom: 10 })
// 订单详情
Row() {
Text('📋')
.fontSize(14)
.margin({ right: 6 })
Text(order.items)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
Row() {
Text('📍')
.fontSize(14)
.margin({ right: 6 })
Text(order.address)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 6 })
Row() {
Text('⏱')
.fontSize(14)
.margin({ right: 6 })
Text('预计 ' + order.estimatedTime + ' 送达')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 6 })
// 接单按钮
Button('立即接单')
.fontSize(14)
.fontColor(COLORS.dark)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(38)
.width('100%')
.margin({ top: 12 })
.onClick(() => {
this.targetOrder = order
this.showAcceptDialog = true
})
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
}
// ========== 配送Tab ==========
@Builder
deliveryContent() {
Scroll() {
Column() {
Text('当前配送')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 16, left: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
// 配送中订单卡片
Column() {
Row() {
Stack() {
Text('🏪')
.fontSize(20)
}
.width(40)
.height(40)
.borderRadius(10)
.backgroundColor('#FFAB91')
Column() {
Text('兰州拉面馆')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('订单 #1001')
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('配送中')
.fontSize(12)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primaryDark)
.borderRadius(10)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 配送路线时间轴
ForEach(DELIVERY_STEPS, (step: DeliveryStepMeta) => {
this.deliveryStepItem(step)
})
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 12 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 操作按钮
Row() {
Button('编辑备注')
.fontSize(14)
.fontColor(COLORS.primaryDark)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.primary })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.targetOrder = ORDER_LIST[0]
this.showEditDialog = true
})
Button('取消订单')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 10 })
.onClick(() => {
this.targetOrder = ORDER_LIST[0]
this.showCancelDialog = true
})
}
.width('92%')
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
@Builder
deliveryStepItem(step: DeliveryStepMeta) {
Row() {
// 时间轴
Column() {
Stack() {
if (step.done) {
Text('✓')
.fontSize(14)
.fontColor(COLORS.white)
.fontWeight(FontWeight.Bold)
} else {
Text(step.step.toString())
.fontSize(12)
.fontColor(step.done ? COLORS.white : COLORS.textHint)
.fontWeight(FontWeight.Bold)
}
}
.width(28)
.height(28)
.borderRadius(14)
.backgroundColor(step.done ? COLORS.success : COLORS.border)
if (step.step < 5) {
Column()
.width(2)
.height(32)
.backgroundColor(step.done ? COLORS.success : COLORS.border)
}
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(step.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(step.done ? COLORS.textPrimary : COLORS.textSecondary)
Text(step.desc)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
Text(step.time)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
// ========== 收入Tab ==========
@Builder
incomeContent() {
Scroll() {
Column() {
// 本周收入概览
Column() {
Text('本周总收入')
.fontSize(13)
.fontColor('#FFFFFFCC')
Text('¥' + WEEKLY_STATS.totalIncome)
.fontSize(32)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.margin({ top: 4 })
Row() {
Column() {
Text('总单量')
.fontSize(11)
.fontColor('#FFFFFFCC')
Text(WEEKLY_STATS.totalOrders.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('在线时长')
.fontSize(11)
.fontColor('#FFFFFFCC')
Text(WEEKLY_STATS.onlineHours + 'h')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('平均评分')
.fontSize(11)
.fontColor('#FFFFFFCC')
Text(WEEKLY_STATS.avgRating.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.margin({ top: 16 })
}
.width('92%')
.padding(20)
.borderRadius(16)
.linearGradient({ angle: 135, colors: [[COLORS.dark, 0.0], [COLORS.darkLight, 1.0]] })
.alignItems(HorizontalAlign.Center)
.margin({ top: 12, bottom: 16 })
.shadow({ radius: 8, color: '#1A37474F', offsetY: 4 })
// 收入柱状图
Column() {
Text('每日收入')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
ForEach(INCOME_LIST, (item: IncomeMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Text('¥' + item.income)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ bottom: 2 })
}
.width('100%')
.height(100 * (item.income / getMaxIncome()))
.linearGradient({ angle: 180, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ topLeft: 4, topRight: 4 })
Text(item.date)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
Text(item.orders + '单')
.fontSize(9)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 12 })
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 收入明细
Text('收入明细')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
.alignSelf(ItemAlign.Start)
ForEach(INCOME_LIST, (item: IncomeMeta) => {
Row() {
Column() {
Text(item.date)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(item.orders + '单 · ' + item.hours + '小时')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥' + item.income.toFixed(1))
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
}
.width('92%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(10)
.margin({ bottom: 6 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
// ========== 评价Tab ==========
@Builder
reviewContent() {
Scroll() {
Column() {
Row() {
Column() {
Text(WEEKLY_STATS.avgRating.toString())
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text(getRatingStars(5))
.fontSize(14)
.fontColor(COLORS.primary)
.margin({ top: 4 })
Text('综合评分')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
this.ratingBar('5星', 80)
this.ratingBar('4星', 15)
this.ratingBar('3星', 3)
this.ratingBar('2星', 1)
this.ratingBar('1星', 1)
}
.layoutWeight(1.2)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ top: 12, bottom: 16 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
ForEach(REVIEW_LIST, (review: ReviewMeta) => {
this.reviewCard(review)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
@Builder
ratingBar(label: string, percent: number) {
Row() {
Text(label)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(28)
Column()
.layoutWeight(1)
.height(6)
.backgroundColor(COLORS.border)
.borderRadius(3)
Column()
.width(percent + '%')
.height(6)
.backgroundColor(COLORS.primary)
.borderRadius(3)
.position({ x: 28, y: 0 })
Text(percent + '%')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(28)
.margin({ left: 4 })
}
.width('100%')
.height(16)
.alignItems(VerticalAlign.Center)
.margin({ bottom: 2 })
}
@Builder
reviewCard(review: ReviewMeta) {
Column() {
Row() {
Stack() {
Text('👤')
.fontSize(20)
}
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor(COLORS.primaryLight)
Column() {
Text(review.customerName)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(review.date)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text(getRatingStars(review.rating))
.fontSize(14)
.fontColor(getRatingColor(review.rating))
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(review.content)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.lineHeight(20)
.margin({ top: 10 })
// 标签
Row() {
ForEach(review.tags, (tag: string) => {
Text(tag)
.fontSize(10)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primaryLight)
.fontColor(COLORS.primaryDark)
.borderRadius(4)
.margin({ right: 6 })
})
}
.margin({ top: 8 })
}
.width('92%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ bottom: 10 })
.shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
}
// ========== 我的Tab ==========
@Builder
mineContent() {
Scroll() {
Column() {
// 骑手信息卡片
Row() {
Stack() {
Text('🛵')
.fontSize(32)
}
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor(COLORS.primary)
Column() {
Text('金牌骑手')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('工号:R20240315')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('本月已配送 358 单')
.fontSize(12)
.fontColor(COLORS.primaryDark)
.margin({ top: 2 })
}
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('92%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(16)
.margin({ top: 16, bottom: 16 })
.alignItems(VerticalAlign.Center)
.shadow({ radius: 4, color: '#0D000000', offsetY: 2 })
// 本月数据
Row() {
this.statBox('本月订单', '358', COLORS.primary)
this.statBox('本月收入', '¥2512', COLORS.success)
this.statBox('好评率', '98%', COLORS.primaryDark)
}
.width('92%')
.margin({ bottom: 16 })
ForEach(['我的钱包', '车辆信息', '保险服务', '在线客服', '骑手规则', '设置'], (item: string) => {
Row() {
Text(item)
.fontSize(14)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('›')
.fontSize(20)
.fontColor(COLORS.textHint)
}
.width('92%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
})
}
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
// ==================== 弹窗 ====================
@Builder
acceptDialog() {
Column() {
Column() {
Text('确认接单')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
// 订单信息
Row() {
Stack() {
Text('🏪')
.fontSize(24)
}
.width(48)
.height(48)
.borderRadius(12)
.backgroundColor(this.targetOrder.shopColor)
Column() {
Text(this.targetOrder.shopName)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(this.targetOrder.distance + ' · 预计' + this.targetOrder.estimatedTime)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥' + this.targetOrder.deliveryFee.toFixed(1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
}
.width('100%')
.margin({ top: 16, bottom: 16 })
.alignItems(VerticalAlign.Center)
Row() {
Text('配送费')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + this.targetOrder.deliveryFee.toFixed(1))
.fontSize(13)
.fontColor(COLORS.primaryDark)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('订单金额')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + this.targetOrder.amount.toFixed(2))
.fontSize(13)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
Row() {
Button('放弃')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showAcceptDialog = false
})
Button('接单')
.fontSize(14)
.fontColor(COLORS.dark)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showAcceptDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
editDialog() {
Column() {
Column() {
Text('编辑配送备注')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ bottom: 16 })
Row() {
Text('订单编号')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('#' + this.targetOrder.id)
.fontSize(13)
.fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('商家名称')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetOrder.shopName)
.fontSize(13)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: COLORS.border })
Row() {
Text('配送地址')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.targetOrder.address)
.fontSize(12)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
Row() {
Button('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showEditDialog = false
})
Button('保存')
.fontSize(14)
.fontColor(COLORS.dark)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showEditDialog = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('80%')
.padding(20)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
cancelDialog() {
Column() {
Column() {
Stack() {
Text('⚠')
.fontSize(40)
.fontColor(COLORS.danger)
}
.width(64)
.height(64)
.borderRadius(32)
.backgroundColor('#FFEBEE')
.margin({ bottom: 16 })
Text('取消订单确认')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定要取消订单 #' + this.targetOrder.id + ' 吗?取消后可能影响您的接单评分,且需重新等待派单。')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.lineHeight(20)
.margin({ top: 12 })
Row() {
Button('继续配送')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
.borderRadius(10)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showCancelDialog = false
})
Button('确认取消')
.fontSize(14)
.fontColor(COLORS.white)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.height(40)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.showCancelDialog = false
})
}
.width('100%')
.margin({ top: 20 })
}
.width('76%')
.padding(24)
.backgroundColor(COLORS.white)
.borderRadius(20)
.onClick(() => {})
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
十四、技术总结与优化展望
通过对外卖骑手工作台应用的全面剖析,我们可以总结出在 HarmonyOS ArkTS 框架下进行商业级应用开发的一系列关键技术要点和最佳实践。首先,在架构设计层面,本应用充分展示了声明式 UI 编程的强大优势。通过 @Component 定义组件、@State 管理状态、@Builder 封装可复用 UI 逻辑,整个应用的代码结构呈现出高度的模块化和可维护性。特别是 Builder 方法的广泛应用,使得复杂的 UI 结构可以被分解为多个独立、可测试、可复用的构建单元,如 statBox 在接单大厅和个人中心的复用、orderCard 和 reviewCard 的独立封装,都极大地提升了代码的整洁度和开发效率。状态管理的简洁性也是 ArkTS 的一大亮点——开发者无需引入额外的状态管理库,仅通过 @State 装饰器就能实现细粒度的响应式更新,框架底层的依赖追踪机制会自动处理 UI 与数据之间的同步关系。

其次,在视觉设计与工程实现的结合方面,本应用通过 ColorPalette 接口和 COLORS 常量对象建立了完整的设计令牌体系。这种将设计语义与代码实现深度绑定的做法,不仅确保了全应用视觉风格的高度一致性,还为未来的主题定制、深色模式适配等扩展需求预留了极大的便利。在布局实现上,综合运用了 Column、Row、Stack 等多种基础容器,配合 layoutWeight、padding、margin 等属性,构建出了灵活且自适应的界面结构。特别是在收入柱状图的实现中,通过纯代码的方式完成了动态数据可视化,避免了引入重量级图表库,有效控制了应用的包体积和运行时性能。弹窗组件的遮罩层设计、事件冒泡控制、以及不同场景下的差异化视觉处理(如取消订单弹窗的警告图标),都体现了开发者对移动端交互细节的深入理解。
然而,作为一款演示性质的应用原型,本代码在多个方面仍有进一步的优化空间。在数据层面,当前所有的业务数据都是硬编码的常量数组,在实际生产环境中需要替换为基于 http 模块或 rcp 模块的后端 API 异步请求,并引入适当的加载态和错误态处理。在状态管理层面,随着应用功能的扩展,当前的单一组件状态模式可能会面临状态难以追溯、跨组件通信复杂等问题,届时可以考虑引入 AppStorage 或自定义的状态管理方案来应对更大规模的业务场景。在组件复用层面,部分 Builder 方法(如三个弹窗)在结构上存在较高的相似性,可以通过提取一个通用的"模态弹窗"基础 Builder 来进一步消除重复代码。在可访问性层面,当前代码尚未考虑屏幕阅读器适配、字体缩放兼容、高对比度模式等无障碍需求,这些是商业级应用必须关注的重要方面。在性能优化层面,对于长列表场景(如订单列表和评价列表),可以考虑使用 LazyForEach 替代 ForEach 以实现按需加载和复用,从而显著提升大数据量下的滚动流畅度。
从类型安全的数据建模、声明式的 UI 构建、响应式的状态管理,到精细化的视觉设计和人性化的交互反馈。它不仅是一个功能完备的原型应用,更是一份值得深入学习和借鉴的技术范本。对于正在探索 HarmonyOS 应用开发的开发者而言,深入理解这款应用的代码结构和设计思路,将有助于快速掌握 ArkTS 的核心编程范式,并在自己的项目中产出高质量、高性能、高体验的应用产品。随着 HarmonyOS 生态的不断成熟和arkUI框架的持续演进,相信基于 ArkTS 的声明式开发模式将成为下一代智能终端应用开发的主流选择,而本应用所体现的技术理念和工程实践,也将在更广泛的业务场景中发挥出更大的价值。
更多推荐
所有评论(0)