一、技术前言

在移动互联网的浪潮中,民宿短租预订已经成为旅游出行领域不可或缺的一环。相比传统酒店,民宿以其独特的在地文化体验、个性化空间设计和房东故事吸引了大量年轻用户群体。一个优秀的民宿预订平台,不仅需要承载民宿浏览、房东展示、订单管理、收藏夹、旅行攻略、积分体系等多个业务模块,更需要在视觉上传递出"温暖、自然、舒适"的品牌气质,让用户在使用过程中感受到如同置身于民宿中的放松与惬意。
在这里插入图片描述

本文将以一款基于 HarmonyOS ArkUI 框架开发的民宿短租预订平台 Demo 应用——"住好点"为例,从架构设计、色彩体系、数据建模、组件拆分、状态管理、动画编排、弹框交互等多个维度,全面深入地剖析其技术实现方案。

ArkUI 是华为 HarmonyOS 生态下的声明式 UI 框架,其核心设计理念融合了 React 的声明式组件思想和 Flutter 的 Widget 组合模式,同时又具备自身独特的装饰器机制和状态管理能力。在 ArkUI 中,开发者通过 @Entry 标记页面入口、@Component 标记自定义组件、@Builder 构建可复用的 UI 片段、@State 管理组件内部状态、@Observed 实现数据模型的深度响应式观察。这些装饰器共同构成了 ArkUI 声明式开发的基石,让开发者能够以更少的代码量实现更复杂的 UI 交互逻辑。

从技术选型的角度来看,这款"住好点"应用采用了以下关键设计决策:

第一,单排六 Tab 导航设计。 底部导航栏采用单行排列六个 Tab(民宿、房东、订单、收藏、攻略、积分)的方式,每个 Tab 都配有 Emoji 图标和文字标签。选中态通过颜色高亮和字体加粗来提供清晰的状态反馈。相比多行 Tab 布局,单排设计在六项以内的导航场景中具有更好的视觉平衡感和操作效率。其核心实现依赖于 @State currentTab 状态变量与条件渲染的组合。
在这里插入图片描述

第二,声明式条件渲染实现 Tab 切换。 所有 Tab 的内容区域共享同一个 Scroll 容器,通过 if/else if 条件链根据 currentTab 的值决定当前展示哪个 Tab 的 @Builder 构建函数。这种方式避免了多页面实例共存的资源浪费,每次只渲染当前激活的 Tab 内容,实现了按需渲染的性能优化。同时,每个 Tab 底部都统一展示月度入住图表,保证了信息展示的一致性。
在这里插入图片描述

第三,响应式数据驱动 UI 更新。 所有列表数据(民宿列表、房东列表、订单列表、收藏列表、攻略列表、任务列表)均通过 @State 装饰器声明。当数据发生增删改操作时,UI 会自动重新渲染,开发者无需手动操作 DOM 或调用刷新方法。这种响应式数据绑定机制显著降低了数据与视图之间的同步成本。
在这里插入图片描述

第四,温暖自然的视觉设计语言。 应用定义了一套完整的 ColorPalette 色彩体系,包含 21 个颜色常量,涵盖了背景色、卡片色、作品色、标题色、副文本色、三级文本色、品牌棕色、绿色、红色、蓝色、金色、线条色、选中色、遮罩色、标签背景色等。整体采用暖奶油棕色调,传递出民宿行业特有的温馨、自然、质朴的品牌气质。所有组件统一引用这些颜色常量,确保了视觉风格的高度一致性。
在这里插入图片描述

第五,丰富的交互动画与视觉反馈。 应用中使用了多种动画效果,包括呼吸动画(breath 状态驱动的透明度交替)、心跳动画(收藏按钮的无限循环缩放动画)、线性渐变(linearGradient)等,为静态页面增添了生动的视觉层次。特别是订单时间轴中"待入住"状态的圆点呼吸动画,巧妙地将用户注意力引导至当前最需要关注的订单上。
在这里插入图片描述

第六,完整的弹框交互系统。 应用实现了三个业务弹框:新增预订弹框、编辑订单弹框和取消预订弹框。三个弹框共享同一个遮罩层和关闭逻辑,通过 @State 布尔变量控制显示与隐藏,形成了一套轻量但完整的模态交互体系。
在这里插入图片描述
下面,我们将从代码的第一行开始,逐段深入分析这个民宿短租预订平台的完整实现。


二、整体架构流程图

弹框层

底部导航

内容区域_Scroll

头部区域

数据层

状态管理

入口组件

Page794
@Entry @Component

currentTab: number

breath: boolean

addModal / editModal / delModal

6 个列表数据
@State 装饰

editIdx / delIdx: number

ColorPalette 色彩体系
21 个颜色字段

7 个数据模型
@Observed export class

7 组模拟数据
mock 列表

工具函数
bookColor

辅助数据
MONTH_IDX / MONTH_NAME / NIGHT_VAL / DEST_LIST

headerTravel
目的地搜索 + 日期选择 + Banner + 目的地横滚

tabStay
民宿双列大卡

tabHost
房东人物卡

tabOrder
订单时间轴

tabFav
收藏大图卡列表

tabGuide
攻略时间轴

tabPoint
积分等级卡+任务清单

chartCard
月度入住柱状图

tabBar
单排 6 Tab

panelAdd
新增预订

panelEdit
编辑订单

panelDel
取消预订

modalOverlay
遮罩容器

从上述架构流程图可以清晰地看到,整个应用以 Page794 组件为核心枢纽,向下连接了数据层、头部区域、内容区域、底部导航和弹框层五个子系统。状态管理通过 @State 装饰器实现响应式绑定,数据层通过 @Observed 装饰器提供数据模型,工具函数负责将业务状态映射为视觉表现,而弹框层则通过 modalOverlay 统一管理三个业务弹框的显示逻辑。


三、文件头部注释与设计意图

// ============================================================
// 794 栖云 · 民宿短租预订(现代行业:民宿短租)
// 头部样式:旅游风(目的地+日期搜索条 + 精选 Banner + 目的地横滚)无动画
// 布局风格:6 个 tab 每个布局完全不同
//   民宿=双列卡片 / 房东=人物大卡 / 订单=状态时间轴
//   收藏=大图卡列表 / 攻略=时间轴+阶段标签 / 积分=等级卡+任务勾选清单
// 底部 6 tab 单排,主题:暖奶油棕(浅色)
// ============================================================

这段文件头部注释是整篇代码的"设计蓝图",它在团队协作中起到了至关重要的作用——任何开发者打开这个文件,都能在第一时间理解整个页面的设计意图和结构布局。我们来逐行解读:

“794 栖云 · 民宿短租预订”:给出了应用名称和所属行业领域。"栖云"二字本身就充满了诗意,暗示着"栖息于云端"的民宿体验,与后面的"民宿短租"行业定位完美契合。这一命名不仅明确了业务边界,也为后续的视觉设计定下了温暖、文艺的基调。

“头部样式:旅游风”:指出头部区域的设计风格,包含目的地搜索条、日期选择、精选 Banner 和目的地横滚四个核心组件。"无动画"的标注说明头部区域采用静态展示,以保持页面加载的即时性和性能的稳定性。头部区域作为用户进入应用的第一个视觉焦点,采用旅游风格的设计能够快速唤起用户的出行欲望。

“6 个 tab 每个布局完全不同”:这是整个应用最核心的设计理念——每个 Tab 都采用独一无二的布局方式,而不是简单的列表复用。这种设计理念体现了"以内容定形式"的交互设计原则:民宿 Tab 采用双列卡片布局,适合展示民宿封面和评分信息;房东 Tab 采用人物大卡布局,突出房东的个人形象和故事;订单 Tab 采用状态时间轴,直观展示订单的流转过程;收藏 Tab 采用大图卡列表,让收藏的民宿一目了然;攻略 Tab 采用时间轴加阶段标签,适合展示多日行程的推进节奏;积分 Tab 采用等级卡加任务勾选清单,将积分体系和任务系统有机结合。

“底部 6 tab 单排”:明确底部导航采用单行布局,六个 Tab 均匀分布在一行中。这种设计在六个导航项以内是最优选择,因为单行布局的视觉扫描效率高于多行布局,用户能够更快地定位目标 Tab。

“主题:暖奶油棕(浅色)”:给出了色彩主题方向,为整个色彩体系的设计提供了基调。暖奶油棕是一种介于米色和浅棕色之间的温暖色调,非常适合民宿行业——它既有木质的自然感,又有奶油的柔和感,能够传递出"家一般的温暖"这一核心品牌价值。


四、色彩体系设计(ColorPalette 接口与 COLORS 常量)

4.1 接口定义

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  brown: string;
  brownD: string;
  brownL: string;
  green: string;
  greenL: string;
  red: string;
  redD: string;
  blue: string;
  blueL: string;
  gold: string;
  goldL: string;
  line: string;
  tabOn: string;
  mask: string;
  tagBg: string;
}

ColorPalette 接口定义了应用所需的全部颜色变量,共计 21 个字段。这种设计方式具有以下几个显著优势:

类型安全:TypeScript 接口确保了所有颜色引用都经过编译时检查。如果开发者在代码中误写了 COLORS.brown_backgroudCOLORS.greenDark 等不存在的字段名,TypeScript 编译器会立即报错,在开发阶段就拦截了潜在的运行时问题。

语义化命名:每个颜色变量都有明确的语义含义,而不是直接使用十六进制色值。例如 brown 表示品牌棕色(主色调),brownD 表示棕色深色变体(Dark),brownL 表示棕色浅色变体(Light),green 表示功能绿色(用于已完成状态),gold 表示金黄色(用于评分星级)。这种命名方式让代码的可读性大大提升,开发者看到 COLORS.brownL 就能立刻理解这是一个浅棕色背景,而不需要记忆 #F5E9D8 这个色值。

可扩展性:如果未来需要支持深色模式或多主题切换(如春季樱花粉主题、冬季雪景白主题),只需要替换 COLORS 常量对象中的色值,而不需要修改任何组件代码。这种"配置与逻辑分离"的设计是大型项目中色彩管理的最佳实践。

色阶体系:接口中对于品牌色(棕色)和功能色(绿色、蓝色、金色)都设置了浅色变体(以 L 后缀标识)和深色变体(以 D 后缀标识),形成了一个完整的色阶体系。浅色变体通常用于背景区域,深色变体通常用于强调文字或重要按钮,标准色用于常规交互元素。

