HarmonyOS 6.1 实战:筛选功能需要配合计算属性或过滤后的数据数组使用,通过computed计算属性或在build方法中直接过滤数组来实现
开篇:鸿蒙生态下的电竞酒店应用全景

在鸿蒙操作系统的生态版图中,声明式UI开发范式正在重新定义移动应用的构建方式。ArkTS作为鸿蒙应用开发的核心语言,将TypeScript的类型安全与声明式UI的简洁优雅完美结合,为开发者提供了一套高效、直观的界面构建工具。本文将通过一个完整的电竞酒店应用案例,深入剖析鸿蒙声明式UI开发的各个技术层面,从数据模型设计到组件化架构,从状态管理到交互实现,带你领略鸿蒙开发的独特魅力。
电竞酒店作为新兴的消费场景,融合了住宿、电竞、社交、赛事等多元业态。其应用界面需要承载丰富的信息层级和复杂的交互逻辑,是检验UI框架能力的绝佳试金石。本文所解析的应用采用暗夜黑搭配赛博青与霓虹紫的视觉风格,完美契合电竞文化的科技感与未来感。
技术栈与整体架构

本应用完全基于鸿蒙ArkTS声明式开发范式构建,核心技术栈包括:
- 开发语言:ArkTS(TypeScript超集)
- UI框架:ArkUI声明式UI
- 状态管理:@State装饰器驱动的响应式状态
- 布局系统:Column、Row、Stack、Flex等基础布局容器
- 组件化方案:@Component + @Builder装饰器
- 数据驱动:ForEach循环渲染 + 静态数据源
- 交互模式:模态弹窗 + 底部Tab导航 + 列表滚动
整个应用采用单文件组件化架构,所有页面和组件定义在同一个模块中,通过枚举值管理底部Tab的切换状态。应用共包含8个主要页面模块,采用双排底部导航设计,上排4个Tab对应核心业务(首页、房间、套餐、赛事),下排4个Tab对应社区与个人中心(战队、周边、陪玩、我的)。
整体架构流程图

一、数据模型与设计令牌:应用的基石
1.1 接口定义:类型安全的数据契约

在鸿蒙ArkTS开发中,接口(interface)是构建类型安全应用的基石。通过明确定义数据结构,开发者可以在编译阶段就能发现潜在的类型错误,大幅提升代码的可维护性和开发效率。
interface GamePalette {
bg: string
primary: string
primarySoft: string
secondary: string
secondarySoft: string
card: string
cardDeep: string
text: string
textSub: string
chip: string
warn: string
warnSoft: string
green: string
greenSoft: string
yellow: string
yellowSoft: string
white: string
}
GamePalette接口定义了应用的完整色彩系统,共包含16个颜色字段。这是一种典型的设计令牌(Design Token)实践,将UI视觉规范抽象为结构化的数据对象。
设计令牌的核心理念在于将视觉设计的原子元素(颜色、字体、间距、圆角等)从具体的组件实现中解耦出来,形成一套可复用、可维护的设计语言体系。在这个应用中,颜色令牌被细分为多个层级:背景色、主色调、次色调、卡片色、文本色、标签色、状态色等,每个主色都配有对应的柔和版本(Soft),用于背景填充和视觉层次区分。
设计令牌的分层设计体现了UI系统的成熟度。主色用于强调和交互,柔和色用于背景和次级区域,二者配合可以在保持品牌辨识度的同时,构建丰富的视觉层次。
在实际开发中,将颜色常量集中管理有诸多优势。首先,当需要调整应用主题时,只需修改一处定义即可全局生效,大大降低了维护成本。其次,统一的颜色命名规范有助于团队协作,设计师和开发者可以使用相同的语言进行沟通。最后,类型系统的加持使得颜色引用变得安全可靠,不存在拼写错误导致的运行时问题。
interface RoomItem {
id: number
name: string
icon: string
config: string
screens: number
beds: number
price: number
originPrice: number
status: string
score: number
desc: string
}
RoomItem接口定义了房间数据的完整结构,包含11个字段。其中id作为唯一标识符,name和icon用于展示,config、screens、beds描述房间配置,price和originPrice构成价格体系,status表示预订状态,score是评分,desc是详细描述。
这种数据结构的设计充分考虑了列表展示和详情展示的双重需求。在列表页中,主要使用icon、name、config、price、status等核心字段快速呈现关键信息;在详情模态框中,则可以展示desc、screens、beds、score等扩展信息。数据结构的合理设计是UI组件高效渲染的前提。
interface PackageItem {
id: number
name: string
icon: string
hours: string
items: string
price: number
originPrice: number
hot: boolean
desc: string
}
PackageItem接口描述套餐数据,与房间数据结构类似但有所区别。套餐有独有的hours(时长)和items(包含内容)字段,以及hot(热卖标记)布尔字段。布尔类型的hot字段在UI渲染时用于条件性显示"热卖"标签,这是电商类应用的常见设计模式。
interface MatchItem {
id: number
name: string
icon: string
game: string
date: string
time: string
place: string
teams: number
prize: number
status: string
}
MatchItem接口定义赛事数据,包含比赛名称、游戏类型、日期时间、地点、参赛队伍数、奖金和状态等字段。其中teams和prize是数值型字段,在UI中需要进行格式化处理——队伍数直接显示,奖金则需要根据金额大小转换为"万"为单位的文本。
interface TeamItem {
id: number
name: string
icon: string
rank: string
winRate: number
members: number
region: string
desc: string
}
TeamItem接口描述战队数据,包含战队名称、段位、胜率、成员数、地区和简介。其中winRate是一个百分比数值,在UI中被用于绘制胜率进度条,这是数据可视化的一种简单实现方式。
interface PeripheryItem {
id: number
name: string
icon: string
category: string
price: number
originPrice: number
hot: boolean
desc: string
}
PeripheryItem接口定义周边商品数据,包含分类字段category,用于商品分类展示。在周边页面中,商品以双列瀑布流的形式呈现,分类信息帮助用户快速识别商品类型。
interface PalItem {
id: number
nickname: string
avatar: string
game: string
rank: string
price: number
score: number
orders: number
desc: string
}
PalItem接口描述陪玩大神数据,包含昵称、头像、游戏、段位、时薪、评分、接单量和个人简介。陪玩列表的设计借鉴了社交应用的用户卡片模式,通过评分和接单量建立信任背书,通过段位标签展示专业能力。
interface GameOrderItem {
id: number
title: string
type: string
date: string
place: string
price: number
status: string
}
GameOrderItem接口定义订单数据,其中type字段特别值得关注。订单类型涵盖了房间、套餐、周边、陪玩、赛事等多种业务类型,在"我的"页面中,通过type字段动态选择不同的图标进行展示,实现了多业务线订单的统一管理。
1.2 设计令牌常量:视觉系统的集中管理

const GP: GamePalette = {
bg: '#0D0F14',
primary: '#00E5FF',
primarySoft: '#0E2A33',
secondary: '#A855F7',
secondarySoft: '#241A33',
card: '#171A22',
cardDeep: '#12141B',
text: '#E8EAEF',
textSub: '#8A90A0',
chip: '#1F232E',
warn: '#FF5C7A',
warnSoft: '#2A1B22',
green: '#3DDC97',
greenSoft: '#132A20',
yellow: '#FFC94D',
yellowSoft: '#2B2413',
white: '#FFFFFF'
}
这段代码实例化了GamePalette接口,创建了一个名为GP的全局常量对象。这是整个应用的色彩中枢,所有组件的颜色引用都通过GP.xxx的方式进行。
颜色命名采用了"基础色 + 修饰词"的模式:primary表示主色调(赛博青),secondary表示次色调(霓虹紫),warn表示警告色(粉红色),green和yellow分别是绿色和黄色的功能色。每个颜色都有对应的Soft版本,用于背景填充和状态标签的底色。
在暗色主题设计中,使用带颜色倾向的深色背景(如
primarySoft是带青色倾向的深蓝),比纯灰色背景更能营造品牌氛围,同时保持了良好的可读性。
卡片颜色也做了分层设计:card是普通卡片背景,cardDeep是更深一级的卡片背景,用于嵌套卡片或需要区分层次的场景。这种微妙的颜色差异在视觉上形成了"卡片中的卡片"效果,增强了界面的深度感。
文本颜色同样分为两级:text是主要文本色(近白色),textSub是次要文本色(灰色)。两级文本色配合字体大小和字重的变化,可以构建清晰的信息层级。
1.3 全局纯函数:数据格式化的工具集

在UI开发中,原始数据往往不能直接用于展示,需要经过格式化处理。本应用定义了一系列纯函数,专门负责将数据转换为适合UI展示的格式。
function roomStatusColor(st: string): string {
if (st === '紧张') { return GP.warn }
if (st === '可预订') { return GP.green }
return GP.yellow
}
function roomStatusBg(st: string): string {
if (st === '紧张') { return GP.warnSoft }
if (st === '可预订') { return GP.greenSoft }
return GP.yellowSoft
}
roomStatusColor和roomStatusBg是一对配套函数,分别返回房间状态标签的文字颜色和背景颜色。函数通过条件判断将状态文本映射到对应的颜色令牌。这种设计将状态与颜色的映射逻辑集中管理,避免了在每个使用状态的地方都写一遍条件判断。
函数式编程的一个重要原则就是纯函数——相同的输入永远产生相同的输出,不产生任何副作用。这些状态颜色函数都是典型的纯函数,它们不依赖外部状态,不修改任何数据,只是根据输入返回对应的颜色值。纯函数易于测试、易于复用,是构建可靠应用的基础。
function matchStatusColor(st: string): string {
if (st === '名额紧张') { return GP.warn }
if (st === '报名中') { return GP.primary }
return GP.yellow
}
function matchStatusBg(st: string): string {
if (st === '名额紧张') { return GP.warnSoft }
if (st === '报名中') { return GP.primarySoft }
return GP.yellowSoft
}
赛事状态颜色映射函数与房间状态类似,但使用了不同的颜色方案。赛事的"报名中"状态使用主色调(赛博青),而房间的"可预订"状态使用绿色。这种差异化的颜色策略体现了不同业务模块的视觉特点。
function orderStatusColor(st: string): string {
if (st === '已完成' || st === '已取消') { return GP.textSub }
if (st === '待入住' || st === '待服务') { return GP.primary }
return GP.yellow
}
订单状态颜色映射函数的逻辑稍有不同,已完成和已取消的订单使用灰色(次要文本色),表示这些订单已经是历史状态,不需要用户关注。待入住和待服务的订单使用主色调,吸引用户注意进行后续操作。
状态颜色设计的核心原则是:重要的、需要用户行动的状态使用高饱和度颜色,历史的、已完成的状态使用低饱和度颜色。这种视觉引导可以有效提升用户的操作效率。
function prizeText(p: number): string {
if (p >= 10000) {
return (p / 10000).toFixed(1) + '万'
}
return p.toString()
}
prizeText函数将奖金额度格式化为易读的文本。当奖金超过1万元时,转换为"X.X万"的格式,避免了长数字造成的阅读困难。这是一个典型的数据格式化函数,在UI开发中非常常见。
toFixed(1)方法确保了转换后的数字保留一位小数,保持了显示的一致性。例如,20000元会被格式化为"2.0万",8000元则保持"8000"不变。
function barH(v: number, max: number): string {
return (v / max * 90).toFixed(0) + 'vp'
}
barH函数是一个有趣的工具函数,用于计算柱状图的高度。它接受当前值和最大值两个参数,返回一个以vp为单位的高度字符串。函数将数值按比例缩放到0-90vp的范围内,确保柱状图不会过高或过低。
vp是鸿蒙系统中的虚拟像素单位,它会根据设备的屏幕密度自动缩放,保证UI在不同分辨率的设备上具有一致的显示效果。使用vp而不是固定像素值,是鸿蒙响应式设计的基础。
function scoreColor(s: number): string {
if (s >= 9.5) { return GP.warn }
if (s >= 9.0) { return GP.primary }
return GP.textSub
}
scoreColor函数根据评分数值返回不同的颜色。9.5分以上使用警告色(粉红色),9.0-9.5使用主色调(赛博青),9.0以下使用次要文本色。这种分级着色让高分评价更加醒目,引导用户选择高评分的服务。
1.4 底部Tab枚举:导航状态的类型化管理

