民宿短租 App 深度技术解析:基于HarmonyOS API 24在状态管理和条件判断中使用枚举值(如 HomestayTab.HOME),在渲染展示时从配置中读取对应的数据
一、开篇引言

在移动互联网高速发展的今天,民宿短租行业已经成为旅游市场中不可或缺的重要组成部分。从 Airbnb 到国内各大民宿平台,用户对于优质住宿体验的追求从未停止。本文将深入剖析一款基于 ArkTS 开发的民宿短租 App,从业务背景、架构设计、数据模型到组件实现,全方位展示其技术实现细节。
这款民宿短租 App 是一个功能完善的移动端应用,涵盖了首页展示、房源浏览、订单管理、体验活动和个人中心五大核心模块。App 采用木质棕与森林绿的自然配色方案,营造出温馨舒适的视觉氛围,与民宿"家外之家"的品牌理念高度契合。在技术实现上,应用采用了声明式 UI 开发范式,结合 ArkTS 的装饰器语法,构建了一套高度可复用、可维护的组件体系。
从业务角度来看,这款 App 覆盖了民宿短租的全链路场景:用户可以在首页快速了解即将到来的行程、查看预订趋势、浏览热门房源和城市;在房源页面进行筛选和比价;在订单页面管理入住记录;在体验页面发现当地特色活动;在个人中心查看评价和账户信息。每个模块都经过精心设计,既保证了功能完整性,又提供了流畅的用户体验。
在技术层面,App 展现了多项值得学习的设计亮点:统一的颜色调色板接口设计、类型安全的数据模型定义、枚举驱动的 Tab 导航机制、自定义 Builder 构建器的灵活运用、以及基于 bindSheet 的弹窗交互模式。这些设计模式不仅提升了代码质量,也为后续的功能扩展奠定了坚实基础。本文将逐一对这些技术细节进行深入剖析,帮助读者全面理解这款 App 的架构设计思想和实现原理。
二、整体架构流程图

在深入代码细节之前,让我们先通过架构图来宏观了解这款民宿短租 App 的整体结构。
数据流与状态管理
组件层级结构
三、颜色与主题系统详解

3.1 颜色调色板接口设计
颜色系统是整个 App 视觉风格的基石。这款民宿短租 App 采用了接口定义 + 常量配置的设计模式,先通过 interface 定义颜色调色板的结构,再通过 const 常量提供具体的颜色值实现。
interface HomestayColorPalette {
primary: string
primaryLight: string
primaryDark: string
accent: string
accentLight: string
wood: string
bg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
success: string
warning: string
danger: string
white: string
}
这段代码定义了 HomestayColorPalette 接口,包含 16 个颜色属性。这种设计方式有多重优势:首先,它提供了完整的类型检查,确保在使用颜色常量时不会出现拼写错误;其次,它为颜色系统建立了清晰的层级结构,主色、辅助色、背景色、文本色各司其职;最后,接口定义本身就起到了文档作用,开发者可以通过接口一目了然地了解可用的颜色种类。
在字段命名上,设计采用了语义化命名策略。primary 系列代表主色调,包含 primaryLight 浅色版本和 primaryDark 深色版本,形成完整的色阶体系。accent 系列代表强调色,用于突出重要交互元素。textPrimary、textSecondary、textHint 三级文本色阶保证了信息层次的清晰度。success、warning、danger 三个语义色分别对应成功、警告、错误状态,符合通用的 UX 设计规范。
3.2 配色方案的设计理念
const COLORS: HomestayColorPalette = {
primary: '#8D6E63',
primaryLight: '#BCAAA4',
primaryDark: '#5D4037',
accent: '#558B2F',
accentLight: '#AED581',
wood: '#D7CCC8',
bg: '#FAF5F0',
cardBg: '#FFFFFF',
textPrimary: '#3E2723',
textSecondary: '#6D4C41',
textHint: '#BCAAA4',
border: '#EFEBE9',
success: '#43A047',
warning: '#FB8C00',
danger: '#E53935',
white: '#FFFFFF'
}
民宿短租 App 的配色方案围绕"自然、温馨、舒适"的核心主题展开。主色调选用木质棕 #8D6E63,这是一种温暖的棕色,能够唤起人们对木质家具、温馨小屋的联想,与民宿的"家"的定位高度契合。primaryLight 浅棕 #BCAAA4 和 primaryDark 深棕 #5D4037 构成了完整的棕色色阶,分别用于浅色背景和深色文字等不同场景。
强调色选用森林绿 #558B2F,代表自然、健康和活力。绿色与棕色的搭配是自然界中最经典的组合之一,树木与大地的配色让人感到放松和安心。accentLight 浅绿 #AED581 用于次要强调和渐变效果,增加了配色的层次感。
背景色 #FAF5F0 是一种暖米色,比纯白更柔和,能够减少用户的视觉疲劳,同时营造温暖的居家氛围。wood 色 #D7CCC8 是一种浅木色,用于输入框背景、标签背景等次要界面元素,既保持了视觉一致性,又提供了足够的对比度。
文本色采用了三级色阶体系:深棕 #3E2723 作为主要文本色,确保在浅色背景上有良好的可读性;中棕 #6D4C41 用于次要文本信息;浅棕 #BCAAA4 作为提示和占位文字。这种分级设计让信息层次更加清晰,用户可以快速识别内容的重要程度。
3.3 工具函数:动态颜色计算
function getRatingColor(rating: number): string {
if (rating >= 4.8) {
return COLORS.accent
} else if (rating >= 4.5) {
return COLORS.success
} else if (rating >= 4.0) {
return COLORS.warning
}
return COLORS.textSecondary
}
除了静态颜色常量外,App 还提供了 getRatingColor 工具函数,根据评分值动态返回对应的颜色。这种动态颜色计算的设计模式非常实用:4.8 分以上的超高分使用森林绿强调色突出显示,4.5-4.8 分使用绿色表示优秀,4.0-4.5 分使用橙色表示良好,4.0 分以下使用次要文本色。通过颜色的渐变映射,用户可以直观地感知评分的高低,提升了信息的可读性。
四、数据模型层深度解析

数据模型是应用的骨架,好的数据模型设计能够让业务逻辑更加清晰,代码更易维护。这款民宿短租 App 定义了 9 个数据接口,覆盖了房源、订单、评价、城市、体验活动等核心业务实体。
4.1 TabMeta 接口
interface TabMeta {
title: string
icon: string
}
TabMeta 是导航标签的数据模型,包含标题和图标两个字段。虽然结构简单,但它体现了"配置驱动"的设计思想——将 Tab 的展示内容与业务逻辑分离,通过配置常量来定义各个 Tab 的显示信息。这种设计使得修改 Tab 标题或图标时不需要改动组件代码,只需修改配置数据即可,大大提升了代码的可维护性。
4.2 HouseMeta 房源接口
interface HouseMeta {
name: string
emoji: string
city: string
price: number
rating: number
isHot: boolean
}
HouseMeta 是房源数据模型,包含 6 个核心字段。name 是房源名称,emoji 用表情符号作为房源的视觉标识(在没有真实图片的原型阶段,这是一种高效的占位方式),city 表示所在城市,price 是每晚价格,rating 是用户评分,isHot 标记是否为热门房源。
这个接口的设计体现了"最小够用"原则——只包含列表展示所必需的字段,避免了数据冗余。价格使用 number 类型便于排序和计算,评分同样使用数值类型便于动态颜色计算。isHot 布尔字段控制热门标签的显示,这种标记字段在列表展示中非常常见。
4.3 OrderMeta 订单接口
interface OrderMeta {
name: string
emoji: string
checkIn: string
checkOut: string
nights: number
isDone: boolean
}
OrderMeta 订单模型包含入住和退房日期、晚数以及完成状态。checkIn 和 checkOut 使用字符串类型存储日期(格式如"08-15"),在原型阶段这种简洁的表示方式非常实用。nights 字段虽然可以通过退房日期减入住日期计算得出,但作为冗余字段存储可以避免重复计算,提升渲染性能。isDone 布尔字段区分已完成订单和待入住订单,在界面上呈现不同的视觉样式和操作按钮。
4.4 ReviewMeta 评价接口
interface ReviewMeta {
user: string
avatar: string
house: string
content: string
rating: number
isTop: boolean
}
ReviewMeta 评价模型包含用户信息、房源信息、评价内容和精选标记。avatar 使用 emoji 表情作为头像,isTop 字段标记是否为精选评价。这种设计允许运营人员将优质评价置顶展示,提升内容的可信度和吸引力。评价内容与用户、房源关联,形成完整的评价链路。
4.5 CityMeta 城市接口
interface CityMeta {
name: string
emoji: string
count: number
isHot: boolean
}
CityMeta 城市模型用于城市筛选和热门城市展示。count 字段记录该城市的房源数量,为用户提供更多决策信息。isHot 热门标记帮助用户快速发现热门旅游目的地。
4.6 WeekOrderMeta 周预订接口
interface WeekOrderMeta {
label: string
value: number
}
WeekOrderMeta 是周预订趋势的数据模型,用于柱状图展示。label 是星期标签,value 是当天的预订数量。这个接口虽然简单,但体现了数据可视化组件的数据标准化设计——图表组件只需要 label-value 格式的数据即可渲染,具体业务含义由上层业务决定。
4.7 ExperienceMeta 体验活动接口
interface ExperienceMeta {
name: string
emoji: string
desc: string
price: number
isHot: boolean
}
ExperienceMeta 体验活动模型描述当地特色体验项目。desc 字段提供简短的活动描述,帮助用户快速了解活动内容。价格字段使用数值类型,便于后续的价格筛选和排序功能。
4.8 HostMeta 房东接口
interface HostMeta {
name: string
emoji: string
houses: number
rating: number
isSuper: boolean
}
HostMeta 房东模型展示房东信息,houses 字段表示房东拥有的房源数量,isSuper 标记是否为超赞房东。超赞房东是民宿平台的重要运营机制,能够激励房东提供更优质的服务。
4.9 MenuMeta 菜单接口
interface MenuMeta {
icon: string
name: string
sub: string
}
MenuMeta 功能菜单模型用于个人中心的功能入口列表。sub 字段是副标题,用于显示补充信息(如收藏数量、余额等),让用户在列表层面就能获取关键信息,减少点击成本。
五、配置常量层分析