4.2 具体色值分析

const COLORS: ColorPalette = {
  bg: '#FAF6F0',
  card: '#FFFFFF',
  chip: '#F2ECE2',
  title: '#33291E',
  sub: '#7A6C58',
  text3: '#A99A83',
  brown: '#B4833E',
  brownD: '#8A5D22',
  brownL: '#F5E9D8',
  green: '#7C9A6D',
  greenL: '#EDF3E8',
  red: '#D9634E',
  redD: '#A63A28',
  blue: '#6B8FA3',
  blueL: '#E9F1F5',
  gold: '#C99A3C',
  goldL: '#F8F0DD',
  line: '#EDE5D8',
  tabOn: '#B4833E',
  mask: 'rgba(0,0,0,0.55)',
  tagBg: '#F5E9D8'
};

这款应用采用了暖奶油棕浅色主题设计,整体色调以温暖的米色和奶油色为基底,搭配棕色和绿色作为点缀。我们来逐一分析每个颜色变量的设计意图:

背景与容器层级(由深到浅的视觉层次):

  • bg: '#FAF6F0'——页面最底层背景色,一种带有微弱暖黄色调的奶油白。这个色值比纯白 #FFFFFF 更温暖,能够营造出类似米色纸张的质感,非常符合民宿行业"自然、质朴"的品牌调性。它在视觉上比卡片背景略暗,形成了自然的层次区分。
  • card: '#FFFFFF'——卡片背景色,纯白色。在暖色背景上使用纯白卡片,能够形成清晰的边界,让每个卡片的内容区域在视觉上"浮起来",增强层次感。
  • chip: '#F2ECE2'——芯片/标签背景色,介于背景色和卡片色之间的暖灰色。用于小型的标签、按钮和分隔区域,既不抢眼又能提供足够的区分度。
  • line: '#EDE5D8'——分割线色,比背景色略深但依然保持暖色调。在订单时间轴的连接线和卡片的隐式分隔中使用,低调而不突兀。

文字层级(三级文字体系):

  • title: '#33291E'——标题文字色,深棕色。这是页面上最深、最醒目的文字颜色,用于标题、重要信息和高优先级内容。深棕色比纯黑色 #000000 更柔和,与整体暖色调主题更加协调。
  • sub: '#7A6C58'——副文本色,中棕色。用于次要信息和辅助说明文字,在视觉权重上低于标题但高于三级文本。
  • text3: '#A99A83'——三级文本色,浅棕色。用于最不重要的辅助信息,如日期、提示文字、占位符等。这个颜色在背景上足够可见但不会分散用户对核心内容的注意力。

品牌色与功能色(暖木色 + 森绿体系):

  • brown: '#B4833E'——品牌主色,暖木棕色。这是应用的核心品牌色,用于搜索按钮、主要操作按钮、Tab 选中态、价格文字等关键交互元素。它既有木质的温暖感,又有足够的饱和度来吸引用户注意。
  • brownD: '#8A5D22'——品牌深色,用于图标背景、强调元素和 Banner 渐变。比主色更深更沉稳,适合作为背景色使用。
  • brownL: '#F5E9D8'——品牌浅色,用于标签背景、卡片底色和柔和区域。这是出现频率最高的背景色之一,几乎每个 Tab 的卡片中都能看到它的身影。
  • green: '#7C9A6D'——森绿色,用于"已完成"状态、攻略标签和积分已完成勾选。这是一种低饱和度的自然绿,与暖木棕搭配时形成和谐的互补关系,传递出"自然、健康"的感觉。
  • greenL: '#EDF3E8'——森绿浅色,用于森绿标签的背景色,提供柔和的绿色底色。
  • red: '#D9634E'——功能红色,用于取消预订按钮和删除操作。这是一种低饱和度的暖红色,与整体暖色调协调而不刺眼。
  • redD: '#A63A28'——深红色,用于取消提示文字,传递警告意味。
  • blue: '#6B8FA3'——功能蓝色,用于编辑按钮和链接操作。这是一种灰蓝色,相较纯蓝色更加柔和。
  • blueL: '#E9F1F5'——蓝色浅色,用于蓝色按钮的背景色。
  • gold: '#C99A3C'——金黄色,用于评分星级和积分等级卡。这是除了棕色外最重要的点缀色,传递出"品质、高级"的感觉。
  • goldL: '#F8F0DD'——金色浅色,用于收藏卡片的图标背景和金色相关的柔和区域。

特殊色

  • tabOn: '#B4833E'——Tab 选中色,与品牌主色 brown 保持一致,确保底部导航栏的选中态与整体品牌色形成统一。
  • mask: 'rgba(0,0,0,0.55)'——弹框遮罩色,半透明黑色。55% 的透明度既能有效遮挡底层内容,又不会让用户感觉完全脱离了当前页面。
  • tagBg: '#F5E9D8'——标签背景色,与品牌浅色 brownL 一致,用于标签的背景填充。

五、Tab 元数据与辅助数据

5.1 Tab 定义

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '🏡', label: '民宿' },
  { icon: '🧑‍🌾', label: '房东' },
  { icon: '🧾', label: '订单' },
  { icon: '❤️', label: '收藏' },
  { icon: '📖', label: '攻略' },
  { icon: '🎁', label: '积分' }
];

TabMeta 接口定义了每个 Tab 的数据结构,仅包含 icon(Emoji 图标)和 label(文字标签)两个字段。在 ArkUI 中,Emoji 可以直接作为文本渲染,这大大简化了图标的管理——不需要引入额外的图标库或图片资源,也不需要处理 SVG 的多色渲染问题。

TAB_LIST 数组按顺序定义了 6 个 Tab,索引 0-5 分别对应民宿、房东、订单、收藏、攻略、积分。这个顺序决定了底部导航栏的排列次序。值得注意的是,这个排列顺序并非随意为之:第一个 Tab(民宿)是应用的核心功能入口,用户打开应用后默认展示;前两个 Tab 是浏览和发现类功能(民宿、房东),中间两个是个人管理类功能(订单、收藏),最后两个是内容与运营类功能(攻略、积分)。这种"发现-管理-运营"的递进式排列符合用户从左到右的视觉扫描习惯。

5.2 目的地列表

const DEST_LIST: string[] = ['大理 · 洱海', '成都 · 街子古镇', '杭州 · 西湖', '厦门 · 鼓浪屿', '北京 · 后海', '苏州 · 平江路'];

DEST_LIST 是一个热门目的地数组,用于头部区域的目的地横向滚动选择器。每个元素采用"城市 · 具体地点"的格式,如"大理 · 洱海"、“成都 · 街子古镇”。这种格式既给出了城市的大致方位,又精确到了具体的民宿聚集区域,能够帮助用户快速定位心仪的旅行目的地。

这六个目的地涵盖了西南(大理、成都)、华东(杭州、苏州)、华南(厦门)和华北(北京)四大区域,形成了全国性的目的地覆盖。每个目的地都有其独特的民宿文化——大理的洱海民宿以白族建筑和湖景著称,成都的街子古镇以川西民居和竹林闻名,杭州的西湖以民国老宅和湖光山色吸引游客,厦门的鼓浪屿以海景别墅和文艺气息取胜,北京的后海以四合院和老北京文化为特色,苏州的平江路以临水民居和江南水乡风情著称。

5.3 图表数据

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['2月', '3月', '4月', '5月', '6月', '7月'];
const NIGHT_VAL: number[] = [6, 9, 4, 12, 8, 14];

这三组数据用于绘制月度入住柱状图。MONTH_IDX 提供循环索引,MONTH_NAME 提供月份标签,NIGHT_VAL 提供每月入住晚数。柱状图的高度由 16 + NIGHT_VAL[mi] * 4 计算得出,使得不同月份的入住量差异在视觉上得到清晰呈现。

从数据来看,2 月到 7 月的入住量呈现波动上升趋势:2 月 6 晚(春节假期后淡季),3 月 9 晚(春季回暖),4 月 4 晚(清明前后的短暂低谷),5 月 12 晚(五一黄金周高峰),6 月 8 晚(初夏),7 月 14 晚(暑期旺季高峰)。这段数据真实地反映了民宿行业的季节性特征——节假日和暑期是入住高峰期,而过渡月份则相对平淡。


六、工具函数(bookColor)

function bookColor(s: string): string {
  if (s === '已入住') {
    return COLORS.green;
  }
  if (s === '待入住') {
    return COLORS.brown;
  }
  if (s === '待评价') {
    return COLORS.gold;
  }
  return COLORS.text3;
}

bookColor 是一个纯函数,接收订单状态字符串作为参数,返回对应的颜色值。这个函数体现了"状态到颜色的映射"这一常见的设计模式,将业务语义(订单状态)转化为视觉语义(颜色)。

状态映射逻辑

  • '已入住' 返回 COLORS.green(森绿色):绿色在 UI 设计中通常代表"完成、成功、通过",暗示这是一段已经完成的愉快住宿体验。
  • '待入住' 返回 COLORS.brown(品牌棕色):棕色作为品牌主色,暗示这是当前需要关注的订单,用品牌色来吸引用户注意力。
  • '待评价' 返回 COLORS.gold(金黄色):金色暗示"有价值、值得关注",提醒用户还有评价未完成(评价通常可以获得积分奖励)。
  • 其他状态('已退房''已取消')返回 COLORS.text3(三级文本色):这些是已经结束或不再需要关注的订单,用最弱的文本色来表示其低优先级。

这个函数在订单时间轴(tabOrder)中用于两个地方:一是状态圆点的背景色,二是状态标签的文字颜色。通过同一个函数统一管理颜色映射,确保了视觉一致性。


七、数据模型层(7 个 @Observed 类)

数据模型是整个应用的数据基础。这款应用定义了 7 个数据模型类,每个都使用 @Observed 装饰器标记,确保当模型实例的属性发生变化时,依赖该数据的 UI 组件能够自动更新。

7.1 Stay —— 民宿模型

@Observed export class Stay {
  name: string;
  place: string;
  score: string;
  price: string;
  tag: string;
  icon: string;