enum GameTab {
HOME = 0,
ROOM = 1,
PACK = 2,
MATCH = 3,
TEAM = 4,
GEAR = 5,
PAL = 6,
MINE = 7
}
GameTab枚举定义了8个Tab页面对应的数值索引。使用枚举而不是直接使用数字常量有多个好处:首先,枚举值具有语义化的名称,代码可读性更高;其次,TypeScript的类型系统可以对枚举值进行类型检查,避免传入无效的Tab索引;最后,当需要调整Tab顺序时,只需修改枚举定义即可。
在鸿蒙开发中,枚举常用于管理有限状态集合,如Tab索引、页面状态、弹窗类型等。配合@State装饰器使用,可以实现类型安全的状态管理。
二、头部组件:应用的信息入口
2.1 @Component装饰器与组件结构
@Component
struct GameHeader {
@State keyword: string = ''
@Component是鸿蒙ArkTS中最核心的装饰器之一,用于标记一个结构体为UI组件。被@Component装饰的结构体必须实现build()方法,在其中描述组件的UI结构。
@State装饰器用于声明组件的内部状态。当@State装饰的变量发生变化时,鸿蒙UI框架会自动重新执行build()方法,更新界面显示。这就是声明式UI的核心思想——状态驱动视图,开发者只需关注状态的变化,框架负责处理UI更新。
在GameHeader组件中,keyword是一个字符串类型的状态变量,用于存储搜索框的输入内容。初始值为空字符串,用户在搜索框中输入时,keyword会实时更新。
2.2 Column与Row的嵌套布局
build() {
Column() {
Row() {
Column() {
Text('多多电竞').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.primary)
Text('开黑 · 住宿 · 赛事 · 陪玩').fontSize(8).fontColor(GP.textSub).margin({ top: 1 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
鸿蒙的布局系统基于Flexbox设计,Column和Row是最基础的两个布局容器。Column用于垂直方向的布局,子组件从上到下排列;Row用于水平方向的布局,子组件从左到右排列。
这段代码展示了一个典型的嵌套布局结构:最外层是Column,包含上下两行内容。第一行是一个Row,水平排列了Logo区域、搜索框和功能图标。Logo区域又是一个Column,垂直排列了品牌名称和副标题。
alignItems(HorizontalAlign.Start)设置Column的子元素在水平方向上左对齐。默认情况下,Column的子元素是居中对齐的,但Logo区域需要左对齐以符合阅读习惯。
layoutWeight(1)是一个非常重要的属性,类似于Web开发中Flexbox的flex: 1。它告诉父容器,这个子组件应该占据剩余空间的比例。在同一行中,如果多个子组件都设置了layoutWeight,它们会按照权重比例分配剩余空间。
layoutWeight是鸿蒙布局系统中实现自适应布局的关键手段。通过合理设置权重,可以让界面在不同屏幕尺寸上都保持良好的布局效果。
Text('🔍').fontSize(15).margin({ left: 4 })
TextInput({ placeholder: '搜索房型 / 套餐 / 战队' })
.placeholderColor('#5A6070').fontSize(12).height(30)
.backgroundColor(GP.cardDeep).borderRadius(15)
.margin({ left: 6, right: 6 }).layoutWeight(2)
.onChange((v: string) => { this.keyword = v })
搜索框使用TextInput组件实现,这是鸿蒙中用于接收用户文本输入的基础组件。通过placeholder属性设置占位提示文字,placeholderColor设置占位文字的颜色。
onChange事件回调在用户输入内容时触发,回调函数接收当前输入值v作为参数。这里将输入值赋值给this.keyword,更新组件状态。由于keyword被@State装饰,赋值操作会触发UI的重新渲染。
搜索框的layoutWeight(2)设置了权重为2,意味着它会占据比Logo区域(权重为1)更多的空间。这样的设计让搜索框成为头部的视觉焦点,符合搜索功能的重要性。
Text('💬').fontSize(16)
Text('🎫').fontSize(16).margin({ left: 8 })
Text('👑').fontSize(16).margin({ left: 8 })
}
.width('100%').padding({ left: 12, right: 12, top: 8, bottom: 8 })
搜索框右侧是三个功能图标:消息、优惠券和会员。使用emoji作为图标是一种简单高效的做法,尤其在原型开发阶段。emoji图标无需额外的图片资源,跨平台一致性好,而且可以通过fontSize控制大小。
整个头部第一行设置了width('100%')使其占满父容器宽度,padding属性设置了内边距。padding可以接受一个对象参数,分别指定上、下、左、右四个方向的边距,这种写法比分别设置四个边距属性更加简洁。
Row() {
Text('📍 上海 · 电竞中心').fontSize(10).fontColor(GP.textSub)
Text('⚡ 千兆光纤').fontSize(10).fontColor(GP.green).margin({ left: 12 })
Text('🏆 本月赛事 8 场').fontSize(10).fontColor(GP.yellow).margin({ left: 12 })
Column().layoutWeight(1)
Text('🎁 新客立减30').fontSize(10).fontColor(GP.warn)
}
.width('100%').padding({ left: 12, right: 12, bottom: 8 })
}
.width('100%').backgroundColor(GP.bg)
}
}
头部的第二行是信息条,展示位置、网络配置、赛事数量和新客优惠。这里有一个巧妙的布局技巧:使用一个空的Column配合layoutWeight(1)来撑开中间的空间,将"新客立减30"推到最右侧。这种方式类似于Web开发中的margin-left: auto技巧。
信息条中的每个文本都使用了不同的颜色,绿色代表网络质量(正面信息),黄色代表赛事信息(吸引注意),粉红色代表优惠信息(行动召唤)。颜色的差异化使用让信息条在有限的空间内传达了丰富的语义。
三、入口页面:应用的骨架与导航
3.1 @Entry装饰器与根组件
@Entry
@Component
struct GameApp {
@State activeTab: number = GameTab.HOME
@Entry装饰器标记一个组件为页面的入口组件。在鸿蒙中,每个页面都需要有一个@Entry组件作为根节点。应用启动时,会加载并渲染@Entry标记的组件。
activeTab是应用的核心状态变量,记录当前激活的Tab索引。初始值为GameTab.HOME(即0),表示默认显示首页。用户点击底部Tab时,activeTab的值会更新,触发界面重新渲染,显示对应的页面内容。
3.2 Stack堆叠布局与页面切换
build() {
Column() {
GameHeader()
Stack() {
if (this.activeTab === GameTab.HOME) {
HomePage()
} else if (this.activeTab === GameTab.ROOM) {
RoomPage()
} else if (this.activeTab === GameTab.PACK) {
PackPage()
} else if (this.activeTab === GameTab.MATCH) {
MatchPage()
} else if (this.activeTab === GameTab.TEAM) {
TeamPage()
} else if (this.activeTab === GameTab.GEAR) {
GearPage()
} else if (this.activeTab === GameTab.PAL) {
PalPage()
} else {
MinePage()
}
}
.layoutWeight(1)
.width('100%')
内容区域使用Stack堆叠布局容器。Stack的特点是子组件会堆叠在一起,后渲染的组件会覆盖在先渲染的组件之上。在这个场景中,虽然同时写了8个页面组件,但通过if-else条件判断,每次只有一个页面组件会被实际渲染。
为什么使用
Stack而不是直接将页面组件放在Column中?因为Stack提供了堆叠上下文,方便后续添加模态弹窗、加载提示等覆盖层。事实上,后续每个页面组件内部也都使用了Stack来承载模态弹窗。
layoutWeight(1)确保内容区域占据头部和底部导航之间的所有剩余空间。这是典型的"上下固定、中间自适应"的三段式布局结构。
条件渲染是声明式UI的重要特性。与命令式编程中手动控制DOM元素的显示隐藏不同,声明式UI通过条件表达式直接描述"在什么状态下应该显示什么",框架会自动处理渲染逻辑。
3.3 双排底部Tab导航
Row() {
this.tabItem('🏠', '首页', GameTab.HOME)
this.tabItem('🛏️', '房间', GameTab.ROOM)
this.tabItem('🎁', '套餐', GameTab.PACK)
this.tabItem('🏆', '赛事', GameTab.MATCH)
}
.width('100%').height(44)
.backgroundColor(GP.cardDeep)
.padding({ left: 4, right: 4, top: 3 })
Row() {
this.tabItem('🛡️', '战队', GameTab.TEAM)
this.tabItem('🖱️', '周边', GameTab.GEAR)
this.tabItem('🎮', '陪玩', GameTab.PAL)
this.tabItem('👤', '我的', GameTab.MINE)
}
.width('100%').height(40)
.backgroundColor(GP.cardDeep)
.padding({ left: 4, right: 4, bottom: 3 })
底部导航采用了不常见的双排设计,共8个Tab项分为上下两行排列。上排4个Tab高度为44vp,下排4个Tab高度为40vp,通过高度差异形成视觉主次。上排是核心业务入口(首页、房间、套餐、赛事),下排是社区与个人功能(战队、周边、陪玩、我的)。
双排Tab的设计在Tab数量较多时是一种创新方案。传统的单排Tab通常最多容纳5个项目,超过后会显得拥挤。双排设计将Tab数量翻倍,同时保持了每个Tab的可点击区域大小,提升了用户体验。
双排底部导航是一种值得关注的交互创新。当应用功能模块较多时,传统的底部Tab或侧边导航都有各自的局限。双排Tab在保持单手可及性的同时,提供了更多的入口选项。
3.4 @Builder装饰器与自定义Tab项
@Builder
tabItem(icon: string, label: string, tab: number) {
Column() {
Row() {
Text(icon).fontSize(13)
Text(label).fontSize(10).fontColor(this.activeTab === tab ? GP.primary : GP.textSub)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal).margin({ left: 3 })
}
.justifyContent(FlexAlign.Center)
Divider().strokeWidth(2).color(this.activeTab === tab ? GP.primary : Color.Transparent)
.width(22).borderRadius(1).margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.activeTab = tab })
}
}
@Builder装饰器用于定义可复用的UI构建函数。与完整的@Component组件不同,@Builder方法是组件内部的一个构建函数,可以访问组件的状态变量和方法。它更轻量,适合抽取组件内部的重复UI片段。
tabItem方法接收三个参数:图标emoji、标签文字和对应的Tab索引值。方法内部构建了一个Column,包含图标+文字的行和底部的指示条。
选中状态通过三元表达式判断:this.activeTab === tab ? GP.primary : GP.textSub。当activeTab等于当前Tab的索引时,文字颜色为主色调且加粗,底部指示条显示为主色调;否则文字为次要文本色,指示条为透明色。
justifyContent(FlexAlign.Center)设置子元素在主轴方向上居中对齐。对于Column来说,主轴是垂直方向;对于Row来说,主轴是水平方向。这里在Column和Row上都设置了居中对齐,确保Tab项的内容在水平和垂直方向上都居中显示。
Divider组件用于绘制底部的选中指示条。通过设置strokeWidth(线宽)、color(颜色)、width(长度)和borderRadius(圆角),实现了一个精致的Tab指示器效果。
onClick事件处理函数将activeTab更新为当前Tab的索引值。由于activeTab被@State装饰,赋值后框架会自动检测到状态变化,重新执行build()方法,更新界面显示。整个过程是声明式的、响应式的,开发者无需手动操作UI元素。
这张状态图展示了8个Tab页面之间的切换关系。每个Tab都是一个独立的状态,用户点击Tab会触发状态转换。在鸿蒙声明式UI中,状态转换的表现形式就是@State变量的更新,以及由此引发的UI重新渲染。
四、首页实现:信息聚合与快速入口
4.1 首页组件结构与状态管理
@Component
struct HomePage {
@State showBook: boolean = false
@State formRoom: string = '双人开黑房'
@State formDate: string = '09月18日'
@State formHours: string = '8小时'
首页组件定义了4个状态变量。showBook是一个布尔值,控制快速订房模态框的显示与隐藏。formRoom、formDate、formHours三个字符串变量分别存储表单中的房型、日期和时长选择。
首页的模态框表单状态由组件自身管理,这是一种合理的状态归属设计。表单数据只在模态框显示时才有意义,模态框关闭后数据可以重置,因此将这些状态放在首页组件内部是合适的。
4.2 @Builder构建的模态遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.65)')
.onClick(onClose)
}
modalOverlay是一个通用的模态遮罩层构建函数,接收一个onClose回调函数作为参数。遮罩层是一个占满全屏的Column,背景色是半透明的黑色(65%不透明度)。点击遮罩层时会调用传入的onClose回调,关闭模态框。
将遮罩层抽取为独立的
@Builder方法是一种优秀的实践。几乎每个模态框都需要遮罩层,通过参数化的onClose回调,同一个遮罩层构建函数可以被不同的模态框复用。
遮罩层的背景色使用rgba()函数定义,这是CSS中常见的颜色表示方式,鸿蒙同样支持。rgba(0,0,0,0.65)表示红、绿、蓝通道都为0(纯黑),alpha通道为0.65(65%不透明度)。半透明的遮罩层既能突出模态框,又能让用户感知到下层的页面内容。
4.3 快速订房模态框的结构
@Builder
quickBookModal() {
Column() {
this.modalOverlay(() => { this.showBook = false })
Column() {
Row() {
Text('⚡ 快速订房').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showBook = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
quickBookModal方法构建了完整的快速订房模态框。最外层是一个Column,包含两个子元素:遮罩层和模态框内容。由于外层是Stack布局,模态框内容会覆盖在遮罩层之上。
模态框内容区域是另一个Column,分为三部分:标题栏、表单内容区和底部操作按钮。标题栏使用Row布局,左侧是标题文字,右侧是关闭按钮。中间的空Column配合layoutWeight(1)将标题和关闭按钮推向两端。
Divider分割线用于区分标题栏和内容区。分割线的颜色使用了一个独立的颜色值#262A35,而不是从GP中引用。这可能是因为分割线颜色在设计令牌中没有对应的命名,属于一次性使用的颜色。
4.4 ForEach与横向滚动选择器
Column() {
Text('选择房型').fontSize(11).fontColor(GP.textSub).width('100%')
Scroll() {
Row() {
ForEach(ROOMS.slice(0, 8), (r: RoomItem) => {
if (this.formRoom === r.name) {
Text(r.name).fontSize(10).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(r.name).fontSize(10).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formRoom = r.name })
}
}, (r: RoomItem) => r.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(34)
.margin({ top: 6 })
房型选择区域使用了Scroll + Row + ForEach的组合,实现了横向滚动的标签选择器。这是移动端常见的选择器模式,当选项数量较多、无法在一屏内完全显示时,横向滚动是一种空间高效的交互方式。
ForEach是鸿蒙声明式UI中的循环渲染组件,它接收三个参数:数据源、生成子组件的回调函数、键值生成函数。ForEach会遍历数据源中的每个元素,调用回调函数生成对应的UI组件,并根据键值高效地更新列表。
ROOMS.slice(0, 8)取房间数据的前8项作为选项。slice方法创建了一个新数组,不会修改原数组。
选中状态通过if-else判断实现。选中状态的标签使用主色调背景和深色文字,未选中状态使用深色背景和灰色文字。只有未选中的标签才有onClick事件,因为选中的标签再次点击没有意义。
ForEach的第三个参数(键值生成函数)对于列表性能至关重要。框架使用键值来识别哪些元素发生了变化,从而进行最小化的DOM更新。使用唯一的id作为键值是最佳实践,可以避免不必要的重渲染。
Scroll组件包裹Row实现横向滚动。scrollable(ScrollDirection.Horizontal)设置滚动方向为水平方向。scrollBar(BarState.Off)隐藏滚动条,让界面更加简洁。height(34)固定滚动区域的高度,防止内容高度变化导致布局抖动。
Text('入住日期').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['09月18日', '09月19日', '09月20日'], (d: string) => {
if (this.formDate === d) {
Text(d).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 9, right: 9, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(d).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 9, right: 9, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formDate = d })
}
}, (d: string) => d)
}
.margin({ top: 6 })
入住日期选择器使用了类似的模式,但选项数量较少(3个),因此不需要横向滚动,直接使用Row排列即可。日期选项是硬编码的字符串数组,在实际项目中应该是动态生成的日期列表。
日期选项的样式与房型选项类似,但尺寸稍大(字号11 vs 10,左右内边距9 vs 8,圆角13 vs 12)。这种细微的尺寸差异可能是为了适应不同长度的文本内容。
Text('入住时长').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['4小时', '8小时', '12小时'], (h: string) => {
if (this.formHours === h) {
Text(h).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(h).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formHours = h })
}
}, (h: string) => h)
}
.margin({ top: 6 })
入住时长选择器的实现模式与日期选择器完全一致。这种重复的模式在整个应用中非常常见——标签选择器是一种通用的UI模式,被广泛应用于各种表单场景。
虽然代码看起来有些重复,但这是声明式UI的特点之一。每个选择器管理自己的状态和交互,逻辑清晰且独立。如果需要进一步抽象,可以将标签选择器封装为一个可复用的自定义组件。
Text('备注').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextInput({ placeholder: '选填:如需要投影、外设等' })
.placeholderColor('#5A6070').fontSize(12
).backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 6 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
备注输入框使用TextInput组件,与头部的搜索框类似。这里的占位文字提示用户可以填写特殊需求,如需要投影设备或额外外设等。
表单内容区的底部有一个空的Column配合layoutWeight(1),它的作用是将表单内容向上推,底部操作按钮区域保持在模态框底部。这是"内容自适应 + 底部固定"的经典布局模式。
4.5 模态框底部操作栏
Row() {
Text('取消').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showBook = false })
Text('提交预订').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showBook = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
}
.width('92%').height('60%').backgroundColor(GP.card).borderRadius(16)
.position({ x: '4%', y: '18%' }).zIndex(999)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
底部操作栏包含两个按钮:“取消"和"提交预订”。取消按钮是次要按钮样式(深色背景+灰色文字),提交按钮是主要按钮样式(主色背景+深色文字)。主按钮在视觉上更突出,引导用户进行正向操作。
两个按钮都使用Text组件实现,通过设置背景色、圆角和内边距来模拟按钮效果。这是鸿蒙开发中常见的做法——Text组件本身就可以承载点击交互,配合样式设置完全可以替代专门的Button组件。
模态框内容区域使用了.position({ x: '4%', y: '18%' })进行定位,宽度为92%,高度为60%。position属性设置组件的左上角相对于父容器的位置,配合zIndex控制堆叠顺序,实现了模态框浮在页面之上的效果。
zIndex是控制堆叠顺序的重要属性。默认情况下,后渲染的组件会覆盖在先渲染的组件之上。通过设置zIndex可以显式地控制组件的前后层级。模态框设置较高的zIndex值(999),确保它始终显示在最上层。
4.6 赛事卡片构建函数
@Builder
activityCard(m: MatchItem) {
Column() {
Text(m.icon).fontSize(28)
Text(m.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text).margin({ top: 5 })
Text(m.game + ' · 奖金' + prizeText(m.prize)).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
Text(m.status).fontSize(9).fontColor(matchStatusColor(m.status))
.backgroundColor(matchStatusBg(m.status)).padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).margin({ top: 5 })
}
.width(130).padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 6, right: 6 })
}
activityCard方法构建了一个赛事卡片组件,接收一个MatchItem类型的参数。卡片是垂直布局的Column,从上到下依次展示:赛事图标、赛事名称、游戏类型和奖金、状态标签。
奖金展示调用了prizeText函数进行格式化,状态标签的颜色则通过matchStatusColor和matchStatusBg两个函数动态计算。这种将数据格式化逻辑与UI渲染分离的做法,让代码更加清晰易维护。
卡片的宽度固定为130vp,这是横向滚动卡片的常见设计——固定宽度的卡片在滚动时具有更好的节奏感和可预期性。
4.7 首页主体内容
build() {
Stack() {
Column() {
Scroll() {
Column() {
// 横幅
Column() {
Row() {
Column() {
Text('🏆 电竞狂欢周').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.white)
Text('房型8折起 · 赛事报名送周边').fontSize(10).fontColor('#BFE9FF').margin({ top: 6 })
Text('立即订房 >').fontSize(10).fontColor(GP.bg).backgroundColor(GP.yellow)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10).margin({ top: 10 })
.onClick(() => { this.showBook = true })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 16 })
Text('🕹️').fontSize(50).opacity(0.95)
}
.width('100%')
}
.width('100%').height(108)
.linearGradient({
angle: 135,
colors: [['#A855F7', 0.0], ['#3A2A66', 0.55], ['#12213A', 1.0]]
})
.borderRadius(14).margin({ left: 12, right: 12, top: 10 })
首页的主体内容包裹在Scroll中,支持垂直滚动。内容区域的第一个元素是活动横幅,这是一个高度为108vp的渐变背景区域。
linearGradient方法设置线性渐变背景,接受一个配置对象参数。angle指定渐变角度(135度即从左上到右下),colors是一个二维数组,每个子数组包含颜色值和位置百分比。这里使用了三个颜色节点:霓虹紫(0%)、深紫色(55%)、深蓝色(100%),营造出从紫色到蓝色的渐变效果。
横幅内容分为左右两部分:左侧是活动标题、副标题和行动按钮,右侧是一个大的游戏手柄emoji作为装饰。行动按钮"立即订房"点击后会设置showBook = true,打开快速订房模态框。
// 快捷入口
Row() {
Column() { Text('🛏️').fontSize(19); Text('订房').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🎁').fontSize(19); Text('套餐').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🏆').fontSize(19); Text('赛事').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🛡️').fontSize(19); Text('战队').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🎮').fontSize(19); Text('陪玩').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ top: 12, bottom: 12 }).backgroundColor(GP.card)
.borderRadius(12).margin({ left: 12, right: 12, top: 8 })
快捷入口区域使用5个等分的图标入口,分别是订房、套餐、赛事、战队、陪玩。每个入口由图标和文字组成,使用Column垂直排列。通过layoutWeight(1)平均分配5个入口的宽度,确保它们在任何屏幕尺寸下都等宽排列。
快捷入口是移动端首页的常见设计模式,将核心功能以图标的形式聚合展示,用户可以快速跳转到对应的功能模块。
// 赛事横滑
Row() {
Text('🏆 近期赛事').fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('全部赛事 >').fontSize(9).fontColor(GP.textSub)
}
.width('100%').padding({ left: 14, right: 14, top: 14, bottom: 6 })
Scroll() {
Row() {
this.activityCard(MATCHES[0])
this.activityCard(MATCHES[2])
this.activityCard(MATCHES[4])
this.activityCard(MATCHES[7])
}
.padding({ left: 6, right: 6 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(150)
近期赛事区域由标题栏和横向滚动卡片列表组成。标题栏左侧是模块标题,右侧是"全部赛事"的查看更多入口。中间的空Column撑开空间,实现两端对齐。
横向滚动卡片列表使用Scroll + Row的组合。这里手动选取了4个赛事数据(索引0、2、4、7)来展示,而不是遍历全部数据。在实际项目中,可能会有"推荐赛事"的标记字段,用于筛选首页展示的赛事。
// 特价房
Row() {
Text('🔥 特价房型').fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('每晚限量').fontSize(9).fontColor(GP.warn)
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 6 })
ForEach(ROOMS.slice(0, 6), (r: RoomItem) => {
Row() {
Text(r.icon).fontSize(22)
.backgroundColor(GP.secondarySoft).width(44).height(44).borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Text(r.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(r.config + ' · ' + r.screens + '屏').fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
Text('⭐ ' + r.score.toFixed(1)).fontSize(9).fontColor(GP.yellow).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + r.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.warn)
Text('¥' + r.originPrice).fontSize(9).fontColor(GP.textSub)
.decoration({ type: TextDecorationType.LineThrough }).margin({ top: 1 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
}, (r: RoomItem) => r.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showBook) { this.quickBookModal() }
}
.width('100%').height('100%')
}
}
特价房型区域使用垂直列表展示6个特价房间。每个房间项是一个横向排列的Row,左侧是房间图标,中间是房间信息(名称、配置、评分),右侧是价格信息(现价和原价)。
原价使用了decoration({ type: TextDecorationType.LineThrough })设置删除线效果,这是电商应用中表示原价的标准做法。现价使用警告色(粉红色)并加粗,突出价格优势。
toFixed(1)方法将评分数值格式化为保留一位小数的字符串,例如9.2、9.0等。统一的小数位数让列表看起来更加整齐。
首页的最外层是Stack布局,当showBook为true时,会在页面内容之上渲染快速订房模态框。这是模态弹窗的标准实现模式——页面内容和弹窗都放在Stack中,通过状态变量控制弹窗的显示与隐藏。
五、房间页实现:列表浏览与预订流程
5.1 房间页的状态设计
@Component
struct RoomPage {
@State showBook: boolean = false
@State showCheckout: boolean = false
@State selectedRoom: RoomItem | null = null
@State filter: string = '全部'
@State formDate: string = '09月18日'
@State formHours: string = '8小时'
@State formPeople: string = '2人'
@State formMeal: string = '不含餐'
房间页的状态比首页更复杂,共定义了8个状态变量。除了预订模态框的状态外,还增加了退房确认模态框、筛选条件、人数选择和餐饮选择等状态。
selectedRoom的类型是RoomItem | null,这是TypeScript中的联合类型,表示这个变量既可以是RoomItem对象,也可以是null。初始值为null,表示当前没有选中的房间。用户点击某个房间后,selectedRoom被设置为对应的房间对象,同时打开预订模态框。
filter状态存储当前的筛选条件,用于过滤房间列表。筛选功能虽然定义了状态变量,但在列表渲染时并没有实际应用过滤逻辑——所有房间都会显示。这是一个待完善的功能点。
5.2 预订模态框的完整结构
@Builder
bookModal() {
Column() {
this.modalOverlay(() => { this.showBook = false })
Column() {
Row() {
Text('🛏️ 预订房型').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showBook = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Scroll() {
Column() {
Row() {
Text(this.selectedRoom?.icon ?? '🛏️').fontSize(30)
Column() {
Text(this.selectedRoom?.name ?? '').fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(this.selectedRoom?.config ?? '').fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Text('¥' + (this.selectedRoom?.price ?? 0)).fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.warn)
}
.width('100%').backgroundColor(GP.cardDeep).borderRadius(10).padding(12)
.margin({ top: 8 })
预订模态框的结构与快速订房模态框类似,但内容更丰富。模态框顶部是选中房间的信息摘要,包括图标、名称、配置和价格。这里使用了TypeScript的可选链操作符?.和空值合并操作符??来安全地访问selectedRoom的属性。
可选链操作符?.在访问属性前会检查对象是否为null或undefined,如果是则表达式的值为undefined,不会抛出错误。空值合并操作符??在左侧值为null或undefined时返回右侧的默认值。两者配合使用,可以优雅地处理可选类型的数据访问。
房间信息区域使用了更深一级的卡片背景(GP.cardDeep),与模态框的主背景(GP.card)形成层次区分。这种"卡片嵌套卡片"的设计在视觉上创建了深度感。
Text('入住日期').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['09月18日', '09月19日', '09月20日'], (d: string) => {
if (this.formDate === d) {
Text(d).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 9, right: 9, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(d).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 9, right: 9, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formDate = d })
}
}, (d: string) => d)
}
.margin({ top: 5 })
Text('入住时长').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['4小时', '8小时', '12小时'], (h: string) => {
if (this.formHours === h) {
Text(h).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(h).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formHours = h })
}
}, (h: string) => h)
}
.margin({ top: 5 })
预订模态框包含4个选择项:入住日期、入住时长、人数、是否含餐。每个选择项的实现模式完全相同——标签文字 + 横向排列的标签按钮。
这种高度一致的模式设计有两个好处:一是用户在使用过程中会逐渐熟悉这种交互方式,降低学习成本;二是开发者可以更高效地构建表单,因为每个字段的结构都很相似。
Text('人数').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['2人', '3人', '5人', '7人'], (p: string) => {
if (this.formPeople === p) {
Text(p).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(p).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formPeople = p })
}
}, (p: string) => p)
}
.margin({ top: 5 })
Text('是否含餐').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['不含餐', '含简餐', '含自助餐'], (m: string) => {
if (this.formMeal === m) {
Text(m).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(m).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formMeal = m })
}
}, (m: string) => m)
}
.margin({ top: 5 })
Text('备注').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextInput({ placeholder: '如:需要多一把电竞椅' })
.placeholderColor('#5A6070').fontSize(12
).backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 })
Column().height(10)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
人数选项提供了4个选择(2人、3人、5人、7人),与房间类型的电脑数量相对应。餐饮选项提供了3个选择(不含餐、含简餐、含自助餐),满足不同用户的用餐需求。
表单内容区域包裹在Scroll中,支持垂直滚动。这是因为表单内容较多,在小屏幕设备上可能无法完全显示,滚动确保了所有内容都能被访问到。.layoutWeight(1)让滚动区域占据模态框的剩余空间,底部操作按钮保持固定。
5.3 退房确认模态框
@Builder
checkoutModal() {
Column() {
this.modalOverlay(() => { this.showCheckout = false })
Column() {
Text('🔓').fontSize(38).margin({ top: 20 })
Text('确认退房?').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(this.selectedRoom?.name ?? '').fontSize(11).fontColor(GP.textSub).margin({ top: 5 })
Text('超时退房将按小时计费,请确认').fontSize(10).fontColor(GP.warn).margin({ top: 4 })
Row() {
Text('再想想').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showCheckout = false })
Text('确认退房').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showCheckout = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 18, bottom: 20 })
}
.width('84%').backgroundColor(GP.card).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '8%', y: '34%' }).zIndex(999)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
退房确认模态框是一个简单的确认对话框,用于在用户执行重要操作前进行二次确认。这是一种常见的防误操作设计。
确认对话框采用居中对齐的布局(alignItems(HorizontalAlign.Center)),从上到下依次是:图标、标题、房间名称、提示文字、操作按钮。整体内容居中,视觉上更加聚焦。
提示文字"超时退房将按小时计费,请确认"使用警告色,提醒用户注意超时费用。这种费用透明化的设计可以减少用户的投诉和误解。
确认对话框的宽度为84%,比预订模态框(94%)窄一些,视觉上更加紧凑。高度不固定,由内容撑开,这是确认对话框的典型设计——只展示必要信息,不需要过多的内容空间。
5.4 房间列表与筛选
build() {
Stack() {
Column() {
Row() {
Text('🛏️ 房间预订').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('+ 订房').fontSize(12).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.showBook = true })
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 8 })
房间页的顶部是标题栏,左侧是页面标题,右侧是"+订房"按钮。按钮使用主色调背景,点击后直接打开预订模态框(此时selectedRoom为null,显示默认房间图标)。
Scroll() {
Row() {
ForEach(['全部', '可预订', '紧张'], (f: string) => {
if (this.filter === f) {
Text(f).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
} else {
Text(f).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
.onClick(() => { this.filter = f })
}
}, (f: string) => f)
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(36)
筛选栏使用横向滚动的标签按钮,提供3个筛选选项:全部、可预订、紧张。用户点击筛选标签时,filter状态更新,但列表内容并没有实际过滤——所有房间都会显示。这是一个功能不完整的地方。
在实际项目中,筛选功能需要配合计算属性或过滤后的数据数组使用。可以通过
computed计算属性或在build方法中直接过滤数组来实现。当前代码虽然定义了筛选状态,但没有将其应用到列表渲染中。
Scroll() {
Column() {
ForEach(ROOMS, (r: RoomItem) => {
Column() {
Row() {
Text(r.icon).fontSize(24)
.backgroundColor(GP.secondarySoft).width(46).height(46).borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(r.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().width(7).height(7).borderRadius(4)
.backgroundColor(r.status === '紧张' ? GP.warn : GP.green)
.margin({ left: 6 })
}
Text(r.config).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
Text(r.screens + '屏 · ' + r.beds + '床 · ⭐' + r.score.toFixed(1)).fontSize(9).fontColor(GP.textSub).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text(r.status).fontSize(8).fontColor(roomStatusColor(r.status))
.backgroundColor(roomStatusBg(r.status)).padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
Text('¥' + r.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.warn).margin({ top: 5 })
Text('¥' + r.originPrice).fontSize(8).fontColor(GP.textSub)
.decoration({ type: TextDecorationType.LineThrough }).margin({ top: 1 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text(r.desc).fontSize(9).fontColor(GP.textSub).layoutWeight(1)
Text('预订').fontSize(10).fontColor(GP.primary)
}
.width('100%').margin({ top: 8 })
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
.onClick(() => { this.selectedRoom = r; this.showBook = true })
}, (r: RoomItem) => r.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
房间列表是房间页的核心内容。每个房间卡片是一个垂直布局的Column,包含两行内容:第一行是房间的主要信息(图标、名称、配置、价格等),第二行是房间描述和预订入口。
房间名称旁边有一个小圆点指示器(7x7vp),通过房间状态动态改变颜色——"紧张"状态显示警告色(粉红色),其他状态显示绿色。这个小指示器让用户在浏览列表时可以快速识别房间的紧张程度。
状态标签的文字颜色和背景颜色通过roomStatusColor和roomStatusBg函数动态计算,保持了与首页特价房区域的一致性。
房间卡片的点击事件会做两件事:设置selectedRoom = r(保存选中的房间数据)和this.showBook = true(打开预订模态框)。预订模态框中会显示选中房间的详细信息。
if (this.showBook) { this.bookModal() }
if (this.showCheckout) { this.checkoutModal() }
}
.width('100%').height('100%')
}
}
房间页的最外层是Stack,包含页面内容和两个模态框。两个模态框各自独立控制,互不干扰。这种设计模式在整个应用中被反复使用,体现了组件化和状态隔离的思想。
六、套餐页与赛事页:列表与模态的复用模式
6.1 套餐页的双模态框设计
@Component
struct PackPage {
@State showBuy: boolean = false
@State showDetail: boolean = false
@State selectedPack: PackageItem | null = null
@State formAddHour: string = '不加时'
套餐页定义了4个状态变量,控制两个模态框(购买模态框和详情模态框)的显示。selectedPack存储当前选中的套餐,formAddHour是购买表单中的加时选项。
套餐页的交互流程是:用户点击套餐卡片 → 打开详情模态框 → 点击"立即购买" → 关闭详情模态框并打开购买模态框。这种"详情 + 购买"的两步流程是电商应用的典型模式。
@Builder
buyModal() {
Column() {
this.modalOverlay(() => { this.showBuy = false })
Column() {
Row() {
Text('🎁 购买套餐').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showBuy = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Row() {
Text(this.selectedPack?.icon ?? '🎁').fontSize(32)
Column() {
Text(this.selectedPack?.name ?? '').fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(this.selectedPack?.hours ?? '').fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Text('¥' + (this.selectedPack?.price ?? 0)).fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.warn)
}
.width('100%').backgroundColor(GP.cardDeep).borderRadius(10).padding(12)
.margin({ top: 8 })
购买模态框的结构与房间预订模态框类似,顶部是套餐信息摘要,下面是表单内容。套餐信息摘要包含图标、名称、时长和价格,使用深一级的卡片背景突出显示。
Text('加时选择').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['不加时', '加1小时', '加2小时'], (h: string) => {
if (this.formAddHour === h) {
Text(h).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(h).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formAddHour = h })
}
}, (h: string) => h)
}
.margin({ top: 5 })
Text('支付方式').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
Text('💳 微信支付').fontSize(11).fontColor(GP.text).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13).margin({ left: 3, right: 3 })
Text('👛 余额支付').fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13).margin({ left: 3, right: 3 })
Text('🎫 会员券').fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(13).margin({ left: 3, right: 3 })
}
.margin({ top: 5 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
加时选择是套餐购买特有的选项,提供不加时、加1小时、加2小时三个选择。支付方式选项展示了三种支付渠道:微信支付、余额支付、会员券。
支付方式的标签与其他选择器有所不同——第一个选项"微信支付"使用主文本色,表示这是默认选中的支付方式。但实际上这里并没有绑定状态变量,点击也不会有任何反应。这是一个简化的展示,在实际项目中应该将支付方式也做成可交互的选择器。
仔细观察可以发现,支付方式区域的三个标签都没有
onClick事件,也没有根据状态变化样式。这是UI原型中常见的"假交互"——视觉上看起来可以点击,但实际上没有功能。在原型阶段,这种做法可以快速展示界面效果,降低开发成本。
6.2 详情模态框的实现
@Builder
detailModal() {
Column() {
this.modalOverlay(() => { this.showDetail = false })
Column() {
Row() {
Text(this.selectedPack?.icon ?? '🎁').fontSize(34)
Column() {
Text(this.selectedPack?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(this.selectedPack?.hours ?? '').fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showDetail = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16 })
Divider().color('#262A35').margin({ top: 12 })
详情模态框的标题栏与其他模态框略有不同——关闭按钮在右上角,图标和标题在左侧。整体布局更加紧凑,突出套餐的图标和名称。
Column() {
Text('包含内容').fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text).width('100%')
Text(this.selectedPack?.items ?? '').fontSize(11).fontColor(GP.textSub).margin({ top: 5 }).width('100%')
Text('套餐说明').fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text).margin({ top: 14 }).width('100%')
Text(this.selectedPack?.desc ?? '').fontSize(11).fontColor(GP.textSub).margin({ top: 5 }).width('100%')
Row() {
Text('¥' + (this.selectedPack?.price ?? 0)).fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.warn)
Text('¥' + (this.selectedPack?.originPrice ?? 0)).fontSize(10).fontColor(GP.textSub)
.decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
if (this.selectedPack?.hot ?? false) {
Text('热卖').fontSize(9).fontColor(GP.white).backgroundColor(GP.warn)
.padding({ left: 7, right: 7, top: 2, bottom: 2 }).borderRadius(8).margin({ left: 8 })
}
}
.margin({ top: 16 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
详情内容区包含"包含内容"和"套餐说明"两个信息块,以及价格和热卖标签。价格区域使用了更大的字号(16vp),强调价格信息。
if (this.selectedPack?.hot ?? false)是一个条件渲染语句,当套餐为热卖商品时显示"热卖"标签。?? false确保当selectedPack为null时条件为false,不会显示标签。
Row() {
Text('取消').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showDetail = false })
Text('立即购买').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showDetail = false; this.showBuy = true })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
详情模态框的底部按钮是"取消"和"立即购买"。点击"立即购买"会执行两个操作:关闭详情模态框(this.showDetail = false)和打开购买模态框(this.showBuy = true)。这实现了从详情到购买的流程跳转。
两个模态框的切换是瞬间完成的,用户可能会感觉是同一个模态框在切换内容。在实际项目中,可以添加过渡动画让切换更加平滑。
6.3 套餐列表渲染
build() {
Stack() {
Column() {
Text('🎁 上网套餐').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
.width('100%').padding({ left: 14, top: 12, bottom: 8 })
Scroll() {
Column() {
ForEach(PACKAGES, (p: PackageItem) => {
Row() {
Text(p.icon).fontSize(26)
.backgroundColor(GP.primarySoft).width(50).height(50).borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.text)
if (p.hot) {
Text('HOT').fontSize(8).fontColor(GP.white).backgroundColor(GP.warn)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6).margin({ left: 6 })
}
}
Text(p.hours + ' · ' + p.items).fontSize(9).fontColor(GP.textSub).margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + p.price).fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.warn)
Text('¥' + p.originPrice).fontSize(9).fontColor(GP.textSub)
.decoration({ type: TextDecorationType.LineThrough }).margin({ top: 1 })
Text('购买 >').fontSize(9).fontColor(GP.primary).margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
.onClick(() => { this.selectedPack = p; this.showDetail = true })
}, (p: PackageItem) => p.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showBuy) { this.buyModal() }
if (this.showDetail) { this.detailModal() }
}
.width('100%').height('100%')
}
}
套餐列表的结构与房间列表类似,都是垂直排列的卡片列表。每个套餐卡片左侧是图标,中间是套餐名称(带热卖标签)和包含内容,右侧是价格和购买入口。
套餐图标的背景色使用了GP.primarySoft(青色柔和色),而房间图标使用的是GP.secondarySoft(紫色柔和色)。这种颜色差异让不同模块的列表具有各自的视觉特征,用户可以通过颜色快速区分当前所在的页面。
点击套餐卡片会设置selectedPack并打开详情模态框,而不是直接打开购买模态框。这与房间页的交互不同——房间页点击卡片直接进入预订流程,套餐页则先展示详情再进入购买。这种差异反映了两种业务的特点:房间预订是高频操作,流程越短越好;套餐购买需要用户了解详情后再决策。
6.4 赛事页的报名流程
@Component
struct MatchPage {
@State showSign: boolean = false
@State showDetail: boolean = false
@State selectedMatch: MatchItem | null = null
@State formTeam: string = ''
@State formPhone: string = ''
赛事页的状态结构与套餐页类似,也是两个模态框(报名模态框和详情模态框)配合一个选中项状态。表单状态包含队伍名称和队长电话两个输入框的内容。
赛事报名流程与套餐购买流程相同:点击列表项 → 打开详情 → 点击报名 → 打开报名表单。这种"详情 + 操作"的两步模式是列表类页面的通用模式。
@Builder
signModal() {
Column() {
this.modalOverlay(() => { this.showSign = false })
Column() {
Row() {
Text('🏆 赛事报名').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showSign = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Row() {
Text(this.selectedMatch?.icon ?? '🏆').fontSize(32)
Column() {
Text(this.selectedMatch?.name ?? '').fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text((this.selectedMatch?.game ?? '') + ' · ' + (this.selectedMatch?.date ?? '')).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
}
.width('100%').backgroundColor(GP.cardDeep).borderRadius(10).padding(12)
.margin({ top: 8 })
报名模态框的顶部是赛事信息摘要,包含图标、赛事名称、游戏类型和日期。与其他模态框不同的是,这里没有在摘要区域显示价格(赛事报名是免费的)。
Text('队伍名称').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextInput({ placeholder: '输入战队名' })
.placeholderColor('#5A6070').fontSize(12
).backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 }).onChange((v: string) => { this.formTeam = v })
Text('队长电话').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextInput({ placeholder: '11位手机号' })
.placeholderColor('#5A6070').fontSize(12
).backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 }).onChange((v: string) => { this.formPhone = v })
Text('参赛人数').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Text('默认5人队(含替补),联赛报名费免费').fontSize(10).fontColor(GP.green).margin({ top: 4 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
报名表包含两个输入字段:队伍名称和队长电话。每个输入框都通过onChange事件将输入值同步到对应的状态变量。
参赛人数字段是纯展示信息,使用绿色文字说明"报名费免费",降低用户的报名门槛心理。这是一种常见的转化优化手段——通过强调"免费"来减少用户的顾虑。
6.5 赛事详情与数据可视化
@Builder
matchDetailModal() {
Column() {
this.modalOverlay(() => { this.showDetail = false })
Column() {
Row() {
Text(this.selectedMatch?.icon ?? '🏆').fontSize(34)
Column() {
Text(this.selectedMatch?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(this.selectedMatch?.game ?? '').fontSize(10).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showDetail = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16 })
Divider().color('#262A35').margin({ top: 12 })
Column() {
Row() {
Column() {
Text('📅').fontSize(15)
Text((this.selectedMatch?.date ?? '') + ' ' + (this.selectedMatch?.time ?? '')).fontSize(10).fontColor(GP.textSub).margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('📍').fontSize(15)
Text(this.selectedMatch?.place ?? '').fontSize(10).fontColor(GP.textSub).margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('🏅').fontSize(15)
Text('奖金' + prizeText(this.selectedMatch?.prize ?? 0)).fontSize(10).fontColor(GP.warn).margin({ top: 3 })
赛事详情模态框的核心信息区域采用三列布局,分别展示时间、地点和奖金。三个信息项平分宽度,每个都包含图标和文字说明。
奖金信息使用警告色(粉红色)突出显示,因为奖金是吸引玩家报名的重要因素。奖金数值通过prizeText函数格式化,超过1万的显示为"X.X万"。
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ top: 14, bottom: 14 }).backgroundColor(GP.cardDeep)
.borderRadius(10).margin({ top: 12 })
Text('报名队伍 ' + (this.selectedMatch?.teams ?? 0) + ' 支').fontSize(11).fontColor(GP.textSub).margin({ top: 12 }).width('100%')
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
Row() {
Text('取消').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showDetail = false })
Text('立即报名').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showDetail = false; this.showSign = true })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
"报名队伍 X 支"显示了当前已报名的队伍数量,利用社会认同心理增加用户的报名意愿——当用户看到很多人已经报名时,会更倾向于认为这是一个有价值的赛事。
6.6 赛事页的柱状图可视化
build() {
Stack() {
Column() {
Text('🏆 赛事中心').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
.width('100%').padding({ left: 14, top: 12, bottom: 8 })
Scroll() {
Column() {
// 参赛队伍统计柱状图
Column() {
Text('📊 各赛事报名队伍数').fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text)
.width('100%').padding({ left: 14, top: 12, bottom: 8 })
Row() {
ForEach(MATCHES.slice(0, 8), (m: MatchItem) => {
Column() {
Text(m.teams.toString()).fontSize(9).fontColor(GP.primary)
Column()
.width(22)
.height(barH(m.teams, 128))
.linearGradient({
angle: 180,
colors: [[GP.primary, 0.0], ['#12213A', 1.0]]
})
.borderRadius({ topLeft: 4, topRight: 4 })
Text(m.game.length > 2 ? m.game.slice(0, 2) : m.game).fontSize(8).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}, (m: MatchItem) => m.id.toString())
}
.width('100%').padding({ left: 10, right: 10, bottom: 12 })
}
.width('100%').backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
赛事页面最有特色的部分是参赛队伍统计柱状图。这是一个纯CSS实现的柱状图,使用Column组件模拟柱状条,通过height属性控制柱子的高度。
barH(m.teams, 128)函数计算每个柱子的高度,以128为最大值(金铲铲棋王赛有128支队伍)。柱子的高度与参赛队伍数成正比,直观地展示了各赛事的热度对比。
柱子使用了垂直方向的线性渐变(angle: 180度),从主色调(赛博青)渐变到深蓝色,营造出柱状图的立体感。顶部设置了圆角(borderRadius({ topLeft: 4, topRight: 4 })),底部是直角,符合柱状图的视觉惯例。
柱状图下方显示游戏名称的缩写,使用slice(0, 2)截取前两个字符。对于名称较长的游戏(如"英雄联盟"、“王者荣耀”),只显示前两个字可以避免文字溢出。
纯代码实现的柱状图是一种轻量级的数据可视化方案。虽然功能不如专业图表库强大,但对于简单的统计展示已经足够,而且无需引入额外的依赖,性能更好。
这张流程图展示了柱状图从数据到渲染的完整流程。从原始数据出发,经过遍历、高度计算、样式设置,最终生成可视化的柱状图。
Text('近期赛程').fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text)
.width('100%').padding({ left: 14, top: 10, bottom: 4 })
ForEach(MATCHES, (m: MatchItem) => {
Row() {
Text(m.icon).fontSize(22)
.backgroundColor(GP.secondarySoft).width(44).height(44).borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(m.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(m.game).fontSize(8).fontColor(GP.textSub).margin({ left: 6 })
}
Text(m.date + ' ' + m.time + ' · ' + m.place).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
Text('奖金 ' + prizeText(m.prize) + ' · 参赛 ' + m.teams + ' 队').fontSize(9).fontColor(GP.yellow).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text(m.status).fontSize(8).fontColor(matchStatusColor(m.status))
.backgroundColor(matchStatusBg(m.status)).padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
Text('报名 >').fontSize(9).fontColor(GP.primary).margin({ top: 5 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
.onClick(() => { this.selectedMatch = m; this.showDetail = true })
}, (m: MatchItem) => m.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showSign) { this.signModal() }
if (this.showDetail) { this.matchDetailModal() }
}
.width('100%').height('100%')
}
}
赛事列表与房间列表、套餐列表的结构类似,每个列表项左侧是图标,中间是赛事名称、时间地点、奖金和参赛队伍数,右侧是状态标签和报名入口。
奖金和参赛队伍数信息使用黄色文字显示,在暗色背景上非常醒目。这行信息同时展示了两个关键数据:奖金金额(激励因素)和参赛队伍数(社会认同),共同促进用户点击报名。
七、战队页与周边页:社区与电商的融合
7.1 战队页的双模态设计
@Component
struct TeamPage {
@State showJoin: boolean = false
@State showCreate: boolean = false
@State selectedTeam: TeamItem | null = null
战队页有两个模态框:申请入队模态框和创建战队模态框。与之前的页面不同,这两个模态框之间没有流程关系,是两个独立的功能入口。
@Builder
joinModal() {
Column() {
this.modalOverlay(() => { this.showJoin = false })
Column() {
Row() {
Text('🛡️ 申请入队').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showJoin = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Text('目标战队').fontSize(11).fontColor(GP.textSub).width('100%')
Text(this.selectedTeam?.name ?? '').fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.primary).margin({ top: 5 })
Text('自我介绍').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextArea({ placeholder: '介绍一下你的段位和擅长位置' })
.placeholderColor('#5A6070').fontSize(12).height(70)
.backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 })
申请入队模态框包含目标战队名称展示、自我介绍输入框和擅长游戏选择。目标战队名称直接显示,不需要用户输入,因为用户是从具体的战队卡片点击进入的。
TextArea是多行文本输入组件,与TextInput的区别在于它支持多行输入,适合输入较长的文本内容。height(70)设置了输入框的高度,可以容纳约3-4行文字。
Text('擅长游戏').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['英雄联盟', '王者荣耀', '无畏契约', 'CS2'], (g: string) => {
if (g === '英雄联盟') {
Text(g).fontSize(10).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 9, right: 9, top: 4, bottom: 4 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(g).fontSize(10).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 9, right: 9, top: 4, bottom: 4 }).borderRadius(12)
.margin({ left: 3, right: 3 })
}
}, (g: string) => g)
}
.margin({ top: 5 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
擅长游戏选择器展示了4个热门游戏,但目前只有"英雄联盟"是选中状态,而且其他选项没有绑定点击事件。这是一个简化的展示,在实际项目中应该将选中状态与状态变量绑定,并支持多选或单选切换。
这里的标签选择器没有绑定状态,也没有点击事件,是"假交互"的又一个例子。在原型开发阶段,这种做法可以快速呈现界面效果,同时减少开发工作量。
Row() {
Text('取消').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showJoin = false })
Text('提交申请').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showJoin = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
}
.width('90%').height('54%').backgroundColor(GP.card).borderRadius(16)
.position({ x: '5%', y: '21%' }).zIndex(999)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
申请入队模态框的底部按钮是"取消"和"提交申请"。提交申请后关闭模态框,在实际项目中应该还会有提交成功的提示或跳转。
7.2 创建战队模态框
@Builder
createModal() {
Column() {
this.modalOverlay(() => { this.showCreate = false })
Column() {
Row() {
Text('🛡️ 创建战队').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showCreate = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Text('战队名称').fontSize(11).fontColor(GP.textSub).width('100%')
TextInput({ placeholder: '输入战队名' })
.placeholderColor('#5A6070').fontSize(12
).backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 })
Text('战队宣言').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextInput({ placeholder: '一句话宣言' })
.placeholderColor('#5A6070').fontSize(12
).backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 })
Text('所在地区').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['上海', '杭州', '南京', '苏州'], (c: string) => {
if (c === '上海') {
Text(c).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(c).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
}
}, (c: string) => c)
}
.margin({ top: 5 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
创建战队模态框包含三个字段:战队名称、战队宣言和所在地区。地区选择器提供了4个城市选项,默认选中"上海"。
地区选择器的标签尺寸比其他选择器稍大(左右内边距12vp vs 9-10vp),因为城市名称通常是两个字,需要更多的空间来保持视觉平衡。
Row() {
Text('取消').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showCreate = false })
Text('创建战队').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showCreate = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
}
.width('90%').height('56%').backgroundColor(GP.card).borderRadius(16)
.position({ x: '5%', y: '20%' }).zIndex(999)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
底部按钮是"取消"和"创建战队"。创建战队后关闭模态框,在实际项目中应该还会将新创建的战队添加到战队列表中。
7.3 战队列表与胜率进度条
build() {
Stack() {
Column() {
Row() {
Text('🛡️ 战队广场').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('+ 创建战队').fontSize(12).fontColor(GP.bg)
.backgroundColor(GP.secondary).borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.showCreate = true })
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 8 })
战队页的顶部标题栏右侧有一个"+ 创建战队"按钮,使用次色调(霓虹紫)背景。这是整个应用中少数使用次色调作为主按钮背景的地方,可能是为了与其他页面的创建按钮形成区分。
Scroll() {
Column() {
ForEach(TEAMS, (t: TeamItem) => {
Column() {
Row() {
Text(t.icon).fontSize(28)
.backgroundColor(GP.secondarySoft).width(52).height(52).borderRadius(26)
.textAlign(TextAlign.Center)
Column() {
Text(t.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(t.region + ' · ' + t.members + '人').fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text(t.rank).fontSize(9).fontColor(GP.yellow)
.backgroundColor(GP.yellowSoft).padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
战队列表的每个卡片顶部是战队头像、名称和段位标签。战队头像使用圆形设计(borderRadius(26),因为宽度和高度都是52vp,所以圆角是边长的一半就是圆形),这是社交类应用中常见的头像展示方式。
段位标签使用黄色背景和黄色文字,突出显示战队的实力等级。段位是电竞用户非常关注的信息,放在显眼的位置有助于用户快速判断战队的水平。
Row() {
Text('胜率').fontSize(9).fontColor(GP.textSub)
Column()
.width(winBar(t.winRate))
.height(5).backgroundColor(GP.primary).borderRadius(2)
Column().layoutWeight(1)
Text(t.winRate + '%').fontSize(9).fontColor(GP.primary)
}
.width('100%').height(5).backgroundColor(GP.chip).borderRadius(2).margin({ top: 8 })
胜率进度条是战队卡片的亮点。它的实现方式很巧妙:外层是一个高度为5vp的Row,背景色是GP.chip(深灰色),作为进度条的轨道。内层是一个Column,宽度由winBar(t.winRate)计算,背景色是主色调(赛博青),作为进度条的填充部分。
winBar函数直接返回胜率值加百分号,例如"82%"。由于外层Row的宽度是100%,设置内层Column的宽度为百分比值,就实现了进度条的效果。这种"宽度百分比 = 进度百分比"的设计非常简洁高效。
进度条的左侧是"胜率"文字标签,右侧是具体的胜率数值。中间通过一个空的Column配合layoutWeight(1)将胜率数值推到最右侧。整个进度条高度只有5vp,非常纤细精致。
纯CSS进度条是一种常见的UI实现技巧。通过两层容器(外层作为轨道,内层作为填充),利用宽度百分比控制进度,不需要任何图片或特殊组件,性能优秀且易于定制。
Text(t.desc).fontSize(9).fontColor(GP.textSub).margin({ top: 7 }).width('100%')
Row() {
Text('申请入队').fontSize(10).fontColor(GP.primary)
.backgroundColor(GP.primarySoft).padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12).margin({ top: 8 })
.onClick(() => { this.selectedTeam = t; this.showJoin = true })
}
.width('100%')
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
}, (t: TeamItem) => t.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showJoin) { this.joinModal() }
if (this.showCreate) { this.createModal() }
}
.width('100%').height('100%')
}
}
战队卡片的底部是战队简介和"申请入队"按钮。申请入队按钮使用主色调文字加柔和色背景的设计,这是次级按钮的常见样式——既有品牌色的辨识度,又不会过度抢夺用户注意力。
7.4 周边页的双列瀑布流布局
@Component
struct GearPage {
@State showDetail: boolean = false
@State showBuy: boolean = false
@State selectedGear: PeripheryItem | null = null
@State formCount: string = '1件'
周边页是电商功能模块,状态结构与套餐页类似——两个模态框(详情和购买)配合选中项和表单状态。
build() {
Stack() {
Column() {
Row() {
Text('🖱️ 电竞周边').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('🛒 购物车 2 件').fontSize(10).fontColor(GP.primary)
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 8 })
Scroll() {
Column() {
Row() {
Column() {
ForEach(PERIPHERIES.slice(0, 6), (g: PeripheryItem) => {
this.gearCard(g)
}, (g: PeripheryItem) => g.id.toString())
}.layoutWeight(1)
Column() {
ForEach(PERIPHERIES.slice(6, 12), (g: PeripheryItem) => {
this.gearCard(g)
}, (g: PeripheryItem) => g.id.toString())
}.layoutWeight(1).padding({ left: 3 })
}
.width('100%').padding({ left: 12, right: 12 })
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
周边商品列表采用双列瀑布流布局,这是电商应用的常见设计。双列布局比单列布局能展示更多的商品信息,提升浏览效率。
实现方式是手动将商品数组分成两半(前6个和后6个),分别放在左右两个Column中。两个Column的权重都是1,平分宽度。右侧列设置了padding({ left: 3 }),创建两列之间的间距。
严格来说这不是真正的"瀑布流"(瀑布流的特点是每列高度不一致,元素依次填充到最短的列中),而是等分布局。但视觉效果类似,都是双列的商品卡片网格。
if (this.showDetail) { this.gearDetailModal() }
if (this.showBuy) { this.buyModal() }
}
.width('100%').height('100%')
}
周边页有两个模态框:商品详情模态框和购买确认模态框。点击商品卡片打开详情,从详情点击购买打开购买确认框。
7.5 商品卡片构建函数
@Builder
gearCard(g: PeripheryItem) {
Column() {
Column() {
Text(g.icon).fontSize(32)
if (g.hot) {
Text('热卖').fontSize(8).fontColor(GP.white).backgroundColor(GP.warn)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6)
.margin({ top: 4 })
}
}
.width('100%').height(78).backgroundColor(GP.primarySoft).borderRadius(10)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Text(g.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text).margin({ top: 6 }).width('100%')
Text(g.category).fontSize(9).fontColor(GP.textSub).margin({ top: 3 }).width('100%')
Row() {
Text('¥' + g.price).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.warn)
Column().layoutWeight(1)
Text('购买').fontSize(10).fontColor(GP.primary).backgroundColor(GP.primarySoft)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
.onClick(() => { this.selectedGear = g; this.showBuy = true })
}
.width('100%').margin({ top: 6 })
}
.width('100%').padding(10).backgroundColor(GP.card).borderRadius(12)
.margin({ top: 6 })
.onClick(() => { this.selectedGear = g; this.showDetail = true })
}
}
gearCard方法构建单个商品卡片。卡片是垂直布局,从上到下依次是:商品图片区、商品名称、商品分类、价格和购买按钮。
商品图片区是一个高度为78vp的区域,背景色是主色调柔和色,居中显示商品图标emoji。如果商品是热卖商品,还会在图标下方显示一个"热卖"标签。
卡片有两个点击区域:点击卡片的大部分区域会打开商品详情模态框,点击右下角的"购买"按钮会直接打开购买模态框。这种设计兼顾了两种用户行为:想了解详情的用户可以点击卡片查看详情,已经决定购买的用户可以直接点击购买按钮快速下单。
双点击区域的设计需要注意事件冒泡问题。在鸿蒙中,如果子组件和父组件都绑定了点击事件,点击子组件时会先触发子组件的事件,然后冒泡到父组件。在这个场景中,点击购买按钮会同时触发购买按钮的点击事件和卡片的点击事件,导致两个模态框都打开。这是一个潜在的Bug,在实际项目中需要处理事件冒泡。
八、陪玩页与我的页面:社交与个人中心
8.1 陪玩页的预约与评价流程
@Component
struct PalPage {
@State showBook: boolean = false
@State showReview: boolean = false
@State selectedPal: PalItem | null = null
@State formHours: string = '1小时'
陪玩页有两个模态框:预约陪玩模态框和评价陪玩模态框。预约模态框在用户点击"预约"按钮时打开,评价模态框在用户完成陪玩服务后使用(当前代码中没有触发入口)。
@Builder
bookModal() {
Column() {
this.modalOverlay(() => { this.showBook = false })
Column() {
Row() {
Text('🎮 预约陪玩').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showBook = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Row() {
Text(this.selectedPal?.avatar ?? '🎮').fontSize(30)
Column() {
Text(this.selectedPal?.nickname ?? '').fontSize(14).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text((this.selectedPal?.game ?? '') + ' · ' + (this.selectedPal?.rank ?? '')).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Text('¥' + (this.selectedPal?.price ?? 0) + '/时').fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.warn)
}
.width('100%').backgroundColor(GP.cardDeep).borderRadius(10).padding(12)
.margin({ top: 8 })
预约模态框顶部的陪玩信息摘要包含头像、昵称、游戏+段位、时薪。时薪显示为"¥X/时"的格式,明确告知用户计费方式。
Text('时长').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Row() {
ForEach(['1小时', '2小时', '3小时'], (h: string) => {
if (this.formHours === h) {
Text(h).fontSize(11).fontColor(GP.bg).backgroundColor(GP.primary)
.padding({ left: 11, right: 11, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
} else {
Text(h).fontSize(11).fontColor(GP.textSub).backgroundColor(GP.chip)
.padding({ left: 11, right: 11, top: 5, bottom: 5 }).borderRadius(13)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formHours = h })
}
}, (h: string) => h)
}
.margin({ top: 5 })
Text('段位要求').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
Text('无特殊要求(可接低段位)').fontSize(10).fontColor(GP.textSub).margin({ top: 4 })
Text('备注').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextArea({ placeholder: '想打什么位置/模式?' })
.placeholderColor('#5A6070').fontSize(12).height(56)
.backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
预约表单包含时长选择、段位要求和备注。时长选项有3个:1小时、2小时、3小时。段位要求是纯展示信息,说明陪玩可以接低段位的单子,降低用户的预约门槛。
备注使用TextArea多行输入框,高度56vp,让用户可以填写具体的需求,如想打的位置、模式等。
8.2 评价模态框
@Builder
reviewModal() {
Column() {
this.modalOverlay(() => { this.showReview = false })
Column() {
Row() {
Text('⭐ 评价陪玩').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showReview = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Text('综合评分').fontSize(11).fontColor(GP.textSub).width('100%')
Text('★★★★★').fontSize(26).fontColor(GP.yellow).margin({ top: 6 })
Text('评价内容').fontSize(11).fontColor(GP.textSub).width('100%').margin({ top: 12 })
TextArea({ placeholder: '分享你的开黑体验…' })
.placeholderColor('#5A6070').fontSize(12).height(80)
.backgroundColor(GP.chip).borderRadius(8).width('100%')
.margin({ top: 5 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
Text('提交评价').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 32, right: 32, top: 9, bottom: 9 })
.alignSelf(ItemAlign.Center).margin({ top: 12, bottom: 16 })
.onClick(() => { this.showReview = false })
}
.width('88%').height('52%').backgroundColor(GP.card).borderRadius(16)
.position({ x: '6%', y: '22%' }).zIndex(999)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
评价模态框是一个简化的评价界面,包含星级评分和评价内容输入。星级评分使用5个星号emoji表示,默认全部选中(5星好评)。
与其他模态框不同,评价模态框的底部只有一个"提交评价"按钮,而不是"取消+确认"的双按钮布局。单按钮设计可能是因为用户已经完成了服务,评价是可选操作,取消按钮可以通过点击遮罩层或右上角的X来实现。
alignSelf(ItemAlign.Center)设置按钮在交叉轴上居中对齐。对于Column来说,交叉轴是水平方向,所以这个属性让按钮水平居中显示。这是alignItems的单个元素版本,用于控制单个子元素的对齐方式。
8.3 陪玩列表展示
build() {
Stack() {
Column() {
Row() {
Text('🎮 陪玩大神').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('在线 ' + PALS.length + ' 位').fontSize(10).fontColor(GP.green)
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 8 })
Scroll() {
Column() {
ForEach(PALS, (p: PalItem) => {
Row() {
Text(p.avatar).fontSize(26)
.backgroundColor(GP.secondarySoft).width(48).height(48).borderRadius(24)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(p.nickname).fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(p.rank).fontSize(8).fontColor(GP.yellow)
.backgroundColor(GP.yellowSoft).padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(7).margin({ left: 6 })
}
Text(p.game + ' · 接单 ' + p.orders).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
Text('⭐ ' + p.score.toFixed(1)).fontSize(9).fontColor(scoreColor(p.score)).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + p.price + '/时').fontSize(13).fontWeight(FontWeight.Bold).fontColor(GP.warn)
Text('预约').fontSize(10).fontColor(GP.primary)
.backgroundColor(GP.primarySoft).padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12).margin({ top: 5 })
.onClick(() => { this.selectedPal = p; this.showBook = true })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
}, (p: PalItem) => p.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showBook) { this.bookModal() }
if (this.showReview) { this.reviewModal() }
}
.width('100%').height('100%')
}
}
陪玩列表的每个卡片包含:圆形头像、昵称+段位标签、游戏+接单量、评分、时薪和预约按钮。
评分的颜色通过scoreColor函数动态计算——9.5分以上是粉红色,9.0-9.5是赛博青,9.0以下是灰色。这种颜色分级让高分陪玩更加醒目,帮助用户快速识别优质服务。
接单量是陪玩的重要信任指标,显示在游戏名称后面,类似电商的销量。接单量越高,说明这个陪玩越受欢迎,用户选择的可能性也越大。
右上角的"在线 X 位"使用绿色文字,实时显示当前在线的陪玩数量,营造出平台活跃的氛围。
九、我的页面:个人中心与订单管理
9.1 用户信息卡与数据统计
@Component
struct MinePage {
@State showOrder: boolean = false
@State showCancel: boolean = false
@State selectedOrder: GameOrderItem | null = null
"我的"页面管理两个模态框:订单详情模态框和取消订单确认模态框。selectedOrder存储当前选中的订单。
build() {
Stack() {
Column() {
// 用户卡
Column() {
Row() {
Text('🕹️').fontSize(36)
.backgroundColor(GP.secondarySoft).width(62).height(62).borderRadius(31)
.textAlign(TextAlign.Center)
Column() {
Text('峡谷常驻选手').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text('Lv.28 · 电竞会员 · 徽章 15 枚').fontSize(10).fontColor(GP.textSub).margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
Text('✏️').fontSize(15).fontColor(GP.textSub)
}
.width('100%').padding({ left: 14, right: 14, top: 14, bottom: 10 })
用户信息卡是个人中心的标志性组件,展示用户头像、昵称、等级和会员状态。头像使用圆形设计(62x62vp,圆角31vp),背景色是次色调柔和色,与战队头像的风格保持一致。
昵称下方显示用户的等级、会员身份和徽章数量。这些数据是用户成就的可视化展示,能够激励用户更多地使用平台功能。等级体系和徽章系统是提升用户留存的重要手段。
编辑图标放在信息卡的右上角,暗示用户可以点击编辑个人资料。这是一种常见的交互设计模式——将编辑入口与展示区域放在一起,用户可以直观地理解操作对象。
Row() {
Column() { Text('12').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.primary); Text('订单').fontSize(9).fontColor(GP.textSub).margin({ top: 2 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('3').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.yellow); Text('优惠券').fontSize(9).fontColor(GP.textSub).margin({ top: 2 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('8600').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.secondary); Text('积分').fontSize(9).fontColor(GP.textSub).margin({ top: 2 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('2').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.warn); Text('战队').fontSize(9).fontColor(GP.textSub).margin({ top: 2 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ top: 8, bottom: 12 }).backgroundColor(GP.cardDeep)
.borderRadius(10)
}
.width('100%').backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
用户数据统计栏是信息卡的下半部分,使用4列等分布局展示4个核心数据:订单数、优惠券数、积分、战队数。每个数据项由数值和标签组成,数值使用不同的颜色区分——订单用青色、优惠券用黄色、积分用紫色、战队用粉红色。
多彩的数据统计栏让个人中心页面更加活泼,同时不同的颜色也帮助用户快速区分不同类型的数据。背景使用更深一级的卡片色(GP.cardDeep),与信息卡的上半部分形成视觉层次。
数据统计栏是个人中心的"信息仪表盘",将用户最关心的几个核心指标集中展示。通过数字和颜色的组合,用户可以一眼了解自己在平台上的活跃度和资产状况。
9.2 快捷功能宫格
// 快捷宫格
Row() {
Column() { Text('🧾').fontSize(18); Text('订单').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('💳').fontSize(18); Text('会员卡').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🎫').fontSize(18); Text('优惠券').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🏆').fontSize(18); Text('赛事记录').fontSize(9).fontColor(GP.textSub).margin({ top: 4 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ top: 12, bottom: 12 }).backgroundColor(GP.card)
.borderRadius(12).margin({ left: 12, right: 12, top: 8 })
快捷功能宫格使用4个等分布局的图标入口,分别是订单、会员卡、优惠券、赛事记录。这与首页的快捷入口设计模式相同,都是使用图标+文字的组合,提供快速跳转功能。
宫格布局的优势在于信息密度高、操作效率高。用户可以一目了然地看到所有可用功能,并且点击目标大,不容易误触。对于个人中心这种功能入口较多的页面,宫格布局是一种高效的组织方式。
9.3 订单列表与多类型图标
Text('我的订单').fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text)
.width('100%').padding({ left: 14, top: 12, bottom: 4 })
Scroll() {
Column() {
ForEach(GAME_ORDERS, (o: GameOrderItem) => {
Row() {
Text(o.type === '房间' ? '🛏️' : (o.type === '套餐' ? '🎁' : (o.type === '陪玩' ? '🎮' : '🏆')))
.fontSize(19).backgroundColor(GP.secondarySoft).width(40).height(40).borderRadius(10)
.textAlign(TextAlign.Center)
Column() {
Text(o.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text(o.date + ' · ' + o.place).fontSize(9).fontColor(GP.textSub).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text(o.status).fontSize(9).fontColor(orderStatusColor(o.status))
Text('¥' + o.price).fontSize(12).fontWeight(FontWeight.Bold).fontColor(GP.warn).margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(12).backgroundColor(GP.card).borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
.onClick(() => { this.selectedOrder = o; this.showOrder = true })
}, (o: GameOrderItem) => o.id.toString())
Column().height(16)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
订单列表展示了用户的所有订单,涵盖房间、套餐、周边、陪玩、赛事等多种类型。每个订单项左侧有一个图标,图标的类型通过三元表达式根据o.type动态选择。
这种多类型图标的实现方式虽然功能上没问题,但嵌套的三元表达式可读性较差。在实际项目中,可以将图标映射逻辑抽取为一个函数,例如orderIcon(type: string): string,让代码更加清晰易维护。
订单状态的颜色通过orderStatusColor函数动态计算。已完成和已取消的订单使用灰色(次要文本色),表示这些订单已经结束;待入住和待服务的订单使用主色调(赛博青),表示需要用户关注或操作;其他状态(如已发货、已报名)使用黄色。
订单列表是个人中心最核心的功能之一。由于订单类型多样(房间、套餐、周边、陪玩、赛事),如何在一个统一的列表中清晰地展示不同类型的订单,是设计中的一个挑战。使用不同的图标来区分订单类型,是一种简单有效的解决方案。
9.4 订单详情模态框
@Builder
orderModal() {
Column() {
this.modalOverlay(() => { this.showOrder = false })
Column() {
Row() {
Text('🧾 订单详情').fontSize(17).fontWeight(FontWeight.Bold).fontColor(GP.text)
Column().layoutWeight(1)
Text('✕').fontSize(17).fontColor(GP.textSub)
.onClick(() => { this.showOrder = false })
}
.width('100%').padding({ left: 18, right: 18, top: 16, bottom: 10 })
Divider().color('#262A35')
Column() {
Text(this.selectedOrder?.title ?? '').fontSize(18).fontWeight(FontWeight.Bold).fontColor(GP.primary)
Text(this.selectedOrder?.type ?? '').fontSize(11).fontColor(GP.textSub).margin({ top: 4 })
订单详情模态框顶部显示订单标题和类型。订单标题使用主色调和较大的字号(18vp),突出显示订单的核心信息。订单类型使用小号灰色文字,作为辅助信息。
Row() {
Text('🗓️').fontSize(14)
Text(this.selectedOrder?.date ?? '').fontSize(12).fontColor(GP.text).margin({ left: 8 })
}
.width('100%').margin({ top: 14 })
Row() {
Text('📍').fontSize(14)
Text(this.selectedOrder?.place ?? '').fontSize(12).fontColor(GP.text).margin({ left: 8 })
}
.width('100%').margin({ top: 10 })
Row() {
Text('💰').fontSize(14)
Text('¥' + (this.selectedOrder?.price ?? 0)).fontSize(15).fontWeight(FontWeight.Bold).fontColor(GP.warn).margin({ left: 8 })
}
.width('100%').margin({ top: 10 })
Row() {
Text('📌').fontSize(14)
Text(this.selectedOrder?.status ?? '').fontSize(12).fontColor(orderStatusColor(this.selectedOrder?.status ?? '')).margin({ left: 8 })
}
.width('100%').margin({ top: 10 })
Column().layoutWeight(1)
}
.padding({ left: 18, right: 18 }).layoutWeight(1)
订单详情内容使用图标+文字的行式布局,依次展示日期、地点、价格和状态。每一行由一个emoji图标和对应的文字信息组成,结构清晰,易于阅读。
价格信息使用较大的字号(15vp)和警告色(粉红色),并加粗显示,强调金额信息。状态文字的颜色通过orderStatusColor函数动态计算,与列表中的状态颜色保持一致。
这种"图标+文字"的详情展示方式是移动端的常见设计。图标的使用降低了用户的认知负荷,让用户可以通过图标快速定位到感兴趣的信息行。
Row() {
if (this.selectedOrder?.status === '待入住' || this.selectedOrder?.status === '待服务') {
Text('取消订单').fontSize(13).fontColor(GP.warn)
.backgroundColor(GP.warnSoft).borderRadius(18)
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.onClick(() => { this.showCancel = true })
}
Text('知道了').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.primary).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showOrder = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
订单详情的底部按钮区域使用了条件渲染。当订单状态为"待入住"或"待服务"时,显示"取消订单"按钮;否则只显示"知道了"按钮。这种根据状态动态调整操作选项的设计,避免了用户执行无效操作。
取消订单按钮使用警告色文字和警告色柔和背景,视觉上是一个次要按钮,但颜色暗示了这是一个需要谨慎操作的功能。点击取消订单按钮会打开取消确认模态框,进行二次确认。
9.5 取消订单确认模态框
@Builder
cancelModal() {
Column() {
this.modalOverlay(() => { this.showCancel = false })
Column() {
Text('🗑️').fontSize(38).margin({ top: 20 })
Text('确认取消订单?').fontSize(16).fontWeight(FontWeight.Bold).fontColor(GP.text)
Text('取消后款项将原路退回').fontSize(10).fontColor(GP.warn).margin({ top: 5 })
Row() {
Text('再想想').fontSize(13).fontColor(GP.textSub)
.backgroundColor(GP.chip).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.onClick(() => { this.showCancel = false })
Text('确认取消').fontSize(13).fontColor(GP.bg)
.backgroundColor(GP.warn).borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => { this.showCancel = false; this.showOrder = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 18, bottom: 20 })
}
.width('84%').backgroundColor(GP.card).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '8%', y: '34%' }).zIndex(999)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
取消订单确认模态框与退房确认模态框的设计风格一致,都是居中对齐的确认对话框。顶部是垃圾桶图标,中间是确认文字和提示信息,底部是两个操作按钮。
"确认取消"按钮使用警告色(粉红色)背景,而非常规的主色调背景。这是一种重要的交互设计原则——破坏性操作使用警告色,让用户清楚地意识到这是一个不可逆的操作,需要谨慎确认。
点击"确认取消"后,会同时关闭取消确认模态框和订单详情模态框(this.showCancel = false; this.showOrder = false),用户回到订单列表页面。在实际项目中,这里还应该调用接口取消订单,并刷新订单列表数据。
二次确认对话框是防止用户误操作的重要机制。对于取消订单、删除数据等破坏性操作,一定要增加二次确认步骤。确认对话框的设计要点是:清晰的标题说明操作后果、明显的颜色区分操作类型、两个操作按钮(取消+确认)易于区分。
if (this.showOrder) { this.orderModal() }
if (this.showCancel) { this.cancelModal() }
}
.width('100%').height('100%')
}
}
"我的"页面的最外层同样是Stack布局,包含页面内容和两个模态框。订单详情模态框和取消确认模态框形成了两级弹窗的嵌套结构——从订单列表打开订单详情,从订单详情打开取消确认。
多级模态框的嵌套使用需要注意用户体验。一般来说,模态框的层级不宜超过3级,否则用户容易迷失在层层弹窗中。在这个应用中,最多只有两级模态框,处于合理范围内。
十、鸿蒙核心技术点深度总结
10.1 声明式UI的核心思想
声明式UI是鸿蒙开发的核心理念。与命令式UI中手动操作DOM元素不同,声明式UI通过描述"界面应该是什么样子"来构建UI,框架负责将声明转换为实际的界面渲染。
在鸿蒙ArkTS中,声明式UI的实现依赖于几个关键机制:
- 状态驱动:通过
@State等装饰器标记状态变量,状态变化时自动触发UI更新。 - 组件树构建:
build()方法返回组件树的声明描述,框架根据描述构建真实的UI节点。 - 差异更新:框架会比较新旧组件树的差异,只更新发生变化的部分,提升渲染性能。
- 事件回调:用户交互通过
onClick、onChange等事件回调处理,回调函数中更新状态。
声明式UI的优势在于代码更加简洁直观,开发者不需要关心UI更新的具体过程,只需要关注状态和状态变化的逻辑。这大大降低了UI开发的复杂度,提升了开发效率。
10.2 布局容器的选择与搭配
鸿蒙提供了丰富的布局容器,不同的容器适用于不同的布局场景。
| 布局容器 | 布局方向 | 适用场景 | 关键属性 |
|---|---|---|---|
| Column | 垂直方向 | 上下排列的内容,如页面主体、卡片内容 | alignItems, justifyContent, layoutWeight |
| Row | 水平方向 | 左右排列的内容,如标题栏、列表项 | alignItems, justifyContent, layoutWeight |
| Stack | 堆叠方向 | 叠加布局,如模态弹窗、轮播图 | alignContent, zIndex |
| Scroll | 可滚动 | 内容超出屏幕高度/宽度时的滚动容器 | scrollable, scrollBar |
| Flex | 弹性布局 | 更灵活的弹性布局 | direction, wrap, alignItems |
在本应用中,最常用的布局组合是Column + Row的嵌套使用。垂直方向的Column作为页面的主体框架,水平方向的Row用于构建标题栏、列表项等横向布局的组件。
layoutWeight是实现自适应布局的关键属性。通过设置权重,可以让子组件按比例分配父容器的剩余空间,实现等分布局、撑满剩余空间等效果。这类似于Web开发中Flexbox的flex-grow属性。
布局容器的选择原则是:优先使用最简单的容器满足需求。
Column和Row是最基础也最高效的布局容器,大多数场景都可以通过它们的嵌套组合来实现。只有在需要叠加效果时才使用Stack,需要弹性换行时才使用Flex。
10.3 组件化与代码复用
鸿蒙提供了两种主要的组件化方式:@Component组件和@Builder构建函数。
| 组件化方式 | 定义位置 | 状态访问 | 适用场景 |
|---|---|---|---|
| @Component | 独立的struct | 有自己的状态 | 完整的页面或可独立复用的UI单元 |
| @Builder | 组件内部的方法 | 可以访问所属组件的状态 | 组件内部的重复UI片段 |
在本应用中,8个页面都是独立的@Component组件,每个页面管理自己的状态和UI。页面内部的模态框、卡片等可复用片段则使用@Builder方法定义。
@Builder方法的优势在于轻量和共享状态。因为它定义在组件内部,可以直接访问组件的@State变量和其他方法,不需要通过参数传递状态。这对于模态框、列表项等与组件状态紧密关联的UI片段来说非常方便。
@Component组件的优势在于独立性和可复用性。每个组件有自己的状态和生命周期,可以在不同的页面中复用。对于跨页面使用的通用组件(如按钮、输入框、卡片等),应该使用@Component定义。
10.4 状态管理的层级设计
本应用的状态管理呈现出明显的层级特征,不同层级的状态由不同的组件管理:
- 应用级状态:如
activeTab,由根组件GameApp管理,影响整个应用的显示。 - 页面级状态:如模态框显示状态、选中项、表单数据等,由各个页面组件管理,只影响当前页面。
- 组件级状态:如搜索框输入内容,由子组件
GameHeader管理,只影响组件自身。
这种分层状态管理的设计遵循了"状态归属最小化"原则——每个状态都由最需要它的组件来管理。这样做的好处是状态作用域清晰,不会出现全局状态泛滥的问题,同时也降低了组件之间的耦合度。
在更复杂的应用中,可能需要跨组件、跨页面共享状态。这时可以使用
@Provide/@Consume装饰器进行跨层级状态传递,或者使用AppStorage、PersistentStorage等全局状态管理方案。选择合适的状态管理方案,是应用架构设计的重要组成部分。
10.5 列表渲染与性能优化
ForEach是鸿蒙中用于列表渲染的核心组件,它可以根据数组数据动态生成子组件。高效的列表渲染是保证应用流畅性的关键。
ForEach的工作原理是:遍历数据源数组,对每个元素调用子组件生成函数,生成对应的子组件,并根据键值(key)来识别每个子组件的身份。当数据源发生变化时,框架会对比新旧数据的键值,确定哪些元素被添加、删除或移动,从而进行最小化的DOM更新。
键值(key)的选择对列表性能至关重要。理想的键值应该是唯一的、稳定的、与数据项一一对应的。在本应用中,所有的ForEach都使用数据项的id作为键值,这是最佳实践。
ForEach(ROOMS, (r: RoomItem) => {
// ... 生成房间卡片
}, (r: RoomItem) => r.id.toString())
第三个参数(键值生成函数)返回的是字符串类型,所以需要用toString()将数字id转换为字符串。
列表性能优化的几个关键点:一是使用唯一且稳定的键值,避免不必要的重渲染;二是控制列表项的复杂度,尽量简化列表项的UI结构;三是对于长列表,可以使用
LazyForEach实现懒加载,只渲染可见区域内的列表项。
10.6 模态弹窗的实现模式
模态弹窗是移动端应用中最常见的交互模式之一。本应用中的模态弹窗形成了一套统一的实现模式。
标准模态框的结构组成:
- 遮罩层:半透明黑色背景,点击可关闭模态框。
- 内容容器:白色(或深色)的卡片容器,包含标题栏、内容区和操作栏。
- 标题栏:左侧标题,右侧关闭按钮,中间用弹性空间撑开。
- 内容区:模态框的主要内容,可以是表单、详情信息、确认文字等。
- 操作栏:底部的操作按钮,通常是"取消+确认"的双按钮布局。
模态弹窗的技术实现要点:
- 使用
Stack布局承载页面内容和模态框,模态框覆盖在页面内容之上。 - 通过
@State布尔变量控制模态框的显示与隐藏。 - 使用
position属性定位模态框的位置,zIndex控制堆叠层级。 - 遮罩层和内容区域分开构建,遮罩层点击关闭,内容区域点击不关闭。
一致的模态框设计可以提升用户体验。用户在使用过程中会逐渐熟悉模态框的交互方式(如点击遮罩层关闭、右上角X关闭、底部按钮操作等),降低学习成本。从开发角度来看,统一的模式也意味着更高的开发效率和更好的可维护性。
十一、整体技术架构总结
11.1 各页面技术实现对比
| 页面 | 核心组件 | 模态框数量 | 列表类型 | 特色功能 |
|---|---|---|---|---|
| 首页 | HomePage | 1个(快速订房) | 垂直列表+横滑卡片 | 渐变横幅、快捷入口、赛事横滑 |
| 房间页 | RoomPage | 2个(预订+退房) | 垂直列表 | 筛选标签、状态指示器、退房确认 |
| 套餐页 | PackPage | 2个(详情+购买) | 垂直列表 | 两步购买流程、支付方式选择 |
| 赛事页 | MatchPage | 2个(详情+报名) | 柱状图+垂直列表 | 数据可视化柱状图、赛事报名 |
| 战队页 | TeamPage | 2个(入队+创建) | 垂直列表 | 胜率进度条、圆形头像、创建战队 |
| 周边页 | GearPage | 2个(详情+购买) | 双列瀑布流 | 双列布局、商品卡片、双点击区域 |
| 陪玩页 | PalPage | 2个(预约+评价) | 垂直列表 | 评分颜色分级、评价功能、接单量展示 |
| 我的页 | MinePage | 2个(详情+取消) | 垂直列表 | 用户信息卡、多类型订单、数据统计 |
8个页面虽然功能各异,但都遵循了相似的架构模式。每个页面都有自己的@Component定义,都管理着模态框的显示状态,都使用Stack作为最外层布局,都通过ForEach渲染列表数据。这种一致性是架构设计成熟的体现。
11.2 设计模式的复用与统一
纵观整个应用,可以发现多种设计模式被反复使用,形成了统一的代码风格和交互体验。
标签选择器模式:在各种表单中,横向排列的标签按钮是最常用的选择器形式。选中态使用主色调背景+深色文字,未选中态使用深色背景+灰色文字。这种模式被应用于房型选择、日期选择、时长选择、人数选择、餐饮选择、加时选择、地区选择、数量选择等众多场景。
模态弹窗模式:所有的二级交互都通过模态弹窗实现。模态弹窗有统一的结构(遮罩层+内容区+标题栏+操作栏)和统一的交互(点击遮罩关闭、点击X关闭、底部按钮操作)。
列表卡片模式:所有的列表项都是卡片式设计,有统一的圆角(12vp)、统一的背景色(GP.card)、统一的外边距(左右12vp,上下6vp)。卡片内部的布局也遵循相似的模式——左侧图标、中间信息、右侧操作。
状态颜色函数模式:对于需要根据状态动态变色的元素(如房间状态、赛事状态、订单状态、评分颜色等),都通过纯函数来计算颜色值。函数命名遵循xxxColor和xxxBg的配对模式,分别返回文字颜色和背景颜色。
统一的设计模式是代码质量的重要保障。当整个应用都遵循相同的模式时,开发者可以快速理解任何一段代码的结构和意图,新功能的开发也可以参照已有模式进行,大大提升了开发效率和代码质量。
11.3 代码质量与可维护性分析
从代码质量的角度来看,这个应用有许多值得肯定的地方,同时也存在一些可以改进的空间。
优点:
- 结构清晰:每个页面都是独立的组件,组件内部按功能划分为状态定义、构建函数、主渲染方法,层次分明。
- 类型安全:使用接口定义数据结构,使用枚举定义Tab索引,TypeScript的类型系统为代码提供了良好的类型保护。
- 命名规范:变量、函数、组件的命名都具有良好的语义性,能够清晰地表达其用途。
- 模式统一:相似的功能采用相似的实现方式,降低了理解成本和维护成本。
- 设计令牌:颜色常量集中管理,便于主题调整和维护。
可改进之处:
- 组件抽象不足:许多重复的UI模式(如标签选择器、列表卡片、模态框框架)可以进一步抽象为可复用的组件,减少代码重复。
- 状态管理分散:表单状态散落在各个页面组件中,如果需要在多个页面间共享数据,可能需要引入更完善的状态管理方案。
- 部分功能不完整:筛选功能只有状态没有逻辑,部分选择器只有展示没有交互,这些都是原型阶段的简化处理。
- 事件冒泡问题:周边商品卡片的双点击区域可能存在事件冒泡问题,需要处理。
- 缺少错误处理:表单提交、数据加载等操作缺少错误处理和加载状态。
11.4 鸿蒙开发的技术价值
通过对这个电竞酒店应用的全面解析,我们可以深刻体会到鸿蒙ArkTS声明式开发的技术价值。
首先,开发效率高是最直观的感受。声明式UI的描述方式让代码更加简洁,嵌套的布局结构与最终的界面结构高度对应,开发者可以快速将设计稿转化为代码。@State装饰器驱动的响应式更新,省去了手动操作UI的繁琐工作。
其次,代码可读性强是声明式范式的另一大优势。组件的结构、状态和交互逻辑都集中在一个结构体中,通过阅读build()方法就可以清晰地了解组件的UI结构。状态变量和事件回调的命名如果足够清晰,代码几乎可以当作文档来阅读。
再次,类型安全为代码质量提供了坚实的保障。接口定义了数据的结构,枚举定义了有限的状态集合,TypeScript的类型检查可以在编译阶段发现大量的潜在错误。这对于大型项目的长期维护尤为重要。
最后,组件化和复用性是构建复杂应用的基础。@Component和@Builder提供了不同粒度的组件化能力,开发者可以根据需要选择合适的复用方式。随着应用复杂度的提升,可以逐步将通用组件抽取出来,形成组件库,进一步提升开发效率。
鸿蒙的声明式UI开发范式代表了移动应用开发的未来方向。它吸收了React、Flutter等主流框架的优秀思想,同时结合了鸿蒙系统的分布式能力,为全场景应用开发提供了统一的解决方案。对于前端开发者和移动端开发者来说,掌握鸿蒙ArkTS开发都是一项具有长远价值的技术投资。
11.5 从原型到生产的演进路径
本文解析的应用作为一个UI原型,已经具备了完整的界面结构和交互框架。如果要将其演进为可上线的生产级应用,还需要在以下几个方面进行完善。
数据层升级:将静态数据替换为真实的API接口调用,引入网络请求库和数据缓存策略。增加数据加载状态、错误状态和空状态的处理。
状态管理升级:根据应用复杂度选择合适的状态管理方案。简单场景可以继续使用@State+@Prop+@Link的组件间传递,复杂场景可以引入@Provide/@Consume或全局状态管理。
组件库建设:将通用的UI组件(按钮、输入框、标签选择器、卡片、模态框等)抽取为独立的组件库,统一设计规范,提升开发效率。
性能优化:对于长列表使用LazyForEach懒加载,对复杂组件使用@Memo缓存计算结果,减少不必要的重渲染。分析页面渲染性能,找出性能瓶颈并优化。
工程化建设:引入代码规范检查、单元测试、自动化构建和部署流程。建立设计规范文档和组件使用文档,方便团队协作。
功能完善:补全筛选、搜索、支付、评价等功能的完整逻辑,增加用户认证、消息推送、数据统计等配套功能。
从原型到生产是一个逐步演进的过程,不需要一步到位。可以根据业务优先级,有计划地逐步完善各个方面。重要的是在原型阶段就建立良好的架构基础和代码规范,为后续的演进铺平道路。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
// ============================================================
// 主题:暗夜黑 #0D0F14 × 赛博青 #00E5FF × 霓虹紫 #A855F7
// 8 Tab 双排:首页 / 房间 / 套餐 / 赛事 / 战队 / 周边 / 陪玩 / 我的
// ============================================================
// ---------- 接口定义 ----------
interface GamePalette {
bg: string
结语

本文通过对一个完整电竞酒店应用的逐行解析,系统性地梳理了鸿蒙ArkTS声明式UI开发的核心技术点。从数据模型的设计令牌,到布局容器的灵活运用;从@Component和@Builder的组件化方案,到@State驱动的响应式状态管理;从ForEach的列表渲染,到模态弹窗的交互模式——每一个技术点都通过具体的代码示例进行了详细的讲解。
更多推荐


所有评论(0)