HarmonyOS 6.1 实战:子组件通过构造参数接收回调函数,“子到父通信“模式子组件触发 onPkg(p) 回调,父组件在回调中更新 selPkg 与 showPkg
在万物互联的鸿蒙生态中,声明式 UI 框架 ArkUI 正在重新定义移动端应用的开发范式。本文将以一个完整的多 Tab 物流应用为蓝本,从数据建模、状态管理、组件拆分到弹窗系统,逐层剖析 ArkTS 在复杂业务场景下的工程实践。
一、技术背景与整体架构概览
鸿蒙操作系统(HarmonyOS)提供的 ArkUI 声明式开发范式,以 @Component、@Entry、@State、@Builder 等装饰器为核心,构建出一套数据驱动的 UI 体系。开发者只需关注数据与状态的变化,框架会自动完成视图的差量更新与渲染。这种模式相比传统的命令式开发,极大降低了状态同步的复杂度。
本代码实现的是一个名为「星际快递 · 银河物流中心」的完整物流应用。它围绕"包裹全生命周期"展开,涵盖包裹查询、在线寄件、驿站地图、物流追踪、积分商城、个人中心六大功能模块。整个应用采用单文件、多组件的工程组织方式,通过一个入口组件统一管理全局状态与弹窗挂载,再分发到各个子 Tab 组件渲染具体业务。
从技术栈维度看,该应用融合了 ArkTS 的诸多核心能力。布局上大量使用 Column、Row、Stack 三大基础容器完成线性与层叠排列;状态管理上借助 @State 实现组件内可变数据的响应式更新;列表渲染上依赖 ForEach 进行数组到视图的映射;滚动场景上通过 Scroll 配合 scrollable、scrollBar 实现纵向与横向滚动;复用逻辑上用 @Builder 抽取无状态 UI 片段。下面这张流程图展示了从入口到各 Tab 再到弹窗的整体调度链路。

一个值得注意的工程细节是:所有弹窗并非各 Tab 内部自行渲染,而是统一上提到主组件的
Stack层,由showXxx布尔状态控制显隐,selXxx对象承载选中数据。这种"状态上提、视图下沉"的设计使弹窗可以跨 Tab 复用,也避免了子组件无法直接覆盖全屏的层级问题。
在配色体系上,应用采用了科技蓝 #0B2F5C 渐变到 #0288D1 的主色调,搭配星橙 #FF8F00 作为强调色,辅以绿色 #43A047、警示红 #E53935、中性灰 #90A4AE 构成完整语义色板。渐变背景通过字符串形式的 linearGradient 内联表达,使卡片头部具备沉浸式视觉层次。
二、数据模型层:interface 类型契约
任何复杂应用都始于清晰的数据建模。本代码在文件开头定义了一组 interface,为整个应用的领域对象建立类型契约。首先看包裹模型。
interface Package {
id: number;
name: string;
icon: string;
no: string;
from: string;
to: string;
status: string;
statusColor: string;
type: string;
time: string;
desc: string;
}

Package 接口描述了一个物流包裹的完整信息。其中 id 是主键,用于 ForEach 的键值生成;name 与 icon 负责卡片标题与图标展示,这里 icon 用的是 Emoji 字符,省去了图片资源加载;from/to 表示寄件与收件地点;status 与 statusColor 是一对关联字段,状态文字与状态色绑定在一起,便于直接渲染彩色标签。
这种将颜色与状态一起冗余进数据模型的写法,是一种"以数据换逻辑"的取舍。常规做法是用函数根据 status 推导颜色(代码中确实也提供了 getStatusColor 函数),但在静态演示数据中直接预置颜色,可减少渲染时的分支判断,让 UI 代码更扁平。
接下来是驿站、快递员、物流轨迹等模型,它们结构类似但各有侧重。
interface Station {
id: number;
name: string;
icon: string;
addr: string;
distance: string;
open: string;
boxes: number;
courier: string;
busy: string;
}
interface Courier {
id: number;
name: string;
icon: string;
phone: string;
score: number;
orders: number;
area: string;
online: boolean;
}
interface TrackLog {
id: number;
title: string;
icon: string;
place: string;
time: string;
done: boolean;
desc: string;
}

Station 模型关注驿站的位置与运营信息,distance、open、boxes、busy 共同构成驿站卡片的核心展示要素。Courier 描述快递员画像,score、orders、online 用于评价与状态展示,online 是布尔类型,直接驱动在线/休息标签的切换。TrackLog 是物流时间轴的每一节点,done 布尔字段决定节点是实心圆还是空心圆,是时间轴进度的关键标志。
可以看到,这些接口普遍使用 string 承载本应是数值或枚举的字段(如 distance: '800m'、busy: '较忙')。这是演示型应用的常见取舍:直接存展示态字符串,省去格式化函数。在真实工程中,建议将 distance 拆为 distanceValue: number 与 distanceUnit: string,便于排序与国际化。
剩余的接口包括计费规则、积分商品、快递柜格口、订单、徽章、快捷入口,它们共同支撑起寄件、积分、个人中心等模块。
interface RateRule {
id: number;
name: string;
icon: string;
base: number;
perKg: number;
days: number;
desc: string;
}
interface PointGoods {
id: number;
name: string;
icon: string;
points: number;
stock: number;
type: string;
desc: string;
}
interface BoxCell {
id: number;
boxNo: string;
size: string;
free: boolean;
code: string;
time: string;
}
interface Order {
id: number;
no: string;
item: string;
icon: string;
date: string;
fee: number;
status: string;
recipient: string;
}
interface Badge {
id: number;
name: string;
icon: string;
locked: boolean;
desc: string;
date: string;
}
interface QuickIcon {
id: number;
name: string;
icon: string;
color: string;
}

RateRule 是计费引擎的数据基础,base 起步价加 perKg 每公斤单价构成运费公式 base + perKg * weight,这个公式在寄件弹窗与运费计算器中反复出现。PointGoods 的 points 与 stock 共同决定可兑换性,兑换时会扣减积分。BoxCell 的 free 布尔决定格口是空闲绿还是占用橙。Badge 的 locked 控制徽章是否灰显。这些布尔字段都是 UI 状态分流的支点。
类型先行是鸿蒙声明式开发的最佳实践。先定义好
interface,再编写数据与组件,可以让 IDE 提供精准的智能补全与类型检查,避免运行时才暴露的字段拼写错误。ArkTS 在 TypeScript 基础上强化了静态类型约束,interface中的字段一旦确定,组件中访问不存在属性会直接报错。
三、静态数据层:常量数组与 Mock 数据
定义完类型契约后,代码用一组 const 数组填充演示数据。这些数组是整个应用的"假后端",在没有真实接口的情况下驱动全部 UI 渲染。先看包裹数据。
const PACKAGES: Package[] = [
{ id: 1, name: '星航耳机', icon: '🎧', no: 'HX-20260826-001', from: '地球总仓', to: '火星基地', status: '运输中', statusColor: '#0288D1', type: '电子产品', time: '08-26 09:12', desc: '旗舰降噪耳机,预计后天送达。' },
{ id: 2, name: '月球岩茶', icon: '🍵', no: 'HX-20260826-002', from: '月球站', to: '地球总仓', status: '待取件', statusColor: '#FF8F00', type: '食品', time: '08-26 08:40', desc: '限定月球岩茶礼盒,请尽快取件。' },
// ... 其余包裹
];

PACKAGES 包含 12 条包裹记录,覆盖了运输中、待取件、派送中、已签收、异常五种状态,确保每个状态分支都有数据可渲染。单号采用 HX-日期-序号 格式,时间字段用 MM-DD HH:mm 简化格式。这种"全覆盖"的 Mock 数据策略,使得开发者无需手动构造边界情况即可验证所有 UI 分支。
类似地,STATIONS 提供 10 家星际驿站,COURIERS 提供 8 名快递员,TRACKS 提供 7 条物流轨迹,RATES 提供 10 条计费规则,POINT_GOODS 提供 10 件积分商品,BOXES 提供 8 个快递柜格口,ORDERS 提供 10 条订单,BADGES 提供 8 枚徽章,QUICK_ICONS 提供 8 个快捷入口。每份数据都经过精心设计,覆盖各种状态组合。
const TRACKS: TrackLog[] = [
{ id: 1, title: '已揽收', icon: '📦', place: '地球总仓', time: '08-26 09:12', done: true, desc: '包裹已从地球总仓揽收' },
{ id: 2, title: '干线运输', icon: '🚀', place: '近地轨道', time: '08-26 11:30', done: true, desc: '搭乘星际货运火箭 HX-9 号' },
{ id: 3, title: '到达中转站', icon: '🛰️', place: '月面中转站', time: '08-26 18:20', done: true, desc: '包裹已抵达月面中转站' },
// ... 其余轨迹
];
TRACKS 是物流时间轴的数据源,所有节点 done 均为 true,表示该包裹已完成全流程签收。在真实场景中,时间轴通常只有部分节点完成,未完成节点显示空心圆。本数据将进度拉满,便于展示完整轨迹链。
在工程实践中,Mock 数据与真实接口的字段结构应当严格对齐。一旦后端字段命名调整,只需修改 Mock 数组与接口适配层,UI 组件无需变动。这就是数据驱动架构带来的解耦红利。
四、工具函数层:双列拆分与状态推导
代码中段定义了大量辅助函数,它们是连接静态数据与视图渲染的桥梁。其中最引人注目的是一组"双列拆分"函数。
function getPkgRows(): Package[] {
let rows: Package[] = [];
for (let i = 0; i < PACKAGES.length; i += 2) {
rows.push(PACKAGES[i]);
}
return rows;
}
function getPkgRows2(): Package[] {
let rows: Package[] = [];
for (let i = 1; i < PACKAGES.length; i += 2) {
rows.push(PACKAGES[i]);
}
return rows;
}
getPkgRows 取偶数索引(0,2,4…),getPkgRows2 取奇数索引(1,3,5…),两个函数将一维数组拆成左右两列。这种拆分在瀑布流双列布局中极其常见:用两个 Column 各持一列,每列内部用 ForEach 渲染卡片,从而实现视觉上的双列错落效果。
代码为几乎每种数据都提供了对应的拆分对:getStationRows/getStationRows2、getCourierRows/getCourierRows2、getTrackRows/getTrackRows2、getRateRows/getRateRows2、getPointRows/getPointRows2、getBoxRows/getBoxRows2、getOrderRows/getOrderRows2、getBadgeRows/getBadgeRows2、getQuickRows/getQuickRows2。
function getQuickRows(): QuickIcon[] {
let rows: QuickIcon[] = [];
for (let i = 0; i < 4; i++) {
rows.push(QUICK_ICONS[i]);
}
return rows;
}
function getQuickRows2(): QuickIcon[] {
let rows: QuickIcon[] = [];
for (let i = 4; i < QUICK_ICONS.length; i++) {
rows.push(QUICK_ICONS[i]);
}
return rows;
}
快捷入口的拆分略有不同:getQuickRows 取前 4 个,getQuickRows2 取后 4 个,构成两行四列的宫格。这与包裹的"奇偶交错"拆分逻辑不同,是按数量均分。两种拆分策略并存,说明开发者根据布局需要灵活选择。
这种成对出现的拆分函数虽然略显重复,但胜在直观可控。若追求极致精简,可用一个泛型函数
splitColumns<T>(arr: T[], col: number)统一处理,但 ArkTS 对泛型函数的支持有限,且会牺牲类型可读性。在演示项目中,保持"一个布局一对函数"的显式风格更易理解。
除了拆分函数,还有一类状态推导函数,根据业务字段计算展示态。
function getStatusColor(s: string): string {
if (s === '运输中') {
return '#0288D1';
} else if (s === '待取件') {
return '#FF8F00';
} else if (s === '派送中') {
return '#43A047';
} else if (s === '异常') {
return '#E53935';
}
return '#90A4AE';
}
function getBusyColor(b: string): string {
if (b === '繁忙') {
return '#E53935';
} else if (b === '较忙') {
return '#FF8F00';
}
return '#43A047';
}