5.1 TAB_CONFIG 导航配置
const TAB_CONFIG: Record<string, TabMeta> = {
'HOME': { title: '首页', icon: '🏡' },
'HOUSE': { title: '房源', icon: '🏠' },
'ORDER': { title: '订单', icon: '📋' },
'EXP': { title: '体验', icon: '🌲' },
'MINE': { title: '我的', icon: '👤' }
}
TAB_CONFIG 是 Tab 导航的配置常量,使用 Record<string, TabMeta> 类型定义,键是枚举对应的字符串,值是 Tab 的元数据。这种配置驱动的设计模式有以下几个优点:
第一,集中管理。所有 Tab 的配置信息集中在一个常量中,修改时一目了然,不需要在组件代码中搜索散落的配置。
第二,类型安全。使用 Record 类型和 TabMeta 接口确保配置结构的一致性,避免遗漏字段或类型错误。
第三,易于扩展。新增 Tab 时只需在配置中添加一项,组件代码会自动适配。
第四,国际化友好。如果后续需要支持多语言,只需替换配置中的标题文本即可,无需修改组件逻辑。
5.2 枚举定义
enum HomestayTab {
HOME,
HOUSE,
ORDER,
EXP,
MINE
}
HomestayTab 枚举定义了五个 Tab 选项的标识符。枚举与配置常量的配合使用是一种经典的设计模式:枚举提供类型安全的标识符,配置常量提供对应的展示数据。在状态管理和条件判断中使用枚举值(如 HomestayTab.HOME),在渲染展示时从配置中读取对应的数据(如 TAB_CONFIG.HOME.title),实现了逻辑与展示的分离。
使用数字枚举(默认从 0 开始递增)在状态比较时效率更高,同时代码可读性也更好。相比于使用字符串字面量,枚举提供了更好的 IDE 自动补全和编译时检查。
5.3 业务配置的设计模式
从整体来看,这款 App 采用了"接口定义 + 常量实现 + 枚举驱动"的三层配置架构:
- 接口层:定义数据结构的形状和类型,提供编译时检查
- 常量层:提供具体的配置数据,集中管理可配置项
- 枚举层:定义业务标识符,用于逻辑判断和状态管理
这种分层设计使得配置系统既灵活又安全。当需要修改展示内容时,只需修改常量层;当需要新增业务类型时,需要同时在枚举层和常量层添加;当数据结构变化时,才需要修改接口层。每一层都有明确的职责边界,降低了修改带来的风险。
六、Mock 数据层分析

6.1 模拟数据的设计思路
const HOUSE_LIST: HouseMeta[] = [
{ name: '山顶木屋', emoji: '🏚️', city: '大理', price: 388, rating: 4.9, isHot: true },
{ name: '海景别墅', emoji: '🏖️', city: '三亚', price: 688, rating: 4.8, isHot: true },
{ name: '竹林小院', emoji: '🎋', city: '莫干山', price: 520, rating: 4.7, isHot: true },
// ... 共12条数据
]
这款 App 使用了丰富的 Mock 数据来模拟真实的业务场景。Mock 数据的设计非常考究,不是简单的重复填充,而是精心设计了多样化的数据组合:
房源数据(HOUSE_LIST) 包含 12 套房源,覆盖了大理、三亚、丽江、莫干山、稻城、腾冲、青海湖、呼伦贝尔、西双版纳、苏州、涠洲岛、阳朔等 12 个热门旅游城市。房源类型丰富多样,有山顶木屋、海景别墅、竹林小院、古镇客栈、湖畔帐篷、雪山木屋、温泉民宿、草原毡房、森林树屋、老宅院落、海岛草屋、山谷别院等,几乎涵盖了所有主流的民宿类型。价格区间从 268 元到 888 元不等,评分从 4.4 到 4.9 分布合理,热门标记也有选择地分布在部分房源上。
这种多样化的 Mock 数据设计有几个重要作用:首先,它让界面展示更加真实,用户可以看到不同类型、不同价位、不同评分的房源;其次,它可以测试各种边界情况,比如长名称、低评分、非热门房源等的展示效果;最后,丰富的数据也让 Demo 演示更加有说服力。
6.2 各数据集的结构特点
订单数据(ORDER_LIST) 包含 10 条订单记录,其中 9 条已完成、1 条待入住,形成了合理的数据分布。每条订单都有不同的入住退房日期和晚数,模拟了真实的订单历史。
评价数据(REVIEW_LIST) 有 10 条评价,每条评价的用户头像、用户名、评价内容都不相同,评分从 4 分到 5 分分布。其中有两条被标记为精选评价(isTop: true),模拟了平台的优质内容筛选机制。
城市数据(CITY_LIST) 包含 8 个城市,每个城市都有对应的房源数量和热门标记。城市数据为用户提供了按城市筛选房源的入口。
周预订数据(WEEK_ORDER) 包含 7 天的预订数据,从周一到周日呈现明显的周末高峰特征(周六 12 单最高),符合真实的预订规律。
体验活动数据(EXP_LIST) 包含 8 项体验活动,涵盖采茶、陶艺、骑马、篝火、徒步、钓鱼、扎染、摄影等多种类型,价格从 88 元到 288 元不等。
房东数据(HOST_LIST) 有 6 位房东,其中 4 位是超赞房东(isSuper: true),房源数量从 2 套到 5 套不等。
菜单数据(MENU_LIST) 包含 6 个功能入口,涵盖收藏、钱包、优惠券、评价、设置、客服等个人中心常见功能。
6.3 Mock 数据的业务价值
精心设计的 Mock 数据不仅仅是为了填充界面,它还具有重要的业务和技术价值:
业务验证:通过模拟真实数据,可以验证产品设计的合理性,比如信息密度是否合适、数据分布是否自然、用户能否快速找到关键信息等。
开发解耦:前端开发可以基于 Mock 数据独立进行,不需要等待后端接口开发完成,大大提升了开发效率。
测试覆盖:多样化的 Mock 数据可以测试各种 UI 场景,比如长文本截断、不同状态的样式切换、空数据处理等。
演示展示:在产品演示和评审中,真实感强的 Mock 数据能够让观众更好地理解产品功能和用户体验。
七、主入口组件逐段分析