  constructor(name: string, place: string, score: string, price: string, tag: string, icon: string) {
    this.name = name;
    this.place = place;
    this.score = score;
    this.price = price;
    this.tag = tag;
    this.icon = icon;
  }
}

Stay 类定义了民宿的核心属性,共 6 个字段:

  • name:民宿名称,如"洱海边白族小院"、“古镇里的竹林客栈”。名称是民宿的第一印象,这些名称通过"地点+风格+建筑类型"的三段式结构,让用户一眼就能想象出民宿的大致样貌。
  • place:所在位置,如"大理 · 才村码头"、“杭州 · 北山街”。采用"城市 · 具体地点"的格式,精确到街道或区域级别。
  • score:评分,字符串类型,如"4.9"。使用字符串而非数字类型,是为了在 UI 中直接拼接显示,避免浮点数精度问题。在 ArkUI 中,Text 组件可以直接渲染字符串拼接的结果。
  • price:每晚价格,字符串类型,如"688"、“358”。在 UI 中显示为"¥688/晚"。价格区间从 358 到 966,覆盖了经济型到高端民宿的多个价位。
  • tag:特色标签,如"湖景房"、“含早”、“景观露台”、“亲子友好”、“独门独院”、“近地铁”、“含接送”、“网红打卡”。这些标签涵盖了景观、服务、设施、位置、亲子等多个维度,帮助用户快速筛选符合需求的民宿。
  • icon:Emoji 图标,如"🏡"、“🎍”、“🏚️”。每个民宿都有一个独特的 Emoji 图标作为视觉标识,在双列卡片中作为封面图展示。

@Observed 装饰器是 ArkUI 中实现深度响应式的关键机制。当一个被 @Observed 标记的类实例的属性发生变化时,所有引用该实例的 @State@Prop 变量都会触发 UI 更新。export 关键字表明这个类可以被其他模块导入使用。

模拟数据 STAY_LIST 包含 8 个民宿实例,覆盖了大理、成都、杭州、厦门、北京、苏州、丽江、莫干山八个热门民宿目的地,形成了丰富的民宿数据池。

7.2 Host —— 房东模型

@Observed export class Host {
  avatar: string;
  name: string;
  house: string;
  years: string;
  score: string;
  reply: string;

  constructor(avatar: string, name: string, house: string, years: string, score: string, reply: string) {
    this.avatar = avatar;
    this.name = name;
    this.house = house;
    this.years = years;
    this.score = score;
    this.reply = reply;
  }
}

Host 类定义了房东的核心属性,共 6 个字段:

  • avatar:房东头像,使用 Emoji 表示,如"👩‍🌾"(阿花姐)、“🧑‍🌾”(老周)、“👨‍🎨”(林先生)。不同职业的 Emoji 暗示了房东的背景和性格。
  • name:房东姓名/昵称,如"阿花姐"、“老周”、“林先生”。使用亲切的称呼拉近与用户的距离。
  • house:管理的房源信息,如"洱海边白族小院 · 6 套房"。同时包含民宿名称和房源数量,让用户了解房东的经营规模。
  • years:房东年限,如"房东 8 年"、“房东 10 年”。年限越长,通常意味着服务经验越丰富。
  • score:评分,如"4.9"、“4.8”。与民宿评分格式一致。
  • reply:回复率,如"回复率 98%"、“回复率 99%”。高回复率是民宿平台的重要信任指标,直接影响用户的预订决策。

模拟数据 HOST_LIST 包含 6 个房东实例,每个房东都有独特的背景故事和经营特色,从白族阿花姐到民国老宅林先生,形成了一个多元化的房东群体。

7.3 BookOrder —— 订单模型

@Observed export class BookOrder {
  name: string;
  date: string;
  state: string;
  price: string;

  constructor(name: string, date: string, state: string, price: string) {
    this.name = name;
    this.date = date;
    this.state = state;
    this.price = price;
  }
}

BookOrder 类定义了订单的核心属性,共 4 个字段:

  • name:订单名称,包含民宿名称和房型,如"洱海边白族小院 · 湖景大床房"。这种格式让用户一眼就能确认预订的是哪个民宿的哪个房型。
  • date:入住日期和晚数,如"08-22 入住 · 2 晚"。同时包含日期和住宿时长两个关键信息。
  • state:订单状态,如"待入住"、“待评价”、“已入住”、“已退房”、“已取消”。这是订单模型中最重要的字段,它决定了订单在时间轴上的显示颜色和操作按钮。五种状态覆盖了订单的完整生命周期。
  • price:订单总金额,字符串类型,如"1376"、“358”。在 UI 中显示为"¥1376"。

模拟数据 BOOK_LIST 包含 6 个订单实例,涵盖五种不同的订单状态,按时间倒序排列,最新的订单在最前面。

7.4 FavStay —— 收藏民宿模型

@Observed export class FavStay {
  name: string;
  place: string;
  price: string;
  score: string;
  icon: string;

  constructor(name: string, place: string, price: string, score: string, icon: string) {
    this.name = name;
    this.place = place;
    this.price = price;
    this.score = score;
    this.icon = icon;
  }
}

FavStay 类定义了收藏民宿的属性,共 5 个字段。与 Stay 类相比,FavStay 少了 tag 字段,因为收藏列表中不需要展示特色标签,更侧重于简洁的信息展示。五个字段分别是:名称、位置、价格、评分和图标。其中 pricescore 的顺序与 Stay 类不同,体现了不同场景下的数据组织优先级。

模拟数据 FAVSTAY_LIST 包含 6 个收藏实例,涵盖了丽江、元阳、舟山、安吉、中卫、武夷山等相对小众但极具特色的民宿目的地,与民宿列表中的热门目的地形成互补。

7.5 Guide —— 攻略模型

@Observed export class Guide {
  day: string;
  title: string;
  spots: string;
  tag: string;

  constructor(day: string, title: string, spots: string, tag: string) {
    this.day = day;
    this.title = title;
    this.spots = spots;
    this.tag = tag;
  }
}

Guide 类定义了旅行攻略的属性,共 4 个字段:

  • day:天数标识,如"D1"、“D2”、“D3”。以"第几天"的形式组织攻略内容,适合多日行程的展示。
  • title:攻略标题,如"环洱海骑行"、“苍山徒步”、“古城漫游”。简洁明了地概括当天的活动主题。
  • spots:行程路线,如"才村码头 → 喜洲古镇 → 海舌公园"。用箭头串联多个景点,清晰展示游览顺序。
  • tag:攻略类型标签,如"轻松"、“运动”、“逛吃”、“摄影”、“亲子”、“收尾”。这些标签帮助用户根据自己的兴趣和体力选择合适的行程。

模拟数据 GUIDE_LIST 包含 6 个攻略实例,以大理为目的地,展示了一个完整的 6 日深度游行程。从第一天的环洱海骑行到第六天的告别洱海,行程节奏由轻松到运动再到收尾,符合旅行体验的节奏曲线。

7.6 Task —— 积分任务模型

@Observed export class Task {
  title: string;
  point: string;
  done: boolean;

  constructor(title: string, point: string, done: boolean) {
    this.title = title;
    this.point = point;
    this.done = done;
  }
}

Task 类定义了积分任务的属性,共 3 个字段:

  • title:任务名称,如"完成一次入住评价"、“上传真实旅行照片”、“邀请好友首次预订”。这些任务覆盖了评价、内容、邀请、消费、体验、互动、总结等多个用户行为维度。
  • point:奖励积分,如"+200"、“+150”、“+500”。使用带正号的字符串格式,强化"获得奖励"的心理暗示。
  • done:是否已完成,布尔类型。这是唯一一个会在运行时被修改的字段,用户点击勾选框时会切换其值。@Observed 装饰器确保了当 done 状态变化时,UI 会立即更新——已完成的文字变为灰色,未完成的文字保持深色。

模拟数据 TASK_LIST 包含 7 个任务实例,其中前两个(入住评价和上传照片)已完成,后五个未完成。这种初始状态设计既展示了已完成任务的样式,又强调了还有积分可赚取。


八、组件主体结构(Page794)

@Entry
@Component
struct Page794 {
  @State currentTab: number = 0;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State breath: boolean = false;
  @State editIdx: number = 0;
  @State delIdx: number = 0;
  @State taskList: Task[] = TASK_LIST;
  @State bookList: BookOrder[] = BOOK_LIST;
  timer: number = -1;

  aboutToAppear() {
    this.timer = setInterval(() => {
      this.breath = !this.breath;
    }, 850);
  }

  aboutToDisappear() {
    if (this.timer >= 0) {
      clearInterval(this.timer);
    }
  }

Page794 是应用的主页面组件,使用 @Entry 标记为页面入口,使用 @Component 标记为自定义组件。struct 关键字是 ArkTS(ArkUI 的 TypeScript 方言)中定义组件的语法。

状态变量分析

  • @State currentTab: number = 0:当前选中的 Tab 索引,初始值为 0(民宿 Tab)。这个变量是整个页面最核心的状态,驱动了内容区域的 Tab 切换和底部导航栏的选中态变化。
  • @State addModal: boolean = false:新增预订弹框的显示状态,false 表示初始隐藏。
  • @State editModal: boolean = false:编辑订单弹框的显示状态。
  • @State delModal: boolean = false:取消预订弹框的显示状态。
  • @State breath: boolean = false:呼吸动画的开关状态,每 850ms 切换一次。这个状态用于驱动订单时间轴中"待入住"状态圆点的透明度交替动画。
  • @State editIdx: number = 0:当前编辑的订单在 bookList 数组中的索引,初始值为 0。
  • @State delIdx: number = 0:当前删除的订单在 bookList 数组中的索引,初始值为 0。
  • @State taskList: Task[] = TASK_LIST:积分任务列表,初始化为模拟数据 TASK_LIST。使用 @State 装饰器确保当任务完成状态变化时 UI 自动更新。
  • @State bookList: BookOrder[] = BOOK_LIST:订单列表,初始化为模拟数据 BOOK_LIST。使用 @State 装饰器确保当订单增删改时 UI 自动更新。
  • timer: number = -1:定时器 ID,初始值为 -1。不使用 @State 装饰器,因为定时器 ID 不需要触发 UI 更新。初始值 -1 是一个安全默认值,表示定时器尚未启动。

生命周期方法

