一、开篇引言

在这里插入图片描述

在移动互联网高速发展的今天,民宿短租行业已经成为旅游市场中不可或缺的重要组成部分。从 Airbnb 到国内各大民宿平台,用户对于优质住宿体验的追求从未停止。本文将深入剖析一款基于 ArkTS 开发的民宿短租 App,从业务背景、架构设计、数据模型到组件实现,全方位展示其技术实现细节。

这款民宿短租 App 是一个功能完善的移动端应用,涵盖了首页展示、房源浏览、订单管理、体验活动和个人中心五大核心模块。App 采用木质棕与森林绿的自然配色方案,营造出温馨舒适的视觉氛围,与民宿"家外之家"的品牌理念高度契合。在技术实现上,应用采用了声明式 UI 开发范式,结合 ArkTS 的装饰器语法,构建了一套高度可复用、可维护的组件体系。

从业务角度来看,这款 App 覆盖了民宿短租的全链路场景:用户可以在首页快速了解即将到来的行程、查看预订趋势、浏览热门房源和城市;在房源页面进行筛选和比价;在订单页面管理入住记录;在体验页面发现当地特色活动;在个人中心查看评价和账户信息。每个模块都经过精心设计,既保证了功能完整性,又提供了流畅的用户体验。

在技术层面,App 展现了多项值得学习的设计亮点:统一的颜色调色板接口设计、类型安全的数据模型定义、枚举驱动的 Tab 导航机制、自定义 Builder 构建器的灵活运用、以及基于 bindSheet 的弹窗交互模式。这些设计模式不仅提升了代码质量,也为后续的功能扩展奠定了坚实基础。本文将逐一对这些技术细节进行深入剖析,帮助读者全面理解这款 App 的架构设计思想和实现原理。

二、整体架构流程图

在这里插入图片描述

在深入代码细节之前,让我们先通过架构图来宏观了解这款民宿短租 App 的整体结构。

HOME

HOUSE

ORDER

EXP

MINE

HomestayApp 主入口

Tab 导航栏

内容区域

首页 🏡

房源 🏠

订单 📋

体验 🌲

我的 👤

activeTab 状态

HomestayHomeContent

HomestayHouseContent

HomestayOrderContent

HomestayExpContent

HomestayMineContent

行程倒计时头卡

周预订柱状图

热门房源横滑

城市横滑

新增房源弹窗

房源列表头卡

城市筛选

房源双列卡片

订单头卡

订单列表

编辑订单弹窗

体验活动头卡

体验双列卡片

优选房东横滑

个人头卡

个人数据统计

我的评价列表

删除评价弹窗

功能菜单列表

数据流与状态管理

弹窗组件 contentArea @State activeTab Tab导航 用户 弹窗组件 contentArea @State activeTab Tab导航 用户 点击Tab项 更新 activeTab 值 触发重新渲染 根据 activeTab 切换页面组件 点击操作按钮 设置 showModal = true bindSheet 显示弹窗 填写表单/确认操作 关闭弹窗

组件层级结构

HomestayApp

Column 主容器

contentArea 内容区

Row Tab导航栏

HomestayHomeContent

HomestayHouseContent

HomestayOrderContent

HomestayExpContent

HomestayMineContent

Scroll 滚动容器

行程倒计时头卡

周预订柱状图卡片

热门房源横滑

热门城市横滑

发布房源按钮

addHouseModal 弹窗

tabItem 首页

tabItem 房源

tabItem 订单

tabItem 体验

tabItem 我的

三、颜色与主题系统详解

在这里插入图片描述

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 系列代表强调色,用于突出重要交互元素。textPrimarytextSecondarytextHint 三级文本色阶保证了信息层次的清晰度。successwarningdanger 三个语义色分别对应成功、警告、错误状态,符合通用的 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 浅棕 #BCAAA4primaryDark 深棕 #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 订单模型包含入住和退房日期、晚数以及完成状态。checkIncheckOut 使用字符串类型存储日期(格式如"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 控制新增房源弹窗的显示隐藏,houseNamehouseCity 分别存储用户输入的房源名称和所在城市。这些状态都使用 @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,包含以下元素:

  1. 房源图片区域:80x80px 的正方形,使用木色到浅绿的 135 度渐变作为背景,中间放置房源的 emoji 图标。如果是热门房源(h.isHot 为 true),右上角会叠加一个火焰 emoji 🔥,使用绝对定位(position)放置在右上角。

  2. 房源名称:10px 大小的主文字色,限制为 1 行,超出部分用省略号截断。

  3. 城市信息:9px 的提示色文字。

  4. 价格和评分:价格使用绿色强调色粗体突出显示,评分使用橙色星星图标。

每个房源卡片左间距 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。卡片内容包括:

  1. 图片区域:占满卡片宽度,高 80px,使用与首页横滑房源相同的渐变背景和 emoji 图标设计,热门标记也同样使用绝对定位的火焰 emoji。

  2. 房源名称:12px 的中等字重主文字色,限制 1 行显示。

  3. 城市和评分:城市信息使用 9px 的次要文字色,评分使用星星图标和动态颜色(通过 getRatingColor 函数根据评分值返回不同颜色)。

  4. 价格: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%')
}

删除评价弹窗是一个确认对话框,与之前的表单弹窗结构不同。它的布局更加简洁,包含三个核心元素:

  1. 标题:带有警告图标的"⚠️ 删除评价",提醒用户这是一个重要操作。
  2. 确认信息:居中显示的确认文字,动态插入要删除的用户名称,让用户明确知道将要删除的是哪条评价。
  3. 操作按钮:取消和删除两个按钮,分别使用木色和红色(危险色 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)
}

遮罩层的作用

  1. 视觉聚焦:半透明黑色背景降低了底层内容的可见度,让用户的注意力集中在弹窗内容上。
  2. 交互阻断:遮罩层覆盖在所有内容之上,阻止用户与底层页面进行交互,保证弹窗是当前唯一的交互焦点。
  3. 关闭入口:点击遮罩层可以关闭弹窗,提供了一种符合直觉的取消操作方式。

参数化设计modalOverlay 接收一个 onClose 回调函数作为参数,这是典型的"依赖注入"模式。不同的弹窗组件可以传入不同的关闭逻辑,而遮罩层本身不需要知道具体的关闭逻辑——它只负责在被点击时调用传入的回调。

复用价值:由于遮罩层被抽取成独立的构建函数,所有弹窗都可以复用同一个遮罩实现。这保证了遮罩效果的一致性(透明度、点击行为都相同),也减少了重复代码。如果后续需要修改遮罩样式(比如调整透明度或添加动画),只需要修改这一处即可。

9.2 bindSheet 的使用方式

.bindSheet(this.showAddHouseModal, this.addHouseModal(), {
  height: SheetSize.LARGE,
})

弹窗的显示使用了 bindSheet 方法,这是 ArkTS 提供的底部抽屉弹窗 API。它接收三个参数:

  1. 显示状态(布尔值)
  2. 弹窗内容(构建函数调用)
  3. 配置选项(这里设置高度为大尺寸 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% 间距

卡片内容层次

  1. 图片/图标区域(渐变背景 + emoji)
  2. 标题文字
  3. 次要信息(位置、描述、评分等)
  4. 价格或操作按钮

双列布局相比单列能展示更多内容,同时每张卡片又有足够的空间展示信息,是信息密度和可读性的良好平衡。

十一、技术亮点总结表格

功能模块 技术实现方式 设计模式 代码量估计 亮点说明
颜色系统 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 布局到交互逻辑,层次分明,结构清晰。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