7.1 组件结构与状态管理
@Entry
@Component
struct HomestayApp {
@State activeTab: HomestayTab = HomestayTab.HOME
HomestayApp 是整个应用的主入口组件,使用 @Entry 装饰器标记为页面入口,@Component 装饰器标记为组件。组件内部只有一个状态变量 activeTab,使用 @State 装饰器修饰,初始值为 HomestayTab.HOME。
@State 是 ArkTS 中最基础的状态管理装饰器,它标记的变量会被 UI 框架监听,当变量值发生变化时,相关的 UI 会自动重新渲染。在这里,activeTab 控制着当前显示的页面,用户点击不同的 Tab 就会更新这个状态,触发内容区域的切换。
这种单一状态驱动页面切换的设计非常简洁优雅。整个应用的导航状态只由一个变量控制,状态流向清晰,易于理解和维护。相比于复杂的路由系统,这种基于状态的页面切换在 Tab 导航场景下更加轻量高效。
7.2 contentArea 自定义构建器
@Builder contentArea() {
Column() {
if (this.activeTab === HomestayTab.HOME) {
HomestayHomeContent()
} else if (this.activeTab === HomestayTab.HOUSE) {
HomestayHouseContent()
} else if (this.activeTab === HomestayTab.ORDER) {
HomestayOrderContent()
} else if (this.activeTab === HomestayTab.EXP) {
HomestayExpContent()
} else if (this.activeTab === HomestayTab.MINE) {
HomestayMineContent()
}
}
.width('100%')
}
contentArea 是一个使用 @Builder 装饰器修饰的自定义构建函数。@Builder 是 ArkTS 的特色语法,它允许开发者将可复用的 UI 片段封装为函数,在 build 方法中通过 this.xxx() 的方式调用。
这个构建函数的核心逻辑是根据 activeTab 的值,通过 if-else 条件判断来渲染对应的页面组件。这种设计体现了"条件渲染"的思想——根据状态值动态决定显示哪些 UI 元素。当 activeTab 变化时,框架会自动检测到依赖关系,重新执行条件判断并更新渲染内容。
使用 @Builder 封装内容区域的好处是代码结构更清晰,将页面切换逻辑与主布局结构分离。build 方法只需要关注整体布局,具体的内容渲染细节交给 contentArea 处理。
7.3 tabItem 自定义构建器
@Builder tabItem(icon: string, label: string, tab: HomestayTab) {
Column() {
Text(icon).fontSize(20)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? COLORS.primary : COLORS.textHint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.activeTab = tab })
}
tabItem 是另一个 @Builder 自定义构建函数,用于创建底部导航栏的每个 Tab 项。它接收三个参数:图标 icon、标签文本 label 和对应的枚举值 tab。
每个 Tab 项是一个垂直排列的 Column,包含图标和文字。图标使用 emoji 表情,大小为 20px;标签文字大小为 10px。文字颜色和字重会根据当前选中状态动态变化:如果当前 Tab 是选中状态(this.activeTab === tab),则使用主色 COLORS.primary 和粗体;否则使用提示色 COLORS.textHint 和常规字重。
.layoutWeight(1) 让每个 Tab 项平均分配导航栏的宽度,保证五个 Tab 等宽排列。.onClick 事件处理函数在用户点击时更新 activeTab 状态,触发页面切换。
这个 tabItem 构建器的设计充分体现了组件化思想:将重复的 Tab 项结构封装成可复用的函数,通过参数控制不同的展示内容和行为。相比于写五遍相似的代码,这种方式不仅减少了代码量,也让后续的修改变得更加方便——只需修改一处,所有 Tab 项都会同步更新。
7.4 build 方法整体布局
build() {
Column() {
this.contentArea()
Row() {
this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, HomestayTab.HOME)
this.tabItem(TAB_CONFIG.HOUSE.icon, TAB_CONFIG.HOUSE.title, HomestayTab.HOUSE)
this.tabItem(TAB_CONFIG.ORDER.icon, TAB_CONFIG.ORDER.title, HomestayTab.ORDER)
this.tabItem(TAB_CONFIG.EXP.icon, TAB_CONFIG.EXP.title, HomestayTab.EXP)
this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, HomestayTab.MINE)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.white)
.shadow({ radius: 10, color: '#8D6E6326', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(COLORS.bg)
}
build 方法定义了组件的 UI 结构。整体是一个垂直排列的 Column,分为上下两部分:上方是内容区域 contentArea,下方是底部导航栏 Row。
内容区域占据了大部分空间,根据 activeTab 状态动态渲染不同的页面组件。底部导航栏是一个水平排列的 Row,包含五个 Tab 项,每个 Tab 项通过调用 tabItem 构建函数创建,传入对应的配置数据和枚举值。
导航栏使用白色背景,上下各有 8px 的内边距,并添加了一个向上的阴影效果(offsetY: -2),阴影颜色使用主色的半透明版本(#8D6E6326,即木质棕色加上 15% 左右的透明度)。这个细节设计非常巧妙——阴影颜色与主色调保持一致,而不是使用通用的黑色阴影,让界面看起来更加协调统一。
整个页面的背景色使用 COLORS.bg 暖米色,营造温馨舒适的视觉氛围。页面宽高都设置为 100%,充满整个屏幕。
八、各页面组件逐段代码分析

8.1 首页组件 HomestayHomeContent
首页是用户打开 App 后看到的第一个页面,承担着信息聚合和功能入口的重要作用。HomestayHomeContent 组件包含了行程倒计时头卡、周预订柱状图、热门房源横滑、热门城市横滑以及发布房源按钮等多个模块。
8.1.1 状态与弹窗定义
@Component
struct HomestayHomeContent {
@State showAddHouseModal: boolean = false
@State houseName: string = ''
@State houseCity: string = ''
首页组件定义了三个内部状态:showAddHouseModal 控制新增房源弹窗的显示隐藏,houseName 和 houseCity 分别存储用户输入的房源名称和所在城市。这些状态都使用 @State 装饰器修饰,当状态变化时会触发相关 UI 的更新。
弹窗表单的数据与弹窗显示状态分离管理,是一种清晰的设计模式。showAddHouseModal 只负责控制弹窗的开关,而表单数据由独立的状态变量管理。这样即使弹窗关闭,表单数据也可以保留,方便用户再次打开时继续编辑。
8.1.2 modalOverlay 遮罩构建器
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
modalOverlay 是一个通用的遮罩层构建函数,接收一个 onClose 回调函数作为参数。遮罩层是一个全屏的半透明黑色(55% 不透明度)Column,点击遮罩层时会调用传入的关闭回调函数。
这个设计体现了"函数式组件"的思想——将可复用的 UI 行为封装成函数,通过参数注入具体的业务逻辑。不同的弹窗可以复用同一个遮罩构建器,只需要传入不同的关闭回调即可。这种方式大大减少了重复代码,同时保证了所有弹窗的遮罩效果一致性。
8.1.3 新增房源弹窗
@Builder addHouseModal() {
Stack() {
this.modalOverlay(() => { this.showAddHouseModal = false })
Column() {
Row() {
Text('🏠 新增房源').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddHouseModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('房源名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
TextInput({ placeholder: '如 山顶木屋' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.houseName = v })
Text('所在城市').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 大理' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.houseCity = v })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.wood).borderRadius(8)
.onClick(() => { this.showAddHouseModal = false })
Column().width(12)
Text('发布').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.showAddHouseModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
addHouseModal 构建函数定义了新增房源的弹窗内容。整个弹窗使用 Stack 堆叠布局,底层是遮罩层,上层是弹窗内容卡片。
弹窗内容是一个白色卡片,宽度为屏幕的 88%,圆角 16px。卡片内部结构清晰,分为三个部分:
顶部标题栏:使用 Row 布局,左侧是带图标的标题文字,右侧是关闭按钮(✕),中间用 layoutWeight(1) 的空 Column 撑开,实现左右两端对齐的效果。标题下方有一条分隔线 Divider,颜色使用边框色。
中间表单区:包含两个表单项——房源名称和所在城市。每个表单项由标签文字和输入框组成。输入框使用 TextInput 组件,背景色使用木色 COLORS.wood,圆角 8px。输入框的 onChange 事件将用户输入的值同步到对应的状态变量中。placeholder 颜色使用提示色 COLORS.textHint,与正常文本形成区分。
底部操作栏:包含"取消"和"发布"两个按钮,使用 Row 布局并平分宽度(各占 layoutWeight(1))。取消按钮使用木色背景和次要文字色,发布按钮使用主色背景和白色文字,通过视觉权重的差异引导用户操作。
弹窗内容设置了 constraintSize({ maxHeight: '70%' }),限制最大高度为屏幕的 70%,防止内容过多时弹窗超出屏幕范围。这种约束设计保证了在各种屏幕尺寸下弹窗都能正常展示。
8.1.4 行程倒计时头卡
Stack() {
Column()
.width('100%').height(170)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Row() {
Column() {
Text('🏡 即将入住').fontSize(11).fontColor('#D7CCC8')
Text('3').fontSize(42).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
.margin({ top: 2 })
Text('天后 · 大理·山顶木屋').fontSize(10).fontColor('#D7CCC8').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Stack() {
Column().width(70).height(70).borderRadius(35)
.backgroundColor('rgba(255,255,255,0.15)')
Column().width(50).height(50).borderRadius(25)
.backgroundColor(COLORS.accent)
Text('🌿').fontSize(28)
}
.width(70).height(70)
}
.width('100%')
.padding({ left: 20, right: 20, top: 30 })
Row() {
Text('📅 08-15 入住').fontSize(10).fontColor('#D7CCC8')
Text(' → ').fontSize(10).fontColor('#D7CCC8')
Text('08-18 退房').fontSize(10).fontColor('#D7CCC8')
Text(' · 3晚').fontSize(10).fontColor(COLORS.accentLight)
Column().layoutWeight(1)
Text('¥1,164').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ left: 20, right: 20, top: 12 })
}
}
.width('100%').height(170)
行程倒计时头卡是首页最醒目的模块,使用渐变背景和大字体倒计时数字营造紧迫感和期待感。
头卡的背景是一个 170px 高的 Column,应用了从主色 COLORS.primary 到深主色 COLORS.primaryDark 的 135 度线性渐变。底部两个角设置了 24px 的大圆角,形成向下延伸的视觉效果。
内容区域使用 Stack 堆叠在背景之上,分为上下两行:
上行:左侧是倒计时信息,包含"即将入住"小标题、大大的数字"3"(天数)和目的地信息。文字颜色使用浅木色 #D7CCC8 和白色,在深色渐变背景上有良好的对比度。右侧是一个圆形的装饰元素,由三层同心圆叠加而成——最外层是半透明白色圆环,中间是强调色实心圆,最上层是树叶 emoji。这个装饰元素既丰富了视觉层次,又通过绿色与整体自然主题呼应。
下行:显示入住退房日期和总价信息。日期信息使用浅色文字,晚数使用浅强调色突出显示,总价使用白色粗体强调。整行信息左对齐,价格右对齐,通过 layoutWeight(1) 的空 Column 实现两端对齐。
这个头卡设计充分利用了视觉层级原理:通过字号大小、颜色对比和字重差异,将最重要的信息(倒计时天数)最突出地展示出来,次要信息(日期、价格)以较小的字号辅助呈现,让用户一眼就能抓住关键信息。
8.1.5 周预订柱状图
Column() {
Text('📊 本周预订趋势').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Row() {
ForEach(WEEK_ORDER, (w: WeekOrderMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column().width(24).height(80)
.backgroundColor(COLORS.wood)
.borderRadius(4)
Column().width(24).height(Number(w.value) * 6)
.linearGradient({ angle: 0, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius(4)
}
.width(24).height(80)
Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
周预订趋势柱状图是一个纯代码实现的数据可视化组件,没有依赖任何图表库,完全通过基础组件搭建而成。
柱状图放置在一个白色卡片容器中,卡片有 16px 的圆角和 12px 的左右外边距。卡片顶部是标题"📊 本周预订趋势",使用粗体主文字色。
图表主体使用 ForEach 遍历 WEEK_ORDER 数组,为每一天生成一个柱状图单元。每个单元是一个垂直排列的 Column,上方是柱子,下方是星期标签。
柱子的实现很巧妙:使用 Stack 堆叠布局,底部对齐(alignContent: Alignment.Bottom)。底层是一个 80px 高的木色背景柱,作为参考基线;上层是一个高度根据数值动态计算的渐变柱(Number(w.value) * 6,即将预订数乘以 6 得到像素高度)。渐变柱使用从绿色强调色到棕色主色的垂直渐变(角度为 0,即从上到下),形成自然的色彩过渡。
每个柱子宽度 24px,圆角 4px。柱状图单元使用 layoutWeight(1) 平均分配行宽,确保七天的柱子均匀分布。
这种纯代码实现的柱状图虽然简单,但对于展示一周的数据趋势已经足够。它的优势在于轻量、灵活,不需要引入额外的依赖,样式也可以完全按照设计需求定制。对于原型开发和简单数据展示场景,这种实现方式非常高效。
8.1.6 热门房源横滑
Row() {
Text('🔥 热门房源').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text('查看全部 >').fontSize(11).fontColor(COLORS.textHint)
}
.width('100%')
.padding({ left: 16, right: 16, top: 16 })
Scroll() {
Row() {
ForEach(HOUSE_LIST, (h: HouseMeta) => {
Column() {
Stack() {
Column().width(80).height(80).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.wood, 0.0], [COLORS.accentLight, 1.0]] })
Text(h.emoji).fontSize(40)
if (h.isHot) {
Text('🔥').fontSize(14).position({ x: 56, y: -4 })
}
}
.width(80).height(80)
Text(h.name).fontSize(10).fontColor(COLORS.textPrimary)
.margin({ top: 6 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(h.city).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
Row() {
Text('¥' + h.price).fontSize(11).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/晚').fontSize(8).fontColor(COLORS.textHint)
Text(' ★' + h.rating).fontSize(9).fontColor(COLORS.warning)
}
.margin({ top: 2 })
}
.width(88)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 8, bottom: 8 })
热门房源横滑模块是首页的核心内容区域之一,展示精选的热门房源供用户快速浏览。
模块顶部是标题栏,左侧是"🔥 热门房源"标题,右侧是"查看全部 >"链接,点击可以跳转到房源列表页。这种"标题 + 更多"的布局模式是列表类模块的标准设计。
房源列表使用水平滚动的 Scroll 组件实现,设置为水平滚动方向(ScrollDirection.Horizontal),并隐藏滚动条(scrollBar(BarState.Off))以保持界面简洁。
每个房源卡片是一个垂直排列的 Column,宽度 88px,包含以下元素:
-
房源图片区域:80x80px 的正方形,使用木色到浅绿的 135 度渐变作为背景,中间放置房源的 emoji 图标。如果是热门房源(
h.isHot为 true),右上角会叠加一个火焰 emoji 🔥,使用绝对定位(position)放置在右上角。 -
房源名称:10px 大小的主文字色,限制为 1 行,超出部分用省略号截断。
-
城市信息:9px 的提示色文字。
-
价格和评分:价格使用绿色强调色粗体突出显示,评分使用橙色星星图标。
每个房源卡片左间距 8px,内容居中对齐。整个滚动区域的左右两侧有 16px 的内边距。
这种横滑列表的设计非常适合移动端场景,用户可以通过左右滑动快速浏览多个选项,同时不会占用太多垂直空间。渐变背景 + emoji 图标的组合在原型阶段既美观又高效,不需要准备真实的图片资源。
8.2 房源页面 HomestayHouseContent
@Component
struct HomestayHouseContent {
build() {
Scroll() {
Column() {
// 房源列表头卡
Column() {
Row() {
Text('🏠').fontSize(28).margin({ right: 8 })
Column() {
Text('精选房源').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('12 套精选 · 8 个城市').fontSize(11).fontColor('#D7CCC8').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('🔍').fontSize(22).fontColor(COLORS.white)
}
.width('100%').padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
房源页面的头卡设计与首页有所不同。它使用了从绿色强调色到棕色主色的渐变,色彩更加丰富。头卡左侧是一个大的房子 emoji 图标,中间是标题和副标题信息,右侧是搜索图标。标题"精选房源"使用 20px 的大字号和粗体,副标题显示房源总数和城市数量。
头卡底部同样使用了大圆角设计,与首页头卡保持一致的视觉语言。这种头卡设计为每个页面提供了清晰的视觉标识,用户可以通过颜色和图标快速识别当前所在的页面。
8.2.1 城市筛选栏
// 城市筛选
Scroll() {
Row() {
ForEach(['全部', '大理', '三亚', '丽江', '莫干山', '稻城', '腾冲'], (t: string) => {
Text(t).fontSize(12).fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.wood)
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ left: 8 })
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 12 })
城市筛选栏是一个水平滚动的标签列表,用户可以通过城市快速筛选房源。筛选标签使用木色背景和次要文字色,圆角 16px 形成胶囊形状。标签之间的间距为 8px,整体左右有 8px 的内边距。
虽然这段代码中的筛选标签还没有选中状态的视觉反馈(所有标签样式相同),但整体结构已经搭建完成。在实际项目中,可以通过增加一个选中状态变量,点击时更新状态并动态改变标签样式来实现完整的筛选交互。
8.2.2 房源双列卡片
// 房源双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(HOUSE_LIST, (h: HouseMeta) => {
Column() {
Stack() {
Column().width('100%').height(80).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.wood, 0.0], [COLORS.accentLight, 1.0]] })
Text(h.emoji).fontSize(40)
if (h.isHot) {
Text('🔥').fontSize(14).position({ x: 90, y: -4 })
}
}
.width('100%').height(80)
Text(h.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 }).maxLines(1)
Row() {
Text(h.city).fontSize(9).fontColor(COLORS.textSecondary)
Text(' · ')
Row() {
Text('★').fontSize(8).fontColor(COLORS.warning)
Text(h.rating + '').fontSize(9).fontColor(getRatingColor(h.rating))
}
}
.margin({ top: 2 })
Row() {
Text('¥' + h.price).fontSize(13).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/晚').fontSize(9).fontColor(COLORS.textHint)
}
.margin({ top: 4 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
})
}
.width('92%')
.margin({ left: 16, right: 16, bottom: 20 })
房源列表使用双列瀑布流布局,通过 Flex 组件的 FlexWrap.Wrap 换行属性和 FlexAlign.SpaceBetween 两端对齐属性实现。每个房源卡片宽度为 48%,两张卡片之间自动留有间距。
每张卡片是一个白色圆角卡片,内边距 12px,底部外边距 8px。卡片内容包括:
-
图片区域:占满卡片宽度,高 80px,使用与首页横滑房源相同的渐变背景和 emoji 图标设计,热门标记也同样使用绝对定位的火焰 emoji。
-
房源名称:12px 的中等字重主文字色,限制 1 行显示。
-
城市和评分:城市信息使用 9px 的次要文字色,评分使用星星图标和动态颜色(通过
getRatingColor函数根据评分值返回不同颜色)。 -
价格:13px 的绿色粗体价格,是整个卡片的视觉重点之一。
双列布局比单列布局能在一屏内展示更多的房源信息,提升浏览效率。同时,卡片化设计也让每个房源的信息更加聚焦,用户可以快速扫描对比。
8.3 订单页面 HomestayOrderContent
订单页面负责展示用户的订单列表,并提供编辑订单的功能。这个页面包含一个头卡、一个订单列表和一个编辑订单弹窗。
8.3.1 编辑订单弹窗
@Builder editOrderModal() {
Stack() {
this.modalOverlay(() => { this.showEditOrderModal = false })
Column() {
Row() {
Text('✏️ 编辑订单').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditOrderModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('订单房源').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
Text(this.selectedOrder).fontSize(14).fontColor(COLORS.textPrimary)
.margin({ left: 20, top: 4 })
Text('入住日期').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 08-15' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('退房日期').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 08-18' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('入住人数').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 2' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.wood).borderRadius(8)
.onClick(() => { this.showEditOrderModal = false })
Column().width(12)
Text('保存').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.showEditOrderModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
编辑订单弹窗与新增房源弹窗采用了相同的设计模式:Stack 堆叠布局 + 遮罩层 + 白色卡片。弹窗内容包括标题栏、分隔线、表单区和操作按钮。
与新增房源弹窗不同的是,编辑订单弹窗有四个表单字段:订单房源(只读显示)、入住日期、退房日期、入住人数。第一个字段"订单房源"使用纯文本显示,而不是输入框,因为它是订单的关联信息,不允许在编辑时修改。
这种"详情信息只读 + 可编辑字段输入框"的混合表单设计在编辑类场景中很常见。它既能让用户确认当前操作的对象,又清晰地标识了哪些字段可以修改。
弹窗的交互逻辑也很清晰:点击遮罩、关闭按钮或取消按钮都会关闭弹窗,点击保存按钮同样关闭弹窗(在实际项目中会先执行保存逻辑)。
8.3.2 订单列表
ForEach(ORDER_LIST, (o: OrderMeta) => {
Row() {
Stack() {
Column().width(48).height(48).borderRadius(12)
.backgroundColor(COLORS.wood)
Text(o.emoji).fontSize(28)
}
.width(48).height(48).margin({ right: 12 })
Column() {
Text(o.name).fontSize(14).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Row() {
Text(o.checkIn + ' → ' + o.checkOut).fontSize(10).fontColor(COLORS.textSecondary)
Text(' · ' + o.nights + '晚').fontSize(10).fontColor(COLORS.accent)
}
.margin({ top: 2 })
if (o.isDone) {
Text('已完成').fontSize(10).fontColor(COLORS.success).margin({ top: 2 })
} else {
Text('待入住').fontSize(10).fontColor(COLORS.warning).fontWeight(FontWeight.Bold).margin({ top: 2 })
}
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (o.isDone) {
Text('编辑').fontSize(11).fontColor(COLORS.primary)
.border({ width: 1, color: COLORS.primary }).borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick(() => {
this.selectedOrder = o.name
this.showEditOrderModal = true
})
} else {
Text('管理').fontSize(11).fontColor(COLORS.white)
.backgroundColor(COLORS.primary).borderRadius(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
订单列表使用 ForEach 遍历 ORDER_LIST 数组,每条订单是一个水平排列的 Row,包含左侧图标、中间信息和右侧操作按钮三部分。
左侧是 48x48px 的木色圆形背景图标,展示房源的 emoji 标识。
中间是订单的主要信息,垂直排列:
- 房源名称:14px 中等字重
- 日期和晚数:入住到退房的日期范围,晚数使用绿色强调色突出
- 状态标签:已完成订单显示绿色"已完成",待入住订单显示橙色粗体"待入住"
右侧操作按钮根据订单状态显示不同内容:
- 已完成订单:显示边框样式的"编辑"按钮,点击时将订单名称存入
selectedOrder状态并打开编辑弹窗 - 待入住订单:显示实心填充的"管理"按钮,视觉权重更高,引导用户进行操作
每条订单记录底部有一条 1px 的分隔线,颜色为边框色。列表项使用白色背景,形成卡片列表的视觉效果。
这种列表设计的信息层次非常清晰:房源名称是主要信息,日期晚数是次要信息,状态标签提供快速识别,操作按钮提供明确的行动入口。不同状态使用不同的视觉样式,让用户一眼就能分辨订单的处理状态。
8.4 体验页面 HomestayExpContent
体验页面展示当地的特色体验活动和优选房东信息。页面包含头卡、体验活动双列卡片和优选房东横滑三个模块。
// 体验双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(EXP_LIST, (e: ExperienceMeta) => {
Column() {
Stack() {
Column().width('100%').height(60).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.accentLight, 0.0], [COLORS.wood, 1.0]] })
Text(e.emoji).fontSize(34)
if (e.isHot) {
Text('🔥').fontSize(12).position({ x: 88, y: -4 })
}
}
.width('100%').height(60)
Text(e.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 })
Text(e.desc).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
Row() {
Text('¥' + e.price).fontSize(13).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/人').fontSize(9).fontColor(COLORS.textHint)
}
.margin({ top: 4 })
Text('预约').fontSize(11).fontColor(COLORS.white)
.backgroundColor(COLORS.accent).borderRadius(10)
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
.margin({ top: 6 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
.alignItems(HorizontalAlign.Center)
})
}
体验活动使用双列卡片布局,与房源双列类似但内容有所不同。每张卡片包含:图标区域(60px 高,比房源卡片矮一些)、活动名称、活动描述、价格和"预约"按钮。
卡片的渐变方向与房源卡片相反——从浅绿到木色,与房源卡片的木色到浅绿形成区分,这是一种微妙的视觉差异化设计。价格下方多了一个绿色的"预约"按钮,引导用户进行预订操作,这符合体验活动"即时预约"的业务特点。
卡片内容居中对齐,整体视觉更加均衡。热门标记的火焰 emoji 使用绝对定位在右上角。
8.5 个人中心 HomestayMineContent
个人中心页面是功能最丰富的页面之一,包含个人信息头卡、数据统计、评价列表和功能菜单等多个模块。
8.5.1 删除评价弹窗
@Builder deleteReviewModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Text('⚠️ 删除评价').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 24 })
Text('确定要删除「' + this.selectedUser + '」的评价吗?')
.fontSize(13).fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.margin({ left: 20, right: 20, top: 12 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.wood).borderRadius(8)
.onClick(() => { this.showDeleteModal = false })
Column().width(12)
Text('删除').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger).borderRadius(8)
.onClick(() => { this.showDeleteModal = false })
}
.margin({ left: 20, right: 20, top: 24, bottom: 24 })
}
.width('80%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
删除评价弹窗是一个确认对话框,与之前的表单弹窗结构不同。它的布局更加简洁,包含三个核心元素:
- 标题:带有警告图标的"⚠️ 删除评价",提醒用户这是一个重要操作。
- 确认信息:居中显示的确认文字,动态插入要删除的用户名称,让用户明确知道将要删除的是哪条评价。
- 操作按钮:取消和删除两个按钮,分别使用木色和红色(危险色
COLORS.danger)背景。
删除按钮使用危险色(红色)是 UX 设计的标准做法——破坏性操作使用醒目的警示颜色,既符合用户的心理预期,又能起到提醒作用,防止误操作。
弹窗宽度为 80%,比表单弹窗略窄,因为内容较少,窄一些显得更加紧凑聚焦。
8.5.2 个人数据统计
Row() {
Column() {
Text('10').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
Text('订单数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('收藏房源').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('32').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.warning)
Text('入住晚数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('4.8').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.danger)
Text('平均评分').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.cardBg)
.margin({ left: 12, right: 12, top: 12 })
.borderRadius(16)
个人数据统计模块用四列等分布局展示用户的核心数据指标。每列包含一个大数字和一个标签说明,数字使用粗体,标签使用小字号次要文字色。
有趣的是,四个数据使用了四种不同的颜色:订单数用棕色主色、收藏房源用绿色强调色、入住晚数用橙色警告色、平均评分用红色危险色。这种"彩虹色"的数据展示方式虽然不是最规范的设计(通常建议统一数据颜色或按语义着色),但在原型阶段可以让界面看起来更加丰富活泼,也能快速区分不同的数据类型。
在实际的产品设计中,可能会选择更统一的配色方案,比如全部使用主色,或者根据数据的含义选择对应的语义色。但这里的多彩设计也展示了 ArkTS 声明式样式的灵活性——每个 Text 组件都可以独立设置颜色,实现多样化的视觉效果。
九、弹窗组件详解
9.1 modalOverlay 的实现原理
贯穿所有弹窗的核心组件是 modalOverlay 这个自定义构建函数。它的实现非常简洁,但蕴含着重要的设计思想。
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
遮罩层的作用:
- 视觉聚焦:半透明黑色背景降低了底层内容的可见度,让用户的注意力集中在弹窗内容上。
- 交互阻断:遮罩层覆盖在所有内容之上,阻止用户与底层页面进行交互,保证弹窗是当前唯一的交互焦点。
- 关闭入口:点击遮罩层可以关闭弹窗,提供了一种符合直觉的取消操作方式。
参数化设计:modalOverlay 接收一个 onClose 回调函数作为参数,这是典型的"依赖注入"模式。不同的弹窗组件可以传入不同的关闭逻辑,而遮罩层本身不需要知道具体的关闭逻辑——它只负责在被点击时调用传入的回调。
复用价值:由于遮罩层被抽取成独立的构建函数,所有弹窗都可以复用同一个遮罩实现。这保证了遮罩效果的一致性(透明度、点击行为都相同),也减少了重复代码。如果后续需要修改遮罩样式(比如调整透明度或添加动画),只需要修改这一处即可。
9.2 bindSheet 的使用方式
.bindSheet(this.showAddHouseModal, this.addHouseModal(), {
height: SheetSize.LARGE,
})
弹窗的显示使用了 bindSheet 方法,这是 ArkTS 提供的底部抽屉弹窗 API。它接收三个参数:
- 显示状态(布尔值)
- 弹窗内容(构建函数调用)
- 配置选项(这里设置高度为大尺寸
SheetSize.LARGE)
bindSheet 会将弹窗绑定到组件上,当状态变量变为 true 时,弹窗会从底部滑出;变为 false 时,弹窗会滑出消失。这种声明式的弹窗管理方式比命令式的 show/dismiss 调用更加直观——只需要关注状态的变化,具体的动画和交互由框架负责。
9.3 三种弹窗的设计模式对比
这款 App 中实现了三种不同类型的弹窗,分别对应三种典型的业务场景:
| 弹窗类型 | 业务场景 | 结构特点 | 按钮样式 |
|---|---|---|---|
| 新增房源 | 表单录入 | 标题 + 多个输入框 + 双按钮 | 取消(木色)+ 发布(主色) |
| 编辑订单 | 表单编辑 | 标题 + 只读信息 + 输入框 + 双按钮 | 取消(木色)+ 保存(主色) |
| 删除评价 | 确认操作 | 标题 + 确认文字 + 双按钮 | 取消(木色)+ 删除(危险色) |
这三种弹窗虽然功能不同,但共享了相同的设计语言:
- 相同的遮罩层实现(modalOverlay)
- 相同的卡片圆角(16px)
- 相同的标题样式(18px 粗体)
- 相同的操作按钮布局(左右平分,中间 12px 间距)
- 相同的内边距规范
这种一致性设计非常重要——用户在使用不同功能的弹窗时,交互模式是统一的,降低了学习成本。同时,对于开发者来说,统一的弹窗模板也提高了开发效率。
十、核心 UI 组件分析
10.1 渐变头卡组件
渐变头卡是每个页面顶部的标志性组件,在整个 App 中被广泛使用。虽然没有被抽取成独立的组件(每个页面自己实现),但它们遵循着相同的设计模式。
设计特点:
- 135 度线性渐变背景(从浅色到深色,或从强调色到主色)
- 底部大圆角(20-24px)
- 白色文字在深色背景上
- 大标题 + 副标题的信息层级
- 高度从 120px 到 190px 不等,根据内容多少调整
渐变头卡的视觉效果非常出色,它打破了传统的矩形页面布局,为界面增添了动感和层次感。渐变色的使用也让界面看起来更加精致和现代。
10.2 列表项组件
列表是移动端 App 中最常见的 UI 形式之一。这款 App 中的列表设计遵循了一套统一的规范:
列表项结构:
- 左侧图标(40-48px 正方形/圆形,木色/暖色背景)
- 中间主要信息(标题 + 副标题/描述)
- 右侧操作按钮或状态标签
- 底部分隔线(1px 边框色)
状态区分:
- 已完成/已打卡:绿色文字或绿色背景标签
- 待完成/待入住:橙色文字或橙色背景标签
- 紧急/重要:红色文字或红色背景
- 编辑操作:边框样式按钮
- 主要操作:实心填充按钮
这种列表设计模式清晰高效,能够适用于订单列表、评价列表、装备列表、活动列表等多种业务场景。
10.3 横滑组件
横滑(水平滚动)是首页中常用的内容展示形式,用于展示热门房源、热门城市、优选房东等内容。
实现方式:
- 外层
Scroll组件,设置scrollable(ScrollDirection.Horizontal) - 内层
Row容器,包含多个子项 - 隐藏滚动条
scrollBar(BarState.Off) - 左右内边距确保首末项有足够边距
优势:
- 节省垂直空间,在有限的屏幕高度内展示更多内容类别
- 符合移动端用户的滑动操作习惯
- 用户可以自主控制浏览节奏
10.4 双列卡片组件
双列卡片网格是房源列表、体验列表等页面的主要布局形式。
实现方式:
Flex组件 +FlexWrap.Wrap换行justifyContent: FlexAlign.SpaceBetween两端对齐- 卡片宽度 48%,两张卡片间自动留出 4% 间距
卡片内容层次:
- 图片/图标区域(渐变背景 + emoji)
- 标题文字
- 次要信息(位置、描述、评分等)
- 价格或操作按钮
双列布局相比单列能展示更多内容,同时每张卡片又有足够的空间展示信息,是信息密度和可读性的良好平衡。
十一、技术亮点总结表格
| 功能模块 | 技术实现方式 | 设计模式 | 代码量估计 | 亮点说明 |
|---|---|---|---|---|
| 颜色系统 | interface + const 常量 | 配置驱动 | 约 40 行 | 16 色完整调色板,语义化命名,动态颜色函数 |
| 数据模型 | 9 个 interface 定义 | 类型优先 | 约 80 行 | 类型安全,字段设计贴合业务,接口即文档 |
| Tab 导航 | enum + @State + @Builder | 状态驱动 | 约 60 行 | 枚举驱动,配置分离,自定义构建器复用 |
| 首页模块 | Scroll + Stack + 渐变头卡 | 声明式布局 | 约 170 行 | 行程倒计时、柱状图、横滑列表多种组件组合 |
| 房源列表 | Flex 双列 + ForEach | 响应式布局 | 约 90 行 | 双列瀑布流,城市筛选横滑,卡片信息层次清晰 |
| 订单管理 | 列表 + 编辑弹窗 | CRUD 模式 | 约 140 行 | 状态区分展示,表单弹窗编辑,bindSheet 管理 |
| 体验活动 | 双列卡片 + 房东横滑 | 卡片式布局 | 约 100 行 | 预约按钮引导转化,房东推荐模块 |
| 个人中心 | 头卡 + 数据统计 + 评价列表 | 信息聚合 | 约 180 行 | 多色数据展示,评价管理,功能菜单列表 |
| 弹窗系统 | modalOverlay + bindSheet | 函数式组件 | 约 120 行 | 三种弹窗类型统一遮罩,声明式显示控制 |
| Mock 数据 | 8 组模拟数据数组 | 数据驱动 | 约 130 行 | 多样化真实感数据,覆盖各业务场景 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 531.ets 民宿短租 APP — HomestayApp
// 主色调: 木质棕 #8D6E63 + 森林绿 #558B2F
// 布局: 行程倒计时头卡 + 周预订柱状图 + 热门房源横滑
// + 房源双列 + 房东评价列表 + 城市横滑 + 体验活动 + 我的
// 弹窗: 新增房源 / 编辑订单 / 删除评价 (全部 modalOverlay)
// ============================================================
interface HomestayColorPalette {
primary: string
primaryLight: string
primaryDark: string
accent: string
accentLight: string
wood: string
bg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
success: string
warning: string
danger: string
white: string
}
interface TabMeta {
title: string
icon: string
}
interface HouseMeta {
name: string
emoji: string
city: string
price: number
rating: number
isHot: boolean
}
interface OrderMeta {
name: string
emoji: string
checkIn: string
checkOut: string
nights: number
isDone: boolean
}
interface ReviewMeta {
user: string
avatar: string
house: string
content: string
rating: number
isTop: boolean
}
interface CityMeta {
name: string
emoji: string
count: number
isHot: boolean
}
interface WeekOrderMeta {
label: string
value: number
}
interface ExperienceMeta {
name: string
emoji: string
desc: string
price: number
isHot: boolean
}
interface HostMeta {
name: string
emoji: string
houses: number
rating: number
isSuper: boolean
}
interface MenuMeta {
icon: string
name: string
sub: string
}
const COLORS: HomestayColorPalette = {
primary: '#8D6E63',
primaryLight: '#BCAAA4',
primaryDark: '#5D4037',
accent: '#558B2F',
accentLight: '#AED581',
wood: '#D7CCC8',
bg: '#FAF5F0',
cardBg: '#FFFFFF',
textPrimary: '#3E2723',
textSecondary: '#6D4C41',
textHint: '#BCAAA4',
border: '#EFEBE9',
success: '#43A047',
warning: '#FB8C00',
danger: '#E53935',
white: '#FFFFFF'
}
const TAB_CONFIG: Record<string, TabMeta> = {
'HOME': { title: '首页', icon: '🏡' },
'HOUSE': { title: '房源', icon: '🏠' },
'ORDER': { title: '订单', icon: '📋' },
'EXP': { title: '体验', icon: '🌲' },
'MINE': { title: '我的', icon: '👤' }
}
const HOUSE_LIST: HouseMeta[] = [
{ name: '山顶木屋', emoji: '🏚️', city: '大理', price: 388, rating: 4.9, isHot: true },
{ name: '海景别墅', emoji: '🏖️', city: '三亚', price: 688, rating: 4.8, isHot: true },
{ name: '竹林小院', emoji: '🎋', city: '莫干山', price: 520, rating: 4.7, isHot: true },
{ name: '古镇客栈', emoji: '🏮', city: '丽江', price: 298, rating: 4.6, isHot: false },
{ name: '湖畔帐篷', emoji: '⛺', city: '青海湖', price: 268, rating: 4.5, isHot: true },
{ name: '雪山木屋', emoji: '🏔️', city: '稻城', price: 888, rating: 4.9, isHot: false },
{ name: '温泉民宿', emoji: '♨️', city: '腾冲', price: 458, rating: 4.7, isHot: false },
{ name: '草原毡房', emoji: '🏕️', city: '呼伦贝尔', price: 328, rating: 4.4, isHot: false },
{ name: '森林树屋', emoji: '🌳', city: '西双版纳', price: 588, rating: 4.8, isHot: true },
{ name: '老宅院落', emoji: '🏡', city: '苏州', price: 418, rating: 4.6, isHot: false },
{ name: '海岛草屋', emoji: '🌴', city: '涠洲岛', price: 498, rating: 4.5, isHot: false },
{ name: '山谷别院', emoji: '🌄', city: '阳朔', price: 368, rating: 4.7, isHot: true }
]
const ORDER_LIST: OrderMeta[] = [
{ name: '山顶木屋', emoji: '🏚️', checkIn: '08-15', checkOut: '08-18', nights: 3, isDone: false },
{ name: '竹林小院', emoji: '🎋', checkIn: '07-20', checkOut: '07-22', nights: 2, isDone: true },
{ name: '海景别墅', emoji: '🏖️', checkIn: '07-10', checkOut: '07-14', nights: 4, isDone: true },
{ name: '古镇客栈', emoji: '🏮', checkIn: '06-28', checkOut: '06-30', nights: 2, isDone: true },
{ name: '森林树屋', emoji: '🌳', checkIn: '06-15', checkOut: '06-18', nights: 3, isDone: true },
{ name: '温泉民宿', emoji: '♨️', checkIn: '05-22', checkOut: '05-24', nights: 2, isDone: true },
{ name: '雪山木屋', emoji: '🏔️', checkIn: '05-01', checkOut: '05-05', nights: 4, isDone: true },
{ name: '草原毡房', emoji: '🏕️', checkIn: '04-18', checkOut: '04-20', nights: 2, isDone: true },
{ name: '湖畔帐篷', emoji: '⛺', checkIn: '04-05', checkOut: '04-07', nights: 2, isDone: true },
{ name: '山谷别院', emoji: '🌄', checkIn: '03-20', checkOut: '03-22', nights: 2, isDone: true }
]
const REVIEW_LIST: ReviewMeta[] = [
{ user: '旅行者小白', avatar: '🧳', house: '山顶木屋', content: '环境太棒了!山景无敌,老板热情好客', rating: 5, isTop: true },
{ user: '度假达人', avatar: '🏖️', house: '海景别墅', content: '推窗就是大海,设施齐全,值得推荐', rating: 5, isTop: true },
{ user: '文艺青年', avatar: '🎨', house: '竹林小院', content: '很有禅意,适合发呆放空,早餐也很棒', rating: 5, isTop: false },
{ user: '家庭出行', avatar: '👨👩👧', house: '古镇客栈', content: '带孩子感受古镇文化,房间干净整洁', rating: 4, isTop: false },
{ user: '露营爱好者', avatar: '⛺', house: '湖畔帐篷', content: '星空太美了!篝火晚会很开心', rating: 5, isTop: false },
{ user: '摄影党', avatar: '📷', house: '雪山木屋', content: '日出日落都是大片,房间也很暖和', rating: 5, isTop: false },
{ user: '温泉控', avatar: '♨️', house: '温泉民宿', content: '私汤很赞,服务周到,下次还来', rating: 4, isTop: false },
{ user: '草原骑士', avatar: '🐎', house: '草原毡房', content: '骑马体验太棒了,毡房也很有特色', rating: 5, isTop: false },
{ user: '探险家', avatar: '🧭', house: '森林树屋', content: '住在树上太有趣了!孩子超兴奋', rating: 5, isTop: false },
{ user: '慢生活家', avatar: '🍵', house: '山谷别院', content: '安静祥和,空气清新,值得二刷', rating: 4, isTop: false }
]
const CITY_LIST: CityMeta[] = [
{ name: '大理', emoji: '🏞️', count: 128, isHot: true },
{ name: '三亚', emoji: '🏝️', count: 256, isHot: true },
{ name: '丽江', emoji: '🏮', count: 186, isHot: true },
{ name: '莫干山', emoji: '🎋', count: 98, isHot: false },
{ name: '稻城', emoji: '🏔️', count: 56, isHot: false },
{ name: '腾冲', emoji: '♨️', count: 72, isHot: false },
{ name: '阳朔', emoji: '🌄', count: 65, isHot: false },
{ name: '青海湖', emoji: '🌊', count: 43, isHot: true }
]
const WEEK_ORDER: WeekOrderMeta[] = [
{ label: '周一', value: 3 },
{ label: '周二', value: 5 },
{ label: '周三', value: 4 },
{ label: '周四', value: 7 },
{ label: '周五', value: 9 },
{ label: '周六', value: 12 },
{ label: '周日', value: 8 }
]
const EXP_LIST: ExperienceMeta[] = [
{ name: '采茶体验', emoji: '🍵', desc: '亲手采制春茶', price: 128, isHot: true },
{ name: '陶艺工坊', emoji: '🏺', desc: '拉胚烧制全程', price: 168, isHot: true },
{ name: '骑马穿越', emoji: '🐎', desc: '草原半日游', price: 268, isHot: false },
{ name: '篝火晚会', emoji: '🔥', desc: '星空下的派对', price: 88, isHot: true },
{ name: '徒步登山', emoji: '🥾', desc: '专业向导带队', price: 198, isHot: false },
{ name: '钓鱼野炊', emoji: '🎣', desc: '湖边垂钓烹饪', price: 158, isHot: false },
{ name: '扎染手作', emoji: '🎨', desc: '白族传统工艺', price: 98, isHot: true },
{ name: '星空摄影', emoji: '📷', desc: '银河拍摄教学', price: 288, isHot: false }
]
const HOST_LIST: HostMeta[] = [
{ name: '山居主人', emoji: '🏚️', houses: 3, rating: 4.9, isSuper: true },
{ name: '海边老王', emoji: '🏖️', houses: 5, rating: 4.8, isSuper: true },
{ name: '竹隐居士', emoji: '🎋', houses: 2, rating: 4.7, isSuper: true },
{ name: '古镇阿婆', emoji: '🏮', houses: 4, rating: 4.6, isSuper: false },
{ name: '湖畔小张', emoji: '⛺', houses: 2, rating: 4.5, isSuper: false },
{ name: '雪域达瓦', emoji: '🏔️', houses: 3, rating: 4.9, isSuper: true }
]
const MENU_LIST: MenuMeta[] = [
{ icon: '❤️', name: '我的收藏', sub: '8 套房源' },
{ icon: '💰', name: '钱包账户', sub: '余额 ¥2,580' },
{ icon: '🎫', name: '优惠券', sub: '3 张可用' },
{ icon: '📢', name: '我的评价', sub: '已评价 10 条' },
{ icon: '⚙️', name: '账号设置', sub: '个人信息/隐私' },
{ icon: '📞', name: '客服中心', sub: '7×24 在线' }
]
function getRatingColor(rating: number): string {
if (rating >= 4.8) {
return COLORS.accent
} else if (rating >= 4.5) {
return COLORS.success
} else if (rating >= 4.0) {
return COLORS.warning
}
return COLORS.textSecondary
}
enum HomestayTab {
HOME,
HOUSE,
ORDER,
EXP,
MINE
}
@Entry
@Component
struct HomestayApp {
@State activeTab: HomestayTab = HomestayTab.HOME
@Builder contentArea() {
Column() {
if (this.activeTab === HomestayTab.HOME) {
HomestayHomeContent()
} else if (this.activeTab === HomestayTab.HOUSE) {
HomestayHouseContent()
} else if (this.activeTab === HomestayTab.ORDER) {
HomestayOrderContent()
} else if (this.activeTab === HomestayTab.EXP) {
HomestayExpContent()
} else if (this.activeTab === HomestayTab.MINE) {
HomestayMineContent()
}
}
.width('100%')
}
@Builder tabItem(icon: string, label: string, tab: HomestayTab) {
Column() {
Text(icon).fontSize(20)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? COLORS.primary : COLORS.textHint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.activeTab = tab })
}
build() {
Column() {
this.contentArea()
Row() {
this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, HomestayTab.HOME)
this.tabItem(TAB_CONFIG.HOUSE.icon, TAB_CONFIG.HOUSE.title, HomestayTab.HOUSE)
this.tabItem(TAB_CONFIG.ORDER.icon, TAB_CONFIG.ORDER.title, HomestayTab.ORDER)
this.tabItem(TAB_CONFIG.EXP.icon, TAB_CONFIG.EXP.title, HomestayTab.EXP)
this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, HomestayTab.MINE)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.white)
.shadow({ radius: 10, color: '#8D6E6326', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(COLORS.bg)
}
}
@Component
struct HomestayHomeContent {
@State showAddHouseModal: boolean = false
@State houseName: string = ''
@State houseCity: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder addHouseModal() {
Stack() {
this.modalOverlay(() => { this.showAddHouseModal = false })
Column() {
Row() {
Text('🏠 新增房源').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddHouseModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('房源名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
TextInput({ placeholder: '如 山顶木屋' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.houseName = v })
Text('所在城市').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 大理' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.houseCity = v })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.wood).borderRadius(8)
.onClick(() => { this.showAddHouseModal = false })
Column().width(12)
Text('发布').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.showAddHouseModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Scroll() {
Column() {
// 行程倒计时头卡
Stack() {
Column()
.width('100%').height(170)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Row() {
Column() {
Text('🏡 即将入住').fontSize(11).fontColor('#D7CCC8')
Text('3').fontSize(42).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
.margin({ top: 2 })
Text('天后 · 大理·山顶木屋').fontSize(10).fontColor('#D7CCC8').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Stack() {
Column().width(70).height(70).borderRadius(35)
.backgroundColor('rgba(255,255,255,0.15)')
Column().width(50).height(50).borderRadius(25)
.backgroundColor(COLORS.accent)
Text('🌿').fontSize(28)
}
.width(70).height(70)
}
.width('100%')
.padding({ left: 20, right: 20, top: 30 })
Row() {
Text('📅 08-15 入住').fontSize(10).fontColor('#D7CCC8')
Text(' → ').fontSize(10).fontColor('#D7CCC8')
Text('08-18 退房').fontSize(10).fontColor('#D7CCC8')
Text(' · 3晚').fontSize(10).fontColor(COLORS.accentLight)
Column().layoutWeight(1)
Text('¥1,164').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
}
.width('100%')
.padding({ left: 20, right: 20, top: 12 })
}
}
.width('100%').height(170)
// 周预订柱状图
Column() {
Text('📊 本周预订趋势').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Row() {
ForEach(WEEK_ORDER, (w: WeekOrderMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column().width(24).height(80)
.backgroundColor(COLORS.wood)
.borderRadius(4)
Column().width(24).height(Number(w.value) * 6)
.linearGradient({ angle: 0, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius(4)
}
.width(24).height(80)
Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
// 热门房源横滑
Row() {
Text('🔥 热门房源').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text('查看全部 >').fontSize(11).fontColor(COLORS.textHint)
}
.width('100%')
.padding({ left: 16, right: 16, top: 16 })
Scroll() {
Row() {
ForEach(HOUSE_LIST, (h: HouseMeta) => {
Column() {
Stack() {
Column().width(80).height(80).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.wood, 0.0], [COLORS.accentLight, 1.0]] })
Text(h.emoji).fontSize(40)
if (h.isHot) {
Text('🔥').fontSize(14).position({ x: 56, y: -4 })
}
}
.width(80).height(80)
Text(h.name).fontSize(10).fontColor(COLORS.textPrimary)
.margin({ top: 6 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(h.city).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
Row() {
Text('¥' + h.price).fontSize(11).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/晚').fontSize(8).fontColor(COLORS.textHint)
Text(' ★' + h.rating).fontSize(9).fontColor(COLORS.warning)
}
.margin({ top: 2 })
}
.width(88)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 8, bottom: 8 })
// 城市横滑
Text('🗺️ 热门城市').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, top: 8, bottom: 8 })
Scroll() {
Row() {
ForEach(CITY_LIST, (c: CityMeta) => {
Column() {
Stack() {
Column().width(56).height(56).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.accentLight, 0.0], [COLORS.wood, 1.0]] })
Text(c.emoji).fontSize(30)
if (c.isHot) {
Text('🔥').fontSize(12).position({ x: 38, y: -4 })
}
}
.width(56).height(56)
Text(c.name).fontSize(11).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 6 })
Text(c.count + ' 套').fontSize(9).fontColor(COLORS.textHint).margin({ top: 1 })
}
.width(72)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ bottom: 8 })
// 新增按钮
Row() {
Text('+ 发布房源').fontSize(14).fontColor(COLORS.white)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(24)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 16, bottom: 20 })
.onClick(() => { this.showAddHouseModal = true })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
.bindSheet(this.showAddHouseModal, this.addHouseModal(), {
height: SheetSize.LARGE,
})
}
}
@Component
struct HomestayHouseContent {
build() {
Scroll() {
Column() {
// 房源列表头卡
Column() {
Row() {
Text('🏠').fontSize(28).margin({ right: 8 })
Column() {
Text('精选房源').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('12 套精选 · 8 个城市').fontSize(11).fontColor('#D7CCC8').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('🔍').fontSize(22).fontColor(COLORS.white)
}
.width('100%').padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
// 城市筛选
Scroll() {
Row() {
ForEach(['全部', '大理', '三亚', '丽江', '莫干山', '稻城', '腾冲'], (t: string) => {
Text(t).fontSize(12).fontColor(COLORS.textSecondary)
.backgroundColor(COLORS.wood)
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ left: 8 })
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 12 })
// 房源双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(HOUSE_LIST, (h: HouseMeta) => {
Column() {
Stack() {
Column().width('100%').height(80).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.wood, 0.0], [COLORS.accentLight, 1.0]] })
Text(h.emoji).fontSize(40)
if (h.isHot) {
Text('🔥').fontSize(14).position({ x: 90, y: -4 })
}
}
.width('100%').height(80)
Text(h.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 }).maxLines(1)
Row() {
Text(h.city).fontSize(9).fontColor(COLORS.textSecondary)
Text(' · ')
Row() {
Text('★').fontSize(8).fontColor(COLORS.warning)
Text(h.rating + '').fontSize(9).fontColor(getRatingColor(h.rating))
}
}
.margin({ top: 2 })
Row() {
Text('¥' + h.price).fontSize(13).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/晚').fontSize(9).fontColor(COLORS.textHint)
}
.margin({ top: 4 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
})
}
.width('92%')
.margin({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
}
}
@Component
struct HomestayOrderContent {
@State showEditOrderModal: boolean = false
@State selectedOrder: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder editOrderModal() {
Stack() {
this.modalOverlay(() => { this.showEditOrderModal = false })
Column() {
Row() {
Text('✏️ 编辑订单').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditOrderModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(COLORS.border)
Column() {
Text('订单房源').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
Text(this.selectedOrder).fontSize(14).fontColor(COLORS.textPrimary)
.margin({ left: 20, top: 4 })
Text('入住日期').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 08-15' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('退房日期').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 08-18' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('入住人数').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 2' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.wood).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.wood).borderRadius(8)
.onClick(() => { this.showEditOrderModal = false })
Column().width(12)
Text('保存').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary).borderRadius(8)
.onClick(() => { this.showEditOrderModal = false })
}
.margin({ left: 20, right: 20, top: 20, bottom: 20 })
}
.constraintSize({ maxHeight: '70%' })
}
.width('88%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Scroll() {
Column() {
// 订单头卡
Stack() {
Column()
.width('100%').height(120)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Text('📋 我的订单').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('10 笔订单 · 1 笔待入住').fontSize(11).fontColor('#D7CCC8').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 30 })
}
.width('100%').height(120)
// 订单列表
ForEach(ORDER_LIST, (o: OrderMeta) => {
Row() {
Stack() {
Column().width(48).height(48).borderRadius(12)
.backgroundColor(COLORS.wood)
Text(o.emoji).fontSize(28)
}
.width(48).height(48).margin({ right: 12 })
Column() {
Text(o.name).fontSize(14).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Row() {
Text(o.checkIn + ' → ' + o.checkOut).fontSize(10).fontColor(COLORS.textSecondary)
Text(' · ' + o.nights + '晚').fontSize(10).fontColor(COLORS.accent)
}
.margin({ top: 2 })
if (o.isDone) {
Text('已完成').fontSize(10).fontColor(COLORS.success).margin({ top: 2 })
} else {
Text('待入住').fontSize(10).fontColor(COLORS.warning).fontWeight(FontWeight.Bold).margin({ top: 2 })
}
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (o.isDone) {
Text('编辑').fontSize(11).fontColor(COLORS.primary)
.border({ width: 1, color: COLORS.primary }).borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick(() => {
this.selectedOrder = o.name
this.showEditOrderModal = true
})
} else {
Text('管理').fontSize(11).fontColor(COLORS.white)
.backgroundColor(COLORS.primary).borderRadius(12)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
.bindSheet(this.showEditOrderModal, this.editOrderModal(), {
height: SheetSize.LARGE,
})
}
}
@Component
struct HomestayExpContent {
build() {
Scroll() {
Column() {
// 体验活动头卡
Column() {
Row() {
Text('🌲').fontSize(28).margin({ right: 8 })
Column() {
Text('在地体验').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('8 项体验活动 · 6 个城市').fontSize(11).fontColor('#AED581').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
// 体验双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(EXP_LIST, (e: ExperienceMeta) => {
Column() {
Stack() {
Column().width('100%').height(60).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.accentLight, 0.0], [COLORS.wood, 1.0]] })
Text(e.emoji).fontSize(34)
if (e.isHot) {
Text('🔥').fontSize(12).position({ x: 88, y: -4 })
}
}
.width('100%').height(60)
Text(e.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 })
Text(e.desc).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
Row() {
Text('¥' + e.price).fontSize(13).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/人').fontSize(9).fontColor(COLORS.textHint)
}
.margin({ top: 4 })
Text('预约').fontSize(11).fontColor(COLORS.white)
.backgroundColor(COLORS.accent).borderRadius(10)
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
.margin({ top: 6 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.width('92%')
.margin({ left: 16, right: 16, top: 16, bottom: 16 })
// 房东横滑
Text('⭐ 优选房东').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
Scroll() {
Row() {
ForEach(HOST_LIST, (h: HostMeta) => {
Column() {
Stack() {
Column().width(56).height(56).borderRadius(28)
.backgroundColor(COLORS.wood)
Text(h.emoji).fontSize(30)
if (h.isSuper) {
Text('⭐').fontSize(12).position({ x: 38, y: -2 })
}
}
.width(56).height(56)
Text(h.name).fontSize(10).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 6 }).maxLines(1)
Text(h.houses + '套 · ★' + h.rating).fontSize(8).fontColor(COLORS.textHint).margin({ top: 1 })
}
.width(72)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
}
}
@Component
struct HomestayMineContent {
@State showDeleteModal: boolean = false
@State selectedUser: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder deleteReviewModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Text('⚠️ 删除评价').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 24 })
Text('确定要删除「' + this.selectedUser + '」的评价吗?')
.fontSize(13).fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.margin({ left: 20, right: 20, top: 12 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.wood).borderRadius(8)
.onClick(() => { this.showDeleteModal = false })
Column().width(12)
Text('删除').fontSize(14).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger).borderRadius(8)
.onClick(() => { this.showDeleteModal = false })
}
.margin({ left: 20, right: 20, top: 24, bottom: 24 })
}
.width('80%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Scroll() {
Column() {
// 个人头卡
Stack() {
Column()
.width('100%').height(160)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Stack() {
Column().width(64).height(64).borderRadius(32)
.backgroundColor('rgba(255,255,255,0.25)')
Text('🧳').fontSize(36)
}
.width(64).height(64)
Text('旅行者小D').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.white).margin({ top: 8 })
Row() {
Text('Lv.5').fontSize(10).fontColor(COLORS.accentLight)
.backgroundColor('rgba(255,255,255,0.2)')
.borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(' · 入住 10 次').fontSize(10).fontColor('#D7CCC8')
}
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 24 })
}
.width('100%').height(160)
// 个人数据
Row() {
Column() {
Text('10').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
Text('订单数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('收藏房源').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('32').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.warning)
Text('入住晚数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('4.8').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.danger)
Text('平均评分').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.cardBg)
.margin({ left: 12, right: 12, top: 12 })
.borderRadius(16)
// 我的评价
Text('📝 我的评价').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, top: 16, bottom: 8 })
ForEach(REVIEW_LIST, (r: ReviewMeta) => {
Column() {
Row() {
Stack() {
Column().width(36).height(36).borderRadius(18)
.backgroundColor(COLORS.wood)
Text(r.avatar).fontSize(20)
}
.width(36).height(36).margin({ right: 10 })
Column() {
Row() {
Text(r.user).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
if (r.isTop) {
Text(' 精选').fontSize(8).fontColor(COLORS.white)
.backgroundColor(COLORS.accent).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
Text(r.house).fontSize(9).fontColor(COLORS.textHint).margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Row() {
Text('★').fontSize(10).fontColor(COLORS.warning)
Text(r.rating + '').fontSize(10).fontColor(getRatingColor(r.rating))
}
Text(' 删除').fontSize(10).fontColor(COLORS.danger)
.onClick(() => {
this.selectedUser = r.user
this.showDeleteModal = true
})
}
Text(r.content).fontSize(13).fontColor(COLORS.textPrimary)
.margin({ top: 8, left: 46 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
// 功能列表
Column() {
ForEach(MENU_LIST, (m: MenuMeta) => {
Row() {
Text(m.icon).fontSize(22).margin({ right: 12 })
Text(m.name).fontSize(14).fontColor(COLORS.textPrimary).layoutWeight(1)
Text(m.sub).fontSize(11).fontColor(COLORS.textHint)
Text(' >').fontSize(14).fontColor(COLORS.textHint)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
.bindSheet(this.showDeleteModal, this.deleteReviewModal(), {
height: SheetSize.LARGE,
})
}
}
十二、结尾总结

通过对这款民宿短租 App 的全面剖析,我们可以看到一个功能完善、设计精良的移动端应用是如何通过 ArkTS 声明式开发范式构建起来的。从整体架构到组件细节,从数据模型到交互逻辑,每一处都体现了开发者的精心设计和对用户体验的深入思考。
在架构层面,App 采用了经典的 Tab 导航 + 内容区域切换的模式,通过单一状态变量 activeTab 驱动整个应用的导航。这种简洁的状态管理方式虽然简单,但对于五个 Tab 的应用来说已经足够高效。配合 @Builder 自定义构建器,代码结构清晰,复用性强。枚举 + 配置常量的双层设计实现了逻辑与展示的分离,提升了代码的可维护性和可扩展性。
在组件设计方面,App 展现了丰富的 UI 组件实现能力:渐变头卡营造视觉冲击力,柱状图纯代码实现数据可视化,横滑列表高效利用屏幕空间,双列卡片平衡信息密度和可读性,列表项统一设计语言。这些组件虽然都是用基础组件搭建而成,但通过巧妙的组合和样式调整,达到了专业级的视觉效果。尤其是渐变背景、圆角卡片、emoji 图标等元素的运用,在没有真实图片资源的情况下,依然营造出了温馨舒适的民宿氛围。
在交互设计方面,三种类型的弹窗(新增表单、编辑表单、删除确认)覆盖了主要的 CRUD 操作场景。统一的遮罩层设计、一致的卡片样式和按钮布局,让用户在不同功能间切换时有着一致的交互体验。bindSheet 底部抽屉弹窗的使用也符合移动端的交互习惯,从底部滑出的动画自然流畅。
颜色系统的设计同样值得称赞。木质棕 + 森林绿的配色方案与民宿主题高度契合,三级色阶设计(主色/浅色/深色)提供了足够的配色选择,文本色的三级划分保证了信息层次的清晰度。语义色(成功/警告/危险)的使用让状态信息更加直观。工具函数 getRatingColor 的设计也展示了动态样式计算的思路。
对于学习 ArkTS 开发的读者来说,这款 App 是一个非常好的学习样本。它涵盖了声明式 UI 开发的核心概念:@Component 组件、@State 状态管理、@Builder 自定义构建、@Entry 页面入口、ForEach 列表渲染、条件渲染、事件绑定等。同时,它也展示了如何组织一个完整的应用项目——从数据模型定义到常量配置,从主入口到各个页面组件,从 UI 布局到交互逻辑,层次分明,结构清晰。
更多推荐



所有评论(0)