  • aboutToAppear():组件即将出现时调用。这里启动了一个 850ms 间隔的定时器,每 850ms 切换 breath 的值。选择 850ms 而不是 1000ms,是为了让呼吸动画的节奏稍快一些,更符合用户对"待入住"状态需要关注的预期。这个定时器会持续运行直到组件销毁。
  • aboutToDisappear():组件即将销毁时调用。这里清理定时器,防止内存泄漏。先检查 this.timer >= 0 确保定时器确实存在,然后调用 clearInterval 释放资源。这是前端开发中非常重要的资源管理实践。

九、头部搜索区域(headerTravel)

  @Builder
  headerTravel() {
    Column({ space: 12 }) {
      Row({ space: 0 }) {
        Column({ space: 6 }) {
          Row({ space: 8 }) {
            Text('📍').fontSize(13)
            Column({ space: 1 }) {
              Text('目的地').fontSize(8).fontColor(COLORS.text3)
              Text('大理 · 洱海').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            }
            .alignItems(HorizontalAlign.Start)
          }

          Row({ space: 8 }) {
            Text('📅').fontSize(13)
            Column({ space: 1 }) {
              Text('入住 — 离开').fontSize(8).fontColor(COLORS.text3)
              Text('08-22 — 08-24 · 2 晚 · 2 人').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            }
            .alignItems(HorizontalAlign.Start)
          }
        }
        .layoutWeight(1).padding({ left: 14, right: 14, top: 10, bottom: 10 })
        .borderRadius(16).backgroundColor(COLORS.card)
        .alignItems(HorizontalAlign.Start)

        Column() {
          Text('🔍').fontSize(18)
        }
        .width(48).height(72).borderRadius(16).backgroundColor(COLORS.brown)
        .margin({ left: 10 })
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
      }
      .width('100%')

headerTravel 是一个 @Builder 构建函数,负责渲染页面的头部搜索区域。整个头部采用旅行风格的设计,包含三个核心部分:搜索栏、精选 Banner 和目的地横滚。

搜索栏部分:使用 Row 容器水平排列,左侧是搜索条件区域,右侧是搜索按钮。左侧区域包含两个子行:

  • 第一行:📍 目的地选择器,显示"目的地"标签和当前选中的目的地"大理 · 洱海"。标签使用 8px 的三级文本色,目的地名称使用 12px 加粗的标题色,形成清晰的视觉层次。alignItems(HorizontalAlign.Start) 确保内容左对齐。
  • 第二行:📅 日期选择器,显示"入住 — 离开"标签和具体的日期范围与人数。格式为"08-22 — 08-24 · 2 晚 · 2 人",简洁明了地传达了入住日期、住宿时长和住客人数三个关键信息。

整个搜索条件区域使用 layoutWeight(1) 占据剩余空间,设置了 16px 的圆角和白色卡片背景,padding 值为 14 左右和 10 上下,确保内容有足够的呼吸空间。

搜索按钮部分:一个 48px 宽、72px 高的圆角矩形,背景色为品牌棕色 COLORS.brown,居中显示 🔍 搜索图标。按钮高度与搜索条件区域对齐(72px 匹配两行搜索条件的高度),margin({ left: 10 }) 提供与搜索条件区域的间距。

精选 Banner 部分

      Column({ space: 8 }) {
        Row({ space: 10 }) {
          Text('🌾').fontSize(26)
          Column({ space: 3 }) {
            Text('秋日稻田季 · 精选房源 5 折起').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('大理 / 丽江 / 莫干山 · 限时优惠进行中').fontSize(9).fontColor('rgba(255,255,255,0.8)')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text('去挑选 →').fontSize(10).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .width('100%')
      }
      .width('100%').padding(16).borderRadius(18)
      .linearGradient({ angle: 135, colors: [[COLORS.brown, 0], [COLORS.green, 1]] })
      .onClick(() => {
        this.currentTab = 0;
      })

Banner 使用 linearGradient 创建了从棕色到绿色的 135 度对角线渐变,传递出"秋日稻田"的季节氛围。Banner 内部分为三个水平区域:左侧是 🌾 Emoji 图标(26px 大号展示),中间是标题和副标题文字(白色文字在深色渐变背景上高对比度显示),右侧是"去挑选 →"行动号召按钮。整个 Banner 可点击,点击后跳转到民宿 Tab(this.currentTab = 0)。

目的地横滚部分

      Scroll() {
        Row({ space: 8 }) {
          ForEach(DEST_LIST, (d: string) => {
            Text(d).fontSize(10).fontColor(COLORS.brownD)
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .borderRadius(16).backgroundColor(COLORS.card)
          }, (d: string) => d)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

使用 Scroll 组件实现横向滚动,scrollable(ScrollDirection.Horizontal) 明确指定水平方向可滚动,scrollBar(BarState.Off) 隐藏滚动条以保持界面简洁。ForEach 遍历 DEST_LIST 渲染每个目的地标签,每个标签使用深棕色文字配白色卡片背景,16px 的圆角与整体设计风格保持一致。ForEach 的第三个参数 (d: string) => d 提供了唯一 key,确保列表渲染的性能优化。

整个头部区域使用 linearGradient 从品牌浅棕色到页面背景色过渡,营造出从头部到内容的自然视觉过渡。


十、民宿 Tab(tabStay)—— 双列大卡布局

  @Builder
  tabStay() {
    Column({ space: 12 }) {
      Row() {
        Text('🏡 甄选民宿').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('评分 4.8+').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(STAY_LIST, (item: Stay) => {
          Column({ space: 8 }) {
            Column() {
              Text(item.icon).fontSize(34)
            }
            .width('100%').padding({ top: 20, bottom: 20 })
            .linearGradient({ angle: 135, colors: [[COLORS.brownL, 0], [COLORS.chip, 1]] })
            .justifyContent(FlexAlign.Center)

            Column({ space: 5 }) {
              Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold).maxLines(1)
              Text(item.place).fontSize(9).fontColor(COLORS.text3)
              Row({ space: 6 }) {
                Text('⭐ ' + item.score).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
                Text(item.tag).fontSize(8).fontColor(COLORS.brownD)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor(COLORS.brownL)
              }
              Row({ space: 4 }) {
                Text('¥' + item.price).fontSize(13).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
                Text('/晚').fontSize(9).fontColor(COLORS.text3)
              }
            }
            .width('100%').padding({ left: 10, right: 10, bottom: 10 })
            .alignItems(HorizontalAlign.Start)
          }
          .width('48.5%')
          .borderRadius(14)
          .backgroundColor(COLORS.card)
          .margin({ bottom: 10 })
          .onClick(() => {
            this.addModal = true;
          })
        }, (item: Stay) => item.name)
      }
      .width('100%')
    }
    .width('100%')
  }

tabStay 是民宿列表的构建函数,采用双列卡片布局。这是整个应用中视觉层次最丰富的 Tab 之一。

标题栏:使用 Row 水平排列,左侧是带 🏡 Emoji 的标题"甄选民宿",右侧是筛选条件"评分 4.8+"。中间使用 Column().layoutWeight(1) 作为弹性空白区域,将标题和筛选条件推到两端。这种布局方式是实现"左标题右操作"的标准做法。

卡片网格:使用 Flex 组件代替 Row 来实现双列布局。wrap: FlexWrap.Wrap 允许子元素换行,justifyContent: FlexAlign.SpaceBetween 让两个卡片均匀分布在行内。每张卡片宽度设为 48.5%(而非 50%),为卡片之间留出必要的间距,同时通过 margin({ bottom: 10 }) 提供行间距。

卡片内部结构:每张卡片分为上下两部分:

  • 上半部分:民宿图标展示区,使用 linearGradient 从品牌浅棕色到芯片色渐变,营造出温暖的氛围感。图标使用 34px 大号 Emoji,上下各 20px 的 padding 确保图标在视觉上居中。这个区域不直接展示民宿照片,而是使用 Emoji 图标,是一种轻量化的设计方案。
  • 下半部分:民宿信息区,包含四个层级:
    • 民宿名称:11px 加粗标题色,maxLines(1) 限制单行显示,过长名称自动截断。
    • 民宿位置:9px 三级文本色,提供地理信息。
    • 评分与标签行:评分使用金黄色加粗显示,标签使用深棕色文字配浅棕色背景,8px 圆角胶囊样式。
    • 价格行:价格使用 13px 深棕色加粗显示,“/晚” 使用 9px 三级文本色,形成"大价格+小单位"的标准价格展示格式。

交互设计:每张卡片可点击,点击后触发 this.addModal = true 打开新增预订弹框。ForEach 使用 item.name 作为唯一 key,确保列表渲染的高效性。


十一、房东 Tab(tabHost)—— 人物大卡布局

  @Builder
  tabHost() {
    Column({ space: 12 }) {
      Column({ space: 2 }) {
        Text('🧑‍🌾 明星房东').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text('他们把生活过成了诗').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Column({ space: 10 }) {
        ForEach(HOST_LIST, (item: Host) => {
          Column({ space: 12 }) {
            Row({ space: 14 }) {
              Column() {
                Text(item.avatar).fontSize(30)
              }
              .width(62).height(62).borderRadius(31).backgroundColor(COLORS.brownL)
              .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

              Column({ space: 5 }) {
                Row({ space: 8 }) {
                  Text(item.name).fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                  Text(item.years).fontSize(8).fontColor(COLORS.brownD)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor(COLORS.brownL)
                }
                Text(item.house).fontSize(10).fontColor(COLORS.sub)
                Row({ space: 10 }) {
                  Text('⭐ ' + item.score).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
                  Text(item.reply).fontSize(9).fontColor(COLORS.text3)
                }
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
            }
            .width('100%')

            Row({ space: 8 }) {
              Column() {
                Text('聊一聊').fontSize(10).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
              }
              .padding({ left: 14, right: 14, top: 5, bottom: 5 }).borderRadius(12).backgroundColor(COLORS.brownL)

              Column() {
                Text('看房源').fontSize(10).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
              }
              .padding({ left: 14, right: 14, top: 5, bottom: 5 }).borderRadius(12).backgroundColor(COLORS.brown)
            }
            .width('100%')
          }
          .width('100%').padding(14).borderRadius(16).backgroundColor(COLORS.card)
          .onClick(() => {
            this.editModal = true;
          })
        }, (item: Host) => item.name + item.house)
      }
      .width('100%')
    }
    .width('100%')
  }

tabHost 是房东列表的构建函数,采用人物大卡布局。每个房东卡片都是一个完整的"人物名片",设计上强调信任感和亲和力。

标题区:采用双层标题设计,主标题"🧑‍🌾 明星房东"使用 13px 加粗标题色,副标题"他们把生活过成了诗"使用 9px 三级文本色。副标题这种诗意化的表达,与民宿行业"慢生活"的品牌调性高度契合,能够引发用户的情感共鸣。alignItems(HorizontalAlign.Start) 确保标题区左对齐。

卡片内部结构:每个房东卡片分为上下两个区域。

上半部分——房东信息行:

  • 头像区:62x62 的圆形头像,背景色为品牌浅棕色,居中显示 30px 的 Emoji 头像。圆形头像在 UI 设计中传递出"友好、可信任"的心理暗示。
  • 信息区:使用 layoutWeight(1) 占据剩余空间,包含三个子行:
    • 姓名 + 年限标签:姓名使用 14px 加粗标题色(整个应用中最大的姓名字号),年限标签使用深棕色文字配浅棕色背景胶囊样式。
    • 房源信息:10px 副文本色,展示房源名称和数量。
    • 评分 + 回复率:评分使用金黄色加粗,回复率使用三级文本色。

下半部分——操作按钮行:

  • "聊一聊"按钮:浅棕色背景、深棕色文字,用于发起与房东的对话。这种低调的配色适合次要操作。
  • "看房源"按钮:品牌棕色背景、白色文字,用于查看房东的房源。这种突出的配色适合主要操作。

两个按钮的视觉权重明确区分了主次操作,引导用户优先查看房源。

交互设计:每张房东卡片可点击,点击后触发 this.editModal = true 打开编辑弹框(在 Demo 中用于演示编辑功能)。ForEach 使用 item.name + item.house 的组合作为唯一 key。


十二、订单 Tab(tabOrder)—— 状态时间轴布局

  @Builder
  tabOrder() {
    Column({ space: 12 }) {
      Row() {
        Text('🧾 预订订单').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text(this.bookList.length + ' 个订单').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Column({ space: 0 }) {
        ForEach(this.bookList, (item: BookOrder, idx: number) => {
          Row({ space: 12 }) {
            Column({ space: 0 }) {
              Column().width(2).layoutWeight(1).backgroundColor(COLORS.line)
              Column().width(10).height(10).borderRadius(5).backgroundColor(bookColor(item.state))
                .opacity(item.state === '待入住' && this.breath ? 0.3 : 1)
                .animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
              Column().width(2).layoutWeight(1).backgroundColor(COLORS.line)
            }
            .width(14).height(66).alignItems(HorizontalAlign.Center)

            Column({ space: 4 }) {
              Text(item.state).fontSize(8).fontColor(bookColor(item.state))
                .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor(COLORS.chip)
              Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium).maxLines(1)
              Text(item.date).fontSize(9).fontColor(COLORS.text3)
              Row({ space: 6 }) {
                Text('¥' + item.price).fontSize(12).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
                Column().layoutWeight(1)
                if (item.state === '待入住') {
                  Text('编辑').fontSize(9).fontColor(COLORS.blue)
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor(COLORS.blueL)
                    .onClick(() => {
                      this.editIdx = idx;
                      this.editModal = true;
                    })
                  Text('取消').fontSize(9).fontColor(COLORS.redD)
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#F8E8E4')
                    .onClick(() => {
                      this.delIdx = idx;
                      this.delModal = true;
                    })
                }
              }
              .width('100%')
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start)
          }
          .width('100%').padding({ top: 8, bottom: 8 })
        }, (item: BookOrder) => item.name + item.date)
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
    }
    .width('100%')
  }

tabOrder 是订单列表的构建函数,采用竖向时间轴布局。这是整个应用中最具视觉特色的 Tab 之一。

标题栏:左侧显示"🧾 预订订单"标题,右侧动态显示订单数量(this.bookList.length + ' 个订单')。使用 this.bookList 而非 BOOK_LIST,是因为订单列表会随着新增和删除操作而变化,必须使用响应式数据源。

时间轴结构:每个订单使用 Row 水平排列,左侧是时间轴指示器,右侧是订单信息。

时间轴指示器(左侧 14px 宽的区域):

  • 上连接线:Column().width(2).layoutWeight(1).backgroundColor(COLORS.line),使用 2px 宽的分割线色竖线,layoutWeight(1) 使其自动填充上半部分空间。
  • 状态圆点:Column().width(10).height(10).borderRadius(5).backgroundColor(bookColor(item.state)),10x10 的圆形,颜色由 bookColor 函数根据订单状态动态计算。圆点通过 borderRadius(5) 实现完美圆形(半径等于宽高的一半)。
  • 下连接线:与上连接线相同,填充下半部分空间。

呼吸动画:对于"待入住"状态的订单,状态圆点会应用呼吸动画:

.opacity(item.state === '待入住' && this.breath ? 0.3 : 1)
.animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })

item.state === '待入住'this.breathtrue 时,圆点透明度降为 0.3;this.breathfalse 时,透明度恢复为 1。动画参数 duration: 650 设置单次动画持续 650ms,iterations: -1 表示无限循环,playMode: PlayMode.Alternate 表示来回交替播放。由于 breath 每 850ms 切换一次,而动画持续 650ms,两者形成了微妙的节奏差异,让动画效果更加自然。

订单信息区(右侧):

  • 状态标签:8px 文字,颜色由 bookColor 动态计算,背景色为芯片色 COLORS.chip,8px 圆角胶囊样式。
  • 订单名称:11px 加粗标题色,maxLines(1) 单行截断。
  • 入住日期:9px 三级文本色。
  • 价格与操作行:价格使用 12px 深棕色加粗,右侧根据订单状态条件渲染操作按钮。只有当订单状态为"待入住"时,才显示"编辑"和"取消"两个按钮。

条件渲染的操作按钮

  • "编辑"按钮:蓝色文字配蓝色浅色背景,点击时设置 editIdx 为当前订单索引并打开编辑弹框。
  • "取消"按钮:深红色文字配浅红色背景(#F8E8E4,一个比 COLORS.brownL 更偏红的暖色),点击时设置 delIdx 为当前订单索引并打开删除弹框。

这种"仅对特定状态显示操作按钮"的设计,避免了在已完成的订单上显示无意义的操作,减少了界面的视觉噪音。

ForEach 的 key 生成:使用 item.name + item.date 的组合作为唯一 key,确保每个订单项的唯一性。


十三、收藏 Tab(tabFav)—— 大图卡列表布局

  @Builder
  tabFav() {
    Column({ space: 12 }) {
      Row() {
        Text('❤️ 心愿单').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('降价会提醒你').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Column({ space: 10 }) {
        ForEach(FAVSTAY_LIST, (item: FavStay) => {
          Row({ space: 12 }) {
            Column() {
              Text(item.icon).fontSize(30)
            }
            .width(76).height(76).borderRadius(14)
            .linearGradient({ angle: 135, colors: [[COLORS.goldL, 0], [COLORS.chip, 1]] })
            .justifyContent(FlexAlign.Center)

            Column({ space: 4 }) {
              Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(item.place).fontSize(9).fontColor(COLORS.text3)
              Row({ space: 8 }) {
                Text('⭐ ' + item.score).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
                Text('已收藏 26 天').fontSize(8).fontColor(COLORS.text3)
              }
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start)

            Column({ space: 6 }) {
              Text('❤️').fontSize(18)
                .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
              Text('¥' + item.price).fontSize(11).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%').padding(12).borderRadius(16).backgroundColor(COLORS.card)
          .onClick(() => {
            this.delModal = true;
          })
        }, (item: FavStay) => item.name + item.place)
      }
      .width('100%')
    }
    .width('100%')
  }

tabFav 是收藏列表的构建函数,采用大图卡列表布局。每个收藏项都是一张水平排列的卡片,左侧是民宿图标,中间是民宿信息,右侧是收藏状态和价格。

标题栏:左侧显示"❤️ 心愿单"标题,右侧显示"降价会提醒你"的提示文字。这个提示文字传递了两个重要信息:一是平台会监控收藏民宿的价格变化,二是暗示用户收藏民宿可以获得实际利益,从而激励用户使用收藏功能。

卡片内部结构(左中右三栏布局):

  • 左侧图标区:76x76 的圆角矩形,使用 linearGradient 从金色浅色到芯片色渐变,居中显示 30px 的民宿图标。76px 的尺寸比民宿 Tab 中的图标区(全宽)小得多,因为收藏列表需要在一行内展示更多信息。
  • 中间信息区:layoutWeight(1) 占据弹性空间,包含民宿名称(12px 加粗)、位置(9px 三级文本色)、评分和收藏天数(评分金黄色加粗,天数三级文本色)。
  • 右侧操作区:❤️ 收藏图标(18px)和价格(11px 深棕色加粗),右对齐排列。收藏图标应用了无限循环的交替动画,模拟心跳效果,增强了收藏功能的情感化表达。

心跳动画

Text('❤️').fontSize(18)
  .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })

这是一个隐式动画,由于没有明确指定动画属性,ArkUI 会对所有可动画的属性进行自动动画处理。duration: 700 设置动画周期为 700ms,iterations: -1 无限循环,playMode: PlayMode.Alternate 来回交替。这个动画让收藏的心形图标持续产生微妙的缩放变化,增强了用户对收藏功能的感知。

交互设计:点击收藏卡片触发 this.delModal = true 打开取消预订弹框(在 Demo 中用于演示删除功能)。ForEach 使用 item.name + item.place 的组合作为唯一 key。


十四、攻略 Tab(tabGuide)—— 时间轴 + 阶段标签布局

  @Builder
  tabGuide() {
    Column({ space: 12 }) {
      Column({ space: 2 }) {
        Text('📖 大理 6 日玩法').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Text('由房东阿花姐整理 · 2.1 万人收藏').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Column({ space: 0 }) {
        ForEach(GUIDE_LIST, (item: Guide) => {
          Row({ space: 12 }) {
            Column() {
              Text(item.day).fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            }
            .width(36).height(36).borderRadius(12).backgroundColor(COLORS.brown)
            .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

            Column({ space: 4 }) {
              Row({ space: 6 }) {
                Text(item.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(item.tag).fontSize(8).fontColor(COLORS.green)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor(COLORS.greenL)
              }
              Text(item.spots).fontSize(9).fontColor(COLORS.text3)
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start)
          }
          .width('100%').padding({ top: 8, bottom: 8 })
        }, (item: Guide) => item.day + item.title)
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
    }
    .width('100%')
  }

tabGuide 是攻略列表的构建函数,采用时间轴加阶段标签的布局。每个攻略项都是一天的行程,通过天数标识和标签清晰地展示了行程的节奏和主题。

标题区:双层标题设计,主标题"📖 大理 6 日玩法"使用 13px 加粗标题色,副标题"由房东阿花姐整理 · 2.1 万人收藏"使用 9px 三级文本色。副标题中融入了社交信任元素——"由房东阿花姐整理"暗示内容的权威性,因为房东是最了解当地的人;"2.1 万人收藏"则利用社会证明(Social Proof)心理效应,暗示这份攻略的受欢迎程度。

时间轴结构:每个攻略项使用 Row 水平排列,左侧是天数标识,右侧是攻略内容。

天数标识(左侧 36x36 的区域):

  • 使用品牌棕色背景的圆角矩形(borderRadius(12)),居中显示白色加粗的天数文字(如"D1"、“D2”)。
  • 尺寸 36x36 配 12px 圆角,营造出类似"印章"或"标签"的视觉效果,强化了时间轴的阶段感。
  • 白色文字在深色背景上形成高对比度,确保天数信息一目了然。

攻略内容区(右侧):

  • 标题与标签行:标题使用 12px 加粗标题色,标签使用森绿色文字配森绿浅色背景,8px 圆角胶囊样式。标签的类型包括"轻松"、“运动”、“逛吃”、“摄影”、“亲子”、“收尾”,覆盖了不同旅行风格的需求。
  • 行程路线:9px 三级文本色,使用箭头"→"串联多个景点,如"才村码头 → 喜洲古镇 → 海舌公园"。这种格式让用户能够快速了解当天的游览路线和节奏。

标签颜色设计:所有攻略标签统一使用森绿色(COLORS.green),与民宿和平、自然的品牌调性一致。绿色在心理学上与"自然、放松、健康"相关联,非常适合旅行攻略的场景。

ForEach 的 key 生成:使用 item.day + item.title 的组合作为唯一 key。


十五、积分 Tab(tabPoint)—— 等级卡 + 任务勾选清单布局

  @Builder
  tabPoint() {
    Column({ space: 12 }) {
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column() {
            Text('🌰').fontSize(28)
          }
          .width(56).height(56).borderRadius(28).backgroundColor('rgba(255,255,255,0.25)')
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

          Column({ space: 4 }) {
            Text('1680 栗子积分').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('可抵 ¥168 · 距金卡还差 320 分').fontSize(9).fontColor('rgba(255,255,255,0.8)')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
        }
        .width('100%')
      }
      .width('100%').padding(18).borderRadius(18)
      .linearGradient({ angle: 135, colors: [[COLORS.gold, 0], [COLORS.brownD, 1]] })

tabPoint 是积分页面的构建函数,采用等级卡加任务清单的双层布局。这是唯一一个不使用列表而是使用功能模块布局的 Tab。

积分等级卡(顶部):

  • 背景:使用 linearGradient 从金色(COLORS.gold)到深棕色(COLORS.brownD)的 135 度对角线渐变。金色到棕色的渐变传递出"尊贵、高级"的视觉感受,非常适合积分和会员体系的展示。
  • 图标区:56x56 的圆形,背景色为半透明白色(rgba(255,255,255,0.25)),居中显示 28px 的 🌰 栗子图标。半透明背景在金色渐变上形成毛玻璃效果,让图标既有存在感又不喧宾夺主。
  • 信息区:使用白色文字在渐变背景上展示。积分数字"1680 栗子积分"使用 15px 加粗(整个应用中最大的文字),副文字"可抵 ¥168 · 距金卡还差 320 分"使用 9px 80% 透明度白色。这行文字包含了两个关键信息:积分可兑换的金额(¥168)和距下一等级的距离(还差 320 分),有效激励用户继续完成任务。

任务清单(底部):

      Column({ space: 10 }) {
        Row() {
          Text('赚积分任务').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Column().layoutWeight(1)
          Text('每周更新').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        ForEach(this.taskList, (item: Task, idx: number) => {
          Row({ space: 10 }) {
            Column() {
              Text(item.done ? '✓' : '').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            }
            .width(22).height(22).borderRadius(11)
            .backgroundColor(item.done ? COLORS.green : COLORS.chip)
            .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
            .onClick(() => {
              if (idx < this.taskList.length) {
                this.taskList[idx].done = !this.taskList[idx].done;
              }
            })

            Text(item.title).fontSize(11).fontColor(item.done ? COLORS.text3 : COLORS.title).layoutWeight(1)

            Text(item.point).fontSize(10).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
          }
          .width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
        }, (item: Task) => item.title)
      }
      .width('100%').alignItems(HorizontalAlign.Start)

任务清单的标题栏使用经典的"左标题右提示"布局,右侧的"每周更新"暗示任务会定期刷新,鼓励用户经常回来查看。

任务项结构(左中右三栏):

  • 左侧勾选框:22x22 的圆形,borderRadius(11) 实现完美圆形。已完成的任务背景色为森绿色,显示白色 ✓ 对勾;未完成的任务背景色为芯片色,不显示任何文字。这个设计让任务的完成状态一目了然。
  • 中间任务名称:11px 文字,已完成的任务使用三级文本色(视觉上弱化),未完成的任务使用标题色(视觉上强调)。颜色的动态变化提供了即时反馈。
  • 右侧积分奖励:10px 深棕色加粗文字,如"+200"、“+500”。使用深棕色保持与品牌色的一致性。

交互设计:点击勾选框切换任务的完成状态。this.taskList[idx].done = !this.taskList[idx].done 直接修改 @State 数组中的 done 属性,由于 Task 类使用了 @Observed 装饰器,属性变化会自动触发 UI 更新。if (idx < this.taskList.length) 是一个安全边界检查,防止数组越界。


十六、月度入住图表(chartCard)

  @Builder
  chartCard() {
    Column({ space: 12 }) {
      Row() {
        Text('📈 近 6 个月入住').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Column().layoutWeight(1)
        Text('单位:晚').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 0 }) {
        ForEach(MONTH_IDX, (mi: number) => {
          Column({ space: 6 }) {
            Text(NIGHT_VAL[mi].toString()).fontSize(9).fontColor(COLORS.sub)
            Column()
              .width(16)
              .height(16 + NIGHT_VAL[mi] * 4)
              .borderRadius({ topLeft: 4, topRight: 4 })
              .linearGradient({ angle: 180, colors: [[COLORS.brown, 0], [COLORS.brownL, 1]] })
            Text(MONTH_NAME[mi]).fontSize(9).fontColor(COLORS.text3)
          }
          .layoutWeight(1)
        }, (mi: number) => mi.toString())
      }
      .width('100%')
    }
    .width('100%').padding(14).borderRadius(14).backgroundColor(COLORS.card)
  }

chartCard 是月度入住柱状图的构建函数。这个图表卡片会在每个 Tab 内容的底部显示,为所有 Tab 提供统一的数据概览。

标题栏:左侧显示"📈 近 6 个月入住"标题,右侧显示"单位:晚"的计量单位提示。计量单位的标注是数据可视化中的基本规范,确保用户能够正确理解数据含义。

柱状图实现:使用纯 ArkUI 组件实现,不依赖任何第三方图表库。每个柱子是一个 Column 组件,通过 layoutWeight(1) 均匀分布:

  • 数值标签:9px 副文本色,显示该月的入住晚数。
  • 柱子:16px 宽,高度由 16 + NIGHT_VAL[mi] * 4 计算。基础高度 16px 确保即使数据为 0 时柱子也有最小高度,乘以 4 的比例系数确保数据差异在视觉上足够明显。例如 7 月 14 晚的柱子高度为 16 + 14 * 4 = 72px,2 月 6 晚的柱子高度为 16 + 6 * 4 = 40px,差异明显。
  • 柱子渐变:使用 linearGradient 从品牌棕色到品牌浅棕色,角度 180 度(从上到下),营造出立体感。
  • 圆角:borderRadius({ topLeft: 4, topRight: 4 }) 只给柱子顶部添加圆角,底部保持直角,模拟真实的柱状图外观。
  • 月份标签:9px 三级文本色,显示月份名称。

设计亮点:这个柱状图虽然简单,但通过渐变色彩和圆角处理,在视觉上比普通的纯色柱子更加精致。纯 ArkUI 组件的实现方式也意味着不需要引入额外的图表库,减少了包体积和依赖复杂度。


十七、底部导航栏(tabBar)

  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(t.icon).fontSize(this.currentTab === idx ? 21 : 19)
          Text(t.label).fontSize(9)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
            .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.currentTab = idx;
        })
      }, (t: TabMeta) => t.label)
    }
    .width('100%').height(58).backgroundColor(COLORS.card)
    .padding({ top: 6, bottom: 6 })
  }

tabBar 是底部导航栏的构建函数,采用单排六 Tab 布局。这是整个应用中最关键的用户导航组件。

整体容器Row 容器,宽度 100%,高度固定 58px,使用白色卡片背景色。上下各 6px 的 padding 为 Tab 内容提供垂直呼吸空间。

Tab 项结构:每个 Tab 项是一个 Column 组件,包含两个子元素:

  • Emoji 图标:选中态字号 21px,非选中态字号 19px。2px 的字号差异产生微妙的缩放效果,同时避免了过于剧烈的动画带来的视觉疲劳。
  • 文字标签:9px 字号,选中态使用品牌棕色(COLORS.tabOn)加粗,非选中态使用三级文本色(COLORS.text3)常规字重。

选中态设计:通过 this.currentTab === idx 三元表达式动态设置样式。选中态的三个视觉变化(图标变大、文字变色、文字加粗)共同构成了清晰的选中反馈。这种设计遵循了"多通道反馈"原则——用户可以通过图标大小、文字颜色和文字粗细三个维度感知当前选中的 Tab。

交互设计:每个 Tab 项可点击,点击后设置 this.currentTab = idx,触发内容区域的 Tab 切换。layoutWeight(1) 确保六个 Tab 均匀分布在一行中。

ForEach 的 key 生成:使用 t.label 作为唯一 key,因为标签文字在 Tab 列表中具有唯一性。


十八、弹框系统

弹框系统是应用中重要的交互组件,包含三个业务弹框和一个统一的遮罩容器。

18.1 新增预订弹框(panelAdd)

  @Builder
  panelAdd(onClose: () => void) {
    Column({ space: 14 }) {
      Text('预订民宿').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text('洱海边白族小院 · 湖景大床房').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
        Text('08-22 入住 — 08-24 离开 · 2 晚 · 2 人').fontSize(10).fontColor(COLORS.sub)
        Row({ space: 8 }) {
          Text('¥688 × 2 晚').fontSize(10).fontColor(COLORS.text3)
          Text('¥1376').fontSize(14).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
        }
      }
      .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.brownL).alignItems(HorizontalAlign.Start)

      Row({ space: 10 }) {
        Column() {
          Text('取消').fontSize(13).fontColor(COLORS.sub).fontWeight(FontWeight.Medium)
        }
        .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.chip)
        .onClick(() => {
          onClose();
        })

        Column() {
          Text('确认预订').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.brown)
        .onClick(() => {
          this.bookList.unshift(new BookOrder('洱海边白族小院 · 湖景大床房', '08-22 入住 · 2 晚', '待入住', '1376'));
          onClose();
        })
      }
      .width('100%')
    }
    .width('100%').padding(18)
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.card)
    .borderRadius({ topLeft: 18, topRight: 18 })
  }

panelAdd 接收一个 onClose 回调函数作为参数,实现弹框的关闭逻辑。弹框内部结构分为三个区域:

标题区:16px 加粗标题色"预订民宿",是整个弹框最大的字号,明确告知用户当前操作。

订单信息区:浅棕色背景的圆角卡片,展示即将预订的民宿信息。包含民宿名称和房型、入住日期和晚数、价格明细(单价 × 晚数 = 总价)。价格明细中单价使用三级文本色(辅助信息),总价使用 14px 深棕色加粗(核心信息),形成了清晰的视觉权重差异。

按钮区:两个水平排列的按钮,各占 50% 宽度:

  • "取消"按钮:芯片色背景、副文本色文字,关闭弹框不执行任何操作。
  • "确认预订"按钮:品牌棕色背景、白色文字,执行预订操作。点击时使用 this.bookList.unshift() 在订单列表头部插入新订单,然后关闭弹框。unshift 方法将新订单添加到数组开头,确保最新的订单显示在最前面。

constraintSize({ maxHeight: '80%' }) 限制弹框最大高度为屏幕的 80%,防止内容过多时弹框撑满屏幕。borderRadius({ topLeft: 18, topRight: 18 }) 只给顶部添加圆角,底部保持直角,模拟底部弹出面板的视觉效果。

18.2 编辑订单弹框(panelEdit)

  @Builder
  panelEdit(onClose: () => void) {
    Column({ space: 14 }) {
      Text('修改入住信息').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text('当前订单').fontSize(10).fontColor(COLORS.text3)
        Text(this.editIdx < this.bookList.length ? this.bookList[this.editIdx].name : '无')
          .fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
        Text(this.editIdx < this.bookList.length ? this.bookList[this.editIdx].date : '')
          .fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('入住人数调整为').fontSize(10).fontColor(COLORS.text3)
        Row({ space: 10 }) {
          Text('👤 2 人').fontSize(11).fontColor(COLORS.sub).padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12).backgroundColor(COLORS.chip)
          Text('👤 3 人').fontSize(11).fontColor(COLORS.brownD).fontWeight(FontWeight.Bold)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(12).backgroundColor(COLORS.brownL)
          Text('👤 4 人').fontSize(11).fontColor(COLORS.sub).padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12).backgroundColor(COLORS.chip)
        }
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Row({ space: 10 }) {
        Column() {
          Text('取消').fontSize(13).fontColor(COLORS.sub).fontWeight(FontWeight.Medium)
        }
        .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.chip)
        .onClick(() => {
          onClose();
        })

        Column() {
          Text('保存修改').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.brown)
        .onClick(() => {
          if (this.editIdx < this.bookList.length) {
            this.bookList[this.editIdx].date = '已调整为 3 人入住';
          }
          onClose();
        })
      }
      .width('100%')
    }
    .width('100%').padding(18)
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.card)
    .borderRadius({ topLeft: 18, topRight: 18 })
  }