getStatusColor 将状态文字映射为颜色,是典型的"枚举到色值"转换。注意它与 Package.statusColor 字段存在功能重叠——包裹数据自带颜色,而订单数据没有,因此订单状态需要靠函数推导。getBusyColor 同理,把"繁忙/较忙/空闲"映射为红/橙/绿。
还有一些统计聚合函数,用于计算各类汇总数字。
function getOnRoadCount(): number {
let n = 0;
for (let i = 0; i < PACKAGES.length; i++) {
if (PACKAGES[i].status === '运输中' || PACKAGES[i].status === '派送中') {
n = n + 1;
}
}
return n;
}
function getFreeBoxCount(): number {
let n = 0;
for (let i = 0; i < BOXES.length; i++) {
if (BOXES[i].free) {
n = n + 1;
}
}
return n;
}
function getCanBuyCount(points: number): number {
let n = 0;
for (let i = 0; i < POINT_GOODS.length; i++) {
if (POINT_GOODS[i].points <= points) {
n = n + 1;
}
}
return n;
}
getOnRoadCount 统计在途包裹数,getFreeBoxCount 统计空闲格口数,getCanBuyCount 根据当前积分计算可兑换商品数。这些函数在标题栏以"X 个待处理"“X 格空闲”"X 件可兑换"的形式实时呈现,是数据到摘要文字的转换层。值得注意的是 getCanBuyCount 接收 points 参数,使其能随积分变化动态重算。
function getBarHeight(val: number, max: number): string {
let p = max > 0 ? (val / max) * 80 : 0;
return p.toFixed(0) + 'vp';
}

