基于 HarmonyOS API 24 的音乐节接驳服务应用开发实战:从 ArkTS 声明式 UI 到多模态弹窗架构的全链路解析(HarmonyOS 6.1.1 / HarmonyOS ArkTS A
技术引言
在 HarmonyOS 6.1.1 全场景分布式操作系统的演进历程中,ArkTS 作为 HarmonyOS ArkTS API 24 的核心开发语言,已经从最初的声明式 UI 框架发展为一套涵盖状态管理、组件化架构、响应式数据流和多模态交互的完整开发范式。本文以一个真实的音乐节接驳服务应用——“FestExpress 音浪音乐节接驳”——为剖析对象,深入探讨基于 HarmonyOS API 24 构建复杂业务级移动应用的架构设计思路。
该应用覆盖了音乐节场景下的六大核心功能域:音乐节日程展示、接驳班线预订、营位实况查询、演出阵容管理、搭子社交匹配和个人装备清单。从技术维度看,它集中展现了 ArkTS 声明式 UI 的 @Entry/@Component 组件化体系、@State 响应式状态驱动机制、@Builder 构建器复用模式、ForEach 列表渲染与 key 生成策略、条件渲染分支控制、自定义弹窗 Overlay 模式、Progress 进度组件、linearGradient 线性渐变绘制、Scroll 容器与横向滚动、Flex 弹性布局换行、scale 缩放动画属性、shadow 阴影投射、linearGradient 渐变背景以及 position 绝对定位等数十项 ArkUI 能力。
更值得深入分析的是,该应用在状态管理层面采用了一种"集中式状态对象 + 条件渲染分发"的架构策略:全部 25 个 @State 变量集中声明于 FestExpress 主组件内部,通过 currentTab 整数索引驱动六大页面切换,通过 5 个布尔型 showXxxModal 标志位驱动五大弹窗的显隐。这种模式在中小型应用中具有极佳的可维护性和可读性,同时也为后续向 AppStorage/Vuex 式全局状态迁移预留了清晰的演进路径。本文将逐段拆解源码,从数据模型定义到视图构建,从交互逻辑到弹窗架构,为开发者呈现一份基于 HarmonyOS ArkTS API 24 的完整工程实践指南。
一、色彩体系架构:ColorPalette 接口与 COLORS 常量定义
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDeep: string;
accent: string;
accentLight: string;
neon: string;
tape: string;
bg: string;
cardBg: string;
cardBg2: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
}
const COLORS: ColorPalette = {
primary: '#8E24AA',
primaryLight: '#CE93D8',
primaryDeep: '#4A148C',
accent: '#00E676',
accentLight: '#B9F6CA',
neon: '#651FFF',
tape: '#FFD54F',
bg: '#150F22',
cardBg: '#211836',
cardBg2: '#2C2147',
textPrimary: '#FFFFFF',
textSecondary: '#B8A8D9',
textHint: '#655391',
border: '#3E3160',
success: '#69F0AE',
warning: '#FFAB40',
danger: '#FF5C8A',
white: '#FFFFFF'
};

应用首先通过 interface ColorPalette 定义了一个包含 18 个颜色字段的接口类型,随后用 const COLORS: ColorPalette 将具体色值实例化为一个不可变常量对象。这是 HarmonyOS ArkTS API 24 中典型的"接口约束 + 常量实例化"设计模式。接口的作用不仅在于提供类型安全(编译器会在赋值时检查每个字段是否齐全且类型正确),更在于形成一份色彩规范文档——任何开发者打开文件,第一时间就能从接口定义中读出整套设计体系包含哪些语义色阶:primary 系列定义品牌主色三阶(深/标准/浅),accent 系列定义强调色,bg/cardBg/cardBg2 定义背景与卡片的三级灰度层次,textPrimary/textSecondary/textHint 定义文本三级层次,success/warning/danger 定义状态语义色。
从具体色值来看,#8E24AA(Material Design Purple 600)作为主色,搭配 #00E676(荧光绿)作为强调色、#FFD54F(磁带黄)作为点缀色,共同构建出"音浪紫罗兰"的视觉基调。背景色 #150F22 是极深的紫黑色,与卡片色 #211836、#2C2147 形成三级深色层次。这种深色主题方案在音乐节类应用中非常常见,既能营造沉浸式夜间体验,又能让荧光色元素获得最大对比度。在 HarmonyOS API 24 中,const 声明的常量对象在编译期即被锁定,运行时不可被重新赋值,保证了全局色彩引用的稳定性。
二、业务数据模型:六大领域接口定义
interface FestDay {
id: number;
day: string;
date: string;
stages: number;
headliner: string;
emoji: string;
heat: number;
tickets: string;
}
interface ShuttleLine {
id: number;
name: string;
from: string;
to: string;
times: string;
duration: string;
price: number;
kind: string;
seats: string;
seatVal: number;
}
interface CampSpot {
id: number;
zone: string;
type: string;
emoji: string;
price: number;
left: number;
total: number;
facility: string;
note: string;
}
interface ArtistItem {
id: number;
name: string;
emoji: string;
stage: string;
day: string;
time: string;
genre: string;
heat: number;
songs: string;
}

应用的核心数据结构通过四个领域接口来定义,分别对应音乐节日程(FestDay)、接驳班线(ShuttleLine)、营位(CampSpot)和艺人阵容(ArtistItem)。这种"一个业务域一个接口"的设计方式是 ArkTS 类型系统在工程实践中的标准用法。每个接口都包含 id: number 作为唯一标识,这在后续 ForEach 渲染的 key 生成中起到关键作用。
值得注意的是 ShuttleLine 接口同时包含 seats: string(如"余 45 座")和 seatVal: number(如 45)两个字段。表面看这是数据冗余,实际上这是 UI 层的刻意设计——seats 直接用于文本展示避免运行时拼接,seatVal 用于数值比较判断(如 seatVal <= 15 时显示危险色)。在 HarmonyOS ArkTS API 24 中,接口字段类型严格,不允许隐式类型转换,因此预存数值型字段可以避免在模板表达式中做字符串到数字的解析操作,提升渲染性能。CampSpot 接口中的 left 和 total 字段同样为后续 Progress 组件的 value 和 total 参数提供了直接可用的数值。ArtistItem 的 heat 字段既是排序依据,也是热度条和进度条的数值来源,体现了"数据驱动视图"的核心设计理念。
三、装备清单与社交搭子数据结构
interface GearItem {
id: number;
name: string;
emoji: string;
category: string;
checked: boolean;
}
interface BuddyItem {
id: number;
name: string;
avatar: string;
artist: string;
day: string;
people: number;
joined: number;
note: string;
}
interface OrderItem {
id: number;
date: string;
line: string;
seats: number;
amount: number;
status: string;
}
这三个接口继续扩展应用的数据模型。GearItem 是一个典型的可变状态项——checked: boolean 字段会在运行时被频繁切换,驱动 UI 的勾选状态变化。BuddyItem 包含 people(目标人数)和 joined(已加入人数)两个字段,形成一种进度比例关系,直接映射到 Progress 组件的 value/total 参数。OrderItem 则是订单的精简模型,status: string 采用字符串而非枚举,虽然牺牲了一定的类型安全,但在小型应用中简化了代码复杂度。
在 HarmonyOS ArkTS API 24 的类型系统中,boolean 是原始类型而非对象包装类,checked 字段直接存储为布尔值,在 @State 状态驱动中能高效触发响应式更新。这些接口定义全部位于组件外部(全局作用域),而非组件内部,这样做的好处是接口可被多个组件复用,且不会因组件实例化而重复创建类型定义。在实际工程中,如果项目进一步扩大,这些接口应当被抽取到独立的 models 目录中,通过 import 引入,实现关注点分离。但对于本应用而言,集中式定义在可读性和可维护性上反而是最优选择。
四、静态数据源:Mock 数据常量数组
const FEST_DAYS: FestDay[] = [
{ id: 1, day: 'DAY 1', date: '09-12 周五', stages: 3, headliner: '落日飞车 Sunset Rollercoaster', emoji: '🌅', heat: 96, tickets: '售罄' },
{ id: 2, day: 'DAY 2', date: '09-13 周六', stages: 4, headliner: '万能青年旅店 Omnipotent Youth', emoji: '🎸', heat: 100, tickets: '余少量' },
{ id: 3, day: 'DAY 3', date: '09-14 周日', stages: 3, headliner: '新裤子 New Pants', emoji: '裤子', heat: 92, tickets: '在售' }
];
const SHUTTLES: ShuttleLine[] = [
{ id: 1, name: '日落专线 · 主城线', from: '市中心大剧院', to: '音浪营地东门', times: '14:00 / 15:30 / 17:00', duration: '40 分钟', price: 25, kind: '单程', seats: '余 45 座', seatVal: 45 },
{ id: 2, name: '星空专线 · 机场线', from: '机场 T3 到达层', to: '音浪营地东门', times: '13:00 / 15:00 / 17:00', duration: '55 分钟', price: 38, kind: '单程', seats: '余 22 座', seatVal: 22 },
// ... 更多班线
];