panelEdit 的结构与 panelAdd 类似,但增加了订单信息展示和入住人数选择器。

订单信息展示:使用 this.editIdx < this.bookList.length 进行安全边界检查,防止索引越界。如果索引有效,显示订单名称和日期;否则显示"无"。这种防御性编程的写法在 @State 数据可能被外部修改的场景下尤为重要。

入住人数选择器:三个选项按钮水平排列,当前选中"3 人"(使用品牌深棕色文字和品牌浅色背景),未选中的"2 人"和"4 人"使用芯片色背景和副文本色文字。这种单选按钮组的设计清晰直观,用户可以一眼看出当前选择。

保存修改:点击"保存修改"按钮时,修改 this.bookList[this.editIdx].date 为"已调整为 3 人入住",然后关闭弹框。由于 BookOrder 类使用了 @Observed 装饰器,属性修改会自动触发订单列表的 UI 更新。

18.3 取消预订弹框(panelDel)

  @Builder
  panelDel(onClose: () => void) {
    Column({ space: 14 }) {
      Text('取消预订').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text('即将取消:').fontSize(10).fontColor(COLORS.text3)
        Text(this.delIdx < this.bookList.length ? this.bookList[this.delIdx].name : '无')
          .fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
        Text('入住前 1 天取消免费,已支付款项将在 3 个工作日内退回。').fontSize(9).fontColor(COLORS.redD)
      }
      .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Start)

      Row({ space: 10 }) {
        Column() {
          Text('再想想').fontSize(13).fontColor(COLORS.sub).fontWeight(FontWeight.Medium)
        }
        .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.chip)
        .onClick(() => {
          onClose();
        })

        Column() {
          Text('确认取消').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.red)
        .onClick(() => {
          if (this.delIdx < this.bookList.length) {
            this.bookList.splice(this.delIdx, 1);
          }
          onClose();
        })
      }
      .width('100%')
    }
    .width('100%').padding(18)
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.card)
    .borderRadius({ topLeft: 18, topRight: 18 })
  }