getBarHeight 是柱状图的关键函数,将数值按最大值比例映射为 0-80vp 的高度字符串。vp 是鸿蒙的虚拟像素单位,随屏幕密度自适应。这个函数让积分走势柱状图能根据数据动态伸缩,最高柱(1380)撑满 80vp,其余按比例缩短。
五、主组件 Index:状态中枢与布局骨架
进入组件层,Index 是整个应用的入口与状态中枢。它由 @Entry 标记为页面入口,@Component 声明为自定义组件。
@Entry
@Component
struct Index {
@State tabIndex: number = 0;
@State showToast: boolean = false;
@State toast: string = '';
@State points: number = 3860;
@State memberLevel: number = 2;
// 弹框开关
@State showPkg: boolean = false;
@State showSend: boolean = false;
// ... 其余弹框开关
@State showVip: boolean = false;
// 选中对象
@State selPkg: Package | null = null;
@State selSend: RateRule | null = null;
// ... 其余选中对象
@State selUrgent: Order | null = null;
// 表单字段
@State fWeight: number = 5;
@State fInsure: boolean = false;
@State fTypeIdx: number = 0;
@State fDayIdx: number = 0;
@State fTimeIdx: number = 0;
@State fQty: number = 1;
@State fMsg: string = '';
@State fAddr: string = '';
@State fScore: number = 5;
@State fStars: number = 5;
private tabs: string[] = ['包裹', '寄件', '驿站', '追踪', '积分', '我的'];
private tabIcons: string[] = ['📦', '📮', '🏪', '🔍', '🛍️', '👤'];

@State 装饰的状态变量是组件响应式更新的核心。每当 @State 变量的值改变,框架会自动重新执行 build() 中依赖该变量的部分,完成视图差量刷新。这里状态可分为三类:全局业务态(points、memberLevel)、弹窗显隐态(showXxx 布尔群)、选中数据态(selXxx 对象)、表单输入态(fXxx 字段)。
points 初始值 3860、memberLevel 初始值 2 是全局业务数据,积分会在兑换商品时扣减,会员等级会在弹窗中点击升级时自增。showPkg/showSend 等 16 个布尔开关分别对应 16 个弹窗,构成一个集中式弹窗调度表。selPkg/selSend 等选中对象与开关成对出现,开关控制显隐、对象承载数据。fWeight、fQty、fStars 等表单字段是弹窗内交互的临时状态。
private tabs 与 tabIcons 是普通成员变量(非 @State),因为标签项是静态的,无需响应式更新。这种"该响应的才加 @State"的克制,是性能优化的基本功。
@State的更新检测基于赋值触发。对于对象类型,直接修改对象属性(如this.selPkg.name = 'x')不会触发刷新,必须整体重新赋值(this.selPkg = {...})或在构建时使用@Observed/@ObjectLink。本代码采用"重新赋值选中对象"的方式规避了这一陷阱。
showTip 是一个通用的轻提示方法,封装了 Toast 的显示与自动隐藏逻辑。
showTip(msg: string): void {
this.toast = msg;
this.showToast = true;
setTimeout(() => {
this.showToast = false;
}, 1800);
}
设置 toast 文本与 showToast 为 true 后,通过 setTimeout 在 1800 毫秒后自动复位。这是用 @State + 定时器模拟原生 Toast 的经典手法。toastBox 这个 @Builder 会在 showToast 为真时渲染在 Stack 顶层。
接下来是 build() 的核心结构,它用 Stack 作为最外层容器,实现"主内容 + 弹窗覆盖层"的层叠布局。
build() {
Stack() {
Column() {
// ========== 主内容区 ==========
if (this.tabIndex === 0) {
PkgTab({ onPkg: (p: Package) => { this.selPkg = p; this.showPkg = true; }, ... })
} else if (this.tabIndex === 1) {
SendTab({ onSend: (r: RateRule) => { this.selSend = r; this.fWeight = 5; this.showSend = true; }, ... })
} else if (this.tabIndex === 2) {
StationTab({ onStation: (s: Station) => { this.selStation = s; this.showStation = true; }, ... })
} else if (this.tabIndex === 3) {
TrackTab({ onPkg: (p: Package) => { this.selPkg = p; this.showPkg = true; }, ... })
} else if (this.tabIndex === 4) {
PointTab({ points: this.points, onPoint: (g: PointGoods) => { this.selPoint = g; this.fQty = 1; this.showPoint = true; }, ... })
} else {
MineTab({ points: this.points, memberLevel: this.memberLevel, onVip: () => { this.showVip = true; }, ... })
}
// ========== 底部 Tab ==========
Row() {
ForEach(this.tabs, (label: string, idx: number) => {
this.bottomTabItem(this.tabIcons[idx], label, idx)
}, (label: string, idx: number) => label + idx)
}
.width('100%')
.height(56)
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%')
.height('100%')
// ========== 弹框挂载 ==========
if (this.showPkg && this.selPkg !== null) {
this.modalOverlay(() => { this.showPkg = false })
this.pkgModal()
}
// ... 其余弹窗同理
if (this.showToast) {
this.toastBox()
}
}
.width('100%')
.height('100%')
}
Stack 是层叠布局容器,后入的子元素覆盖在先入的之上。这里第一层是 Column(主内容 + 底部 Tab),第二层起是各弹窗的遮罩与卡片,最后一层是 Toast。这种"主内容在底、弹窗在中、Toast 在顶"的层级安排,保证了交互优先级正确。
主内容区用 if/else if 根据 tabIndex 渲染对应 Tab 组件。注意这里不是用 Tabs 容器,而是手动 if 分发,这意味着切换 Tab 时旧 Tab 会被销毁、新 Tab 重新构建,没有缓存。对于演示应用这是可接受的简化,但在频繁切换且子 Tab 重的场景,建议用 Tabs 配合 cachedCount 保留状态。
子组件通过构造参数接收回调函数,这是 ArkTS 的"子到父通信"模式。子组件触发 onPkg(p) 回调,父组件在回调中更新 selPkg 与 showPkg,从而打开弹窗。这种"事件上抛、状态留在父"的设计,使子组件保持无状态、可复用。
PointTab 与 MineTab 还接收 points 与 memberLevel 作为 @Prop 式的向下数据流,父组件积分变化会自动同步到子组件展示。底部 Tab 用 Row + ForEach + bottomTabItem 渲染,shadow 的负 offsetY 让阴影投射向上,模拟从底部浮起的视觉。
弹窗挂载段是一连串 if (this.showXxx && this.selXxx !== null),每个弹窗由 modalOverlay(半透明遮罩)+ 具体 @Builder 卡片组成。双重条件判断确保既有开关又数据才渲染,避免空对象访问。Toast 因为无选中对象,只需 showToast 单条件。
Stack的层叠特性是构建弹窗、引导层、悬浮按钮的利器。相比 Web 中依赖position: fixed或z-index,ArkUI 的Stack天然支持层级,且子元素尺寸默认撑满父容器,遮罩全屏覆盖只需一行.width('100%').height('100%')。
六、弹窗系统:@Builder 的复用艺术
弹窗是本应用最密集的 UI 单元,共 16 个 @Builder。先看包裹详情弹窗,它是典型的居中卡片样式。
@Builder
pkgModal() {
Column() {
Row() {
Text(this.selPkg!.icon).fontSize(40)
Column({ space: 4 }) {
Text(this.selPkg!.name).fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text('单号 ' + this.selPkg!.no).fontSize(12).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Row() {
Text(this.selPkg!.status)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor(this.selPkg!.statusColor)
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
}
}
.width('100%')
Row() {
Column({ space: 2 }) {
Text('寄件').fontSize(11).fontColor('#90A4AE')
Text(this.selPkg!.from).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Text('🚀').fontSize(20)
Column({ space: 2 }) {
Text('收件').fontSize(11).fontColor('#90A4AE')
Text(this.selPkg!.to).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.backgroundColor('#F1F8FF')
.borderRadius(14)
.padding({ top: 12, bottom: 12 })
.margin({ top: 14 })
// ... 信息行与按钮
}
.width('84%')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.padding(20)
.constraintSize({ maxHeight: '82%' })
.clip(true)
}
@Builder 是 ArkUI 的 UI 复用单元,它编译后展开为内联视图,没有独立组件实例与状态,因此访问的 this.selPkg 直接是宿主组件的状态。! 非空断言表明开发者确信渲染时 selPkg 已非空,这与外层 if (this.selPkg !== null) 的守卫呼应。
头部 Row 用图标 + 名称列 + 状态标签三段式布局,layoutWeight(1) 让名称列占据剩余空间,状态标签靠右。Column({ space: 4 }) 的 space 参数控制子元素纵向间距,是替代逐个 margin 的批量间距语法。状态标签的彩色背景直接取自 selPkg.statusColor,呼应数据预置颜色的设计。
中间的寄件-收件区用两个 Column 各占 layoutWeight(1),中间夹一个火箭 Emoji,形成"左站 - 箭头 - 右站"的流向示意。alignItems(HorizontalAlign.Center) 让文字居中对齐,视觉对称。淡蓝背景 #F1F8FF 与圆角构成信息块卡片感。
卡片容器 .width('84%') 居中、.constraintSize({ maxHeight: '82%' }) 限高、.clip(true) 裁剪溢出,这三件套是居中弹窗的标准配置。clip(true) 配合 borderRadius 让顶部渐变背景不超出圆角,是渐变 + 圆角组合的必备项。
@Builder与@Component的区别在于:@Builder是"代码片段"复用,无独立状态与生命周期;@Component是"独立组件"复用,有状态与参数。当一段 UI 只依赖宿主状态、无需独立管理时,用@Builder更轻量;当需要跨页面复用且自带状态时,用@Component。本应用的弹窗因共享宿主状态,统一用@Builder。
寄件弹窗是底部抽屉样式,展示了表单交互与动态计费。
@Builder
sendModal() {
Column() {
Row() {
Text('寄快递').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text('✕')
.fontSize(16)
.fontColor('#90A4AE')
.padding(6)
.onClick(() => { this.showSend = false })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
// ... 线路选择、重量步进、保价开关、地址输入、运费预估
Row() {
Text('预估运费').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
Text('¥ ' + (this.selSend!.base + this.selSend!.perKg * this.fWeight))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FF8F00')
}
.width('100%')
.margin({ top: 12 })
// ... 确认下单按钮
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 22, topRight: 22 })
.padding(20)
.padding({ bottom: 30 })
.constraintSize({ maxHeight: '84%' })
}
底部抽屉的标志是 .width('100%') 全宽 + .borderRadius({ topLeft: 22, topRight: 22 }) 仅顶部圆角,视觉上从屏幕底部滑出。头部用 justifyContent(FlexAlign.SpaceBetween) 让标题靠左、关闭按钮靠右两端对齐。FlexAlign.SpaceBetween 是 Row/Column 主轴对齐的常用值,实现首尾贴边、中间均分。
运费计算 this.selSend!.base + this.selSend!.perKg * this.fWeight 是计费公式的直接体现。由于 fWeight 是 @State,每次点击"+/-"按钮改变重量,运费数字会即时刷新——这是响应式 UI 的直观体现。线路选择标签点击后更新 selSend,同样触发运费重算。
重量步进器用两个 Text 充当按钮,onClick 内做边界保护 if (this.fWeight > 1),防止负数重量。这种用 Text 模拟按钮的做法省去了 Button 的默认样式干扰,但要自行处理点击反馈样式。保价服务用 Toggle({ type: ToggleType.Switch, isOn: this.fInsure }) 开关,onChange 回调同步状态,是表单开关的标准组件。
layoutWeight与百分比width的取舍:当希望元素按剩余空间分配时用layoutWeight,当希望按固定比例时用百分比。本代码大量混用——标签列用layoutWeight(1)撑满、操作列用固定width(32)定宽,形成"弹性 + 定宽"的经典布局组合。
扫码取件弹窗展示了 Stack 的层叠与动画。
@Builder
scanModal() {
Column() {
Text('扫码取件').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('请将包裹条形码对准取件柜扫描口').fontSize(12).fontColor('#B3E5FC').margin({ top: 6 })
Stack() {
Column() {
Text('▚▞').fontSize(54).fontColor('#FF8F00').opacity(0.9)
}
.width(160)
.height(120)
.justifyContent(FlexAlign.Center)
.backgroundColor('#1AFFFFFF')
.borderRadius(12)
Row() {
Text('—').fontSize(24).fontColor('#FF8F00')
}
.width('60%')
.justifyContent(FlexAlign.Center)
.translate({ y: 30 })
.animation({ duration: 900, curve: Curve.EaseInOut })
}
.width(200)
.height(140)
.margin({ top: 20 })
// ... 包裹信息与按钮
}
.width('86%')
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#01579B", 1]] })')
.borderRadius(20)
.padding(20)
.constraintSize({ maxHeight: '82%' })
.clip(true)
}
Stack 内层叠了两层:底层是扫描框背景(半透明白 #1AFFFFFF),上层是一条扫描线 Text('—'),通过 translate({ y: 30 }) 偏移并配 animation 实现扫描线上下扫动的动效。Curve.EaseInOut 是先慢后快再慢的缓动曲线,让扫描线运动更自然。深蓝渐变背景 #0B2F5C 到 #01579B 营造科技感。
.opacity(0.9) 与 .translate、.scale、.animation 都是 ArkUI 的属性动画体系。声明 .animation 后,紧邻它的属性变化(如 translate 的 y 值改变)会以指定时长与曲线过渡,而非瞬变。本代码在取件码、积分数字、会员升级按钮等多处用 scale({ x: 1.05, y: 1.05 }).animation(...) 制造"放大强调"的微动效。
异常处理弹窗用红色渐变制造警示氛围。
@Builder
abnormalModal() {
Column() {
Row() {
Text('⚠️').fontSize(34)
Text('包裹运输异常')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ left: 10 })
.opacity(1)
.animation({ duration: 600, curve: Curve.EaseInOut })
}
.width('100%')
.justifyContent(FlexAlign.Center)
// ... 异常详情与处理方案按钮
}
.width('84%')
.backgroundColor('linearGradient({ colors: [["#B71C1C", 0], ["#E53935", 1]] })')
.borderRadius(20)
.padding(20)
.constraintSize({ maxHeight: '80%' })
.clip(true)
}
深红到亮红的渐变 #B71C1C 到 #E53935 是警示语义色,与正常弹窗的蓝白形成强烈对比。处理方案用"重新派送"(绿)与"申请退款"(红)两个 Button,颜色即语义。layoutWeight(1) 让两按钮均分宽度。
积分明细弹窗内置了一个柱状图,是数据可视化的亮点。
ForEach(['6 月', '7 月', '8 月', '9 月', '10 月', '11 月'], (m: string, mi: number) => {
Column({ space: 4 }) {
Text([620, 880, 1200, 950, 1380, 860][mi] + '')
.fontSize(9)
.fontColor('#90A4AE')
Column() {
Row() {
Column() {
}
.width(20)
.height(getBarHeight([620, 880, 1200, 950, 1380, 860][mi], 1380))
.backgroundColor(mi === 4 ? '#FF8F00' : '#81D4FA')
.borderRadius({ topLeft: 4, topRight: 4 })
.scale({ y: 1.02 })
.animation({ duration: 500, curve: Curve.EaseOut })
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height(86)
.justifyContent(FlexAlign.End)
.backgroundColor('#F5F8FC')
.borderRadius(8)
Text(m).fontSize(9).fontColor('#90A4AE')
}
.layoutWeight(1)
}, (m: string) => m)
柱状图由 6 个 Column 组成,每个 Column 内含数值标签、柱体、月份标签三段。柱体是一个空 Column 设定 .height(getBarHeight(...)) 与背景色,高度由数据按最大值 1380 比例换算。mi === 4(即 10 月,最高值)用强调橙,其余用浅蓝。外层 Column 高 86vp、justifyContent(FlexAlign.End) 让柱体从底部向上生长,模拟柱状图的对齐方式。
ForEach 的第三个参数是键值生成器 (m: string) => m,用月份字符串作键。键值的稳定性决定 ForEach 的 diff 效率——若键不变,框架复用旧视图;若键变化,则重建。这里月份固定,键稳定,渲染高效。
ArkUI 没有内置图表组件,柱状图、进度条都需要用基础容器手动搭建。这种"用
Column的高度模拟柱体"的手法是声明式 UI 的典型技巧:把数值映射为尺寸属性,借响应式更新实现数据驱动图表。配合animation可获得柱体生长动画。
会员升级弹窗展示了进度条的实现。
Row() {
Row() {
Column() {
}
.width('68%')
.height(8)
.backgroundColor('linearGradient({ colors: [["#FFB300", 0], ["#FF8F00", 1]] })')
.borderRadius(4)
}
.width('100%')
.height(8)
.backgroundColor('#F0F4F8')
.borderRadius(4)
}
.width('100%')
.margin({ top: 6 })
进度条是两个嵌套 Row:外层灰色背景全宽,内层渐变填充 68% 宽度。这种"灰底 + 彩填充"的双层结构是进度条的标准实现,百分比直接写在内层 width。若要动态进度,将 '68%' 替换为 this.progress + '%' 即可响应式更新。
七、包裹 Tab:PkgTab 的瀑布流与横滑
进入各 Tab 子组件,先看包裹页 PkgTab。它是用户首屏看到的主页面,信息密度最高。
@Component
struct PkgTab {
onPkg: (p: Package) => void = () => {};
onPickup: (p: Package) => void = () => {};
onScan: (p: Package) => void = () => {};
onAbnormal: (p: Package) => void = () => {};
onSend: () => void = () => {};
build() {
Scroll() {
Column() {
// 头部:搜索框 + 扫一扫
Row() {
Text('🔍 搜索包裹单号 / 驿站').fontSize(13).fontColor('#90A4AE').layoutWeight(1)
Text('📷').fontSize(18)
}
.width('100%')
.height(38)
.backgroundColor('#F1F8FF')
.borderRadius(19)
.padding({ left: 14, right: 14 })
.margin({ top: 10 })
// ... banner、快捷宫格、在途横滑、双列包裹
}
.width('100%')
.padding({ left: 14, right: 14 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
PkgTab 通过成员属性声明回调函数并给默认空实现,这是 ArkTS 子组件接收父回调的语法。父组件构造时传入实际回调,子组件内部触发时调用。Scroll 包裹整个 Column,.scrollBar(BarState.Off) 隐藏滚动条,.layoutWeight(1) 让 Scroll 占据父容器剩余高度。
搜索框用 Row 模拟,左侧占位文字 layoutWeight(1) 撑满、右侧相机图标定宽。borderRadius(19) 配 height(38) 构成药丸形搜索框。这种用 Text 模拟输入框的方式是演示应用的简化,真实场景应替换为 TextInput。
// 横幅 banner
Row() {
Column({ space: 4 }) {
Text('星际快递 · 七夕专场').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('全场运费 8 折 · 星球直达').fontSize(12).fontColor('#B3E5FC')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🚀').fontSize(40)
}
.width('100%')
.height(96)
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
.borderRadius(16)
.padding(16)
.margin({ top: 10 })
横幅 Banner 是典型的"左文右图"布局:左侧 Column 持标题与副标题,alignItems(HorizontalAlign.Start) 左对齐;右侧大号火箭 Emoji 作装饰。深蓝渐变背景承载活动信息,是首页视觉焦点。
在途包裹区用了横向 Scroll。
Scroll() {
Row({ space: 10 }) {
ForEach(getTopPackages(), (p: Package) => {
this.flyCard(p)
}, (p: Package) => p.id + 'fly')
}
.padding({ right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(150)
.margin({ top: 8 })
scrollable(ScrollDirection.Horizontal) 将 Scroll 切为横向滚动模式,内层 Row 排列 flyCard 卡片。getTopPackages 取前 6 个包裹,构成横滑卡片轮播。ForEach 键值用 p.id + 'fly' 拼接,避免与其他 ForEach 的键冲突——同一组件树内键值必须全局唯一,否则 diff 会错乱。
全部包裹用双列瀑布流。
Row({ space: 10 }) {
Column({ space: 10 }) {
ForEach(getPkgRows(), (p: Package) => {
this.pkgCard(p)
}, (p: Package) => p.id + 'pa')
}
.layoutWeight(1)
Column({ space: 10 }) {
ForEach(getPkgRows2(), (p: Package) => {
this.pkgCard(p)
}, (p: Package) => p.id + 'pb')
}
.layoutWeight(1)
}
.width('100%')
.margin({ top: 8 })
两个 Column 各 layoutWeight(1) 均分宽度,内部各自 ForEach 渲染拆分后的半数卡片。Column({ space: 10 }) 的 space 控制卡片间纵向间距。这种"外 Row 定宽均分 + 内 Column 纵向堆叠"是瀑布流的经典骨架。键值后缀 'pa'/'pb' 区分左右列,保证唯一。
pkgCard 与 flyCard 是两个 @Builder,前者是竖向卡片用于双列、后者是横向卡片用于横滑。它们都通过 this.onPkg(p) 把点击事件上抛给父组件,由父组件打开详情弹窗。这种"卡片只负责展示与点击上报、弹窗由父统一管理"的分工,是组件解耦的关键。
ForEach是 ArkUI 列表渲染的唯一原生方案。它的性能取决于键值生成器的质量:稳定且唯一的键让框架精准 diff,仅更新变化项;不稳定的键(如数组索引)会导致全量重建。本代码普遍用业务id加后缀作键,是良好实践。
八、寄件 Tab:SendTab 的步骤条与计费卡
SendTab 聚焦寄件流程,头部是一张深蓝渐变主卡,内含步骤条。
@Component
struct SendTab {
onSend: (r: RateRule) => void = () => {};
onRate: (r: RateRule) => void = () => {};
onAppoint: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Column({ space: 3 }) {
Text('在线寄件').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('一键下单 · 星球直达').fontSize(12).fontColor('#B3E5FC')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('📮').fontSize(36)
}
.width('100%')
Row({ space: 8 }) {
this.sendStep(1, '填单', true)
this.sendStep(2, '揽收', false)
this.sendStep(3, '运输', false)
this.sendStep(4, '签收', false)
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.backgroundColor('linearGradient({ colors: [["#0288D1", 0], ["#0B2F5C", 1]] })')
.borderRadius(16)
.padding(16)
.margin({ top: 10 })
// ... 快捷入口、运输线路、寄件历史
}
.width('100%')
.padding({ left: 14, right: 14 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
sendStep 是步骤项 @Builder,接收序号、标签、是否激活三参。激活态用黄底深字、非激活态用半透明白字,构成横向四步进度条。active 参数硬编码为 true, false, false, false,表示当前处于第一步。真实场景应绑定到流程状态变量。
@Builder
sendStep(step: number, label: string, active: boolean) {
Column({ space: 4 }) {
Text(step + '')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(active ? '#0B2F5C' : '#90A4AE')
.width(22)
.height(22)
.textAlign(TextAlign.Center)
.backgroundColor(active ? '#FFD54F' : '#33FFFFFF')
.borderRadius(11)
Text(label).fontSize(10).fontColor(active ? '#FFD54F' : '#B3E5FC')
}
.layoutWeight(1)
}
圆形序号用 width(22).height(22).borderRadius(11)(半径=直径一半)构成正圆,textAlign(TextAlign.Center) 让数字居中。#33FFFFFF 是带透明度的白,在渐变背景上呈现半透明灰白效果。
运输线路区用双列展示计费规则卡片。
@Builder
rateCard(r: RateRule) {
Column() {
Row() {
Text(r.icon).fontSize(22)
Text(r.days + ' 天达')
.fontSize(10)
.fontColor('#FFFFFF')
.backgroundColor(r.days <= 1 ? '#E53935' : '#FF8F00')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Text(r.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#0B2F5C').margin({ top: 6 })
Text(r.desc).fontSize(10).fontColor('#90A4AE').margin({ top: 3 })
Row() {
Text('¥' + r.base).fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
Text(' 起').fontSize(10).fontColor('#90A4AE')
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 6 })
}
.layoutWeight(1)
.height(110)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(10)
.shadow({ radius: 4, color: '#12000000', offsetY: 2 })
.onClick(() => {
this.onSend(r);
})
}
rateCard 是计费卡,r.days <= 1 当日达/次日达用红色标签强调,其余橙色。shadow({ radius: 4, color: '#12000000', offsetY: 2 }) 给卡片加投影,offsetY: 2 让阴影向下偏移模拟自然光照。点击卡片触发 onSend(r) 回调,打开寄件弹窗。卡片用 layoutWeight(1) 在双列中均分宽度,height(110) 固定高度保证视觉齐整。
九、驿站 Tab:StationTab 的位置服务与快递员
StationTab 围绕"附近的驿站"展开,头部是定位横幅。
@Component
struct StationTab {
onStation: (s: Station) => void = () => {};
onBox: (b: BoxCell) => void = () => {};
onCourier: (c: Courier) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('📍').fontSize(22)
Column({ space: 2 }) {
Text('当前定位 · 地球总仓').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('附近 10 家驿站营业中').fontSize(11).fontColor('#B3E5FC')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text('🛰️').fontSize(28)
}
.width('100%')
.height(64)
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
.borderRadius(16)
.padding(14)
.margin({ top: 10 })
// ... 附近驿站双列、智能快递柜横滑、快递员列表
}
.width('100%')
.padding({ left: 14, right: 14 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
定位横幅用定位图标 + 定位文本 + 卫星装饰 Emoji 三段式。StationCard 是驿站卡片,展示驿站名、距离、营业时间、空闲格口数,点击触发 onStation 打开驿站详情弹窗。
快递柜横滑区直接遍历 BOXES 全量数据。
Scroll() {
Row({ space: 10 }) {
ForEach(BOXES, (b: BoxCell) => {
this.boxCard(b)
}, (b: BoxCell) => b.id + 'bx')
}
.padding({ right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(96)
.margin({ top: 8 })
boxCard 根据 b.free 切换绿色空闲或橙色占用样式,点击触发 onBox 打开快递柜格口弹窗。快递员列表用 courierRow 行渲染,在线状态用绿底"在线"标签、离线用灰底"休息"。
十、追踪 Tab:TrackTab 的时间轴
TrackTab 的核心是物流时间轴,用 trackLine @Builder 渲染每个节点。
@Builder
trackLine(t: TrackLog) {
Row() {
Column() {
Text(t.done ? '●' : '○').fontSize(12).fontColor(t.done ? '#0288D1' : '#B0BEC5')
Row() {
Column() {
}
.width(2)
.height(34)
.backgroundColor('#DDE8F5')
}
}
.width(18)
.margin({ left: 10 })
Column({ space: 3 }) {
Text(t.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(t.done ? '#0B2F5C' : '#90A4AE')
Text(t.place + ' · ' + t.desc).fontSize(11).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 6 })
Text(t.time).fontSize(10).fontColor('#B0BEC5')
}
.width('100%')
.height(52)
.margin({ right: 10 })
}
时间轴节点用 Column 纵向排列圆点与连线:顶部是实心圆 ●(已完成)或空心圆 ○(未完成),下方是一条 2vp 宽 34vp 高的灰色竖线连接下一节点。圆点颜色根据 done 切换蓝灰。右侧 Column 持节点标题与地点描述,layoutWeight(1) 撑满,最右是时间。这种"圆点 + 竖线 + 内容"三段式是时间轴的通用骨架。
异常包裹区用红色浅底 #FFEBEE 突出警示。
ForEach(getAbnormalPkgs(), (p: Package) => {
Row() {
Text(p.icon).fontSize(22)
Column({ space: 2 }) {
Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text(p.no + ' · ' + p.desc).fontSize(10).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text('处理 ›').fontSize(12).fontColor('#E53935')
}
.width('100%')
.height(52)
.backgroundColor('#FFEBEE')
.borderRadius(12)
.padding({ left: 10, right: 10 })
.onClick(() => {
this.onAbnormal(p);
})
}, (p: Package) => p.id + 'abn')
getAbnormalPkgs 过滤出 status === '异常' 的包裹,红色背景与"处理 ›"红字引导用户处理。点击触发 onAbnormal 打开异常处理弹窗。
十一、积分 Tab:PointTab 的柱状图与商城
PointTab 头部是积分余额卡,含余额、明细入口、三条积分获取规则。
@Component
struct PointTab {
points: number = 0;
onPoint: (g: PointGoods) => void = () => {};
onDetail: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('💰').fontSize(30)
Column({ space: 2 }) {
Text(this.points + '').fontSize(26).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('我的积分').fontSize(12).fontColor('#B3E5FC')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('明细 ›').fontSize(12).fontColor('#FFD54F')
.onClick(() => {
this.onDetail();
})
}
.width('100%')
Row({ space: 8 }) {
this.pointStat('📮', '寄件得积分', '+50/单')
this.pointStat('⭐', '评价得积分', '+20/次')
this.pointStat('🎯', '每日任务', '+100/天')
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
.borderRadius(16)
.padding(16)
.margin({ top: 10 })
// ... 月度柱状图、积分商城双列
}
.width('100%')
.padding({ left: 14, right: 14 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
points 是父组件传入的属性,父组件积分变化时自动同步刷新。pointStat 是三等分的规则卡,半透明白底 #1AFFFFFF。柱状图与弹窗中的实现一致,用 getBarHeight 动态算高。积分商城双列用 goodsCard 展示商品,点击触发 onPoint 打开兑换弹窗。
十二、我的 Tab:MineTab 的会员与徽章
MineTab 是个人中心,头部用户卡 + 会员卡 + 订单 + 徽章墙。
@Component
struct MineTab {
points: number = 0;
memberLevel: number = 1;
onVip: () => void = () => {};
onBadge: () => void = () => {};
onOrder: (o: Order) => void = () => {};
onUrgent: (o: Order) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🧑🚀').fontSize(34)
Column({ space: 3 }) {
Text('星际骑士').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row({ space: 6 }) {
Text('Lv.' + this.memberLevel).fontSize(11).fontColor('#FFD54F')
Text('· 会员').fontSize(11).fontColor('#B3E5FC')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column({ space: 2 }) {
Text(this.points + '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFD54F')
Text('积分').fontSize(10).fontColor('#B3E5FC')
}
.onClick(() => {
this.onBadge();
})
}
.width('100%')
.height(72)
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
.borderRadius(16)
.padding(14)
.margin({ top: 10 })
// ... 会员卡、订单、徽章墙
}
.width('100%')
.padding({ left: 14, right: 14 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
}
用户卡左侧头像 + 昵称 + 等级,右侧积分块可点击跳转徽章。memberLevel 与 points 都从父组件透传,保证全局一致。会员卡是金色 #FFF8E1 背景,内嵌进度条展示升级进度,"升级 ›"触发 onVip 打开会员弹窗。
徽章墙用横向 Scroll 展示所有徽章。
Scroll() {
Row({ space: 10 }) {
ForEach(BADGES, (b: Badge) => {
Column({ space: 4 }) {
Text(b.icon).fontSize(26).opacity(b.locked ? 0.35 : 1)
Text(b.name).fontSize(10).fontColor(b.locked ? '#B0BEC5' : '#37474F')
}
.width(64)
.height(64)
.justifyContent(FlexAlign.Center)
.backgroundColor(b.locked ? '#ECEFF1' : '#FFF8E1')
.borderRadius(12)
.onClick(() => {
this.onBadge();
})
}, (b: Badge) => b.id + 'bd')
}
.padding({ right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(84)
.margin({ top: 8 })
b.locked 控制徽章灰显:opacity(0.35) 半透明 + 灰底 #ECEFF1;已解锁徽章 opacity(1) 全显 + 金底 #FFF8E1。opacity 是 ArkUI 的透明度属性,0-1 区间,常用于禁用态视觉降级。
订单项 orderItem 根据 o.status 分流:运输中显示"加急 ›"橙色可点击触发 onUrgent,其他状态显示彩色状态标签。这是条件渲染在列表项中的应用。
@Builder
orderItem(o: Order) {
Row() {
Text(o.icon).fontSize(18)
Column({ space: 2 }) {
Text(o.item).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#37474F')
Text(o.date + ' · ¥' + o.fee).fontSize(10).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
if (o.status === '运输中') {
Text('加急 ›')
.fontSize(11)
.fontColor('#FF8F00')
.onClick(() => {
this.onUrgent(o);
})
} else {
Text(o.status)
.fontSize(11)
.fontColor('#FFFFFF')
.backgroundColor(getStatusColor(o.status))
.borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
}
.width('100%')
.height(48)
.onClick(() => {
this.onOrder(o);
})
}
if/else 在 @Builder 内根据状态渲染不同 Text,是 ArkUI 条件渲染的基本用法。整行 onClick 触发 onOrder 打开投诉弹窗,"加急 ›"额外 onClick 触发 onUrgent——但注意内层点击会冒泡到外层,可能同时触发两个回调,这是潜在交互瑕疵,真实工程需用 stopPropagation 或调整结构。
十三、公共 Builder:遮罩、Toast、Tab 项
最后看几个贯穿全局的公共 @Builder。遮罩层是弹窗的半透明背景。
@Builder
modalOverlay(onClose: () => void) {
Column() {
Row() {
Column() {
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => {
onClose();
})
}
.width('100%')
.height('100%')
}
modalOverlay 接收一个 onClose 回调,用 #66000000(40% 黑)覆盖全屏,点击空白处关闭弹窗。onClose 是箭头函数参数,调用时执行 () => { this.showXxx = false }。这种"遮罩逻辑复用、关闭行为注入"的设计,是 @Builder 参数化的典型用法。注意嵌套的空 Column/Row 是为了让背景色撑满,ArkUI 中空容器需要显式设尺寸。
Toast 是顶层轻提示。
@Builder
toastBox() {
Row() {
Text(this.toast).fontSize(14).fontColor('#FFFFFF')
}
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor('#CC333333')
.borderRadius(20)
}
toastBox 用 #CC333333(80% 黑灰)圆角药丸承载 toast 文本。它挂在 Stack 最顶层,由 showToast 控制显隐,showTip 方法定时复位。由于没有居中对齐约束,Toast 默认在 Stack 内居中显示。
底部 Tab 项是导航骨架。
@Builder
bottomTabItem(icon: string, label: string, idx: number) {
Column({ space: 2 }) {
Text(icon).fontSize(20)
Text(label)
.fontSize(11)
.fontColor(this.tabIndex === idx ? '#0288D1' : '#90A4AE')
.fontWeight(this.tabIndex === idx ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.height(56)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.tabIndex = idx;
})
}
bottomTabItem 用 tabIndex === idx 三元表达式切换激活态的颜色与字重。layoutWeight(1) 让 6 个 Tab 项均分底部 Row 宽度,justifyContent(FlexAlign.Center) 让图标与文字纵向居中。点击修改 tabIndex,触发主内容区 if/else 重新分发。
@Builder接收参数时,参数变化会触发重建。bottomTabItem的idx固定,但tabIndex变化时,由于fontColor/fontWeight依赖this.tabIndex,这些属性会响应式更新,无需手动刷新。这就是声明式的核心红利:开发者只管改数据,视图自洽。
十四、关键装饰器与布局容器对比总结
下表汇总了本代码涉及的核心装饰器与容器的职责定位。
| 装饰器/容器 | 职责定位 | 本代码典型用法 | 响应式特性 |
|---|---|---|---|
@Entry | 标记页面入口组件 | struct Index 唯一入口 | 标识作用,无运行时响应 |
@Component | 声明自定义组件 | PkgTab/SendTab 等子 Tab | 仅声明,状态由内部 @State 驱动 |
@State | 组件内可变状态 | tabIndex/points/showPkg 等 | 赋值即触发依赖视图刷新 |
@Builder | UI 片段复用 | pkgModal/rateCard/trackLine 等 | 随宿主状态自动更新 |
Column | 纵向线性布局 | 卡片内部纵向堆叠、双列子列 | 支持子元素 layoutWeight |
Row | 横向线性布局 | 标题行、按钮组、双列外层 | 支持 FlexAlign 主轴对齐 |
Stack | 层叠布局 | 弹窗挂载、扫描线特效 | 后入元素覆盖先入 |
Scroll | 滚动容器 | 各 Tab 整页纵向、横滑卡片 | scrollable 切方向、scrollBar 控条 |
ForEach | 列表渲染 | 包裹双列、轨迹时间轴等 | 依赖键值生成器 diff |
FlexAlign | 主轴对齐枚举 | SpaceBetween/Center/End | 配合 justifyContent |
layoutWeight | 剩余空间分配 | 标签列撑满、Tab 项均分 | 弹性布局核心 |
下表对比了代码中几种典型的弹窗样式与适用场景。
| 弹窗 | 样式类型 | 宽度/圆角特征 | 典型场景 |
|---|---|---|---|
| 包裹详情 | 居中卡 | 84% 全圆角 | 信息展示为主 |
| 寄件表单 | 底部抽屉 | 100% 顶圆角 | 表单填写、需避让键盘 |
| 扫码取件 | 居中深色卡 | 86% 全圆角 | 沉浸式操作 |
| 异常处理 | 居中红渐变 | 84% 全圆角 | 警示与决策 |
| 积分明细 | 居中卡 | 84% 全圆角 | 数据图表展示 |
| 快递柜格口 | 底部抽屉 | 100% 顶圆角 | 宫格选择 |
十五、总结
纵观整段代码,这是一个结构完整、视觉精致的鸿蒙声明式 UI 应用范本。它以星际快递为题材,把包裹、寄件、驿站、追踪、积分、个人中心六大模块装进单页面多 Tab 架构,用纯前端 Mock 数据驱动全部交互,完整呈现了一个物流应用从首屏到深度交互的全链路体验。
在架构层面,最值得借鉴的是"状态上提、视图下沉"的弹窗调度模式。16 个弹窗的显隐开关与选中对象全部集中在主组件 Index 的 @State 中,子 Tab 组件只负责触发回调上报,不持有弹窗状态。这种集中式状态管理让弹窗可跨 Tab 复用(如包裹详情弹窗在包裹 Tab 与追踪 Tab 都能打开),也避免了子组件层级不足以覆盖全屏的问题。子组件通过构造参数接收回调函数与下行数据,形成清晰的单向数据流:父到子用属性、子到父用回调。
在布局层面,代码系统性地运用了 Column/Row/Stack 三大容器配合 layoutWeight、FlexAlign、space 构建从卡片到列表到弹窗的各种结构。双列瀑布流用"外 Row 均分 + 内 Column 纵堆 + 拆分函数"三件套实现;横向轮播用 Scroll + scrollable(Horizontal) 实现;时间轴用"圆点 + 竖线 + 内容"三段式 Row 实现;柱状图用 Column 高度映射数值实现;进度条用双层 Row 灰底彩填实现。这些手法是声明式 UI 的通用积木,可迁移到任何鸿蒙应用。
在交互层面,代码大量运用 @State 的响应式特性:重量步进改变 fWeight 后运费即时重算、积分兑换后余额即时扣减、星级点击后 fStars 更新点亮、Tab 切换后 tabIndex 驱动整页重渲染。配合 animation、scale、translate 等属性动画,关键数字与按钮都有微动效反馈。showTip 用 @State + setTimeout 模拟原生 Toast,是纯前端实现轻提示的经典手法。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 主题:科技橙蓝 #0B2F5C -> #0288D1 + 星橙 #FF8F00
// 6 Tab 单排:包裹 / 寄件 / 驿站 / 追踪 / 积分 / 我的
interface Package {
id: number;
name: string;
icon: string;
no: string;
from: string;
to: string;
status: string;
statusColor: string;
type: string;
time: string;
desc: string;
}
interface Station {
id: number;
name: string;
icon: string;
addr: string;
distance: string;
open: string;
boxes: number;
courier: string;
busy: string;
}
interface Courier {
id: number;
name: string;
icon: string;
phone: string;
score: number;
orders: number;
area: string;
online: boolean;
}
interface TrackLog {
id: number;
title: string;
icon: string;
place: string;
time: string;
done: boolean;
desc: string;
}
interface RateRule {
id: number;
name: string;
icon: string;
base: number;
perKg: number;
days: number;
desc: string;
}
interface PointGoods {
id: number;
name: string;
icon: string;
points: number;
stock: number;
type: string;
desc: string;
}
interface BoxCell {
id: number;
boxNo: string;
size: string;
free: boolean;
code: string;
time: string;
}
interface Order {
id: number;
no: string;
item: string;
icon: string;
date: string;
fee: number;
status: string;
recipient: string;
}
interface Badge11 {
id: number;
name: string;
icon: string;
locked: boolean;
desc: string;
date: string;
}
interface QuickIcon {
id: number;
name: string;
icon: string;
color: string;
}
const PACKAGES: Package[] = [
{ id: 1, name: '星航耳机', icon: '🎧', no: 'HX-20260826-001', from: '地球总仓', to: '火星基地', status: '运输中', statusColor: '#0288D1', type: '电子产品', time: '08-26 09:12', desc: '旗舰降噪耳机,预计后天送达。' },
{ id: 2, name: '月球岩茶', icon: '🍵', no: 'HX-20260826-002', from: '月球站', to: '地球总仓', status: '待取件', statusColor: '#FF8F00', type: '食品', time: '08-26 08:40', desc: '限定月球岩茶礼盒,请尽快取件。' },
{ id: 3, name: '星图绘本', icon: '📚', no: 'HX-20260825-018', from: '地球总仓', to: '木星轨道站', status: '派送中', statusColor: '#43A047', type: '图书', time: '08-25 20:15', desc: '儿童星图绘本套装,快递员正在派送。' },
{ id: 4, name: '火星护目镜', icon: '🥽', no: 'HX-20260825-006', from: '金星驿站', to: '火星基地', status: '已签收', statusColor: '#90A4AE', type: '装备', time: '08-24 16:30', desc: '火星尘暴护目镜,已于昨日签收。' },
{ id: 5, name: '星际巧克力', icon: '🍫', no: 'HX-20260824-023', from: '地球总仓', to: '土星环站', status: '运输中', statusColor: '#0288D1', type: '食品', time: '08-24 11:05', desc: '零重力巧克力,全程冷链运输。' },
{ id: 6, name: '量子手环', icon: '⌚', no: 'HX-20260824-011', from: '地球总仓', to: '月球站', status: '待取件', statusColor: '#FF8F00', type: '电子产品', time: '08-23 19:20', desc: '健康监测手环,已入快递柜。' },
{ id: 7, name: '星尘香氛', icon: '🌌', no: 'HX-20260823-015', from: '海王星站', to: '地球总仓', status: '已签收', statusColor: '#90A4AE', type: '美妆', time: '08-22 10:45', desc: '星尘味限定香氛,已签收。' },
{ id: 8, name: '复古留声机', icon: '📻', no: 'HX-20260823-002', from: '地球总仓', to: '金星驿站', status: '异常', statusColor: '#E53935', type: '家电', time: '08-22 14:00', desc: '运输中碰撞,需要您确认处理方案。' },
{ id: 9, name: '太空种子包', icon: '🌱', no: 'HX-20260822-020', from: '木星轨道站', to: '火星基地', status: '已签收', statusColor: '#90A4AE', type: '农业', time: '08-21 13:25', desc: '太空育种种子,已签收。' },
{ id: 10, name: '星云拼图', icon: '🧩', no: 'HX-20260822-009', from: '地球总仓', to: '月球站', status: '运输中', statusColor: '#0288D1', type: '玩具', time: '08-21 09:50', desc: '10000 片星云拼图,运输中。' },
{ id: 11, name: '超导体鞋', icon: '👟', no: 'HX-20260821-014', from: '金星驿站', to: '地球总仓', status: '已签收', statusColor: '#90A4AE', type: '服饰', time: '08-20 17:35', desc: '悬浮运动鞋,已签收。' },
{ id: 12, name: '星际蛋糕', icon: '🎂', no: 'HX-20260821-003', from: '地球总仓', to: '火星基地', status: '派送中', statusColor: '#43A047', type: '生鲜', time: '08-20 08:00', desc: '生日蛋糕,冷链配送,请注意查收。' }
];
const STATIONS: Station[] = [
{ id: 1, name: '地球总仓驿站', icon: '🌍', addr: '赤道轨道区 12 号', distance: '800m', open: '06:00-22:00', boxes: 12, courier: '老王', busy: '较忙' },
{ id: 2, name: '月球站驿站', icon: '🌕', addr: '静海基地 A3 区', distance: '3.2km', open: '24 小时', boxes: 8, courier: '小月', busy: '空闲' },
{ id: 3, name: '火星基地驿站', icon: '🔴', addr: '奥林帕斯山脚', distance: '5.6km', open: '07:00-23:00', boxes: 20, courier: '阿火', busy: '繁忙' },
{ id: 4, name: '金星驿站', icon: '🟠', addr: '云层观测站 2 层', distance: '9.8km', open: '08:00-20:00', boxes: 6, courier: '金金', busy: '较忙' },
{ id: 5, name: '木星轨道站', icon: '🟤', addr: '大红斑观景台旁', distance: '12km', open: '09:00-21:00', boxes: 10, courier: '木木', busy: '空闲' },
{ id: 6, name: '土星环站', icon: '🪐', addr: 'B 环飘浮区 7 号', distance: '15km', open: '10:00-20:00', boxes: 8, courier: '环环', busy: '繁忙' },
{ id: 7, name: '海王星站', icon: '🔵', addr: '蓝色风暴观测站', distance: '20km', open: '11:00-19:00', boxes: 4, courier: '小海', busy: '空闲' },
{ id: 8, name: '冥王星站', icon: '🩶', addr: '心形平原站', distance: '28km', open: '12:00-18:00', boxes: 3, courier: '冥冥', busy: '较忙' },
{ id: 9, name: '小行星带站', icon: '☄️', addr: '谷神星中转点', distance: '18km', open: '09:30-19:30', boxes: 5, courier: '阿带', busy: '空闲' },
{ id: 10, name: '银河枢纽站', icon: '🌌', addr: '银心十字路口', distance: '30km', open: '24 小时', boxes: 30, courier: '枢纽王', busy: '繁忙' }
];
const COURIERS: Courier[] = [
{ id: 1, name: '老王', icon: '🧑🦱', phone: '13****6201', score: 4.9, orders: 3820, area: '地球总仓', online: true },
{ id: 2, name: '小月', icon: '👩', phone: '13****8302', score: 5.0, orders: 2960, area: '月球站', online: true },
{ id: 3, name: '阿火', icon: '🧑', phone: '13****4403', score: 4.8, orders: 3150, area: '火星基地', online: false },
{ id: 4, name: '金金', icon: '👩🦰', phone: '13****2504', score: 4.7, orders: 2230, area: '金星', online: true },
{ id: 5, name: '木木', icon: '👨', phone: '13****6605', score: 4.9, orders: 1870, area: '木星轨道', online: true },
{ id: 6, name: '环环', icon: '👧', phone: '13****0706', score: 4.6, orders: 1540, area: '土星环', online: false },
{ id: 7, name: '小海', icon: '👨🦲', phone: '13****4807', score: 4.8, orders: 1320, area: '海王星', online: true },
{ id: 8, name: '枢纽王', icon: '🧔', phone: '13****9908', score: 4.9, orders: 5210, area: '银河枢纽', online: true }
];
const TRACKS: TrackLog[] = [
{ id: 1, title: '已揽收', icon: '📦', place: '地球总仓', time: '08-26 09:12', done: true, desc: '包裹已从地球总仓揽收' },
{ id: 2, title: '干线运输', icon: '🚀', place: '近地轨道', time: '08-26 11:30', done: true, desc: '搭乘星际货运火箭 HX-9 号' },
{ id: 3, title: '到达中转站', icon: '🛰️', place: '月面中转站', time: '08-26 18:20', done: true, desc: '包裹已抵达月面中转站' },
{ id: 4, title: '二次运输', icon: '🛸', place: '地火航线', time: '08-27 03:45', done: true, desc: '转入火星航线运载舰' },
{ id: 5, title: '到达目的地', icon: '🏁', place: '火星基地驿站', time: '08-27 10:15', done: true, desc: '包裹已到达火星基地驿站' },
{ id: 6, title: '派送中', icon: '🏃', place: '火星基地', time: '08-27 11:40', done: true, desc: '快递员阿火正在派送' },
{ id: 7, title: '已签收', icon: '✅', place: '火星基地', time: '08-27 14:02', done: true, desc: '包裹已被本人签收' }
];
const RATES: RateRule[] = [
{ id: 1, name: '行星快递', icon: '🌍', base: 12, perKg: 4, days: 2, desc: '同行星 2 天达' },
{ id: 2, name: '星际快递', icon: '🚀', base: 28, perKg: 8, days: 4, desc: '跨行星 4 天达' },
{ id: 3, name: '星系速递', icon: '🌌', base: 45, perKg: 12, days: 7, desc: '跨星系 7 天达' },
{ id: 4, name: '极速专线', icon: '⚡', base: 68, perKg: 16, days: 1, desc: '加急 1 天达' },
{ id: 5, name: '冷链生鲜', icon: '❄️', base: 36, perKg: 10, days: 3, desc: '全程零下运输' },
{ id: 6, name: '贵重保价', icon: '💎', base: 52, perKg: 14, days: 4, desc: '最高保价 ¥10 万' },
{ id: 7, name: '同城闪送', icon: '⚡', base: 8, perKg: 3, days: 0, desc: '站内 2 小时达' },
{ id: 8, name: '大件货运', icon: '📦', base: 80, perKg: 6, days: 6, desc: '重物大件专线' },
{ id: 9, name: '顺风捎带', icon: '🛸', base: 15, perKg: 5, days: 5, desc: '顺路捎带优惠' },
{ id: 10, name: '定时达', icon: '⏰', base: 32, perKg: 9, days: 3, desc: '指定时段送达' }
];
const POINT_GOODS: PointGoods[] = [
{ id: 1, name: '星航钥匙扣', icon: '🔑', points: 300, stock: 200, type: '周边', desc: '星际快递限定钥匙扣' },
{ id: 2, name: '快递员贴纸', icon: '🏷️', points: 150, stock: 500, type: '周边', desc: '火箭贴纸一套 12 张' },
{ id: 3, name: '保温杯', icon: '🥤', points: 800, stock: 80, type: '生活', desc: '真空保温 12 小时' },
{ id: 4, name: '火箭模型', icon: '🚀', points: 1200, stock: 30, type: '收藏', desc: 'HX-9 号火箭 1:100 模型' },
{ id: 5, name: '快递袋盲盒', icon: '🎁', points: 500, stock: 100, type: '盲盒', desc: '随机神秘礼品盲盒' },
{ id: 6, name: '星球抱枕', icon: '🛋️', points: 650, stock: 60, type: '生活', desc: '火星主题抱枕' },
{ id: 7, name: '星图帆布包', icon: '👜', points: 700, stock: 50, type: '生活', desc: '银河星图印花帆布包' },
{ id: 8, name: '无人机摆件', icon: '🛸', points: 900, stock: 40, type: '收藏', desc: '配送无人机桌面摆件' },
{ id: 9, name: '降噪耳塞', icon: '👂', points: 400, stock: 150, type: '数码', desc: '星际旅途助眠耳塞' },
{ id: 10, name: '徽章套装', icon: '🎖️', points: 1000, stock: 35, type: '收藏', desc: '全星球驿站徽章套装' }
];
const BOXES: BoxCell[] = [
{ id: 1, boxNo: 'A-01', size: '小格', free: true, code: '', time: '' },
{ id: 2, boxNo: 'A-02', size: '小格', free: false, code: '3842', time: '08-27 10:20' },
{ id: 3, boxNo: 'A-03', size: '小格', free: true, code: '', time: '' },
{ id: 4, boxNo: 'A-04', size: '中格', free: false, code: '7956', time: '08-27 09:05' },
{ id: 5, boxNo: 'B-01', size: '中格', free: true, code: '', time: '' },
{ id: 6, boxNo: 'B-02', size: '中格', free: true, code: '', time: '' },
{ id: 7, boxNo: 'B-03', size: '大格', free: false, code: '6120', time: '08-26 21:40' },
{ id: 8, boxNo: 'B-04', size: '大格', free: true, code: '', time: '' }
];
const ORDERS: Order[] = [
{ id: 1, no: 'HX-20260826-001', item: '星航耳机', icon: '🎧', date: '08-26 09:00', fee: 32, status: '运输中', recipient: '火星 · 阿强' },
{ id: 2, no: 'HX-20260825-012', item: '月球岩茶', icon: '🍵', date: '08-25 19:30', fee: 18, status: '已签收', recipient: '地球 · 小美' },
{ id: 3, no: 'HX-20260824-007', item: '星图绘本', icon: '📚', date: '08-24 14:20', fee: 26, status: '已签收', recipient: '木星 · 球球' },
{ id: 4, no: 'HX-20260824-003', item: '火星护目镜', icon: '🥽', date: '08-24 10:10', fee: 45, status: '已签收', recipient: '火星 · 小战' },
{ id: 5, no: 'HX-20260823-019', item: '星际巧克力', icon: '🍫', date: '08-23 16:45', fee: 22, status: '运输中', recipient: '土星 · 环仔' },
{ id: 6, no: 'HX-20260823-004', item: '量子手环', icon: '⌚', date: '08-23 11:00', fee: 38, status: '已签收', recipient: '月球 · 静海' },
{ id: 7, no: 'HX-20260822-016', item: '星尘香氛', icon: '🌌', date: '08-22 18:30', fee: 30, status: '已签收', recipient: '地球 · 香香' },
{ id: 8, no: 'HX-20260822-008', item: '复古留声机', icon: '📻', date: '08-22 09:15', fee: 88, status: '异常', recipient: '金星 · 金金' },
{ id: 9, no: 'HX-20260821-011', item: '太空种子包', icon: '🌱', date: '08-21 15:40', fee: 15, status: '已签收', recipient: '火星 · 农农' },
{ id: 10, no: 'HX-20260821-002', item: '星际蛋糕', icon: '🎂', date: '08-21 08:20', fee: 65, status: '派送中', recipient: '火星 · 寿星' }
];
const Badge11S: Badge11[] = [
{ id: 1, name: '快递新星', icon: '✨', locked: false, desc: '完成首单寄件', date: '2026.07.20' },
{ id: 2, name: '百单达人', icon: '📦', locked: false, desc: '累计寄件 100 单', date: '2026.08.10' },
{ id: 3, name: '准时狂魔', icon: '⏰', locked: false, desc: '连续 30 天准时收件', date: '2026.08.18' },
{ id: 4, name: '快递柜玩家', icon: '🗄️', locked: false, desc: '使用快递柜 50 次', date: '2026.08.24' },
{ id: 5, name: '银河包邮王', icon: '👑', locked: true, desc: '单月寄件消费满 ¥1000', date: '未解锁' },
{ id: 6, name: '极速传说', icon: '⚡', locked: true, desc: '使用极速专线 20 次', date: '未解锁' },
{ id: 7, name: '星级鉴定师', icon: '⭐', locked: true, desc: '评价快递员 100 次', date: '未解锁' },
{ id: 8, name: '积分大户', icon: '💰', locked: true, desc: '累计获得 10000 积分', date: '未解锁' }
];
const QUICK_ICONS: QuickIcon[] = [
{ id: 1, name: '寄快递', icon: '📮', color: '#FF8F00' },
{ id: 2, name: '查件', icon: '🔍', color: '#0288D1' },
{ id: 3, name: '取件码', icon: '🎫', color: '#43A047' },
{ id: 4, name: '驿站', icon: '🏪', color: '#7C4DFF' },
{ id: 5, name: '运费计算', icon: '🧮', color: '#FFB300' },
{ id: 6, name: '积分商城', icon: '🛍️', color: '#E91E63' },
{ id: 7, name: '预约取件', icon: '📅', color: '#00ACC1' },
{ id: 8, name: '客服', icon: '🎧', color: '#5C6BC0' }
];
function getTopPackages(): Package[] {
let rows: Package[] = [];
for (let i = 0; i < 6 && i < PACKAGES.length; i++) {
rows.push(PACKAGES[i]);
}
return rows;
}
function getPkgRows(): Package[] {
let rows: Package[] = [];
for (let i = 0; i < PACKAGES.length; i += 2) {
rows.push(PACKAGES[i]);
}
return rows;
}
function getPkgRows2(): Package[] {
let rows: Package[] = [];
for (let i = 1; i < PACKAGES.length; i += 2) {
rows.push(PACKAGES[i]);
}
return rows;
}
function getStationRows(): Station[] {
let rows: Station[] = [];
for (let i = 0; i < STATIONS.length; i += 2) {
rows.push(STATIONS[i]);
}
return rows;
}
function getStationRows2(): Station[] {
let rows: Station[] = [];
for (let i = 1; i < STATIONS.length; i += 2) {
rows.push(STATIONS[i]);
}
return rows;
}
function getCourierRows(): Courier[] {
let rows: Courier[] = [];
for (let i = 0; i < COURIERS.length; i += 2) {
rows.push(COURIERS[i]);
}
return rows;
}
function getCourierRows2(): Courier[] {
let rows: Courier[] = [];
for (let i = 1; i < COURIERS.length; i += 2) {
rows.push(COURIERS[i]);
}
return rows;
}
function getTrackRows(): TrackLog[] {
let rows: TrackLog[] = [];
for (let i = 0; i < TRACKS.length; i += 2) {
rows.push(TRACKS[i]);
}
return rows;
}
function getTrackRows2(): TrackLog[] {
let rows: TrackLog[] = [];
for (let i = 1; i < TRACKS.length; i += 2) {
rows.push(TRACKS[i]);
}
return rows;
}
function getRateRows(): RateRule[] {
let rows: RateRule[] = [];
for (let i = 0; i < RATES.length; i += 2) {
rows.push(RATES[i]);
}
return rows;
}
function getRateRows2(): RateRule[] {
let rows: RateRule[] = [];
for (let i = 1; i < RATES.length; i += 2) {
rows.push(RATES[i]);
}
return rows;
}
function getPointRows(): PointGoods[] {
let rows: PointGoods[] = [];
for (let i = 0; i < POINT_GOODS.length; i += 2) {
rows.push(POINT_GOODS[i]);
}
return rows;
}
function getPointRows2(): PointGoods[] {
let rows: PointGoods[] = [];
for (let i = 1; i < POINT_GOODS.length; i += 2) {
rows.push(POINT_GOODS[i]);
}
return rows;
}
function getBoxRows(): BoxCell[] {
let rows: BoxCell[] = [];
for (let i = 0; i < BOXES.length; i += 2) {
rows.push(BOXES[i]);
}
return rows;
}
function getBoxRows2(): BoxCell[] {
let rows: BoxCell[] = [];
for (let i = 1; i < BOXES.length; i += 2) {
rows.push(BOXES[i]);
}
return rows;
}
function getOrderRows(): Order[] {
let rows: Order[] = [];
for (let i = 0; i < ORDERS.length; i += 2) {
rows.push(ORDERS[i]);
}
return rows;
}
function getOrderRows2(): Order[] {
let rows: Order[] = [];
for (let i = 1; i < ORDERS.length; i += 2) {
rows.push(ORDERS[i]);
}
return rows;
}
function getBadge11Rows(): Badge11[] {
let rows: Badge11[] = [];
for (let i = 0; i < Badge11S.length; i += 2) {
rows.push(Badge11S[i]);
}
return rows;
}
function getBadge11Rows2(): Badge11[] {
let rows: Badge11[] = [];
for (let i = 1; i < Badge11S.length; i += 2) {
rows.push(Badge11S[i]);
}
return rows;
}
function getQuickRows(): QuickIcon[] {
let rows: QuickIcon[] = [];
for (let i = 0; i < 4; i++) {
rows.push(QUICK_ICONS[i]);
}
return rows;
}
function getQuickRows2(): QuickIcon[] {
let rows: QuickIcon[] = [];
for (let i = 4; i < QUICK_ICONS.length; i++) {
rows.push(QUICK_ICONS[i]);
}
return rows;
}
function getBarHeight(val: number, max: number): string {
let p = max > 0 ? (val / max) * 80 : 0;
return p.toFixed(0) + 'vp';
}
function getPercent(val: number, total: number): string {
let p = total > 0 ? (val / total) * 100 : 0;
return p.toFixed(0) + '%';
}
function getStatusColor(s: string): string {
if (s === '运输中') {
return '#0288D1';
} else if (s === '待取件') {
return '#FF8F00';
} else if (s === '派送中') {
return '#43A047';
} else if (s === '异常') {
return '#E53935';
}
return '#90A4AE';
}
function getBusyColor(b: string): string {
if (b === '繁忙') {
return '#E53935';
} else if (b === '较忙') {
return '#FF8F00';
}
return '#43A047';
}
function getAbnormalPkgs(): Package[] {
let rows: Package[] = [];
for (let i = 0; i < PACKAGES.length; i++) {
if (PACKAGES[i].status === '异常') {
rows.push(PACKAGES[i]);
}
}
return rows;
}
function getOnRoadCount(): number {
let n = 0;
for (let i = 0; i < PACKAGES.length; i++) {
if (PACKAGES[i].status === '运输中' || PACKAGES[i].status === '派送中') {
n = n + 1;
}
}
return n;
}
function getFastCount(): number {
let n = 0;
for (let i = 0; i < RATES.length; i++) {
if (RATES[i].days === 0 || RATES[i].days === 1) {
n = n + 1;
}
}
return n;
}
function getFreeBoxCount(): number {
let n = 0;
for (let i = 0; i < BOXES.length; i++) {
if (BOXES[i].free) {
n = n + 1;
}
}
return n;
}
function getOnlineCount(): number {
let n = 0;
for (let i = 0; i < COURIERS.length; i++) {
if (COURIERS[i].online) {
n = n + 1;
}
}
return n;
}
function getCanBuyCount(points: number): number {
let n = 0;
for (let i = 0; i < POINT_GOODS.length; i++) {
if (POINT_GOODS[i].points <= points) {
n = n + 1;
}
}
return n;
}
function getUnlockedCount(): number {
let n = 0;
for (let i = 0; i < Badge11S.length; i++) {
if (!Badge11S[i].locked) {
n = n + 1;
}
}
return n;
}
@Entry
@Component
struct Index {
@State tabIndex1: number = 0;
@State showToast: boolean = false;
@State toast: string = '';
@State points: number = 3860;
@State memberLevel: number = 2;
// 弹框开关
@State showPkg: boolean = false;
@State showSend: boolean = false;
@State showStation: boolean = false;
@State showPickup: boolean = false;
@State showScan: boolean = false;
@State showRate: boolean = false;
@State showPoint: boolean = false;
@State showBox: boolean = false;
@State showAbnormal: boolean = false;
@State showComplaint: boolean = false;
@State showCourier: boolean = false;
@State showChangeAddr: boolean = false;
@State showAppoint: boolean = false;
@State showUrgent: boolean = false;
@State showPointDetail: boolean = false;
@State showVip: boolean = false;
// 选中对象
@State selPkg: Package | null = null;
@State selSend: RateRule | null = null;
@State selStation: Station | null = null;
@State selPickup: Package | null = null;
@State selScan: Package | null = null;
@State selRate: RateRule | null = null;
@State selPoint: PointGoods | null = null;
@State selBox: BoxCell | null = null;
@State selAbn: Package | null = null;
@State selComplaint: Order | null = null;
@State selCourier: Courier | null = null;
@State selAddr: Package | null = null;
@State selUrgent: Order | null = null;
// 表单字段
@State fWeight: number = 5;
@State fInsure: boolean = false;
@State fTypeIdx: number = 0;
@State fDayIdx: number = 0;
@State fTimeIdx: number = 0;
@State fQty: number = 1;
@State fMsg: string = '';
@State fAddr: string = '';
@State fScore: number = 5;
@State fStars: number = 5;
private tabs: string[] = ['包裹', '寄件', '驿站', '追踪', '积分', '我的'];
private tabIcons: string[] = ['📦', '📮', '🏪', '🔍', '🛍️', '👤'];
showTip(msg: string): void {
this.toast = msg;
this.showToast = true;
setTimeout(() => {
this.showToast = false;
}, 1800);
}
build() {
Stack() {
Column() {
// ========== 主内容区 ==========
if (this.tabIndex1 === 0) {
PkgTab({
onPkg: (p: Package) => {
this.selPkg = p;
this.showPkg = true;
},
onPickup: (p: Package) => {
this.selPickup = p;
this.showPickup = true;
},
onScan: (p: Package) => {
this.selScan = p;
this.showScan = true;
},
onAbnormal: (p: Package) => {
this.selAbn = p;
this.showAbnormal = true;
},
onSend: () => {
this.selSend = RATES[1];
this.fWeight = 5;
this.showSend = true;
}
})
} else if (this.tabIndex1 === 1) {
SendTab({
onSend: (r: RateRule) => {
this.selSend = r;
this.fWeight = 5;
this.showSend = true;
},
onRate: (r: RateRule) => {
this.selRate = r;
this.fWeight = 5;
this.showRate = true;
},
onAppoint: () => {
this.fDayIdx = 0;
this.fTimeIdx = 0;
this.showAppoint = true;
}
})
} else if (this.tabIndex1 === 2) {
StationTab({
onStation: (s: Station) => {
this.selStation = s;
this.showStation = true;
},
onBox: (b: BoxCell) => {
this.selBox = b;
this.showBox = true;
},
onCourier: (c: Courier) => {
this.selCourier = c;
this.fStars = 5;
this.showCourier = true;
}
})
} else if (this.tabIndex1 === 3) {
TrackTab({
onPkg: (p: Package) => {
this.selPkg = p;
this.showPkg = true;
},
onAbnormal: (p: Package) => {
this.selAbn = p;
this.showAbnormal = true;
}
})
} else if (this.tabIndex1 === 4) {
PointTab({
points: this.points,
onPoint: (g: PointGoods) => {
this.selPoint = g;
this.fQty = 1;
this.showPoint = true;
},
onDetail: () => {
this.showPointDetail = true;
}
})
} else {
MineTab({
points: this.points,
memberLevel: this.memberLevel,
onVip: () => {
this.showVip = true;
},
onBadge11: () => {
this.showPointDetail = true;
},
onOrder: (o: Order) => {
this.selComplaint = o;
this.showComplaint = true;
},
onUrgent: (o: Order) => {
this.selUrgent = o;
this.showUrgent = true;
}
})
}
// ========== 底部 Tab ==========
Row() {
ForEach(this.tabs, (label: string, idx: number) => {
this.bottomTabItem(this.tabIcons[idx], label, idx)
}, (label: string, idx: number) => label + idx)
}
.width('100%')
.height(56)
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%')
.height('100%')
// ========== 弹框挂载 ==========
if (this.showPkg && this.selPkg !== null) {
this.modalOverlay(() => { this.showPkg = false })
this.pkgModal()
}
if (this.showSend && this.selSend !== null) {
this.modalOverlay(() => { this.showSend = false })
this.sendModal()
}
if (this.showStation && this.selStation !== null) {
this.modalOverlay(() => { this.showStation = false })
this.stationModal()
}
if (this.showPickup && this.selPickup !== null) {
this.modalOverlay(() => { this.showPickup = false })
this.pickupModal()
}
if (this.showScan && this.selScan !== null) {
this.modalOverlay(() => { this.showScan = false })
this.scanModal()
}
if (this.showRate && this.selRate !== null) {
this.modalOverlay(() => { this.showRate = false })
this.rateModal()
}
if (this.showPoint && this.selPoint !== null) {
this.modalOverlay(() => { this.showPoint = false })
this.pointModal()
}
if (this.showBox && this.selBox !== null) {
this.modalOverlay(() => { this.showBox = false })
this.boxModal()
}
if (this.showAbnormal && this.selAbn !== null) {
this.modalOverlay(() => { this.showAbnormal = false })
this.abnormalModal()
}
if (this.showComplaint && this.selComplaint !== null) {
this.modalOverlay(() => { this.showComplaint = false })
this.complaintModal()
}
if (this.showCourier && this.selCourier !== null) {
this.modalOverlay(() => { this.showCourier = false })
this.courierModal()
}
if (this.showChangeAddr && this.selAddr !== null) {
this.modalOverlay(() => { this.showChangeAddr = false })
this.changeAddrModal()
}
if (this.showAppoint) {
this.modalOverlay(() => { this.showAppoint = false })
this.appointModal()
}
if (this.showUrgent && this.selUrgent !== null) {
this.modalOverlay(() => { this.showUrgent = false })
this.urgentModal()
}
if (this.showPointDetail) {
this.modalOverlay(() => { this.showPointDetail = false })
this.pointDetailModal()
}
if (this.showVip) {
this.modalOverlay(() => { this.showVip = false })
this.vipModal()
}
if (this.showToast) {
this.toastBox()
}
}
.width('100%')
.height('100%')
}
// ========== 弹框① 包裹详情(居中卡 蓝白) ==========
@Builder
pkgModal() {
Column() {
Row() {
Text(this.selPkg!.icon).fontSize(40)
Column({ space: 4 }) {
Text(this.selPkg!.name).fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text('单号 ' + this.selPkg!.no).fontSize(12).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Row() {
Text(this.selPkg!.status)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor(this.selPkg!.statusColor)
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
}
}
.width('100%')
Row() {
Column({ space: 2 }) {
Text('寄件').fontSize(11).fontColor('#90A4AE')
Text(this.selPkg!.from).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Text('🚀').fontSize(20)
Column({ space: 2 }) {
Text('收件').fontSize(11).fontColor('#90A4AE')
Text(this.selPkg!.to).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#0B2F5C')
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.backgroundColor('#F1F8FF')
.borderRadius(14)
.padding({ top: 12, bottom: 12 })
.margin({ top: 14 })
Column({ space: 10 }) {
this.infoLine('🧾 类型', this.selPkg!.type)
this.infoLine('🕐 时间', this.selPkg!.time)
this.infoLine('📝 备注', this.selPkg!.desc)
}
.width('100%')
.margin({ top: 12 })
Row({ space: 10 }) {
Button() {
Text('追踪包裹').fontSize(14).fontColor('#FFFFFF')
}
.backgroundColor('#FF8F00')
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showPkg = false;
this.tabIndex1 = 3;
})
Button() {
Text('改地址').fontSize(14).fontColor('#0288D1')
}
.backgroundColor('#E1F0FF')
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.selAddr = this.selPkg;
this.showPkg = false;
this.showChangeAddr = true;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('84%')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.padding(20)
.constraintSize({ maxHeight: '82%' })
.clip(true)
}
// ========== 弹框② 寄件表单(底部抽屉) ==========
@Builder
sendModal() {
Column() {
Row() {
Text('寄快递').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text('✕')
.fontSize(16)
.fontColor('#90A4AE')
.padding(6)
.onClick(() => { this.showSend = false })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
Text(this.selSend!.icon).fontSize(26)
Column({ space: 3 }) {
Text(this.selSend!.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text(this.selSend!.desc + ' · ' + this.selSend!.days + ' 天达').fontSize(12).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('已选').fontSize(11).fontColor('#FFFFFF').backgroundColor('#0288D1').borderRadius(10).padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
.width('100%')
.backgroundColor('#F1F8FF')
.borderRadius(14)
.padding(12)
.margin({ top: 12 })
Row({ space: 8 }) {
ForEach(getRateRows(), (r: RateRule, i: number) => {
Text(r.name)
.fontSize(12)
.fontColor(this.selSend!.id === r.id ? '#FFFFFF' : '#0B2F5C')
.backgroundColor(this.selSend!.id === r.id ? '#0288D1' : '#F1F8FF')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.selSend = RATES[i * 2] })
}, (r: RateRule) => r.id + 'a')
ForEach(getRateRows2(), (r: RateRule, i: number) => {
Text(r.name)
.fontSize(12)
.fontColor(this.selSend!.id === r.id ? '#FFFFFF' : '#0B2F5C')
.backgroundColor(this.selSend!.id === r.id ? '#0288D1' : '#F1F8FF')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.selSend = RATES[i * 2 + 1] })
}, (r: RateRule) => r.id + 'b')
}
.width('100%')
.margin({ top: 10 })
Row() {
Text('重量').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
Text('-')
.fontSize(20)
.fontColor('#0288D1')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.backgroundColor('#E1F0FF')
.borderRadius(16)
.onClick(() => {
if (this.fWeight > 1) {
this.fWeight = this.fWeight - 1;
}
})
Text(this.fWeight + ' kg').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#0B2F5C').width(64).textAlign(TextAlign.Center)
Text('+')
.fontSize(20)
.fontColor('#0288D1')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.backgroundColor('#E1F0FF')
.borderRadius(16)
.onClick(() => {
this.fWeight = this.fWeight + 1;
})
}
.width('100%')
.margin({ top: 12 })
Row() {
Text('保价服务').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
Text('最高赔付 ¥2000').fontSize(11).fontColor('#FF8F00')
Toggle({ type: ToggleType.Switch, isOn: this.fInsure })
.selectedColor('#FF8F00')
.onChange((v: boolean) => {
this.fInsure = v;
})
}
.width('100%')
.margin({ top: 10 })
TextInput({ placeholder: '填写收件地址', text: this.fAddr })
.fontSize(13)
.height(40)
.backgroundColor('#F5F8FC')
.borderRadius(10)
.margin({ top: 10 })
.onChange((v: string) => {
this.fAddr = v;
})
Row() {
Text('预估运费').fontSize(14).fontColor('#0B2F5C').layoutWeight(1)
Text('¥ ' + (this.selSend!.base + this.selSend!.perKg * this.fWeight))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FF8F00')
}
.width('100%')
.margin({ top: 12 })
Button() {
Text('确认下单').fontSize(15).fontColor('#FFFFFF')
}
.width('100%')
.height(44)
.backgroundColor('#0288D1')
.borderRadius(22)
.margin({ top: 14 })
.onClick(() => {
this.showSend = false;
this.showTip('寄件下单成功');
})
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 22, topRight: 22 })
.padding(20)
.padding({ bottom: 30 })
.constraintSize({ maxHeight: '84%' })
}
// ========== 弹框③ 驿站详情(上下拼接卡) ==========
@Builder
stationModal() {
Column() {
Row() {
Text(this.selStation!.icon).fontSize(38)
Column({ space: 4 }) {
Text(this.selStation!.name).fontSize(19).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row({ space: 6 }) {
Text('距您 ' + this.selStation!.distance).fontSize(12).fontColor('#B3E5FC')
Text(this.selStation!.busy)
.fontSize(11)
.fontColor('#0B2F5C')
.backgroundColor('#FFD54F')
.borderRadius(9)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#0288D1", 1]] })')
.padding(18)
Column({ space: 10 }) {
Row() {
Text('🏠').fontSize(16)
Text(this.selStation!.addr).fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
}
.width('100%')
Row() {
Text('🕐').fontSize(16)
Text('营业时间 ' + this.selStation!.open).fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
}
.width('100%')
Row() {
Text('🗄️').fontSize(16)
Text('空闲格口 ' + this.selStation!.boxes + ' 个').fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
}
.width('100%')
Row() {
Text('🧑').fontSize(16)
Text('驻站快递员 ' + this.selStation!.courier).fontSize(14).fontColor('#37474F').layoutWeight(1).margin({ left: 8 })
}
.width('100%')
Row({ space: 8 }) {
this.serveItem('📮', '寄件')
this.serveItem('📦', '取件')
this.serveItem('🛅', '暂存')
this.serveItem('🖨️', '打印')
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding(16)
Row({ space: 10 }) {
Button() {
Text('导航前往').fontSize(14).fontColor('#FFFFFF')
}
.backgroundColor('#FF8F00')
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showStation = false;
this.showTip('已发起星际导航');
})
Button() {
Text('知道了').fontSize(14).fontColor('#0288D1')
}
.backgroundColor('#E1F0FF')
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => { this.showStation = false })
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ left: 16, right: 16, bottom: 18 })
}
.width('84%')
.borderRadius(20)
.clip(true)
.constraintSize({ maxHeight: '82%' })
}
// ========== 弹框④ 取件确认(票根卡) ==========
@Builder
pickupModal() {
Column() {
Text('取件码').fontSize(12).fontColor('#0288D1').margin({ top: 18 })
Text(this.selPickup!.no.slice(-4))
.fontSize(44)
.fontWeight(FontWeight.Bold)
.fontColor('#0B2F5C')
.letterSpacing(10)
.margin({ top: 6 })
.scale({ x: 1.05, y: 1.05 })
.animation({ duration: 500, curve: Curve.EaseOut })
Row() {
Text('请至驿站快递柜输入取件码')
.fontSize(12)
.fontColor('#90A4AE')
.textAlign(TextAlign.Center)
.width('70%')
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
Divider()
.strokeWidth(1)
.color('#B3E5FC')
.margin({ top: 14, bottom: 14 })
Row() {
Text(this.selPickup!.icon).fontSize(28)
Column({ space: 3 }) {
Text(this.selPickup!.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text('地球总仓驿站 · ' + this.selPickup!.time).fontSize(12).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text(this.selPickup!.status)
.fontSize(11)
.fontColor('#FFFFFF')
.backgroundColor('#FF8F00')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
}
.width('100%')
Button() {
Text('确认取件').fontSize(15).fontColor('#FFFFFF')
}
.width('100%')
.height(44)
.backgroundColor('#0288D1')
.borderRadius(22)
.margin({ top: 18 })
.onClick(() => {
this.showPickup = false;
this.showTip('取件成功,柜门已开');
})
}
.width('84%')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.padding({ left: 20, right: 20, bottom: 20 })
.constraintSize({ maxHeight: '80%' })
.clip(true)
}
// ========== 弹框⑤ 扫码取件(深色卡 + 扫描特效) ==========
@Builder
scanModal() {
Column() {
Text('扫码取件').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('请将包裹条形码对准取件柜扫描口').fontSize(12).fontColor('#B3E5FC').margin({ top: 6 })
Stack() {
Column() {
Text('▚▞').fontSize(54).fontColor('#FF8F00').opacity(0.9)
}
.width(160)
.height(120)
.justifyContent(FlexAlign.Center)
.backgroundColor('#1AFFFFFF')
.borderRadius(12)
Row() {
Text('—').fontSize(24).fontColor('#FF8F00')
}
.width('60%')
.justifyContent(FlexAlign.Center)
.translate({ y: 30 })
.animation({ duration: 900, curve: Curve.EaseInOut })
}
.width(200)
.height(140)
.margin({ top: 20 })
.scale({ x: 1, y: 1 })
.animation({ duration: 600, curve: Curve.EaseOut })
Row() {
Text(this.selScan!.icon).fontSize(22)
Column({ space: 2 }) {
Text(this.selScan!.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text(this.selScan!.no).fontSize(11).fontColor('#90A4AE')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.backgroundColor('#1AFFFFFF')
.borderRadius(12)
.padding(12)
.margin({ top: 16 })
Row({ space: 10 }) {
Button() {
Text('模拟扫描').fontSize(14).fontColor('#FFFFFF')
}
.backgroundColor('#FF8F00')
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.showScan = false;
this.showTip('扫描成功,柜门已开启');
})
Button() {
Text('关闭').fontSize(14).fontColor('#FFFFFF')
}
.backgroundColor('#455A64')
.borderRadius(20)
.height(40)
.layoutWeight(1)
.onClick(() => { this.showScan = false })
}
.width('100%')
.margin({ top: 16 })
}
.width('86%')
.backgroundColor('linearGradient({ colors: [["#0B2F5C", 0], ["#01579B", 1]] })')
.borderRadius(20)
.padding(20)
.constraintSize({ maxHeight: '82%' })
.clip(true)
}
// ========== 弹框⑥ 运费计算器(居中卡) ==========
@Builder
rateModal() {
Column() {
Text('运费计算器').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0B2F5C')
Text('根据线路与重量预估运费').fontSize(12).fontColor('#90A4AE').margin({ top: 4 })
Row({ space: 8 }) {
ForEach(getRateRows(), (r: RateRule, i: number) => {
Column({ space: 2 }) {
Text(r.icon).fontSize(18)
Text(r.name).fontSize(11).fontColor(this.selRate!.id === r.id ? '#0288D1' : '#90A4AE')
}
.width(56)
.height(60)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.selRate!.id === r.id ? '#E1F0FF' : '#F5F8FC')
.borderRadius(12)
.onClick(() => { this.selRate = RATES[i * 2] })
}, (r: RateRule) => r.id + 'c')
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.height(48)
.onClick(() => {
this.onOrder(o);
})
}
}
在工程细节层面,代码展现了若干值得注意的实践。ForEach 普遍用业务 id 加后缀作键,保证 diff 稳定;@Builder 大量参数化复用(如 modalOverlay(onClose)、infoLine(label, value)),减少重复;条件渲染 if/else 在列表项内根据状态分流展示;constraintSize({ maxHeight }) 限制弹窗最大高度防止内容过长溢出;.clip(true) 配合圆角裁剪渐变背景。这些细节共同构成一个生产级的 UI 工程范本。
当然,代码也有一些可优化之处。例如用 Text 模拟按钮与输入框、onClick 嵌套冒泡、if/else if 手动分发 Tab 无缓存、状态颜色与数据冗余等,在真实工程中可分别用 Button/TextInput、stopPropagation、Tabs 容器、函数推导等方式改进。但作为声明式 UI 的教学与范例代码,它已经把 ArkTS 的核心能力——类型契约、状态驱动、组件拆分、复用 Builder、列表渲染、层叠布局、动画反馈——完整地串联在一处,是一份极具学习价值的鸿蒙开发参考。

掌握这份代码的精髓,等于掌握了鸿蒙声明式 UI 的半壁江山:从数据到状态、从布局到动画、从组件到弹窗,每一步都有清晰的范式可循。无论是初学者系统入门,还是有经验的开发者查漏补缺,都能从中获得可直接复用的工程模式与设计思路。
更多推荐


所有评论(0)