应用定义了六个 const 常量数组作为静态数据源:FEST_DAYS(3 天日程)、SHUTTLES(8 条班线)、CAMPS(8 个营位)、ARTISTS(10 组艺人)、GEARS(8 件装备)、BUDDIES(10 个搭子组)。这些数组在应用启动时即被加载到内存,作为 ForEach 列表渲染的数据源。
在 HarmonyOS API 24 中,const 声明的数组本身不可被重新赋值,但数组元素的属性在运行时是可以被修改的。不过本应用采取了更严格的不可变策略——当需要修改装备列表或搭子列表时,不是直接修改原数组元素,而是创建新数组替换旧数组(如 this.gearList = next)。这种不可变数据更新模式虽然会产生额外的内存分配,但它能确保 ArkTS 的响应式状态管理系统能正确检测到数组引用的变化并触发 ForEach 重新渲染。如果直接 gearList[0].checked = true 修改元素属性,ArkTS 的 @State 依赖追踪在某些场景下可能无法捕获到变化,导致 UI 不更新。这也是为什么 GEARS.slice(0) 被用于初始化 @State gearList——slice(0) 创建数组的浅拷贝,使组件拥有一个可独立操作的数据副本。
五、辅助常量数组与枚举式定义
const DAY_HEAT: number[] = [96, 100, 92];
const SHUTTLE_KINDS: string[] = ['全部', '单程', '往返'];
const GEAR_CATS: string[] = ['装备', '防护', '氛围', '露营'];
const TICKET_KINDS: string[] = ['单程票', '往返票'];
const SHUTTLE_DATES: string[] = ['09-12', '09-13', '09-14'];
这五个常量数组承担了"枚举替代品"的角色。在 ArkTS 中,虽然支持 enum 关键字,但在 UI 模板中直接引用枚举值并不总是方便的——ForEach 需要的是可迭代的数组,而非枚举类型。因此,将这些固定选项定义为 string[] 或 number[] 是一种务实的选择。
DAY_HEAT 单独提取三日人气值为一个纯数值数组,供柱状图渲染时直接使用。SHUTTLE_KINDS 和 GEAR_CATS 分别作为筛选 chips 和类别标签的数据源,在 ForEach 中被遍历渲染为可点击的筛选按钮。TICKET_KINDS 和 SHUTTLE_DATES 则在接驳预订弹窗中被使用,驱动票种和日期的选择逻辑。这种将固定选项数据与渲染逻辑分离的方式,使得后续新增筛选项时只需修改常量数组而无需触碰模板代码,符合开闭原则。
六、@Entry 主组件入口与 @State 状态集群
@Entry
@Component
struct FestExpress {
@State currentTab: number = 0
@State showShuttleModal: boolean = false
@State showLineupModal: boolean = false
@State showGearModal: boolean = false
@State showBuddyEditModal: boolean = false
@State showGearDeleteModal: boolean = false
@State selectedShuttle: ShuttleLine = SHUTTLES[0]
@State selectedArtist: ArtistItem = ARTISTS[0]
@State editingBuddy: BuddyItem = BUDDIES[0]
@State deleteGearId: number = 0
@State shuttleDate: string = '09-13'
@State shuttleKind: string = '单程票'
@State shuttleCount: number = 1
@State shuttleFilter: string = '全部'
@State campFilter: string = '全部营区'
@State artistFilter: string = '全部'
@State gearName: string = ''
@State gearCat: string = '装备'
@State editBuddyNote: string = ''
@State editBuddySize: number = 4
@State myOrders: OrderItem[] = [
{ id: 1, date: '09-12 周五', line: '日落专线 · 主城线', seats: 1, amount: 25, status: '待出行' },
{ id: 2, date: '09-14 周日', line: '通宵专线 · 夜归线', seats: 2, amount: 50, status: '待出行' }
]
@State gearList: GearItem[] = GEARS.slice(0)
@State buddyList: BuddyItem[] = BUDDIES.slice(0)

@Entry 装饰器标识 FestExpress 为应用的入口组件,@Component 声明它是一个自定义组件。组件内部声明了 22 个 @State 变量,构成了应用完整的状态空间。这些状态可以分为四大类:
第一类是导航状态:currentTab 驱动六个 Tab 页面的切换。第二类是弹窗状态:5 个 showXxxModal 布尔值各自控制一个模态弹窗的显隐,互斥但不同时为 true。第三类是选中数据状态:selectedShuttle、selectedArtist、editingBuddy、deleteGearId 存储当前弹窗正在操作的目标对象。第四类是表单输入状态:shuttleDate、shuttleKind、shuttleCount、gearName、gearCat、editBuddyNote、editBuddySize 等存储弹窗内的用户输入。第五类是列表数据状态:myOrders、gearList、buddyList 是可变列表,在运行时会被增删改。
在 HarmonyOS ArkTS API 24 中,@State 装饰的变量一旦被赋值修改,ArkUI 框架会自动检测依赖关系,触发所有引用该变量的 Builder 或组件重新执行渲染函数。这种细粒度的响应式更新机制是声明式 UI 的核心优势。值得注意的是 gearList 和 buddyList 使用 GEARS.slice(0) 和 BUDDIES.slice(0) 初始化——通过 slice 创建浅拷贝,使组件持有独立的数据副本,避免对全局常量的直接修改。
七、build() 方法:主布局骨架与条件渲染分发
build() {
Column() {
Scroll() {
Column() {
this.festHeader()
if (this.currentTab === 0) {
this.festTab()
} else if (this.currentTab === 1) {
this.shuttleTab()
} else if (this.currentTab === 2) {
this.campTab()
} else if (this.currentTab === 3) {
this.lineupTab()
} else if (this.currentTab === 4) {
this.buddyTab()
} else {
this.mineTab()
}
}
.width('100%')
.padding({ bottom: 6 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
if (this.showShuttleModal) {
this.shuttleModalOverlay(() => {
this.showShuttleModal = false
})
}
if (this.showLineupModal) {
this.lineupModalOverlay(() => {
this.showLineupModal = false
})
}
// ... 其他弹窗
if (this.showGearDeleteModal) {
this.gearDeleteModalOverlay(() => {
this.showGearDeleteModal = false
})
}
this.equalizerTabBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}

build() 方法是 ArkTS 组件的生命入口,它描述了整个页面的 UI 结构。该布局采用三层嵌套结构:最外层 Column 占满全屏(width 100%, height 100%),背景色为 COLORS.bg 深紫黑。内部第一段是 Scroll 容器,占据 layoutWeight(1) 的弹性空间,内部包裹一个 Column,先渲染 this.festHeader() 头部,然后通过 if-else 条件渲染链根据 currentTab 值分发到六个不同的 Tab 页面 Builder。
Scroll 组件配置了三个关键属性:scrollBar(BarState.Off) 隐藏滚动条以获得更沉浸的视觉体验;edgeEffect(EdgeEffect.Spring) 在滚动到边缘时产生弹性回弹效果,这是 HarmonyOS 原生交互的标志性特性;align(Alignment.Top) 确保内容从顶部开始排列。在 Scroll 之后是五个条件弹窗的 Overlay 渲染——每个弹窗通过对应的 showXxxModal 布尔值控制是否渲染。这里采用 Overlay 模式(全屏遮罩 + 内容面板),而非 ArkTS 原生的 @CustomDialog,原因在于 Overlay 模式给予开发者对弹窗布局、动画、关闭逻辑的完全控制权。每个 Overlay Builder 接收一个 onClose: () => void 回调函数,点击遮罩层时调用该回调关闭弹窗。最底部是 this.equalizerTabBar() 均衡器式自定义 Tab 栏,始终固定在页面底部。
八、festHeader():音乐节头部横幅与金刚区
@Builder
festHeader() {
Column() {
// 顶部状态条
Row() {
Column() {
Text('🎪 音浪音乐节 · 青龙湖营地')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('距开唱 21 天 · 三日票余少量')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🎫 已购 DAY 2')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDeep)
.padding({ left: 9, right: 9, top: 5, bottom: 5 })
.backgroundColor(COLORS.tape)
.borderRadius(12)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 14, right: 14, top: 10 })
// 主视觉横幅
Column() {
Row() {
Column() {
Text('SUMMER SONIC WAVE')
.fontSize(9)
.fontColor(COLORS.accentLight)
Text('音浪音乐节 2026')
.fontSize(21)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text('09-12 至 09-14 · 青龙湖营地 · 10 组艺人')
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 5 })
Row() {
Text('官方接驳 8 折')
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.accent)
.borderRadius(9)
Text('营地联票立减 50')
.fontSize(9)
.fontColor(COLORS.primaryDeep)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.tape)
.borderRadius(9)
.margin({ left: 6 })
}
.margin({ top: 10 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🎧')
.fontSize(46)
}
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
.width('100%')
.linearGradient({
angle: 125,
colors: [['#4A148C', 0], ['#8E24AA', 1]]
})
.borderRadius(18)
.margin({ left: 14, right: 14, top: 12 })
.shadow({
radius: 16,
color: '#8E24AA55',
offsetX: 0,
offsetY: 6
})
}
.width('100%')
.padding({ bottom: 4 })
}

festHeader() 是一个 @Builder 修饰的构建器方法,它定义了页面的头部区域,包含两个主要部分。第一部分是顶部状态条,采用 Row 横向布局:左侧 Column 占据 layoutWeight(1) 弹性宽度,展示音乐节名称和倒计时信息;右侧是一个胶囊形标签 Text('🎫 已购 DAY 2'),使用磁带黄背景色 COLORS.tape 与深紫文字色 COLORS.primaryDeep 的组合,形成高对比度标识。
第二部分是主视觉横幅,这是一个带 linearGradient 线性渐变背景的 Column,渐变从 #4A148C(深紫)到 #8E24AA(标准紫),角度 125 度,营造从左上到右下的紫光流转效果。borderRadius(18) 圆角处理和 shadow 阴影投射(radius 16, color ‘#8E24AA55’ 带透明度的紫色, offsetY 6 向下偏移)共同创建了卡片悬浮于背景之上的视觉层次感。内部 Row 中,左侧 Column 展示活动英文名、中文名、日期信息和两个促销标签(官方接驳 8 折用荧光绿、营地联票立减 50 用磁带黄),右侧是一个大号耳机 emoji Text('🎧').fontSize(46) 作为视觉锚点。这种左文右图的布局是移动端横幅的经典范式。
在 HarmonyOS API 24 中,linearGradient 属性接受一个对象参数,包含 angle(渐变角度)和 colors(颜色断点数组,每个元素为 [color, position] 二元组)。shadow 属性的 color 字段支持 8 位十六进制色值(后两位为 alpha 通道),#8E24AA55 表示 33% 不透明度的紫色阴影,这种半透明阴影比纯黑阴影更自然,能融入主题色调。
九、金刚区:横向滚动的艺人快捷入口
// 金刚区
Scroll() {
Row() {
ForEach(ARTISTS, (a: ArtistItem) => {
Column() {
Text(a.emoji)
.fontSize(21)
Text(a.name)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.padding({ left: 11, right: 11, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ right: 8 })
.onClick(() => {
this.selectedArtist = a
this.showLineupModal = true
})
}, (a: ArtistItem) => 'hking' + a.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 12 })

金刚区(源自移动端设计中"金刚位"的术语,指页面顶部横向排列的快捷功能入口)是 festHeader 的第三部分。它使用一个横向 Scroll 包裹 Row,内部通过 ForEach(ARTISTS, ...) 遍历全部 10 组艺人,每个艺人生成一个卡片式入口。每个卡片是一个 Column,顶部展示 emoji 表情符号,底部展示艺人名称,整体以 COLORS.cardBg 深色为背景、borderRadius(14) 圆角呈现。
Scroll 组件配置了 .scrollable(ScrollDirection.Horizontal) 使其支持水平方向滑动,这在艺人数量超出屏幕宽度时尤为重要。每个卡片的 onClick 事件处理器执行两步操作:先将当前点击的艺人对象 a 赋值给 this.selectedArtist 状态变量,再将 this.showLineupModal 设为 true 触发阵容详情弹窗显示。这种"先选中后弹窗"的模式确保弹窗打开时已经持有正确的目标数据。
ForEach 的第三个参数是 key 生成器函数 (a: ArtistItem) => 'hking' + a.id.toString(),这里将 ‘hking’ 前缀与艺人 id 拼接生成唯一键。在 HarmonyOS ArkTS API 24 中,key 生成器的返回值用于 ForEach 的 diff 算法——当数据列表发生变化时,框架通过 key 来判断哪些元素是新增、删除或位置变化,从而最小化 DOM 操作。使用稳定的 id 作为 key 的一部分是最佳实践,避免使用数组索引(因为索引会在增删时错位导致渲染错误)。
十、equalizerTabBar():均衡器式自定义 Tab 栏
@Builder
equalizerTabBar() {
Row() {
ForEach(this.tabNames, (name: string, idx: number) => {
Column() {
Row() {
ForEach([0, 1, 2], (b: number) => {
Column() {
}
.width(4)
.height(this.currentTab === idx ? (b === 0 ? 12 : (b === 1 ? 8 : 14)) : 5)
.backgroundColor(this.currentTab === idx ? this.eqColors[b] : COLORS.border)
.borderRadius(2)
.margin({ left: 2, right: 2 })
}, (b: number) => b.toString() + idx.toString() + this.currentTab.toString())
}
.alignItems(VerticalAlign.Bottom)
.height(14)
Text(this.tabIcons[idx])
.fontSize(17)
.margin({ top: 2 })
Text(name)
.fontSize(10)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentTab === idx ? COLORS.accent : COLORS.textSecondary)
.margin({ top: 1 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.currentTab === idx ? COLORS.cardBg2 : COLORS.cardBg)
.borderRadius({ topLeft: 15, topRight: 15, bottomLeft: 15, bottomRight: 15 })
.scale({
x: this.currentTab === idx ? 1.04 : 1,
y: this.currentTab === idx ? 1.04 : 1
})
.onClick(() => {
this.currentTab = idx
})
}, (name: string, idx: number) => name + idx.toString() + this.currentTab.toString())
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 6, right: 6, top: 5 })
.backgroundColor(COLORS.cardBg)
.shadow({
radius: 14,
color: '#8E24AA44',
offsetX: 0,
offsetY: -3
})
}
这是整个应用中最具创意的组件——均衡器式 Tab 栏。它不使用 ArkTS 原生的 Tabs 组件,而是完全自定义实现,核心特色在于:选中项顶部展示 3 根高低不一的彩色竖条(模拟音频均衡器跳动效果),未选中项展示 3 根灰色短条。
实现方式是在每个 Tab 项的 Column 最顶部放置一个 Row,内部通过 ForEach([0, 1, 2], ...) 渲染 3 个空的 Column 作为竖条。当 this.currentTab === idx(当前 Tab 等于该项索引)时,三根竖条的高度分别为 12、8、14(高低不一模拟跳动),颜色分别为 this.eqColors[0](荧光绿)、this.eqColors[1](浅紫)、this.eqColors[2](磁带黄);否则高度统一为 5,颜色为 COLORS.border(灰色边框色)。Row 的 alignItems(VerticalAlign.Bottom) 确保竖条底部对齐,形成均衡器的视觉效果。
选中项还通过 scale({ x: 1.04, y: 1.04 }) 实现 4% 的轻微放大,配合 COLORS.cardBg2 更深的背景色和 FontWeight.Bold 加粗文字,从多个视觉维度强化选中态。Tab 栏整体添加了 shadow 阴影(offsetY -3 向上偏移),模拟底部导航栏悬浮于内容之上的层次感。这种自定义 Tab 栏的实现方式展示了 ArkTS 声明式 UI 的灵活性——任何原生组件无法实现的视觉效果,都可以通过基础组件的组合和属性配置来完成。
十一、festTab():音乐节日程与人气柱状图
@Builder
festTab() {
Column() {
// 三日日程卡
Row() {
Text('📅 三日日程')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('点击查看阵容')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 14 })
ForEach(FEST_DAYS, (d: FestDay) => {
Column() {
Row() {
Column() {
Text(d.emoji)
.fontSize(26)
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(14)
Column() {
Row() {
Text(d.day)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(d.tickets)
.fontSize(8)
.fontColor(d.tickets === '售罄' ? COLORS.danger : (d.tickets === '余少量' ? COLORS.warning : COLORS.success))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(d.tickets === '售罄' ? '#FF5C8A1A' : (d.tickets === '余少量' ? '#FFAB401A' : '#69F0AE1A'))
.borderRadius(6)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
Text(d.date + ' · ' + d.stages.toString() + ' 个舞台')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('压轴:' + d.headliner)
.fontSize(10)
.fontColor(COLORS.primaryLight)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 11 })
Column() {
Text(d.heat.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('人气')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
}
.alignItems(VerticalAlign.Center)
.width('100%')
Progress({ value: d.heat, total: 100, type: ProgressType.Linear })
.width('100%')
.height(5)
.margin({ top: 10 })
.color(COLORS.primaryLight)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(13)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })
.onClick(() => {
this.currentTab = 3
})
}, (d: FestDay) => 'day' + d.id.toString())
festTab 是第一个 Tab 页面,展示音乐节的三日日程信息。ForEach 遍历 FEST_DAYS 数组为每一天生成一个日程卡片。每个卡片内部采用经典的"左图标 + 中信息 + 右数据"三栏布局:左侧是 52x52 的圆角方块容器(justifyContent(FlexAlign.Center) 使 emoji 居中),中间是日期、舞台数、压轴艺人等信息,右侧是人气数值。
票务状态标签的颜色和背景色通过嵌套三元表达式动态计算:d.tickets === '售罄' 时用红色文字 + #FF5C8A1A(10% 不透明度红色)背景,'余少量' 时用橙色 + #FFAB401A,'在售' 时用绿色 + #69F0AE1A。这种 10% 不透明度的背景色搭配实色文字是移动端标签设计的经典手法——既能传达状态语义,又不会过于刺眼。
卡片底部使用 Progress 线性进度条展示人气值(value: d.heat, total: 100),ProgressType.Linear 指定为线性条样式,height(5) 控制为细条,color(COLORS.primaryLight) 设置进度色为浅紫。Progress 组件是 HarmonyOS ArkTS API 24 的内置组件,无需自定义实现,直接传入 value 和 total 即可自动计算比例并渲染。整个卡片的 onClick 将 currentTab 设为 3,跳转到阵容 Tab,实现了日程到阵容的导航流。
十二、三日人气柱状图:自定义渐变柱状图
// 三日人气柱状图
Column() {
Text('📊 三日人气指数')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Row() {
ForEach(DAY_HEAT, (v: number, idx: number) => {
Column() {
Text(v.toString())
.fontSize(8)
.fontColor(idx === 1 ? COLORS.accent : COLORS.textSecondary)
Column() {
}
.width(34)
.height(this.heatBarHeight(v))
.linearGradient({
angle: 180,
colors: idx === 1 ? [['#00E676', 0], ['#651FFF', 1]] : [['#8E24AA', 0], ['#CE93D8', 1]]
})
.borderRadius({
topLeft: 6,
topRight: 6,
bottomLeft: 0,
bottomRight: 0
})
.margin({ top: 4 })
Text(FEST_DAYS[idx].day)
.fontSize(8)
.fontColor(idx === 1 ? COLORS.accent : COLORS.textHint)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (v: number, idx: number) => 'heat' + idx.toString())
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.height(120)
.margin({ top: 12 })
}
在不引入任何图表库的情况下,应用通过纯 ArkTS 组件实现了一个柱状图。核心思路是:Row 容器以 alignItems(VerticalAlign.Bottom) 底部对齐,内部 ForEach 遍历 DAY_HEAT 数组(值为 [96, 100, 92]),为每个数据点生成一个 Column。每个 Column 内部包含三部分:顶部数值标签、中间柱体、底部日期标签。
柱体本身是一个空的 Column(无子组件),通过 .width(34) 和 .height(this.heatBarHeight(v)) 设定尺寸。heatBarHeight 方法的实现是 Math.round(v * 0.75)——将人气值乘以 0.75 并取整,使得 100 的热度对应 75 的高度(加上标签后总高度约 120),视觉效果合理。柱体使用 linearGradient 渐变填充:中间柱(idx === 1,即人气最高的 DAY 2)使用荧光绿到紫色的渐变 [['#00E676', 0], ['#651FFF', 1]],角度 180 度(从上到下),两侧柱使用紫色系渐变 [['#8E24AA', 0], ['#CE93D8', 1]]。borderRadius 仅设置顶部圆角(topLeft 6, topRight 6),底部为 0,模拟柱状图的标准形态。
这种纯组件实现柱状图的方式虽然不如专业图表库灵活,但对于简单的数据可视化场景已完全足够,且具有零依赖、高可控的优势。DAY_HEAT 数组与 FEST_DAYS 数组通过索引保持对应关系——FEST_DAYS[idx].day 获取日期标签,这要求数组顺序必须一致,是数据层面的一种隐式约束。
十三、shuttleTab():接驳班线筛选与统计卡片
@Builder
shuttleTab() {
Column() {
// 类型筛选 chips
Scroll() {
Row() {
ForEach(SHUTTLE_KINDS, (k: string) => {
Text(k)
.fontSize(11)
.fontColor(k === this.shuttleFilter ? COLORS.white : COLORS.textSecondary)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(k === this.shuttleFilter ? COLORS.primary : COLORS.cardBg)
.borderRadius(14)
.margin({ right: 8 })
.onClick(() => {
this.shuttleFilter = k
})
}, (k: string) => k + this.shuttleFilter)
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 14 })
// 统计三卡
Row() {
Column() {
Text('8')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
Text('接驳专线')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// ... 另外两个统计卡
}
.width('100%')
.margin({ left: 14, right: 14, top: 12 })
shuttleTab 是接驳功能页,顶部是筛选 chips 区——使用横向 Scroll + Row + ForEach(SHUTTLE_KINDS) 渲染三个筛选标签(全部、单程、往返)。每个 chip 的颜色和背景色通过 k === this.shuttleFilter 三元表达式判断:选中时白字紫底,未选中时灰字深底。onClick 将 this.shuttleFilter 设置为对应值,触发 @State 响应式更新。ForEach 的 key 生成器 'k' + this.shuttleFilter 包含了当前筛选值,确保筛选状态变化时 key 也变化,强制 ForEach 重新渲染所有 chip 以更新选中态。
筛选区下方是统计三卡,采用 Row + 三个等宽 Column(各 layoutWeight(1))的布局,分别展示接驳专线总数(8)、今日余座(230)、末班发车时间(23:30)。三个数值使用不同的语义色(primaryLight 浅紫、accent 荧光绿、tape 磁带黄),形成视觉区分。卡片间距通过 .margin({ left: 10 }) 在第二和第三个卡片上设置左外边距实现。这种统计卡片模式在电商类应用中极为常见,是信息密度的有效组织方式。
十四、接驳班线列表:预订交互流程
ForEach(SHUTTLES, (s: ShuttleLine) => {
Row() {
Column() {
Text('🚌')
.fontSize(22)
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(13)
Column() {
Row() {
Text(s.kind)
.fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(s.kind === '往返' ? COLORS.tape : COLORS.primary)
.borderRadius(6)
Text(s.seats)
.fontSize(8)
.fontColor(s.seatVal <= 15 ? COLORS.danger : COLORS.success)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text(s.from + ' → ' + s.to)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text('发车 ' + s.times + ' · ' + s.duration)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 11 })
Column() {
Text('¥' + s.price.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('预订')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 13, right: 13, top: 6, bottom: 6 })
.linearGradient({
angle: 160,
colors: [['#651FFF', 0], ['#8E24AA', 1]]
})
.borderRadius(14)
.margin({ top: 6 })
.onClick(() => {
this.selectedShuttle = s
this.shuttleDate = '09-13'
this.shuttleKind = '单程票'
this.shuttleCount = 1
this.showShuttleModal = true
})
}
.alignItems(HorizontalAlign.End)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })
}, (s: ShuttleLine) => 'shuttle' + s.id.toString() + this.shuttleFilter)
接驳班线列表是 shuttleTab 的核心内容。ForEach 遍历 SHUTTLES 数组(8 条班线),每条班线生成一个卡片。卡片布局为三栏:左侧班线图标、中间班线信息、右侧价格和预订按钮。
中间信息列顶部是一个 Row,包含两个标签:班线类型(单程/往返,往返用磁带黄背景、单程用紫色背景)和余座信息。余座文字颜色通过 s.seatVal <= 15 判断——当余座不大于 15 时用 COLORS.danger(红色)营造紧迫感,否则用 COLORS.success(绿色)表示充足。这里就体现了前面分析的数据冗余设计意图:seats 字段(如"余 14 座")直接用于显示,seatVal 字段(14)用于数值比较,各司其职。
右侧预订按钮使用 linearGradient 渐变背景(#651FFF 到 #8E24AA,角度 160 度),onClick 事件处理器执行四步操作:设置选中的班线对象 this.selectedShuttle = s、重置弹窗表单的日期/票种/人数为默认值、最后 this.showShuttleModal = true 打开预订弹窗。这种在打开弹窗前重置表单状态的做法确保每次打开弹窗时表单都是干净的初始状态,避免上次操作的残留数据影响当前交互。ForEach 的 key 包含 this.shuttleFilter,使得筛选值变化时整个列表重新渲染。
十五、营地 Tab:营位横滑卡与余量进度条
@Builder
campTab() {
Column() {
// 营位类型横滑卡
Scroll() {
Row() {
ForEach(CAMPS, (c: CampSpot) => {
Column() {
Text(c.emoji)
.fontSize(30)
Text(c.zone)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 6 })
Text(c.type)
.fontSize(9)
.fontColor(COLORS.primaryLight)
.margin({ top: 3 })
Text('¥' + c.price.toString() + ' / 晚')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
.margin({ top: 5 })
}
.alignItems(HorizontalAlign.Center)
.width(118)
.padding({ top: 14, bottom: 14 })
.linearGradient({
angle: 140,
colors: [['#4A148C', 0], ['#651FFF', 1]]
})
.borderRadius(16)
.margin({ right: 10 })
}, (c: CampSpot) => 'campcard' + c.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 14 })
campTab 展示营地营位信息。顶部是横向滚动的营位类型卡片,每张卡片固定宽度 118px,使用 linearGradient(#4A148C 到 #651FFF,角度 140)紫色渐变背景,内部从上到下依次展示 emoji、营区名称、类型和价格。卡片间距通过 .margin({ right: 10 }) 设置右侧外边距实现。
横滑卡下方是营位余量列表,每条营位信息包含一个关键组件——余量进度条。这个进度条结合了 Text(余位文字)、Progress(进度条)和 Text(状态标签)三个组件,通过 Row 横向排列。Progress 的 value 使用 c.left,total 使用 c.total,color 根据 c.left <= 10 判断:余位不大于 10 时用 COLORS.danger 红色进度条配合"手慢无"标签,否则用 COLORS.accent 绿色进度条配合"可预订"标签。这种数值驱动的条件渲染让进度条不仅是视觉装饰,更承载了紧迫程度的语义信息。
营位卡片整体的布局逻辑是:Scroll 横向滚动容器 -> Row 横向排列 -> ForEach 遍历生成卡片 -> 每张卡片 Column 竖向排列内容。这种四层嵌套是 ArkTS 列表渲染的标准范式,scrollable(ScrollDirection.Horizontal) 是启用横向滚动的关键属性。
十六、lineupTab():演出阵容与舞台筛选
@Builder
lineupTab() {
Column() {
// 舞台筛选
Scroll() {
Row() {
ForEach(['全部', '主舞台', '河岸舞台', '森林舞台'], (st: string) => {
Text(st)
.fontSize(11)
.fontColor(st === this.artistFilter ? COLORS.white : COLORS.textSecondary)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(st === this.artistFilter ? COLORS.accent : COLORS.cardBg)
.borderRadius(14)
.margin({ right: 8 })
.onClick(() => {
this.artistFilter = st
})
}, (st: string) => st + this.artistFilter)
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 14 })
// 阵容列表
ForEach(ARTISTS, (a: ArtistItem) => {
Row() {
Column() {
Text(a.emoji)
.fontSize(26)
}
.width(54)
.height(54)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(14)
Column() {
Row() {
Text(a.day)
.fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor(COLORS.primary)
.borderRadius(6)
Text(a.stage)
.fontSize(8)
.fontColor(COLORS.primaryLight)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor('#CE93D81A')
.borderRadius(6)
.margin({ left: 5 })
}
Text(a.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text(a.genre + ' · ' + a.time + ' 登场')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text('代表作:' + a.songs)
.fontSize(8)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
lineupTab 展示演出阵容信息。顶部舞台筛选区使用内联数组 ['全部', '主舞台', '河岸舞台', '森林舞台'] 直接传入 ForEach,而非定义一个全局常量——这在选项固定且仅在此处使用时是合理的简化。筛选逻辑通过 this.artistFilter 状态驱动,选中时用荧光绿背景 COLORS.accent,未选中用深色背景。
阵容列表的每个卡片信息密度很高:顶部双标签(日期 + 舞台)、艺人名称、流派和登场时间、代表作。代表作文本使用了 .maxLines(1) 限制为一行、.textOverflow({ overflow: TextOverflow.Ellipsis }) 设置超出部分显示省略号——这在文本可能过长时是必要的防御性处理,避免文本换行破坏卡片布局。TextOverflow.Ellipsis 是 HarmonyOS API 24 文本溢出的标准处理方式,配合 maxLines 使用可以优雅地处理动态文本长度。
每个艺人卡片还包含一个内嵌的热度进度条 Progress({ value: a.heat, total: 100 }),color 根据 a.heat >= 90 判断:热度 90 以上用荧光绿,以下用浅紫。这种数据驱动的条件着色贯穿整个应用,形成一致的视觉语言。
十七、buddyTab():搭子社交匹配与进度可视化
@Builder
buddyTab() {
Column() {
// 搭子说明卡
Column() {
Row() {
Text('🤝 找到你的音乐搭子')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('10 个组正在招人')
.fontSize(9)
.fontColor(COLORS.accent)
}
.alignItems(VerticalAlign.Center)
.width('100%')
Row() {
Column() {
Text('10')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
Text('在招小组')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
// ... 另外两个统计
}
.width('100%')
.margin({ top: 14 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.linearGradient({
angle: 130,
colors: [['#4A148C', 0], ['#651FFF', 1]]
})
.borderRadius(16)
.margin({ left: 14, right: 14, top: 14 })
buddyTab 是社交匹配功能页,顶部搭子说明卡使用了 linearGradient 紫色渐变背景(#4A148C 到 #651FFF,角度 130),内部三列统计数据(在招小组、已入组、即将满员)用不同颜色区分。这种渐变背景卡片在视觉上与普通深色卡片形成层次区分,用于强调该区域是"核心功能入口"。
搭子列表中每个小组卡片包含一个进度条 Progress({ value: b.joined, total: b.people }),进度色根据 b.joined >= b.people 判断——满员时用 COLORS.tape 磁带黄,未满员时用 COLORS.accent 荧光绿。同时右侧的加入/编辑按钮也会根据满员状态变化:满员时显示"已满员"(灰色背景),未满员时显示"加入"(绿色背景)。编辑按钮的 onClick 执行三步操作:设置 this.editingBuddy = b、将 b.note 和 b.people 赋值给表单状态变量、打开编辑弹窗。这种将选中对象数据预填充到表单状态的做法,确保用户打开编辑弹窗时看到的是当前小组的已有配置。
十八、mineTab():用户中心与装备清单管理
@Builder
mineTab() {
Column() {
// 用户卡
Row() {
Column() {
Text('🎪')
.fontSize(28)
}
.width(56)
.height(56)
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF26')
.borderRadius(28)
Column() {
Text('Livehouse 常驻选手')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('乐迷等级 Lv.12 · 今年已看 18 场演出')
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
Progress({ value: 80, total: 100, type: ProgressType.Linear })
.width(150)
.height(5)
.margin({ top: 6 })
.color(COLORS.tape)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(16)
.linearGradient({
angle: 130,
colors: [['#651FFF', 0], ['#00E676', 1]]
})
.borderRadius(18)
.margin({ left: 14, right: 14, top: 14 })
mineTab 是个人中心页。用户卡片使用了一个独特的渐变方案——#651FFF(霓虹紫)到 #00E676(荧光绿)的对角线渐变(角度 130),这种紫绿渐变在整个应用中仅此一处使用,用于强化用户卡片的"个人身份"属性。头像容器使用半透明白色背景 #FFFFFF26(15% 不透明度白色),在渐变背景上形成毛玻璃效果。
装备清单是 mineTab 最具交互性的部分。顶部展示已备进度 this.gearDone() 返回已勾选数量,this.gearList.length 返回总数,两者传入 Progress 组件形成进度条。每个装备条目通过 ForEach 渲染,Text(g.checked ? '✅' : '⬜') 根据勾选状态显示不同 emoji,onClick 调用 this.toggleGear(g.id) 切换状态。删除按钮的 onClick 设置 this.deleteGearId = g.id 并打开删除确认弹窗。ForEach 的 key 为 'gear' + g.id.toString() + g.checked.toString(),包含 checked 状态,确保勾选状态变化时 key 变化,强制 ForEach 重新渲染对应条目以更新 emoji 和文字颜色。
十九、弹窗架构:Overlay 模式与遮罩层实现
@Builder
shuttleModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.shuttleModal()
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.height('100%')
.zIndex(999)
}
应用采用了自定义 Overlay 模式实现弹窗,而非使用 ArkTS 原生的 @CustomDialog 装饰器。每个弹窗由两个 Builder 组成:xxxModal(内容面板)和 xxxModalOverlay(遮罩容器)。Overlay 接收一个 onClose: () => void 回调函数参数,这是 ArkTS Builder 函数支持参数传递的体现。
Overlay 的结构是:最外层 Column 占满全屏(width 100%, height 100%)并设置 zIndex(999) 确保浮于所有内容之上。内部第一个 Column 是遮罩层——一个空的容器,使用 rgba(21,15,34,0.72)(72% 不透明度的深紫黑)半透明背景色,通过 .position({ x: 0, y: 0 }) 绝对定位到左上角覆盖全屏,onClick 调用 onClose() 回调关闭弹窗。第二个 Column 设置 justifyContent(FlexAlign.End) 使内容面板从底部弹出。
这种 Overlay 模式相比 @CustomDialog 有几个优势:第一,弹窗内容完全由 Builder 定义,可以自由使用 linearGradient、shadow 等属性,不受 CustomDialog 的布局约束。第二,遮罩层的透明度和颜色可精确控制(rgba 格式),而 CustomDialog 的遮罩样式需要通过 DialogController 配置。第三,关闭逻辑通过回调函数传递,灵活度更高。第四,条件渲染 if (this.showShuttleModal) 控制弹窗的挂载和卸载,而非通过 dialogController.open()/close() 命令式 API,更符合声明式 UI 范式。
二十、shuttleModal():接驳预订弹窗与价格计算
@Builder
shuttleModal() {
Column() {
Column() {
Text('🚌 ' + this.selectedShuttle.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(this.selectedShuttle.from + ' → ' + this.selectedShuttle.to)
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
Text('发车 ' + this.selectedShuttle.times + ' · 全程 ' + this.selectedShuttle.duration)
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 14 })
.linearGradient({
angle: 135,
colors: [['#4A148C', 0], ['#8E24AA', 1]]
})
// ... 日期选择、票种选择、人数选择
Row() {
Text('−')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.shuttleCount > 1 ? COLORS.white : COLORS.textHint)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(17)
.onClick(() => {
if (this.shuttleCount > 1) {
this.shuttleCount -= 1
}
})
Text(this.shuttleCount.toString() + ' 人')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: 16, right: 16 })
Text('+')
.onClick(() => {
if (this.shuttleCount < 8) {
this.shuttleCount += 1
}
})
}
// ...
Row() {
Column() {
Text('¥' + this.shuttleTotal().toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('合计')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('确认预订')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.linearGradient({
angle: 160,
colors: [['#651FFF', 0], ['#00E676', 1]]
})
.borderRadius(18)
}
}
.width('100%')
.borderRadius({ topLeft: 22, topRight: 22, bottomLeft: 0, bottomRight: 0 })
.constraintSize({ maxHeight: '80%' })
}
shuttleTotal(): number {
return this.selectedShuttle.price * this.shuttleCount + (this.shuttleKind === '往返票' ? 5 : 0) - 5
}
接驳预订弹窗是应用中最复杂的弹窗,集成了日期选择(3 个 chips)、票种选择(2 个 chips)、人数步进器(加减按钮)和价格展示。弹窗顶部头部使用紫色渐变背景展示班线信息,底部通过 borderRadius 仅设置顶部圆角(topLeft 22, topRight 22)模拟从底部滑出的抽屉效果。constraintSize({ maxHeight: '80%' }) 限制弹窗最大高度为屏幕的 80%,防止内容过多时撑满全屏。
人数步进器是弹窗的核心交互组件,减号按钮的 fontColor 根据 this.shuttleCount > 1 判断——当人数为 1 时用 COLORS.textHint 灰色暗示无法再减,同时 onClick 内部的 if 条件确保不会减到 0 以下。加号按钮同理,上限为 8 人。这种视觉反馈与逻辑约束同步的设计是良好的交互实践。
价格计算由 shuttleTotal() 方法实现:单价 × 人数 + 往返票加价 5 元 - 演出票立减 5 元。这个方法在 build() 中被调用,由于它依赖 @State 变量(selectedShuttle.price、shuttleCount、shuttleKind),任何一个变量变化都会触发方法重新执行并更新显示的价格。这是 ArkTS 响应式状态驱动计算属性的典型模式——方法本身不含副作用,仅根据状态计算返回值,框架自动处理依赖追踪和 UI 更新。
二十一、gearModal() 与 addGear():装备添加与不可变数据更新
@Builder
gearModal() {
Column() {
Column() {
Text('🎒 添加装备')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('缺啥补啥,出发前清空清单')
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 14 })
.linearGradient({
angle: 135,
colors: [['#00695C', 0], ['#00E676', 1]]
})
Column() {
Text('装备名称')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
TextInput({ placeholder: '例如:荧光发箍、防水手机袋', text: this.gearName })
.fontSize(11)
.fontColor(COLORS.white)
.placeholderColor(COLORS.textHint)
.placeholderFont({ size: 11 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(12)
.padding({ left: 12, right: 12 })
.height(40)
.margin({ top: 8 })
.onChange((v: string) => {
this.gearName = v
})
// ... 类别选择和确认按钮
}
}
}
addGear(): void {
const ng: GearItem = {
id: this.gearList.length + 1,
name: this.gearName === '' ? '未命名装备' : this.gearName,
emoji: '🎁',
category: this.gearCat,
checked: false
}
this.gearList = [ng].concat(this.gearList)
this.gearName = ''
this.showGearModal = false
this.currentTab = 5
}
装备添加弹窗使用绿色系渐变头部(#00695C 到 #00E676),与装备功能的"补充准备"语义对应。弹窗内含一个 TextInput 输入框,onChange 回调将输入值同步到 this.gearName 状态变量。placeholder 提供输入提示,placeholderColor 和 placeholderFont 精细控制提示文本的样式。
addGear() 方法展示了 ArkTS 中不可变数据更新的标准模式。它不直接修改 this.gearList 数组(如 this.gearList.push(ng)),而是创建一个新数组 [ng].concat(this.gearList)(新装备在前 + 原列表),然后将新数组赋值给 this.gearList。这种不可变更新确保 ArkTS 的 @State 响应式系统能检测到数组引用的变化,触发 ForEach 重新渲染。新装备的 id 使用 this.gearList.length + 1 生成,这在当前场景下可用(因为列表只增不减且不删除后重新添加),但在生产环境中应使用更可靠的唯一 id 生成策略(如时间戳或 UUID)。
方法最后执行三步清理:清空输入框 this.gearName = ''、关闭弹窗 this.showGearModal = false、切换到我的 Tab this.currentTab = 5,让用户看到新添加的装备出现在列表顶部。
二十二、saveBuddy():搭子编辑与不可变数据替换
saveBuddy(): void {
const next: BuddyItem[] = []
this.buddyList.forEach((b: BuddyItem) => {
if (b.id === this.editingBuddy.id) {
const nb: BuddyItem = {
id: b.id,
name: b.name,
avatar: b.avatar,
artist: b.artist,
day: b.day,
people: this.editBuddySize,
joined: b.joined,
note: this.editBuddyNote
}
next.push(nb)
} else {
next.push(b)
}
})
this.buddyList = next
this.showBuddyEditModal = false
this.currentTab = 4
}
saveBuddy() 方法实现了搭子小组信息的更新逻辑,与 addGear() 一样采用不可变数据更新模式,但更为复杂——它需要遍历整个列表,找到匹配 id 的元素进行替换,其余元素原样保留。
方法首先创建一个空数组 next,然后通过 forEach 遍历当前 this.buddyList。对于每个元素,如果 b.id === this.editingBuddy.id(正在编辑的目标),则创建一个新的 BuddyItem 对象 nb,其中 people 使用表单中的 this.editBuddySize,note 使用表单中的 this.editBuddyNote,其余字段从原对象 b 复制;否则直接将原对象 b push 到 next 数组。最终将 next 赋值给 this.buddyList,触发 @State 响应式更新。
这种"遍历 + 条件替换 + 重建数组"的模式在 ArkTS 中是处理列表元素更新的标准做法。它虽然比直接修改属性(如 this.buddyList[idx].note = this.editBuddyNote)多了一些代码,但保证了数据的不可变性,使状态变化可追踪、可调试。在 HarmonyOS ArkTS API 24 中,如果直接修改对象属性而不替换数组引用,@State 可能无法检测到变化,导致 UI 不更新——这是 ArkTS 响应式系统的一个重要特性,开发者必须理解并遵循。
二十三、gearDeleteModal():删除确认弹窗与居中弹出
@Builder
gearDeleteModal() {
Column() {
Text('🎒')
.fontSize(34)
Text('从清单移除这件装备?')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 10 })
Text('移除后需要重新手动添加')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 6 })
Row() {
Text('再想想')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textSecondary)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(18)
.onClick(() => {
this.showGearDeleteModal = false
})
Text('确认移除')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(18)
.margin({ left: 12 })
.onClick(() => {
this.gearList = this.gearList.filter((g: GearItem) => g.id !== this.deleteGearId)
this.showGearDeleteModal = false
})
}
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Center)
.margin({ top: 20, bottom: 20 })
}
.width('86%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 22, bottom: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
}
@Builder
gearDeleteModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.gearDeleteModal()
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.zIndex(999)
}
删除确认弹窗与其他弹窗不同,它采用居中弹出而非底部弹出。其 Overlay 的内容容器使用 justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 使弹窗在屏幕正中央显示,而其他弹窗用 justifyContent(FlexAlign.End) 从底部弹出。弹窗内容面板宽度为 86%(而非全宽),形成居中对话框的标准视觉。
弹窗内部包含一个装备 emoji、标题文本、说明文本和双按钮行。"再想想"按钮用灰色背景表示取消操作,"确认移除"按钮用 COLORS.danger 红色背景表示危险操作。确认按钮的 onClick 执行 this.gearList = this.gearList.filter((g: GearItem) => g.id !== this.deleteGearId)——使用 Array.filter 方法创建一个不包含目标 id 的新数组,然后赋值给 this.gearList。这同样是不可变数据更新模式,filter 返回新数组而非修改原数组。
这种在执行不可逆操作前弹出确认对话框的做法是移动端 UX 的基本准则。通过 this.deleteGearId 临时存储待删除的目标 id,弹窗可以显示通用的确认文案而无需硬编码装备名称,保持弹窗组件的复用性。
二十四、工具方法:toggleGear() 与 gearDone()
toggleGear(id: number): void {
const next: GearItem[] = []
this.gearList.forEach((g: GearItem) => {
if (g.id === id) {
const ng: GearItem = {
id: g.id,
name: g.name,
emoji: g.emoji,
category: g.category,
checked: !g.checked
}
next.push(ng)
} else {
next.push(g)
}
})
this.gearList = next
}
gearDone(): number {
let n: number = 0
this.gearList.forEach((g: GearItem) => {
if (g.checked) {
n += 1
}
})
return n
}
heatBarHeight(v: number): number {
return Math.round(v * 0.75)
}
这三个工具方法分别承担不同职责。toggleGear(id) 切换指定 id 装备的勾选状态,实现方式与 saveBuddy() 相同——遍历数组、条件替换、重建数组。关键在于 checked: !g.checked 这一行,它将原对象的 checked 值取反后赋给新对象,实现了勾选/取消勾选的切换。由于采用了不可变更新,ForEach 的 key 中包含 g.checked.toString(),状态变化后 key 变化,框架会重新渲染该条目,emoji 从 ⬜ 变为 ✅,文字颜色从白色变为灰色。
gearDone() 方法遍历装备列表统计已勾选数量,返回一个数字。这个方法在 mineTab() 中被两处调用:一次用于 Progress 的 value 参数(展示进度条),一次用于文本展示(“已备 X/Y”)。由于该方法依赖 @State gearList,当 gearList 变化时(如勾选切换、添加、删除),方法会自动重新执行,驱动 UI 更新。
heatBarHeight(v) 是一个纯数学计算方法,将人气值(0-100)映射为像素高度(0-75),通过 Math.round 取整。该方法在 festTab 的柱状图中被调用,是 ArkTS 中将业务数据转换为 UI 尺寸参数的典型做法——分离计算逻辑到独立方法,使模板表达式更简洁。
核心流程图
应用启动与页面分发流程
接驳预订弹窗交互流程
装备清单增删改流程
对比表格
表格一:五种弹窗模式对比
| 弹窗名称 | 触发状态变量 | 弹出位置 | 渐变配色 | 核心交互 | 关闭方式 |
|---|---|---|---|---|---|
| 接驳预订弹窗 | showShuttleModal | 底部弹出 | 紫色系 #4A148C→#8E24AA | 日期/票种/人数选择 | 遮罩点击 / 确认预订 |
| 阵容详情弹窗 | showLineupModal | 底部弹出 | 紫绿系 #651FFF→#8E24AA | 查看详情/加入想看 | 遮罩点击 / 跳转搭子 |
| 添加装备弹窗 | showGearModal | 底部弹出 | 绿色系 #00695C→#00E676 | 文本输入/类别选择 | 取消按钮 / 加入清单 |
| 编辑搭子弹窗 | showBuddyEditModal | 底部弹出 | 深紫系 #4A148C→#651FFF | 人数步进/文本输入 | 取消按钮 / 保存修改 |
| 删除确认弹窗 | showGearDeleteModal | 居中弹出 | 无渐变(纯色背景) | 二选一确认 | 再想想 / 确认移除 |
表格二:六大 Tab 页面功能对比
| Tab 名称 | 索引 | 核心数据源 | 列表渲染方式 | 交互特色 | 状态变量依赖 |
|---|---|---|---|---|---|
| 音乐节 | 0 | FEST_DAYS / DAY_HEAT | ForEach + 自定义柱状图 | 点击跳转阵容 Tab | currentTab |
| 接驳 | 1 | SHUTTLES / myOrders | ForEach + 统计卡片 | 筛选chips + 预订弹窗 | shuttleFilter / selectedShuttle |
| 营地 | 2 | CAMPS | 横滑卡 + 列表 | Progress 余量可视化 | campFilter |
| 阵容 | 3 | ARTISTS | ForEach + 热度条 | 舞台筛选 + 详情弹窗 | artistFilter / selectedArtist |
| 搭子 | 4 | buddyList(可变) | ForEach + 进度条 | 编辑弹窗 + 满员判断 | editingBuddy / editBuddyNote |
| 我的 | 5 | gearList(可变) | ForEach + 进度条 | 勾选/添加/删除 | gearName / gearCat / deleteGearId |
表格三:ArkTS 状态管理模式对比
| 状态类型 | 装饰器 | 数据更新方式 | 响应式触发机制 | 适用场景 | 本应用实例 |
|---|---|---|---|---|---|
| 基础状态 | @State | 直接赋值 | 值变化自动触发 | 简单值类型 | currentTab / shuttleCount |
| 对象状态 | @State | 创建新对象替换 | 引用变化触发 | 选中对象 | selectedShuttle / selectedArtist |
| 数组状态 | @State | filter/concat/重建数组 | 引用变化触发 | 列表数据 | gearList / buddyList / myOrders |
| 表单状态 | @State | onChange 回调赋值 | 值变化自动触发 | 用户输入 | gearName / editBuddyNote |
| 筛选状态 | @State | onClick 回调赋值 | 值变化触发列表重渲染 | 列表过滤 | shuttleFilter / artistFilter |
| 弹窗状态 | @State | 布尔值赋值 | 条件渲染挂载/卸载 | 弹窗显隐 | showShuttleModal 等 |
表格四:ArkUI 渐变背景使用场景对比
| 使用位置 | 渐变色值 | 角度 | 视觉语义 | 实现方式 |
|---|---|---|---|---|
| 主视觉横幅 | #4A148C → #8E24AA | 125° | 品牌主色区 | linearGradient + shadow |
| 金刚区卡片 | 无渐变 | - | 内容区 | 纯色 backgroundColor |
| 搭子说明卡 | #4A148C → #651FFF | 130° | 功能入口强调 | linearGradient |
| 用户卡片 | #651FFF → #00E676 | 130° | 个人身份 | linearGradient 紫绿渐变 |
| 营位横滑卡 | #4A148C → #651FFF | 140° | 卡片层次 | linearGradient |
| 柱状图选中柱 | #00E676 → #651FFF | 180° | 数据高亮 | linearGradient 纵向 |
| 预订按钮 | #651FFF → #8E24AA | 160° | 行动召唤 | linearGradient |
| 确认预订按钮 | #651FFF → #00E676 | 160° | 主行动召唤 | linearGradient 紫绿渐变 |
| 弹窗头部 | 各异 | 135° | 弹窗标题区 | linearGradient |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 场景:夏日音浪音乐节官方接驳(音乐节/接驳/营地/阵容/搭子/我的)
// 视觉:音浪紫罗兰 · 深紫渐变 + 荧光绿点缀 + 磁带黄
// Tab 栏:均衡器式(选中项顶部 3 根彩色竖条高低跳动,未选中灰短条)
// ============================================================
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDeep: string;
accent: string;
accentLight: string;
neon: string;
tape: string;
bg: string;
cardBg: string;
cardBg2: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
}
const COLORS: ColorPalette = {
primary: '#8E24AA',
primaryLight: '#CE93D8',
primaryDeep: '#4A148C',
accent: '#00E676',
accentLight: '#B9F6CA',
neon: '#651FFF',
tape: '#FFD54F',
bg: '#150F22',
cardBg: '#211836',
cardBg2: '#2C2147',
textPrimary: '#FFFFFF',
textSecondary: '#B8A8D9',
textHint: '#655391',
border: '#3E3160',
success: '#69F0AE',
warning: '#FFAB40',
danger: '#FF5C8A',
white: '#FFFFFF'
};
interface FestDay {
id: number;
day: string;
date: string;
stages: number;
headliner: string;
emoji: string;
heat: number;
tickets: string;
}
const FEST_DAYS: FestDay[] = [
{ id: 1, day: 'DAY 1', date: '09-12 周五', stages: 3, headliner: '落日飞车 Sunset Rollercoaster', emoji: '🌅', heat: 96, tickets: '售罄' },
{ id: 2, day: 'DAY 2', date: '09-13 周六', stages: 4, headliner: '万能青年旅店 Omnipotent Youth', emoji: '🎸', heat: 100, tickets: '余少量' },
{ id: 3, day: 'DAY 3', date: '09-14 周日', stages: 3, headliner: '新裤子 New Pants', emoji: '裤子', heat: 92, tickets: '在售' }
];
interface ShuttleLine {
id: number;
name: string;
from: string;
to: string;
times: string;
duration: string;
price: number;
kind: string;
seats: string;
seatVal: number;
}
const SHUTTLES: ShuttleLine[] = [
{ id: 1, name: '日落专线 · 主城线', from: '市中心大剧院', to: '音浪营地东门', times: '14:00 / 15:30 / 17:00', duration: '40 分钟', price: 25, kind: '单程', seats: '余 45 座', seatVal: 45 },
{ id: 2, name: '星空专线 · 机场线', from: '机场 T3 到达层', to: '音浪营地东门', times: '13:00 / 15:00 / 17:00', duration: '55 分钟', price: 38, kind: '单程', seats: '余 22 座', seatVal: 22 },
{ id: 3, name: '通宵专线 · 夜归线', from: '音浪营地东门', to: '市中心大剧院', times: '23:30 / 00:30 / 01:30', duration: '40 分钟', price: 25, kind: '单程', seats: '余 60 座', seatVal: 60 },
{ id: 4, name: '两日往返 · 周末套票', from: '市中心大剧院', to: '音浪营地东门', times: '09-13 往返', duration: '40 分钟', price: 45, kind: '往返', seats: '余 30 座', seatVal: 30 },
{ id: 5, name: '磁带专线 · 高校线', from: '大学城地铁 B 口', to: '音浪营地东门', times: '14:30 / 16:00', duration: '35 分钟', price: 18, kind: '单程', seats: '余 52 座', seatVal: 52 },
{ id: 6, name: '荧光专线 · 夜场线', from: '老城音乐厅', to: '音浪营地东门', times: '18:00 / 19:00', duration: '30 分钟', price: 22, kind: '单程', seats: '余 14 座', seatVal: 14 },
{ id: 7, name: '两日往返 · 三日套票', from: '市中心大剧院', to: '音浪营地东门', times: '09-12 至 09-14', duration: '40 分钟', price: 68, kind: '往返', seats: '余 9 座', seatVal: 9 },
{ id: 8, name: '接驳快线 · 高铁线', from: '高铁南站西广场', to: '音浪营地东门', times: '13:30 / 15:30 / 17:30', duration: '50 分钟', price: 32, kind: '单程', seats: '余 38 座', seatVal: 38 }
];
interface CampSpot {
id: number;
zone: string;
type: string;
emoji: string;
price: number;
left: number;
total: number;
facility: string;
note: string;
}
const CAMPS: CampSpot[] = [
{ id: 1, zone: '星空草坪区', type: '自带帐篷位', emoji: '⛺', price: 128, left: 34, total: 120, facility: '淋浴间 · 充电桩', note: '离主舞台 800m,安静看星' },
{ id: 2, zone: '音浪前排区', type: '豪华印第安帐篷', emoji: '🏕️', price: 688, left: 6, total: 40, facility: '空调 · 双人床 · 早餐', note: '紧邻主舞台,隔音棉降噪' },
{ id: 3, zone: '荧光露营区', type: '星空球帐篷', emoji: '🔮', price: 428, left: 12, total: 60, facility: '透明顶 · 充电桩', note: '躺着看银河的泡泡屋' },
{ id: 4, zone: '房车营地区', type: '房车泊位', emoji: '🚐', price: 258, left: 18, total: 50, facility: '上下水 · 220V 电', note: '自带房车专属泊位' },
{ id: 5, zone: '森林树影区', type: '自带帐篷位', emoji: '🌲', price: 108, left: 41, total: 100, facility: '淋浴间 · 便利店', note: '树荫环绕,白天不晒' },
{ id: 6, zone: '社交派对区', type: '拼帐位', emoji: '🎉', price: 88, left: 25, total: 80, facility: '公共天幕 · 篝火', note: '社牛聚集地,夜夜拼局' },
{ id: 7, zone: '安静休息区', type: '自带帐篷位', emoji: '🌙', price: 118, left: 15, total: 90, facility: '淋浴间 · 隔音带', note: '远离舞台,早睡党福音' },
{ id: 8, zone: '音乐主题区', type: '磁带帐篷', emoji: '📼', price: 368, left: 8, total: 30, facility: '蓝牙音箱 · 氛围灯', note: '复古磁带造型主题帐' }
];
interface ArtistItem {
id: number;
name: string;
emoji: string;
stage: string;
day: string;
time: string;
genre: string;
heat: number;
songs: string;
}
const ARTISTS: ArtistItem[] = [
{ id: 1, name: '落日飞车', emoji: '🌅', stage: '主舞台', day: 'DAY 1', time: '21:30', genre: 'City Pop', heat: 96, songs: 'My Jinji · Bomb of Love' },
{ id: 2, name: '万能青年旅店', emoji: '🎸', stage: '主舞台', day: 'DAY 2', time: '21:00', genre: '摇滚', heat: 100, songs: '杀死那个石家庄人 · 十万嬉皮' },
{ id: 3, name: '新裤子', emoji: '裤子', stage: '主舞台', day: 'DAY 3', time: '21:00', genre: '新浪潮', heat: 92, songs: '你要跳舞吗 · 没有理想的人不伤心' },
{ id: 4, name: '五条人', emoji: '🛶', stage: '河岸舞台', day: 'DAY 2', time: '18:30', genre: '民谣摇滚', heat: 88, songs: '阿珍爱上了阿强 · 道山靓仔' },
{ id: 5, name: '橘子海', emoji: '🍊', stage: '河岸舞台', day: 'DAY 1', time: '17:00', genre: '独立流行', heat: 79, songs: '夏日漱石 · Deep Sea Diving' },
{ id: 6, name: '傻子与白痴', emoji: '🤍', stage: '森林舞台', day: 'DAY 3', time: '16:30', genre: '独立', heat: 74, songs: '5:00a.m. · Oasis' },
{ id: 7, name: '血肉果汁机', emoji: '🥤', stage: '森林舞台', day: 'DAY 2', time: '15:00', genre: '金属核', heat: 68, songs: '怒江之战 · 米奇血肉' },
{ id: 8, name: 'Deca Joins', emoji: '🌊', stage: '河岸舞台', day: 'DAY 3', time: '19:00', genre: '梦幻流行', heat: 82, songs: '海浪 · 巫堵' },
{ id: 9, name: '回春丹', emoji: '💊', stage: '森林舞台', day: 'DAY 1', time: '19:30', genre: '摇滚', heat: 81, songs: '鲜花 · 爱 tasted' },
{ id: 10, name: '的秘密', emoji: '🌙', stage: '主舞台', day: 'DAY 3', time: '19:30', genre: '后摇', heat: 71, songs: '银河 · 星轨' }
];
interface GearItem {
id: number;
name: string;
emoji: string;
category: string;
checked: boolean;
}
const GEARS: GearItem[] = [
{ id: 1, name: '荧光手环', emoji: '💠', category: '氛围', checked: true },
{ id: 2, name: '防晒霜 SPF50', emoji: '🧴', category: '防护', checked: true },
{ id: 3, name: '野餐垫', emoji: '🧺', category: '露营', checked: false },
{ id: 4, name: '充电宝 20000mAh', emoji: '🔋', category: '装备', checked: true },
{ id: 5, name: '雨衣(音乐节必备)', emoji: '🧥', category: '防护', checked: false },
{ id: 6, name: '耳塞(前排神器)', emoji: '🎧', category: '装备', checked: false },
{ id: 7, name: '小马扎', emoji: '🪑', category: '露营', checked: false },
{ id: 8, name: '透明水壶(可入场)', emoji: '🚰', category: '装备', checked: true }
];
interface BuddyItem {
id: number;
name: string;
avatar: string;
artist: string;
day: string;
people: number;
joined: number;
note: string;
}
const BUDDIES: BuddyItem[] = [
{ id: 1, name: '万青十年老粉', avatar: '🎸', artist: '万能青年旅店', day: 'DAY 2', people: 4, joined: 3, note: '前排见,喊哑嗓子那种' },
{ id: 2, name: 'City Pop 舞蹈组', avatar: '🕺', artist: '落日飞车', day: 'DAY 1', people: 6, joined: 4, note: '会跳会拍,出片友好' },
{ id: 3, name: '新裤子蹦迪团', avatar: '🪩', artist: '新裤子', day: 'DAY 3', people: 8, joined: 5, note: '蹦到闭园才走的' },
{ id: 4, name: '五条人方言小队', avatar: '🛶', artist: '五条人', day: 'DAY 2', people: 3, joined: 1, note: '会讲潮汕话优先' },
{ id: 5, name: '后摇发呆联盟', avatar: '🌙', artist: '的秘密', day: 'DAY 3', people: 5, joined: 2, note: '安静听完最后一首' },
{ id: 6, name: '血肉开火车组', avatar: '🚂', artist: '血肉果汁机', day: 'DAY 2', people: 10, joined: 7, note: 'circle pit 常驻选手' },
{ id: 7, name: '橘子海冲浪团', avatar: '🏄', artist: '橘子海', day: 'DAY 1', people: 4, joined: 2, note: '第一排一起喊安可' },
{ id: 8, name: '露营早睡组', avatar: '⛺', artist: '不限', day: '全程', people: 6, joined: 3, note: '听完 22 点就回帐篷' },
{ id: 9, name: '拍照搭子组', avatar: '📸', artist: '不限', day: 'DAY 1', people: 2, joined: 1, note: '长焦镜头互拍' },
{ id: 10, name: '拼车接驳小队', avatar: '🚐', artist: '不限', day: '全程', people: 5, joined: 4, note: '机场线拼往返套票' }
];
interface OrderItem {
id: number;
date: string;
line: string;
seats: number;
amount: number;
status: string;
}
const DAY_HEAT: number[] = [96, 100, 92];
const SHUTTLE_KINDS: string[] = ['全部', '单程', '往返'];
const GEAR_CATS: string[] = ['装备', '防护', '氛围', '露营'];
const TICKET_KINDS: string[] = ['单程票', '往返票'];
const SHUTTLE_DATES: string[] = ['09-12', '09-13', '09-14'];
@Entry
@Component
struct FestExpress {
@State currentTab: number = 0
@State showShuttleModal: boolean = false
@State showLineupModal: boolean = false
@State showGearModal: boolean = false
@State showBuddyEditModal: boolean = false
@State showGearDeleteModal: boolean = false
@State selectedShuttle: ShuttleLine = SHUTTLES[0]
@State selectedArtist: ArtistItem = ARTISTS[0]
@State editingBuddy: BuddyItem = BUDDIES[0]
@State deleteGearId: number = 0
@State shuttleDate: string = '09-13'
@State shuttleKind: string = '单程票'
@State shuttleCount: number = 1
@State shuttleFilter: string = '全部'
@State campFilter: string = '全部营区'
@State artistFilter: string = '全部'
@State gearName: string = ''
@State gearCat: string = '装备'
@State editBuddyNote: string = ''
@State editBuddySize: number = 4
@State myOrders: OrderItem[] = [
{ id: 1, date: '09-12 周五', line: '日落专线 · 主城线', seats: 1, amount: 25, status: '待出行' },
{ id: 2, date: '09-14 周日', line: '通宵专线 · 夜归线', seats: 2, amount: 50, status: '待出行' }
]
@State gearList: GearItem[] = GEARS.slice(0)
@State buddyList: BuddyItem[] = BUDDIES.slice(0)
private tabNames: string[] = ['音乐节', '接驳', '营地', '阵容', '搭子', '我的']
private tabIcons: string[] = ['🎵', '🚌', '⛺', '🎧', '🧑🤝🧑', '👤']
private eqColors: string[] = [COLORS.accent, COLORS.primaryLight, COLORS.tape]
build() {
Column() {
Scroll() {
Column() {
this.festHeader()
if (this.currentTab === 0) {
this.festTab()
} else if (this.currentTab === 1) {
this.shuttleTab()
} else if (this.currentTab === 2) {
this.campTab()
} else if (this.currentTab === 3) {
this.lineupTab()
} else if (this.currentTab === 4) {
this.buddyTab()
} else {
this.mineTab()
}
}
.width('100%')
.padding({ bottom: 6 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
if (this.showShuttleModal) {
this.shuttleModalOverlay(() => {
this.showShuttleModal = false
})
}
if (this.showLineupModal) {
this.lineupModalOverlay(() => {
this.showLineupModal = false
})
}
if (this.showGearModal) {
this.gearModalOverlay(() => {
this.showGearModal = false
})
}
if (this.showBuddyEditModal) {
this.buddyEditModalOverlay(() => {
this.showBuddyEditModal = false
})
}
if (this.showGearDeleteModal) {
this.gearDeleteModalOverlay(() => {
this.showGearDeleteModal = false
})
}
this.equalizerTabBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
// ============ 音乐节头部(演出电商风 · 无动画) ============
@Builder
festHeader() {
Column() {
// 顶部状态条
Row() {
Column() {
Text('🎪 音浪音乐节 · 青龙湖营地')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('距开唱 21 天 · 三日票余少量')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🎫 已购 DAY 2')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDeep)
.padding({ left: 9, right: 9, top: 5, bottom: 5 })
.backgroundColor(COLORS.tape)
.borderRadius(12)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 14, right: 14, top: 10 })
// 主视觉横幅
Column() {
Row() {
Column() {
Text('SUMMER SONIC WAVE')
.fontSize(9)
.fontColor(COLORS.accentLight)
Text('音浪音乐节 2026')
.fontSize(21)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text('09-12 至 09-14 · 青龙湖营地 · 10 组艺人')
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 5 })
Row() {
Text('官方接驳 8 折')
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.accent)
.borderRadius(9)
Text('营地联票立减 50')
.fontSize(9)
.fontColor(COLORS.primaryDeep)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.tape)
.borderRadius(9)
.margin({ left: 6 })
}
.margin({ top: 10 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🎧')
.fontSize(46)
}
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
.width('100%')
.linearGradient({
angle: 125,
colors: [['#4A148C', 0], ['#8E24AA', 1]]
})
.borderRadius(18)
.margin({ left: 14, right: 14, top: 12 })
.shadow({
radius: 16,
color: '#8E24AA55',
offsetX: 0,
offsetY: 6
})
// 金刚区
Scroll() {
Row() {
ForEach(ARTISTS, (a: ArtistItem) => {
Column() {
Text(a.emoji)
.fontSize(21)
Text(a.name)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.padding({ left: 11, right: 11, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ right: 8 })
.onClick(() => {
this.selectedArtist = a
this.showLineupModal = true
})
}, (a: ArtistItem) => 'hking' + a.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 12 })
}
.width('100%')
.padding({ bottom: 4 })
}
// ============ 均衡器式 Tab 栏 ============
@Builder
equalizerTabBar() {
Row() {
ForEach(this.tabNames, (name: string, idx: number) => {
Column() {
Row() {
ForEach([0, 1, 2], (b: number) => {
Column() {
}
.width(4)
.height(this.currentTab === idx ? (b === 0 ? 12 : (b === 1 ? 8 : 14)) : 5)
.backgroundColor(this.currentTab === idx ? this.eqColors[b] : COLORS.border)
.borderRadius(2)
.margin({ left: 2, right: 2 })
}, (b: number) => b.toString() + idx.toString() + this.currentTab.toString())
}
.alignItems(VerticalAlign.Bottom)
.height(14)
Text(this.tabIcons[idx])
.fontSize(17)
.margin({ top: 2 })
Text(name)
.fontSize(10)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentTab === idx ? COLORS.accent : COLORS.textSecondary)
.margin({ top: 1 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.currentTab === idx ? COLORS.cardBg2 : COLORS.cardBg)
.borderRadius({
topLeft: 15,
topRight: 15,
bottomLeft: 15,
bottomRight: 15
})
.scale({
x: this.currentTab === idx ? 1.04 : 1,
y: this.currentTab === idx ? 1.04 : 1
})
.onClick(() => {
this.currentTab = idx
})
}, (name: string, idx: number) => name + idx.toString() + this.currentTab.toString())
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 6, right: 6, top: 5 })
.backgroundColor(COLORS.cardBg)
.shadow({
radius: 14,
color: '#8E24AA44',
offsetX: 0,
offsetY: -3
})
}
// ============ Tab0 音乐节 ============
@Builder
festTab() {
Column() {
// 三日日程卡
Row() {
Text('📅 三日日程')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('点击查看阵容')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 14 })
ForEach(FEST_DAYS, (d: FestDay) => {
Column() {
Row() {
Column() {
Text(d.emoji)
.fontSize(26)
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(14)
Column() {
Row() {
Text(d.day)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(d.tickets)
.fontSize(8)
.fontColor(d.tickets === '售罄' ? COLORS.danger : (d.tickets === '余少量' ? COLORS.warning : COLORS.success))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(d.tickets === '售罄' ? '#FF5C8A1A' : (d.tickets === '余少量' ? '#FFAB401A' : '#69F0AE1A'))
.borderRadius(6)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
Text(d.date + ' · ' + d.stages.toString() + ' 个舞台')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('压轴:' + d.headliner)
.fontSize(10)
.fontColor(COLORS.primaryLight)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 11 })
Column() {
Text(d.heat.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('人气')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
}
.alignItems(VerticalAlign.Center)
.width('100%')
Progress({ value: d.heat, total: 100, type: ProgressType.Linear })
.width('100%')
.height(5)
.margin({ top: 10 })
.color(COLORS.primaryLight)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(13)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })
.onClick(() => {
this.currentTab = 3
})
}, (d: FestDay) => 'day' + d.id.toString())
// 三日人气柱状图
Column() {
Text('📊 三日人气指数')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Row() {
ForEach(DAY_HEAT, (v: number, idx: number) => {
Column() {
Text(v.toString())
.fontSize(8)
.fontColor(idx === 1 ? COLORS.accent : COLORS.textSecondary)
Column() {
}
.width(34)
.height(this.heatBarHeight(v))
.linearGradient({
angle: 180,
colors: idx === 1 ? [['#00E676', 0], ['#651FFF', 1]] : [['#8E24AA', 0], ['#CE93D8', 1]]
})
.borderRadius({
topLeft: 6,
topRight: 6,
bottomLeft: 0,
bottomRight: 0
})
.margin({ top: 4 })
Text(FEST_DAYS[idx].day)
.fontSize(8)
.fontColor(idx === 1 ? COLORS.accent : COLORS.textHint)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (v: number, idx: number) => 'heat' + idx.toString())
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.height(120)
.margin({ top: 12 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16 })
// 场地舞台地图
Column() {
Text('🗺️ 场地舞台分布')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(ARTISTS, (a: ArtistItem) => {
Text(a.stage)
.fontSize(9)
.fontColor(a.stage === '主舞台' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(a.stage === '主舞台' ? COLORS.primary : COLORS.cardBg2)
.borderRadius(10)
.margin({ right: 8, top: 10 })
}, (a: ArtistItem) => 'stagewrap' + a.id.toString())
}
.width('100%')
Text('主舞台 · 河岸舞台 · 森林舞台,营地与舞台间有接驳摆渡车')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 10 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16 })
// 新人攻略
Column() {
Text('🎫 音乐节新手攻略')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('① 官方接驳车凭演出票可再减 5 元\n② 营地淋浴间 7:00-23:00 开放,避开高峰\n③ 主舞台前排建议 19:00 前占位,带好水壶\n④ 夜归请认准通宵专线,末班 01:30')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.lineHeight(19)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16, bottom: 10 })
}
.width('100%')
}
// ============ Tab1 接驳 ============
@Builder
shuttleTab() {
Column() {
// 类型筛选 chips
Scroll() {
Row() {
ForEach(SHUTTLE_KINDS, (k: string) => {
Text(k)
.fontSize(11)
.fontColor(k === this.shuttleFilter ? COLORS.white : COLORS.textSecondary)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(k === this.shuttleFilter ? COLORS.primary : COLORS.cardBg)
.borderRadius(14)
.margin({ right: 8 })
.onClick(() => {
this.shuttleFilter = k
})
}, (k: string) => k + this.shuttleFilter)
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 14 })
// 统计三卡
Row() {
Column() {
Text('8')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
Text('接驳专线')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column() {
Text('230')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('今日余座')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ left: 10 })
Column() {
Text('23:30')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('末班发车')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ left: 10 })
}
.width('100%')
.margin({ left: 14, right: 14, top: 12 })
// 班线列表
Row() {
Text('🚌 官方接驳班线')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('凭演出票再减 5 元')
.fontSize(9)
.fontColor(COLORS.accent)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 16 })
ForEach(SHUTTLES, (s: ShuttleLine) => {
Row() {
Column() {
Text('🚌')
.fontSize(22)
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(13)
Column() {
Row() {
Text(s.kind)
.fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(s.kind === '往返' ? COLORS.tape : COLORS.primary)
.borderRadius(6)
Text(s.seats)
.fontSize(8)
.fontColor(s.seatVal <= 15 ? COLORS.danger : COLORS.success)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text(s.from + ' → ' + s.to)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text('发车 ' + s.times + ' · ' + s.duration)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 11 })
Column() {
Text('¥' + s.price.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('预订')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 13, right: 13, top: 6, bottom: 6 })
.linearGradient({
angle: 160,
colors: [['#651FFF', 0], ['#8E24AA', 1]]
})
.borderRadius(14)
.margin({ top: 6 })
.onClick(() => {
this.selectedShuttle = s
this.shuttleDate = '09-13'
this.shuttleKind = '单程票'
this.shuttleCount = 1
this.showShuttleModal = true
})
}
.alignItems(HorizontalAlign.End)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })
}, (s: ShuttleLine) => 'shuttle' + s.id.toString() + this.shuttleFilter)
// 我的接驳订单
Row() {
Text('🎫 我的接驳订单')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text(this.myOrders.length.toString() + ' 单')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 18 })
ForEach(this.myOrders, (o: OrderItem) => {
Row() {
Column() {
Row() {
Text(o.line)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text(o.status)
.fontSize(9)
.fontColor(o.status === '已完成' ? COLORS.textHint : COLORS.accent)
}
.alignItems(VerticalAlign.Center)
.width('100%')
Text(o.date + ' · ' + o.seats.toString() + ' 座 · 实付 ¥' + o.amount.toString())
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 5 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('改签')
.fontSize(9)
.fontColor(COLORS.primaryLight)
.padding({ left: 11, right: 11, top: 5, bottom: 5 })
.backgroundColor('#CE93D81A')
.borderRadius(12)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ left: 14, right: 14, top: 8 })
}, (o: OrderItem) => 'order' + o.id.toString() + this.myOrders.length.toString())
// 乘车贴士
Column() {
Text('📌 接驳贴士')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('① 上车点均在营地东门 3 号旗杆处\n② 通宵专线末班 01:30,蹦迪别坐过站\n③ 往返套票需一次性锁定往返日期')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.lineHeight(19)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16, bottom: 10 })
}
.width('100%')
}
// ============ Tab2 营地 ============
@Builder
campTab() {
Column() {
// 营位类型横滑卡
Scroll() {
Row() {
ForEach(CAMPS, (c: CampSpot) => {
Column() {
Text(c.emoji)
.fontSize(30)
Text(c.zone)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 6 })
Text(c.type)
.fontSize(9)
.fontColor(COLORS.primaryLight)
.margin({ top: 3 })
Text('¥' + c.price.toString() + ' / 晚')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
.margin({ top: 5 })
}
.alignItems(HorizontalAlign.Center)
.width(118)
.padding({ top: 14, bottom: 14 })
.linearGradient({
angle: 140,
colors: [['#4A148C', 0], ['#651FFF', 1]]
})
.borderRadius(16)
.margin({ right: 10 })
}, (c: CampSpot) => 'campcard' + c.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 14 })
// 营位列表
Row() {
Text('⛺ 营位余量实况')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('余量实时更新')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 16 })
ForEach(CAMPS, (c: CampSpot) => {
Column() {
Row() {
Column() {
Text(c.emoji)
.fontSize(25)
}
.width(50)
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(13)
Column() {
Text(c.zone)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(c.type + ' · ' + c.facility)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text(c.note)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 11 })
Column() {
Text('¥' + c.price.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('每晚')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.alignItems(VerticalAlign.Center)
.width('100%')
Row() {
Text('余位 ' + c.left.toString() + '/' + c.total.toString())
.fontSize(9)
.fontColor(c.left <= 10 ? COLORS.danger : COLORS.textSecondary)
Progress({ value: c.left, total: c.total, type: ProgressType.Linear })
.width(130)
.height(5)
.margin({ left: 8 })
.color(c.left <= 10 ? COLORS.danger : COLORS.accent)
Text(c.left <= 10 ? '手慢无' : '可预订')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(c.left <= 10 ? COLORS.danger : COLORS.success)
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
.width('100%')
.margin({ top: 10 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(13)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })
}, (c: CampSpot) => 'camp' + c.id.toString())
// 营地设施
Column() {
Text('🏕️ 营地公共设施')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(GEAR_CATS, (g: string) => {
Text('✔ ' + g + '区可用')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(10)
.margin({ right: 8, top: 10 })
}, (g: string) => 'fac' + g)
}
.width('100%')
Text('淋浴间 · 充电桩 · 24h 便利店 · 医疗点 · 寄存柜 · 摆渡车站')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 10 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16, bottom: 10 })
}
.width('100%')
}
// ============ Tab3 阵容 ============
@Builder
lineupTab() {
Column() {
// 舞台筛选
Scroll() {
Row() {
ForEach(['全部', '主舞台', '河岸舞台', '森林舞台'], (st: string) => {
Text(st)
.fontSize(11)
.fontColor(st === this.artistFilter ? COLORS.white : COLORS.textSecondary)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(st === this.artistFilter ? COLORS.accent : COLORS.cardBg)
.borderRadius(14)
.margin({ right: 8 })
.onClick(() => {
this.artistFilter = st
})
}, (st: string) => st + this.artistFilter)
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.margin({ left: 14, right: 0, top: 14 })
// 阵容列表
ForEach(ARTISTS, (a: ArtistItem) => {
Row() {
Column() {
Text(a.emoji)
.fontSize(26)
}
.width(54)
.height(54)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(14)
Column() {
Row() {
Text(a.day)
.fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor(COLORS.primary)
.borderRadius(6)
Text(a.stage)
.fontSize(8)
.fontColor(COLORS.primaryLight)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor('#CE93D81A')
.borderRadius(6)
.margin({ left: 5 })
}
.alignItems(VerticalAlign.Center)
Text(a.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 4 })
Text(a.genre + ' · ' + a.time + ' 登场')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text('代表作:' + a.songs)
.fontSize(8)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
Row() {
Text('热度')
.fontSize(8)
.fontColor(COLORS.textHint)
Progress({ value: a.heat, total: 100, type: ProgressType.Linear })
.width(80)
.height(4)
.margin({ left: 5 })
.color(a.heat >= 90 ? COLORS.accent : COLORS.primaryLight)
Text(a.heat.toString())
.fontSize(8)
.fontColor(COLORS.tape)
.margin({ left: 5 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 5 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 11 })
Column() {
Text('详情')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 11, right: 11, top: 6, bottom: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.selectedArtist = a
this.showLineupModal = true
})
Text('🔥 想看')
.fontSize(8)
.fontColor(COLORS.accent)
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Center)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })
}, (a: ArtistItem) => 'artist' + a.id.toString() + this.artistFilter)
}
.width('100%')
}
// ============ Tab4 搭子 ============
@Builder
buddyTab() {
Column() {
// 搭子说明卡
Column() {
Row() {
Text('🤝 找到你的音乐搭子')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('10 个组正在招人')
.fontSize(9)
.fontColor(COLORS.accent)
}
.alignItems(VerticalAlign.Center)
.width('100%')
Row() {
Column() {
Text('10')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
Text('在招小组')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Text('32')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('已入组')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Text('6')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('即将满员')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 14 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.linearGradient({
angle: 130,
colors: [['#4A148C', 0], ['#651FFF', 1]]
})
.borderRadius(16)
.margin({ left: 14, right: 14, top: 14 })
// 搭子列表
Row() {
Text('🧑🤝🧑 搭子小组')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('点击卡片编辑我的小组')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 16 })
ForEach(this.buddyList, (b: BuddyItem) => {
Row() {
Column() {
Text(b.avatar)
.fontSize(22)
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(22)
Column() {
Row() {
Text(b.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(b.day)
.fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor(COLORS.primary)
.borderRadius(6)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
Text('目标艺人:' + b.artist)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text('已加 ' + b.joined.toString() + '/' + b.people.toString() + ' 人 · ' + b.note)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
Progress({ value: b.joined, total: b.people, type: ProgressType.Linear })
.width(110)
.height(4)
.margin({ top: 5 })
.color(b.joined >= b.people ? COLORS.tape : COLORS.accent)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(b.joined >= b.people ? '已满员' : '加入')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(b.joined >= b.people ? COLORS.textHint : COLORS.white)
.padding({ left: 11, right: 11, top: 5, bottom: 5 })
.backgroundColor(b.joined >= b.people ? COLORS.cardBg2 : COLORS.accent)
.borderRadius(12)
Text('编辑')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
.padding({ left: 11, right: 11, top: 5, bottom: 5 })
.backgroundColor('#CE93D81A')
.borderRadius(12)
.margin({ top: 6 })
.onClick(() => {
this.editingBuddy = b
this.editBuddyNote = b.note
this.editBuddySize = b.people
this.showBuddyEditModal = true
})
}
.alignItems(HorizontalAlign.Center)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(11)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ left: 14, right: 14, top: 8 })
}, (b: BuddyItem) => 'buddy' + b.id.toString() + b.note + b.people.toString())
// 热门话题
Column() {
Text('🔥 搭子热门话题')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('#前排占位技巧# 2.1k 讨论')
.fontSize(10)
.fontColor(COLORS.primaryLight)
.margin({ top: 10 })
Text('#带什么零食进场合规# 1.8k 讨论')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 8 })
Text('#散场拼车攻略# 1.2k 讨论')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 8 })
Text('#荧光装扮大赏# 986 讨论')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16, bottom: 10 })
}
.width('100%')
}
// ============ Tab5 我的 ============
@Builder
mineTab() {
Column() {
// 用户卡
Row() {
Column() {
Text('🎪')
.fontSize(28)
}
.width(56)
.height(56)
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF26')
.borderRadius(28)
Column() {
Text('Livehouse 常驻选手')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('乐迷等级 Lv.12 · 今年已看 18 场演出')
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
Progress({ value: 80, total: 100, type: ProgressType.Linear })
.width(150)
.height(5)
.margin({ top: 6 })
.color(COLORS.tape)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding(16)
.linearGradient({
angle: 130,
colors: [['#651FFF', 0], ['#00E676', 1]]
})
.borderRadius(18)
.margin({ left: 14, right: 14, top: 14 })
// 统计三格
Row() {
Column() {
Text('18')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('看过演出')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column() {
Text('42')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('收藏艺人')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ left: 10 })
Column() {
Text('3')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('音乐节打卡')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ left: 10 })
}
.width('100%')
.margin({ left: 14, right: 14, top: 12 })
// 装备清单(新增 + 勾选 + 删除)
Row() {
Text('🎒 我的音乐节装备清单')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
Text('已备 ' + this.gearDone().toString() + '/' + this.gearList.length.toString())
.fontSize(9)
.fontColor(COLORS.accent)
}
.alignItems(VerticalAlign.Bottom)
.width('100%')
.margin({ left: 14, right: 14, top: 16 })
Progress({ value: this.gearDone(), total: this.gearList.length, type: ProgressType.Linear })
.width('92%')
.height(6)
.margin({ top: 10 })
.color(COLORS.accent)
Row() {
Text('+ 添加装备')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(13)
Text('轻触条目切换已备状态')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 10 })
}
.alignItems(VerticalAlign.Center)
.width('92%')
.margin({ top: 10 })
.justifyContent(FlexAlign.SpaceBetween)
ForEach(this.gearList, (g: GearItem) => {
Row() {
Text(g.checked ? '✅' : '⬜')
.fontSize(15)
.onClick(() => {
this.toggleGear(g.id)
})
Text(g.emoji)
.fontSize(16)
.margin({ left: 8 })
Column() {
Text(g.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(g.checked ? COLORS.textSecondary : COLORS.white)
Text(g.category)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text('删除')
.fontSize(9)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FF5C8A1A')
.borderRadius(10)
.onClick(() => {
this.deleteGearId = g.id
this.showGearDeleteModal = true
})
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.borderRadius(13)
.margin({ left: 14, right: 14, top: 7 })
}, (g: GearItem) => 'gear' + g.id.toString() + g.checked.toString())
// 设置列表
Column() {
Row() {
Text('🎫')
.fontSize(15)
Text('我的演出票')
.fontSize(12)
.fontColor(COLORS.white)
.layoutWeight(1)
.margin({ left: 10 })
Text('DAY 2 单日票')
.fontSize(10)
.fontColor(COLORS.accent)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ top: 12, bottom: 12 })
Row() {
Text('🔔')
.fontSize(15)
Text('开唱提醒')
.fontSize(12)
.fontColor(COLORS.white)
.layoutWeight(1)
.margin({ left: 10 })
Text('提前 1 小时')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ top: 12, bottom: 12 })
Row() {
Text('📍')
.fontSize(15)
Text('营地订单')
.fontSize(12)
.fontColor(COLORS.white)
.layoutWeight(1)
.margin({ left: 10 })
Text('星空球帐篷')
.fontSize(10)
.fontColor(COLORS.tape)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ top: 12, bottom: 12 })
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 14, right: 14, top: 16, bottom: 10 })
}
.width('100%')
}
// ============ 弹框1:预订接驳班线 ============
@Builder
shuttleModal() {
Column() {
Column() {
Text('🚌 ' + this.selectedShuttle.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(this.selectedShuttle.from + ' → ' + this.selectedShuttle.to)
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
Text('发车 ' + this.selectedShuttle.times + ' · 全程 ' + this.selectedShuttle.duration)
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 14 })
.linearGradient({
angle: 135,
colors: [['#4A148C', 0], ['#8E24AA', 1]]
})
Column() {
Text('出发日期')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Row() {
ForEach(SHUTTLE_DATES, (d: string) => {
Text(d)
.fontSize(11)
.fontColor(d === this.shuttleDate ? COLORS.white : COLORS.textSecondary)
.padding({ left: 15, right: 15, top: 7, bottom: 7 })
.backgroundColor(d === this.shuttleDate ? COLORS.primary : COLORS.cardBg2)
.borderRadius(13)
.margin({ right: 8, top: 10 })
.onClick(() => {
this.shuttleDate = d
})
}, (d: string) => d + this.shuttleDate)
}
.width('100%')
Text('票种')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 16 })
Row() {
ForEach(TICKET_KINDS, (k: string) => {
Text(k + (k === '往返票' ? '(立省 ¥5)' : ''))
.fontSize(10)
.fontColor(k === this.shuttleKind ? COLORS.white : COLORS.textSecondary)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(k === this.shuttleKind ? COLORS.accent : COLORS.cardBg2)
.borderRadius(13)
.margin({ right: 8, top: 10 })
.onClick(() => {
this.shuttleKind = k
})
}, (k: string) => k + this.shuttleKind)
}
.width('100%')
Text('乘车人数')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 16 })
Row() {
Text('−')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.shuttleCount > 1 ? COLORS.white : COLORS.textHint)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(17)
.onClick(() => {
if (this.shuttleCount > 1) {
this.shuttleCount -= 1
}
})
Text(this.shuttleCount.toString() + ' 人')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: 16, right: 16 })
Text('+')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(17)
.onClick(() => {
if (this.shuttleCount < 8) {
this.shuttleCount += 1
}
})
}
.alignItems(VerticalAlign.Center)
.margin({ top: 10 })
Row() {
Text('凭演出票立减')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('- ¥5')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Row() {
Column() {
Text('¥' + this.shuttleTotal().toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('合计')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('确认预订')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.linearGradient({
angle: 160,
colors: [['#651FFF', 0], ['#00E676', 1]]
})
.borderRadius(18)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.margin({ top: 16, bottom: 18 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(COLORS.cardBg)
}
.width('100%')
.borderRadius({
topLeft: 22,
topRight: 22,
bottomLeft: 0,
bottomRight: 0
})
.constraintSize({ maxHeight: '80%' })
}
@Builder
shuttleModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.shuttleModal()
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.height('100%')
.zIndex(999)
}
shuttleTotal(): number {
return this.selectedShuttle.price * this.shuttleCount + (this.shuttleKind === '往返票' ? 5 : 0) - 5
}
// ============ 弹框2:艺人阵容详情 ============
@Builder
lineupModal() {
Column() {
Column() {
Text(this.selectedArtist.emoji)
.fontSize(42)
Text(this.selectedArtist.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 6 })
Text(this.selectedArtist.genre + ' · ' + this.selectedArtist.day + ' · ' + this.selectedArtist.stage)
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 20, bottom: 16 })
.linearGradient({
angle: 135,
colors: [['#651FFF', 0], ['#8E24AA', 1]]
})
Column() {
Row() {
Column() {
Text(this.selectedArtist.time)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('登场时间')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Text(this.selectedArtist.heat.toString())
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.tape)
Text('热度指数')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Text('90 分钟')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
Text('预计时长')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
Text('🎧 代表曲目')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 16 })
Text(this.selectedArtist.songs)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 8 })
Text('⏰ 设置开唱提醒')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 16 })
Row() {
ForEach(['提前 30 分钟', '提前 1 小时', '当天 9:00'], (t: string) => {
Text(t)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(11)
.margin({ right: 8, top: 10 })
}, (t: string) => 'remind' + t)
}
.width('100%')
Row() {
Text('加入想看清单')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDeep)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(COLORS.tape)
.borderRadius(18)
Text('找同好搭子')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(18)
.margin({ left: 12 })
.onClick(() => {
this.showLineupModal = false
this.currentTab = 4
})
}
.alignItems(VerticalAlign.Center)
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 18, bottom: 20 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(COLORS.cardBg)
}
.width('100%')
.borderRadius({
topLeft: 22,
topRight: 22,
bottomLeft: 0,
bottomRight: 0
})
.constraintSize({ maxHeight: '80%' })
}
@Builder
lineupModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.lineupModal()
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.height('100%')
.zIndex(999)
}
// ============ 弹框3:添加装备 ============
@Builder
gearModal() {
Column() {
Column() {
Text('🎒 添加装备')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('缺啥补啥,出发前清空清单')
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 14 })
.linearGradient({
angle: 135,
colors: [['#00695C', 0], ['#00E676', 1]]
})
Column() {
Text('装备名称')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
TextInput({ placeholder: '例如:荧光发箍、防水手机袋', text: this.gearName })
.fontSize(11)
.fontColor(COLORS.white)
.placeholderColor(COLORS.textHint)
.placeholderFont({ size: 11 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(12)
.padding({ left: 12, right: 12 })
.height(40)
.margin({ top: 8 })
.onChange((v: string) => {
this.gearName = v
})
Text('所属类别')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 14 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(GEAR_CATS, (c: string) => {
Text(c)
.fontSize(10)
.fontColor(c === this.gearCat ? COLORS.white : COLORS.textSecondary)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(c === this.gearCat ? COLORS.accent : COLORS.cardBg2)
.borderRadius(12)
.margin({ right: 8, top: 10 })
.onClick(() => {
this.gearCat = c
})
}, (c: string) => c + this.gearCat)
}
.width('100%')
Row() {
Text('取消')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textSecondary)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(18)
.onClick(() => {
this.showGearModal = false
})
Text('加入清单')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(18)
.margin({ left: 12 })
.onClick(() => {
this.addGear()
})
}
.alignItems(VerticalAlign.Center)
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 20, bottom: 18 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(COLORS.cardBg)
}
.width('100%')
.borderRadius({
topLeft: 22,
topRight: 22,
bottomLeft: 0,
bottomRight: 0
})
.constraintSize({ maxHeight: '80%' })
}
@Builder
gearModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.gearModal()
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.height('100%')
.zIndex(999)
}
addGear(): void {
const ng: GearItem = {
id: this.gearList.length + 1,
name: this.gearName === '' ? '未命名装备' : this.gearName,
emoji: '🎁',
category: this.gearCat,
checked: false
}
this.gearList = [ng].concat(this.gearList)
this.gearName = ''
this.showGearModal = false
this.currentTab = 5
}
// ============ 弹框4:编辑搭子小组 ============
@Builder
buddyEditModal() {
Column() {
Column() {
Text('✏️ 编辑我的小组')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text(this.editingBuddy.name + ' · ' + this.editingBuddy.artist)
.fontSize(10)
.fontColor('#E1BEE7')
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 14 })
.linearGradient({
angle: 135,
colors: [['#4A148C', 0], ['#651FFF', 1]]
})
Column() {
Text('小组人数')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Row() {
Text('−')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.editBuddySize > 2 ? COLORS.white : COLORS.textHint)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(17)
.onClick(() => {
if (this.editBuddySize > 2) {
this.editBuddySize -= 1
}
})
Text(this.editBuddySize.toString() + ' 人')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: 16, right: 16 })
Text('+')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.cardBg2)
.borderRadius(17)
.onClick(() => {
if (this.editBuddySize < 12) {
this.editBuddySize += 1
}
})
}
.alignItems(VerticalAlign.Center)
.margin({ top: 10 })
Text('小组说明')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 16 })
TextInput({ placeholder: '例如:前排见,喊哑嗓子那种', text: this.editBuddyNote })
.fontSize(11)
.fontColor(COLORS.white)
.placeholderColor(COLORS.textHint)
.placeholderFont({ size: 11 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(12)
.padding({ left: 12, right: 12 })
.height(40)
.margin({ top: 8 })
.onChange((v: string) => {
this.editBuddyNote = v
})
Row() {
Text('取消')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textSecondary)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(18)
.onClick(() => {
this.showBuddyEditModal = false
})
Text('保存修改')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(18)
.margin({ left: 12 })
.onClick(() => {
this.saveBuddy()
})
}
.alignItems(VerticalAlign.Center)
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 20, bottom: 18 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(COLORS.cardBg)
}
.width('100%')
.borderRadius({
topLeft: 22,
topRight: 22,
bottomLeft: 0,
bottomRight: 0
})
.constraintSize({ maxHeight: '80%' })
}
@Builder
buddyEditModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.buddyEditModal()
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.height('100%')
.zIndex(999)
}
saveBuddy(): void {
const next: BuddyItem[] = []
this.buddyList.forEach((b: BuddyItem) => {
if (b.id === this.editingBuddy.id) {
const nb: BuddyItem = {
id: b.id,
name: b.name,
avatar: b.avatar,
artist: b.artist,
day: b.day,
people: this.editBuddySize,
joined: b.joined,
note: this.editBuddyNote
}
next.push(nb)
} else {
next.push(b)
}
})
this.buddyList = next
this.showBuddyEditModal = false
this.currentTab = 4
}
// ============ 弹框5:删除装备确认 ============
@Builder
gearDeleteModal() {
Column() {
Text('🎒')
.fontSize(34)
Text('从清单移除这件装备?')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: 10 })
Text('移除后需要重新手动添加')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 6 })
Row() {
Text('再想想')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textSecondary)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg2)
.borderRadius(18)
.onClick(() => {
this.showGearDeleteModal = false
})
Text('确认移除')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(18)
.margin({ left: 12 })
.onClick(() => {
this.gearList = this.gearList.filter((g: GearItem) => g.id !== this.deleteGearId)
this.showGearDeleteModal = false
})
}
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Center)
.margin({ top: 20, bottom: 20 })
}
.width('86%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 22, bottom: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
}
@Builder
gearDeleteModalOverlay(onClose: () => void) {
Column() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('rgba(21,15,34,0.72)')
.position({ x: 0, y: 0 })
.onClick(() => {
onClose()
})
Column() {
this.gearDeleteModal()
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.zIndex(999)
}
// ============ 工具方法 ============
toggleGear(id: number): void {
const next: GearItem[] = []
this.gearList.forEach((g: GearItem) => {
if (g.id === id) {
const ng: GearItem = {
id: g.id,
name: g.name,
emoji: g.emoji,
category: g.category,
checked: !g.checked
}
next.push(ng)
} else {
next.push(g)
}
})
this.gearList = next
}
gearDone(): number {
let n: number = 0
this.gearList.forEach((g: GearItem) => {
if (g.checked) {
n += 1
}
})
return n
}
heatBarHeight(v: number): number {
return Math.round(v * 0.75)
}
}

总结
本文以"FestExpress 音浪音乐节接驳"应用为完整案例,系统性地剖析了基于 HarmonyOS 6.1.1 和 HarmonyOS ArkTS API 24 构建复杂移动应用的全套技术方案。从架构设计层面看,该应用采用了"单入口组件 + 集中式状态 + 条件渲染分发"的架构模式,22 个 @State 变量构成了完整的应用状态空间,通过 currentTab 整数索引驱动六个页面 Builder 的条件渲染,通过 5 个布尔型弹窗标志位驱动五个 Overlay 弹窗的挂载与卸载。这种架构在中小型应用中具有状态可追踪、调试方便、代码集中可读的优势,同时也为后续向 AppStorage 全局状态或 MVVM 模式迁移预留了清晰的演进路径。
从 ArkTS 声明式 UI 的技术维度看,本案例覆盖了以下核心能力的工程实践:@Builder 构建器复用模式实现了 14 个独立 UI 模块的封装与组合;ForEach 列表渲染通过精心设计的 key 生成器(包含 id、状态值、筛选值等维度)确保了列表更新的精确性和高效性;linearGradient 线性渐变在 9 个不同场景下的应用展示了渐变配色从品牌色到行动召唤色的完整语义体系;Progress 线性进度条在日程人气、余座余位、搭子进度、装备完成度四个业务场景中实现了数据可视化;不可变数据更新模式(filter/concat/遍历重建)贯穿了装备增删改和搭子编辑的全部交互逻辑,确保了 @State 响应式系统的正确触发。
自定义均衡器式 Tab 栏通过 ForEach 渲染三根高度不一的彩色竖条,实现了远超原生 Tabs 组件的视觉表现力;Overlay 自定义弹窗模式通过遮罩层 + 内容面板 + zIndex 层叠 + position 绝对定位的组合,实现了比 @CustomDialog 更灵活的弹窗控制;人数步进器、筛选 chips、日期/票种选择器等交互组件均通过 @State 状态驱动实现实时响应。从工程规范层面看,接口定义先行、常量数据分离、不可变更新、key 唯一性保证、文本溢出防御性处理等实践,均为 HarmonyOS ArkTS API 24 的工程化开发提供了可复用的模式参考。该案例证明,HarmonyOS ArkTS API 24 的声明式 UI 框架已经具备了构建复杂业务级移动应用的完整能力,开发者只需深入理解状态管理机制、组件组合策略和响应式更新原理,即可高效开发出视觉精美、交互流畅、架构清晰的鸿蒙原生应用。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)