panelDel 是取消预订的确认弹框,设计上强调警示和确认。

取消政策提示:使用深红色文字(COLORS.redD)展示取消政策"入住前 1 天取消免费,已支付款项将在 3 个工作日内退回"。红色文字在 UI 中通常用于警告和重要提示,这里使用深红色而非亮红色,保持了与整体暖色调的协调性。

按钮设计

  • "再想想"按钮:芯片色背景、副文本色文字,名称使用了更加口语化的表达,比"取消"更有人情味,降低了用户取消预订的冲动。
  • "确认取消"按钮:红色背景(COLORS.red)、白色文字。红色背景明确传递了"这是危险操作"的信号,白色文字在红色背景上形成高对比度,确保用户不会忽略这个按钮。

删除操作this.bookList.splice(this.delIdx, 1) 从订单列表中移除指定索引的订单。splice 方法会直接修改原数组,由于 bookList 使用 @State 装饰,数组变化会自动触发 UI 更新,订单时间轴中的对应订单会立即消失。

18.4 遮罩容器(modalOverlay)

  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
      Column().layoutWeight(1).width('100%')
        .onClick(() => {
          onClose();
        })

      if (this.addModal) {
        this.panelAdd(onClose)
      }
      if (this.editModal) {
        this.panelEdit(onClose)
      }
      if (this.delModal) {
        this.panelDel(onClose)
      }
    }
    .width('100%').height('100%')
    .backgroundColor(COLORS.mask)
  }

modalOverlay 是弹框的统一遮罩容器,接收 onClose 回调函数作为参数。

结构设计:使用 Column 容器,分为上下两部分:

  • 上半部分:layoutWeight(1) 占据弹性空间,点击遮罩区域关闭弹框。这实现了"点击遮罩关闭"的标准模态交互。
  • 下半部分:根据 addModaleditModaldelModal 三个布尔状态,条件渲染对应的弹框面板。三个弹框不会同时显示,因为每次只有一个状态变量为 true

遮罩背景COLORS.maskrgba(0,0,0,0.55))提供半透明黑色遮罩,既能让用户感知到底层页面仍然存在,又能有效聚焦于弹框内容。

设计优势:将三个弹框的渲染逻辑集中在一个 @Builder 中,避免了在每个弹框面板中重复编写遮罩层代码,体现了 DRY(Don’t Repeat Yourself)原则。


十九、build 入口方法

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        this.headerTravel()

        Scroll() {
          Column({ space: 12 }) {
            if (this.currentTab === 0) {
              this.tabStay()
            } else if (this.currentTab === 1) {
              this.tabHost()
            } else if (this.currentTab === 2) {
              this.tabOrder()
            } else if (this.currentTab === 3) {
              this.tabFav()
            } else if (this.currentTab === 4) {
              this.tabGuide()
            } else {
              this.tabPoint()
            }
            this.chartCard()
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 12, bottom: 16 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')

        this.tabBar()
      }
      .width('100%').height('100%')

      if (this.addModal || this.editModal || this.delModal) {
        this.modalOverlay(() => {
          this.addModal = false;
          this.editModal = false;
          this.delModal = false;
        })
      }
    }
    .width('100%').height('100%')
    .backgroundColor(COLORS.bg)
  }

build() 是 ArkUI 组件的入口方法,每个 @Entry@Component 组件都必须实现这个方法。它定义了组件的 UI 结构。

整体布局:使用 Stack 作为根容器,alignContent: Alignment.Center 将子元素居中。Stack 支持层叠布局,允许弹框遮罩层覆盖在主内容层之上。

主内容层Column):

  • 顶部:this.headerTravel() 渲染头部搜索区域。头部区域固定在顶部,不随内容滚动。
  • 中部:Scroll 容器包裹 Tab 内容和图表卡片。layoutWeight(1) 使其占据剩余空间,scrollBar(BarState.Off) 隐藏滚动条以保持界面整洁。
  • 底部:this.tabBar() 渲染底部导航栏,固定在底部。

Tab 条件渲染:使用 if/else if 链根据 currentTab 的值决定渲染哪个 Tab 的构建函数。currentTab === 0 渲染民宿 Tab,currentTab === 1 渲染房东 Tab,以此类推。最后的 else 分支渲染积分 Tab(currentTab === 5)。这种条件渲染方式确保同一时间只有一个 Tab 的内容被渲染,减少了 DOM 节点数量和内存占用。

图表卡片this.chartCard() 在每个 Tab 内容的底部渲染月度入住图表,为所有 Tab 提供统一的数据视图。

弹框层:通过 if (this.addModal || this.editModal || this.delModal) 条件判断,当任一弹框状态为 true 时,渲染遮罩层。关闭回调函数统一将三个状态变量重置为 false,确保弹框关闭后所有状态恢复。

页面背景COLORS.bg#FAF6F0)作为整个页面的底色,营造出温暖的视觉氛围。


二十、六大 Tab 布局对比

对比维度 民宿 Tab (tabStay) 房东 Tab (tabHost) 订单 Tab (tabOrder) 收藏 Tab (tabFav) 攻略 Tab (tabGuide) 积分 Tab (tabPoint)
布局方式 Flex 双列网格 单列大卡列表 竖向时间轴 单列大图卡列表 时间轴 + 阶段标签 等级卡 + 任务清单
核心视觉元素 民宿图标 + 评分星级 圆形头像 + 年限标签 状态圆点 + 连接线 大图图标 + 心跳动画 天数标识 + 类型标签 积分卡 + 勾选框
数据模型 Stay (6 字段) Host (6 字段) BookOrder (4 字段) FavStay (5 字段) Guide (4 字段) Task (3 字段)
数据量 8 个民宿 6 个房东 6 个订单(动态) 6 个收藏 6 篇攻略 7 个任务(动态)
特色动画 待入住状态呼吸动画 收藏心形心跳动画 勾选切换即时反馈
交互操作 点击卡片 → 新增预订 点击卡片 → 编辑弹框 待入住:编辑/取消按钮 点击卡片 → 取消预订 无独立操作 点击勾选框切换状态
颜色基调 棕色 + 金色 棕色 + 金色 状态色(绿/棕/金/灰) 金色 + 棕色 棕色 + 绿色 金色渐变 + 绿色
信息密度 中等(双列展示) 高(大卡完整信息) 中等(时间轴紧凑) 高(横向全信息) 中等(简洁路线) 低(任务清单稀疏)
适用场景 浏览与发现 信任建立 状态追踪 快速管理 行程规划 任务激励

二十一、色彩体系对比

颜色用途 色值 变量名 使用场景
页面背景 #FAF6F0 bg 整个页面底色,营造温暖氛围
卡片背景 #FFFFFF card 民宿卡片、房东卡片、订单卡片、收藏卡片、攻略卡片、Tab 栏
芯片/标签背景 #F2ECE2 chip 订单状态标签、按钮背景、分割区域
标题文字 #33291E title 所有标题文字、民宿名称、房东姓名
副文本 #7A6C58 sub 房源信息、价格明细、辅助文字
三级文本 #A99A83 text3 位置、日期、提示文字、辅助信息
品牌主色 #B4833E brown 搜索按钮、主要操作按钮、Tab 选中态、价格文字
品牌深色 #8A5D22 brownD 图标背景、目的地标签文字、总价文字
品牌浅色 #F5E9D8 brownL 标签背景、卡片底色、头像背景、Banner 渐变
森绿色 #7C9A6D green 已完成状态、攻略标签、任务勾选
森绿浅色 #EDF3E8 greenL 攻略标签背景
功能红色 #D9634E red 取消预订按钮
深红色 #A63A28 redD 取消提示文字
功能蓝色 #6B8FA3 blue 编辑按钮文字
蓝色浅色 #E9F1F5 blueL 编辑按钮背景
金黄色 #C99A3C gold 评分星级、积分等级卡
金色浅色 #F8F0DD goldL 收藏图标背景
分割线 #EDE5D8 line 时间轴连接线
Tab 选中色 #B4833E tabOn 底部 Tab 选中态文字
遮罩色 rgba(0,0,0,0.55) mask 弹框遮罩背景
标签背景 #F5E9D8 tagBg 通用标签背景

二十二、数据模型对比

模型 字段数 字段列表 模拟数据量 是否动态修改 是否 @Observed
Stay 6 name, place, score, price, tag, icon 8 个
Host 6 avatar, name, house, years, score, reply 6 个
BookOrder 4 name, date, state, price 6 个 是(增删改)
FavStay 5 name, place, price, score, icon 6 个
Guide 4 day, title, spots, tag 6 个
Task 3 title, point, done 7 个 是(done 切换)

二十三、状态管理总结

状态变量 类型 初始值 驱动范围 修改时机
currentTab number 0 内容区域 Tab 切换 + 底部导航栏选中态 点击 Tab / Banner 点击
breath boolean false 订单时间轴呼吸动画 定时器每 850ms 切换
addModal boolean false 新增预订弹框 点击民宿卡片
editModal boolean false 编辑订单弹框 点击房东卡片 / 编辑按钮
delModal boolean false 取消预订弹框 点击取消按钮 / 收藏卡片
editIdx number 0 编辑弹框中的订单信息 点击编辑按钮时设置
delIdx number 0 删除弹框中的订单信息 点击取消按钮时设置
taskList Task[] TASK_LIST 积分任务清单 点击勾选框切换 done
bookList BookOrder[] BOOK_LIST 订单时间轴 新增 / 编辑 / 删除订单

二十四、总结

本文从代码的第一行开始,逐段深入分析了这款基于鸿蒙 ArkUI 框架开发的民宿短租预订平台 Demo 应用——"住好点"的完整技术实现。在分析过程中,我们重点关注了以下几个方面的设计思想和技术实践:

一、声明式 UI 的组件化思维。 整个应用通过 @Builder 构建函数将一个复杂的页面拆解为 12 个独立的构建块(1 个头部 + 6 个 Tab + 1 个图表 + 1 个底部栏 + 3 个弹框),每个构建块有明确的职责和独立的数据依赖。这种组件化拆分极大地提升了代码的可读性和可维护性,也让团队协作变得更加高效。当一个 Tab 的布局需要调整时,只需要修改对应的 @Builder 函数,不会影响其他 Tab 和整体结构。

二、状态驱动的响应式架构。 应用通过 @State 装饰器管理了 9 个核心状态变量,涵盖了 Tab 切换、动画控制、弹框显示、数据列表等多个维度。当任何一个状态变量发生变化时,依赖该状态的 UI 部分会自动重新渲染,无需手动更新 DOM。特别是 bookListtaskList 这两个 @State 数组,在新增订单、编辑订单、删除订单、切换任务状态时,UI 都能自动响应变化,体现了响应式编程的核心优势。

三、色彩体系的设计方法。 21 个颜色常量构成了一个完整的色彩体系,从背景到文字、从品牌色到功能色、从标准色到浅色变体,每个颜色都有明确的语义和使用场景。这种"配置与逻辑分离"的设计让色彩管理变得更加系统化,也为未来的主题切换和深色模式适配奠定了坚实的基础。暖奶油棕的主色调与民宿行业"自然、温暖、舒适"的品牌调性高度契合,是视觉设计服务于产品定位的优秀范例。

四、多 Tab 布局的差异化设计。 六个 Tab 各自采用了完全不同的布局方式——双列卡片、人物大卡、时间轴、大图卡列表、时间轴标签、等级卡加清单。这种"以内容定形式"的设计理念值得所有多 Tab 应用借鉴:每个 Tab 的布局应该服务于其内容的特点,而不是简单地复用同一种列表模板。民宿需要展示封面和评分,所以用双列卡片;房东需要展示人物形象和信任信息,所以用人物大卡;订单需要展示状态流转,所以用时间轴;攻略需要展示行程节奏,所以用天数标识加标签。

五、动画编排的情感化设计。 应用中的动画并非简单的"为了动而动",而是服务于特定的用户体验目标。订单时间轴中"待入住"状态的呼吸动画,巧妙地将用户注意力引导至当前最需要关注的订单上,这种"功能性动画"的设计思路值得借鉴。收藏列表中的心跳动画则为静态的界面注入了情感温度,让"收藏"这个动作变得更加生动有趣。

六、弹框系统的统一管理。 三个业务弹框通过共享的 modalOverlay 遮罩容器统一管理,避免了重复的遮罩层代码。弹框面板通过 onClose 回调实现关闭逻辑,形成了清晰的父子通信模式。constraintSize({ maxHeight: '80%' }) 的限制确保了弹框内容不会溢出屏幕,体现了良好的防御性设计思维。

七、数据模型的完整生命周期。 7 个数据模型类覆盖了民宿、房东、订单、收藏、攻略、积分六个业务领域,每个模型都使用 @Observed 装饰器确保响应式特性。BookOrderTask 两个模型的数据在运行时会被修改,其余模型的数据保持静态。这种设计真实反映了实际应用中不同数据的生命周期特征——核心交易数据(订单)是动态的,运营数据(任务)是可变的状态,而基础数据(民宿、房东、攻略)则是相对静态的。

八、代码的防御性编程实践。 在多处代码中可以看到防御性编程的影子:aboutToDisappear 中清理定时器防止内存泄漏,弹框中使用 this.editIdx < this.bookList.length 进行数组边界检查,ForEach 中为每个列表项提供唯一的 key 确保渲染正确性。这些细节虽然看似微小,但在生产环境中能够有效避免因边界条件导致的崩溃和异常。

通过本文的分析,我们可以看到,一个看似简单的 Demo 应用背后,实际上蕴含了丰富的软件工程实践和交互设计思想。希望本文能够为正在学习鸿蒙 ArkUI 开发的读者提供有价值的参考,也期待大家在实践中能够将这些设计模式和编码技巧灵活运用到自己的项目中,构建出更加优秀的鸿蒙原生应用。


附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤 操作 关键点
创建项目 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 使用 Stage 模型 + ArkTS 语言
查看 SDK 设置 → HarmonyOS SDK SDK 已内置,无需手动安装
跨平台扩展 设置 → ArkUI-X 根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