引言:当鸿蒙开发遇上海洋美学

在这里插入图片描述

在鸿蒙生态不断壮大的今天,ArkTS 作为鸿蒙应用开发的核心语言,正以其声明式 UI、响应式数据和组件化开发的特性,重新定义着移动应用的开发方式。本文将深入剖析一款完整的美人鱼潜水俱乐部应用,从数据层、组件层到交互层,全方位拆解鸿蒙应用的设计思想与实现技巧。

声明式 UI 的精髓在于"描述你想要什么,而不是告诉系统怎么做"。ArkTS 将这一理念发挥到极致,通过 @Component、@State、@Builder 等装饰器,让开发者以极简的代码构建出丰富的界面。

这款潜水俱乐部应用采用深海蓝(#0B3B5C)为主色调,珊瑚红(#FF6B6B)为强调色,海沫白(#EAF6F5)为文字色,构建了一个沉浸式的海洋主题界面。应用包含六大功能模块:首页、课程、潜点、装备、日志、我的,每个模块都有独立的状态管理和弹窗交互。

从技术架构角度来看,这款应用采用了单文件自包含的设计模式,所有接口定义、数据常量、工具函数、组件结构都集中在一个文件中。这种设计虽然在大型项目中不推荐,但对于理解鸿蒙应用的完整结构和数据流非常有价值。它展示了从数据定义到界面渲染的完整链路。

下面是整个应用的架构流程图:

数据层
接口定义 + 常量数据

工具函数层
纯函数计算

主入口组件
DiveApp + Tab导航

首页Tab
活动+课程+教练

课程Tab
考证+报名+评价

潜点Tab
潜点列表+计划

装备Tab
装备网格+租借

日志Tab
统计+日志CRUD

我的Tab
会员+证书+订单

报名弹窗

报名弹窗 + 评价弹窗

计划弹窗 + 删除确认

租借弹窗 + 归还弹窗

新增/编辑/删除弹窗

充值弹窗 + 订单详情

接下来,我们将从数据层开始,逐层深入分析这款应用的实现细节。


一、数据层:接口定义与类型系统

在这里插入图片描述

1.1 调色板接口 SeaPalette

interface SeaPalette {
  bg: string
  primary: string
  primarySoft: string
  secondary: string
  secondarySoft: string
  card: string
  cardDeep: string
  cardLight: string
  text: string
  textSub: string
  chip: string
  warn: string
  warnSoft: string
  green: string
  greenSoft: string
  yellow: string
  yellowSoft: string
  white: string
}

这是整个应用的色彩体系接口定义。SeaPalette 接口定义了 18 种颜色属性,涵盖了背景、主色、次色、卡片、文字、标签、警告、成功、提示等各种视觉场景。

这种设计模式的核心优势在于颜色的集中管理。当需要调整主题色时,只需要修改调色板常量对象,整个应用的颜色就会同步更新。这比在每个组件中硬编码颜色字符串要优雅得多,也更容易维护。

值得注意的是,每种颜色都有对应的"软"版本,比如 primary 对应 primarySoft,green 对应 greenSoft。这些软色调通常用作文字标签的背景色,通过降低饱和度和亮度,营造出一种"发光"的视觉效果,同时保证文字的可读性。

在鸿蒙开发中,我们通常推荐将颜色资源定义在资源文件中,但在单文件应用中,使用接口 + 常量对象的方式也是一种非常实用的模式。它提供了类型安全,TypeScript/ArkTS 的类型系统会在编译时检查颜色属性是否存在,避免拼写错误。

chip(碎片/标签)颜色是一个比较特殊的字段,它通常用于输入框的背景、标签的底色等场景,比卡片背景色稍亮一些,但又不会太突出,起到视觉分层的作用。

1.2 核心业务接口定义

在这里插入图片描述

interface OceanActivity {
  id: number
  title: string
  date: string
  time: string
  price: number
  spotsLeft: number
  totalSpots: number
  target: string
  pickUp: string
  gearIncluded: boolean
  desc: string
}

OceanActivity 接口定义了出海活动的数据结构。每个活动包含 11 个字段,从基本信息(id、标题、日期、时间)到价格信息(price),再到名额信息(spotsLeft、totalSpots),以及集合地点、是否包含装备、活动描述等。

这种数据结构的设计体现了业务建模的思维。一个出海活动不仅仅是一个标题和价格,它还包含了用户决策所需的所有关键信息:剩余名额决定了紧迫感,是否包含装备影响了用户的成本计算,集合地点关系到出行便利性。

在鸿蒙开发中,接口定义不仅仅是为了类型检查,更重要的是为组件的 props 提供类型约束。当我们使用 ForEach 遍历数据数组时,明确的接口类型可以让 IDE 提供更好的代码补全和错误提示。

interface Course {
  id: number
  name: string
  level: string
  hours: number
  theoryCount: number
  openWaterCount: number
  price: number
  origPrice: number
  certOrg: string
  startDate: string
  doneLessons: number
  totalLessons: number
  passRate: number
}

Course 接口定义了潜水课程的数据结构。相比 OceanActivity,Course 包含了更多维度的信息:课程等级(level)、课时总数(hours)、理论课次数(theoryCount)、开放水域次数(openWaterCount)、原价(origPrice)、发证机构(certOrg)、已完成课时(doneLessons)、总课时(totalLessons)、通过率(passRate)。

doneLessons 和 totalLessons 这两个字段的设计非常巧妙,它们不仅用于显示进度文本,还可以通过计算得到进度百分比,用于渲染进度条。这种"原始数据 + 计算属性"的模式在鸿蒙开发中非常常见,原始数据存储在状态中,计算结果通过纯函数实时得出。

passRate(通过率)字段是一个很有说服力的设计。在教育类产品中,通过率是用户选择课程的重要参考指标,将其作为数据的一部分,而不是通过计算得出,说明这是一个经过统计的固定值,而不是动态计算的结果。

interface Instructor {
  id: number
  name: string
  cert: string
  years: number
  dives: number
  specialty: string
  rating: number
  courses: string
  avatar: string
}

Instructor 接口定义了教练的数据结构。这里有一个很有意思的设计:avatar 字段是 string 类型,但实际存储的是 emoji 表情(如 🦈、🐙、🐬)。

使用 emoji 作为头像在很多场景下是一种轻量级的解决方案。它不需要加载图片资源,不会有网络请求,渲染速度快,而且在不同设备上都能保持一致的显示效果。当然,这种方式也有局限性,比如 emoji 的样式在不同平台上可能略有差异,但在原型开发和小型应用中,这是一种非常高效的做法。

教练的评分(rating)使用 number 类型,保留一位小数,这是评分系统的常见设计。评分会通过专门的工具函数转换为颜色,高分显示绿色,中等显示黄色,低分显示红色,这种视觉反馈可以帮助用户快速识别教练的水平。

1.3 更多业务接口

在这里插入图片描述

interface DiveSpot {
  id: number
  name: string
  region: string
  maxDepth: number
  visibility: number
  current: string
  difficulty: number
  rating: number
  type: string
  bestSeason: string
  temp: number
}

DiveSpot 接口定义了潜点的数据结构。这是一个信息密度很高的接口,包含了潜点的地理位置、深度、能见度、水流、难度、评分、类型、最佳季节、水温等 11 个字段。

难度(difficulty)使用 number 类型,取值范围是 1-5。这种数值化的设计有很多好处:可以通过工具函数转换为文字描述(简单、中等、较难、极难),也可以转换为对应的颜色(绿色、黄色、红色),还可以用于排序和筛选。

能见度(visibility)和水温(temp)都是数值类型,这使得它们可以参与各种计算,比如统计平均能见度、计算水温范围等。在界面上,这些数值会配上对应的图标(👁️ 代表能见度,🌡️ 代表水温),形成图文并茂的信息展示。

interface Equipment {
  id: number
  name: string
  category: string
  size: string
  rentPrice: number
  deposit: number
  condition: string
  conditionLevel: number
  stock: number
  note: string
}

Equipment 接口定义了潜水装备的数据结构。这里有一个值得关注的设计:condition(成色描述)和 conditionLevel(成色等级)是两个独立的字段。

condition 是文字描述,比如"9成新"、“8成新”、“全新”,直接显示给用户看。conditionLevel 是数值等级,比如 88、90、78,用于计算成色对应的颜色。这种"显示值 + 计算值"分离的设计,在保持用户友好的同时,也为逻辑判断提供了便利。

押金(deposit)字段是装备租赁业务中的关键数据。在用户租借装备时,押金金额会显示在租金旁边,提醒用户需要支付的额外费用。归还装备时,押金信息也会出现在确认弹窗中,告知用户押金将原路退回。

interface DiveLog {
  id: number
  date: string
  spot: string
  maxDepth: number
  duration: number
  tank: string
  waterTemp: number
  visibility: number
  buddy: string
  note: string
  weather: string
}

DiveLog 接口定义了潜水日志的数据结构。这是整个应用中字段最丰富的接口之一,包含了日期、潜点、最大深度、时长、气瓶类型、水温、能见度、潜伴、备注、天气等 11 个字段。

潜水日志是潜水爱好者的重要记录,每一次下潜的数据都值得被详细记录。maxDepth(最大深度)和 duration(时长)是两个核心指标,它们不仅显示在日志列表中,还会被用于统计计算,比如总潜数、累计深度、平均时长、最大深度等。

buddy(潜伴)字段体现了潜水的社交属性。潜水是一项需要伙伴的运动,潜伴不仅是安全保障,也是分享体验的对象。在界面上,潜伴信息会用绿色高亮显示,强调其重要性。

1.4 辅助业务接口

在这里插入图片描述

interface CertLevel {
  id: number
  name: string
  level: string
  progress: number
  lessonsDone: number
  lessonsTotal: number
  target: string
  status: string
  icon: string
  price: number
  date: string
}

CertLevel 接口定义了证书等级的数据结构。它结合了课程信息和进度信息,用于展示用户的考证进阶路线。status 字段有三种状态:已获得、学习中、计划中,不同状态对应不同的颜色和视觉表现。

progress 字段虽然在代码中定义了,但实际计算进度时使用的是 lessonsDone 和 lessonsTotal。这是一个有趣的设计,可能是为了在某些场景下直接使用进度值,而不需要每次都计算。不过在实际代码中,主要还是通过 courseProgressW 函数动态计算进度。

icon 字段同样使用 emoji 表情,不同等级对应不同的奖牌图标(🏅、🥈、🛟、🗺️),这种设计既直观又有趣。

interface RechargeOption {
  id: number
  amount: number
  bonus: number
  tag: string
  icon: string
  desc: string
  savePercent: number
  hot: boolean
}

RechargeOption 接口定义了充值档位的数据结构。这是一个典型的电商充值模块设计,包含充值金额、赠送金额、标签、图标、描述、节省比例、是否热门等字段。

hot 字段是一个 boolean 值,用于标记热门充值档位。在界面上,热门档位会有特殊的视觉标记,引导用户选择。savePercent 字段计算了充值的优惠比例,虽然在当前代码中没有直接使用,但它为后续功能扩展留下了空间。

interface OrderInfo {
  id: number
  orderNo: string
  title: string
  date: string
  amount: number
  status: string
  items: string
  payMethod: string
}

OrderInfo 接口定义了订单信息的数据结构。订单号(orderNo)是订单的唯一标识,在订单详情页会显示出来。订单状态(status)有三种:已完成、进行中、待出行,分别对应绿色、黄色、红色三种颜色。

items 字段是商品明细的文字描述,它将多个商品信息拼接成一个字符串。这种设计虽然简单,但灵活性较差,如果需要展示更详细的商品列表,可能需要改为数组结构。不过在订单详情这种简化展示的场景下,字符串描述已经足够。


二、常量数据:主题色与业务数据

在这里插入图片描述

2.1 调色板常量

const SEA: SeaPalette = {
  bg: '#0B3B5C',
  primary: '#FF6B6B',
  primarySoft: '#4A2430',
  secondary: '#FF8E72',
  secondarySoft: '#4A2A20',
  card: '#12456B',
  cardDeep: '#0A2F4A',
  cardLight: '#FFFFFF',
  text: '#EAF6F5',
  textSub: '#8FB8CC',
  chip: '#1B5278',
  warn: '#FF5252',
  warnSoft: '#4A1E24',
  green: '#5BC8AF',
  greenSoft: '#124036',
  yellow: '#FFD166',
  yellowSoft: '#4A3A18',
  white: '#FFFFFF'
}

SEA 常量是整个应用的色彩核心,它实现了 SeaPalette 接口,定义了 18 种具体的颜色值。这个调色板的设计非常考究,我们来逐一分析。

主背景色 bg 使用深海蓝 #0B3B5C,这是一种偏暗的蓝绿色,营造出深海的沉浸感。在深色背景上,文字和元素需要足够高的对比度才能保证可读性,因此主文字色 text 使用了海沫白 #EAF6F5,这是一种偏青的白色,与深海蓝形成和谐的对比。

主色调 primary 使用珊瑚红 #FF6B6B,这是一种温暖的粉红色,在深蓝背景上非常醒目,用于按钮、价格、强调文字等关键元素。primarySoft #4A2430 是珊瑚红的深色版本,它实际上是在深色背景上的"柔光"效果,用作标签背景时,红色文字配深红色背景,既和谐又有层次感。

次色调 secondary #FF8E72 是一种橙红色,比主色稍浅稍暖,用于次要强调。secondarySoft #4A2A20 对应的深色版本也更偏橙色一些。

卡片背景色 card #12456B 比背景色稍亮,形成微妙的层次感。cardDeep #0A2F4A 比背景色更深,用于底部导航栏和头部背景,形成"顶底更深、中间稍亮"的视觉结构。

chip #1B5278 是一种更亮的蓝色,用作输入框背景和未选中标签的背景,它比卡片背景更亮,但又不会太突出,是非常好的中间色调。

绿色系 green #5BC8AF 和 greenSoft #124036 用于成功状态、通过状态、潜伴信息等。绿色在深海主题中代表安全和生命,与潜水的安全理念相契合。

黄色系 yellow #FFD166 和 yellowSoft #4A3A18 用于警告、提醒、热门标签等场景。黄色在深蓝背景上非常醒目,但又不会像红色那样强烈,是一种很好的中间提醒色。

警告色 warn #FF5252 是一种更亮更纯的红色,比主色更鲜艳,用于危险操作(如删除)的确认,强烈警示用户。warnSoft #4A1E24 是对应的深色版本。

2.2 业务数据常量

在这里插入图片描述

const ACTIVITIES: OceanActivity[] = [
  { id: 1, title: '分界洲岛·珊瑚湾船潜双潜', date: '09月12日', time: '08:30 码头集合', price: 468, spotsLeft: 6, totalSpots: 12, target: '分界洲岛珊瑚湾', pickUp: '陵水码头', gearIncluded: true, desc: '一日双潜 · 含装备与午餐 · 能见度20m' },
  { id: 2, title: '蜈支洲岛·沉船探秘', date: '09月19日', time: '09:00 码头集合', price: 588, spotsLeft: 3, totalSpots: 10, target: '蜈支洲岛沉船区', pickUp: '后海码头', gearIncluded: true, desc: '沉船区深度22m · 需AOW证书 · 含装备' },
  { id: 3, title: '加井岛·夜潜体验', date: '09月26日', time: '18:30 集合', price: 398, spotsLeft: 8, totalSpots: 12, target: '加井岛北湾', pickUp: '石梅湾码头', gearIncluded: false, desc: '夜潜观珊瑚 · 配潜水灯 · 不含装备' }
]

ACTIVITIES 数组存储了三条出海活动数据。每条数据都包含了完整的活动信息,从标题、日期到价格、剩余名额,再到装备情况和描述。

这些数据都是静态写死的,在实际项目中,这类数据通常会从后端 API 获取。但在原型开发和演示项目中,静态数据是一种快速有效的方式。它可以让开发者专注于界面和交互的实现,而不必等待后端接口。

剩余名额(spotsLeft)的设计是一个很好的用户体验细节。当剩余名额较少时(比如 3 位),会用黄色高亮显示,制造紧迫感,促使用户尽快报名。这种"稀缺性"设计在电商和活动报名中非常常见。

gearIncluded 是一个 boolean 字段,表示活动是否包含装备。在界面上,包含装备会显示绿色的 ✅ 标记,不包含则显示黄色的 ⚠️ 标记,用户一眼就能看出活动的性价比。

const COURSES: Course[] = [
  { id: 1, name: 'OW 开放水域潜水员', level: '入门', hours: 48, theoryCount: 5, openWaterCount: 4, price: 2980, origPrice: 3480, certOrg: 'PADI', startDate: '09月15日', doneLessons: 2, totalLessons: 5, passRate: 96 },
  { id: 2, name: 'AOW 进阶开放水域', level: '进阶', hours: 40, theoryCount: 3, openWaterCount: 5, price: 2680, origPrice: 3180, certOrg: 'PADI', startDate: '09月20日', doneLessons: 1, totalLessons: 5, passRate: 94 },
  // ... 更多课程
]

COURSES 数组包含了 12 门潜水课程,从入门级的 OW 开放水域潜水员到专业级的 DM 潜水长,再到各种专长课程(深潜、沉船、夜潜、水下摄影、自由潜、美人鱼)。

课程的等级分为三类:入门、进阶、专业。不同等级对应不同的颜色:入门是绿色,进阶是黄色,专业是红色。这种色彩编码系统让用户可以快速识别课程的难度级别。

原价(origPrice)和现价(price)的对比设计,是促销类产品的标准做法。原价用删除线显示,现价用大号加粗的红色字体显示,形成强烈的视觉对比,突出优惠力度。

通过率(passRate)字段是一个信任建设元素。高通过率(90%+)可以降低用户的学习焦虑,让用户更有信心报名。在界面上,通过率用绿色显示,强化"容易通过"的感知。

在鸿蒙应用中,数据常量的命名建议使用全大写加下划线的方式(UPPER_SNAKE_CASE),这是一种通用的常量命名约定,可以让开发者一眼就识别出这是不可变的静态数据。

const INSTRUCTORS: Instructor[] = [
  { id: 1, name: '阿豪', cert: 'PADI 教练长', years: 8, dives: 3200, specialty: '深潜 / 沉船', rating: 4.9, courses: 'OW/AOW/救援', avatar: '🦈' },
  { id: 2, name: '琳达', cert: 'PADI 教练', years: 6, dives: 2100, specialty: '水下摄影', rating: 4.8, courses: '摄影/夜潜', avatar: '🐙' },
  // ... 更多教练
]

INSTRUCTORS 数组包含了 9 位教练的数据,每位教练都有独特的 emoji 头像和专业特长。教练的资历通过教龄(years)和潜水次数(dives)两个维度来体现,比单一维度更有说服力。

教练的评分(rating)是一个关键指标,4.9、4.8、5.0 这样的高分能够建立用户对教练的信任。评分在界面上用星号和数字双重展示,视觉上非常醒目。

专长(specialty)字段描述了教练的专业领域,用户可以根据自己的兴趣选择合适的教练。比如对水下摄影感兴趣的用户,会更倾向于选择专长为水下摄影的教练。

2.3 潜点数据

const SPOTS: DiveSpot[] = [
  { id: 1, name: '分界洲岛珊瑚湾', region: '陵水', maxDepth: 18, visibility: 20, current: '弱', difficulty: 2, rating: 4.8, type: '珊瑚礁', bestSeason: '全年', temp: 26 },
  { id: 2, name: '蜈支洲岛沉船区', region: '三亚', maxDepth: 22, visibility: 18, current: '中', difficulty: 3, rating: 4.7, type: '沉船', bestSeason: '10-4月', temp: 25 },
  // ... 更多潜点
]

SPOTS 数组包含了 14 个海南潜点的数据,覆盖了陵水、三亚、万宁、文昌等多个地区。每个潜点都有详细的环境数据,包括深度、能见度、水流、难度、水温等。

潜点类型(type)的多样性很有意思,包括珊瑚礁、沉船、海蚀沟、陡坡、礁石、浅滩、峭壁、海草床、外海礁群等。不同类型的潜点适合不同水平的潜水员,也提供了不同的潜水体验。

最佳季节(bestSeason)字段是一个很实用的信息。潜水受季节影响很大,某些潜点在特定季节才能达到最佳状态。比如沉船区最佳季节是 10-4 月,而海草床最佳季节是 3-8 月,用户可以根据季节选择合适的潜点。

难度等级(difficulty)从 1 到 5,对应"简单"到"极难"五个等级。这个等级是综合了深度、水流、地形等多种因素得出的,是潜水员选择潜点的重要参考。

2.4 装备与日志数据

const EQUIPS: Equipment[] = [
  { id: 1, name: 'BCD 浮力背心', category: '浮力装置', size: 'S/M/L', rentPrice: 50, deposit: 500, condition: '9成新', conditionLevel: 88, stock: 6, note: '含充排气阀,智能气囊' },
  { id: 2, name: '调节器(一二级)', category: '呼吸系统', size: '均码', rentPrice: 60, deposit: 800, condition: '9成新', conditionLevel: 90, stock: 8, note: '含压力表与备用气源' },
  // ... 更多装备
]

EQUIPS 数组包含了 18 种潜水装备,从浮力装置、呼吸系统、气瓶到潜水服、面镜、脚蹼,再到各种配件和工具,覆盖了潜水所需的全套装备。

装备的分类(category)设计很清晰,包括浮力装置、呼吸系统、气瓶、潜水服、面镜、脚蹼、配重、仪表、照明、信号装置、工具、配件等。分类信息显示在装备卡片顶部,帮助用户快速识别装备类型。

库存(stock)字段反映了装备的可用数量。库存充足时用户可以放心租借,库存紧张时则会增加紧迫感。成色(condition)和成色等级(conditionLevel)的双重设计,既方便用户理解,又便于程序计算颜色。

备注(note)字段提供了装备的补充说明,比如 BCD “含充排气阀,智能气囊”,调节器 “含压力表与备用气源”。这些信息可以帮助用户了解装备的特性和功能。

const LOGS: DiveLog[] = [
  { id: 1, date: '08月24日', spot: '分界洲岛珊瑚湾', maxDepth: 18.6, duration: 42, tank: '12L', waterTemp: 27, visibility: 18, buddy: '阿豪', note: '遇到海龟群,非常震撼', weather: '晴' },
  { id: 2, date: '08月22日', spot: '加井岛北湾', maxDepth: 15.2, duration: 38, tank: '12L', waterTemp: 26, visibility: 20, buddy: '琳达', note: '珊瑚花园超美,练习中性浮力', weather: '多云' },
  // ... 更多日志
]

LOGS 数组包含了 16 条潜水日志记录,时间跨度从 6 月到 8 月,覆盖了多个潜点和不同的潜水体验。每条日志都是一次完整潜水的记录,包含了深度、时长、水温、能见度、潜伴、天气、备注等完整信息。

这些日志数据不仅用于展示列表,还为统计功能提供了数据源。总潜数、累计深度、最大深度、平均时长等统计数据都是通过遍历日志数组计算得出的。这种"数据驱动统计"的模式非常灵活,只要更新日志数据,统计结果就会自动更新。

备注(note)字段是日志中最有人情味的部分。“遇到海龟群,非常震撼”、“珊瑚花园超美,练习中性浮力”、“看大货!遇见礁鲨一只”……这些文字记录了潜水的独特体验,让日志不再是冰冷的数据,而是有温度的回忆。


三、工具函数层:纯函数的艺术

3.1 格式化与进度计算函数

function formatPrice(p: number): string {
  return '¥' + p
}

formatPrice 函数是最简单的格式化函数,它接受一个数字价格,返回带有人民币符号的字符串。虽然这个函数非常简单,但将价格格式化逻辑抽离成独立函数仍然是一个好习惯。

如果未来需要修改价格格式,比如添加千位分隔符或改变货币符号,只需要修改这一个函数,所有使用该函数的地方都会自动更新。这就是"单一职责原则"的体现。

function courseProgressW(done: number, total: number): string {
  if (total <= 0) {
    return '0%'
  }
  const p: number = Math.min(100, Math.round(done / total * 100))
  return p + '%'
}

courseProgressW 函数计算课程进度的百分比宽度。它接受已完成课时数和总课时数,返回一个百分比字符串,用于设置进度条的宽度。

函数名中的 W 代表 Width,说明这个函数的返回值是用于设置宽度的。这种命名约定在代码量较大时非常有用,可以一眼看出函数的用途。

函数内部有两个关键点:一是边界检查,当 total 小于等于 0 时,直接返回 0%,避免除以零的错误;二是使用 Math.min(100, …) 确保进度不会超过 100%,即使因为数据问题 done 大于 total,进度条也不会溢出。

Math.round 用于四舍五入,确保进度是整数百分比。在 UI 显示中,整数百分比比小数百分比更简洁、更易读。

function spotDepthW(depth: number): string {
  if (depth >= 40) {
    return '100%'
  }
  return Math.round(depth / 40 * 100) + '%'
}

spotDepthW 函数计算潜点深度条的宽度百分比。它以 40 米为最大值,将深度转换为百分比宽度。深度大于等于 40 米时,宽度为 100%。

40 米这个最大值不是随意设定的,它对应了数据中最深的潜点(七洲列岛外海,40 米)。选择一个合理的最大值可以确保深度条有合适的视觉比例,不会因为某个极深的潜点而导致其他潜点的深度条都显得很短。

3.2 颜色映射函数

function diffColor(diff: number): string {
  if (diff <= 2) {
    return SEA.green
  }
  if (diff === 3) {
    return SEA.yellow
  }
  return SEA.primary
}

diffColor 函数根据难度等级返回对应的颜色。难度 1-2 级返回绿色(简单),难度 3 级返回黄色(中等),难度 4-5 级返回红色(较难/极难)。

这种三级颜色映射是一种非常经典的信息可视化模式:绿色表示安全/简单,黄色表示警告/中等,红色表示危险/困难。用户通过颜色就能快速理解信息的含义,不需要阅读文字。

这种颜色映射函数的好处是集中管理颜色逻辑。如果未来需要调整颜色方案,只需要修改这一个函数,所有使用难度颜色的地方都会自动更新。这比在每个组件中重复 if-else 判断要优雅得多。

function diffText(diff: number): string {
  if (diff <= 2) {
    return '简单'
  }
  if (diff === 3) {
    return '中等'
  }
  if (diff === 4) {
    return '较难'
  }
  return '极难'
}

diffText 函数将数值难度转换为文字描述。它比 diffColor 多了一个等级区分,将 4 级和 5 级区分为"较难"和"极难",而颜色上它们都用红色表示。

这种设计体现了"颜色三级、文字五级"的信息层次。颜色提供快速的视觉分类,文字提供更精确的描述。两者结合,既高效又准确。

function levelColor(level: string): string {
  if (level === '入门') {
    return SEA.green
  }
  if (level === '进阶') {
    return SEA.yellow
  }
  return SEA.primary
}

function levelBg(level: string): string {
  if (level === '入门') {
    return SEA.greenSoft
  }
  if (level === '进阶') {
    return SEA.yellowSoft
  }
  return SEA.primarySoft
}

levelColor 和 levelBg 是一对配套函数,分别返回等级的文字颜色和背景颜色。它们都根据课程等级(入门/进阶/专业)返回对应的颜色,形成"文字色 + 背景色"的标签效果。

这种配套函数的设计模式在 UI 开发中非常常见。标签组件通常需要前景色和背景色的搭配,两个函数分别处理,职责清晰,调用方便。

颜色搭配的原则是:背景色是主色的深色/低饱和度版本,确保文字在背景上有足够的对比度,同时形成柔和的视觉效果。比如绿色文字配深绿色背景,黄色文字配深黄色背景,红色文字配深红色背景。

3.3 状态颜色函数

function conditionColor(level: number): string {
  if (level >= 90) {
    return SEA.green
  }
  if (level >= 80) {
    return SEA.yellow
  }
  return SEA.warn
}

conditionColor 函数根据装备成色等级返回颜色。90 分以上(9成新以上)显示绿色,80-89 分(8成新)显示黄色,80 分以下显示红色警告色。

这个函数使用 warn 而不是 primary 作为最低等级的颜色,这是因为装备成色低到一定程度就不仅仅是"等级低"的问题了,而是涉及安全隐患。用警告红色更能引起用户的注意。

成色等级的划分也很有讲究:90 分以上是"9成新"或"全新",状态良好;80-89 分是"8成新",正常使用但有磨损;80 分以下则需要特别注意。

function ratingColor(r: number): string {
  if (r >= 4.7) {
    return SEA.green
  }
  if (r >= 4.4) {
    return SEA.yellow
  }
  return SEA.primary
}

ratingColor 函数根据评分返回颜色。4.7 分以上是绿色(优秀),4.4-4.6 分是黄色(良好),4.4 分以下是红色(一般)。

评分的阈值设置比较高,4.7 分才是绿色,这说明在潜水教练这个领域,用户对评分的期望较高。大部分教练的评分都在 4.6 以上,高评分区间的区分度很重要。

function starColor(idx: number, score: number): string {
  if (idx <= score) {
    return SEA.yellow
  }
  return 'rgba(255,255,255,0.18)'
}

starColor 函数用于星级评分组件。它接受星星的索引和当前分数,判断该星星应该显示黄色(点亮)还是半透明白色(未点亮)。

这个函数的设计很巧妙,它利用了索引比较的方式来判断星星状态。比如评分是 4 分,那么索引 1-4 的星星都是黄色,索引 5 的星星是半透明的。

未点亮的星星使用半透明白色 ‘rgba(255,255,255,0.18)’,而不是纯灰色或纯透明。这样设计的好处是,未点亮的星星仍然有淡淡的轮廓,保持了星形的完整性,视觉上更加美观。

function certStatusColor(status: string): string {
  if (status === '已获得') {
    return SEA.green
  }
  if (status === '学习中') {
    return SEA.yellow
  }
  return SEA.primary
}

function certStatusBg(status: string): string {
  if (status === '已获得') {
    return SEA.greenSoft
  }
  if (status === '学习中') {
    return SEA.yellowSoft
  }
  return SEA.primarySoft
}

certStatusColor 和 certStatusBg 是证书状态的颜色函数。证书有三种状态:已获得(绿色)、学习中(黄色)、计划中(红色)。

这种状态色彩系统贯穿了整个应用,从课程等级、装备成色到证书状态、订单状态,都使用了相同的绿-黄-红三色体系。这就是设计系统中的"一致性"原则,用户在一个地方理解了颜色的含义,就能在其他地方快速理解。

function orderStatusColor(status: string): string {
  if (status === '已完成') {
    return SEA.green
  }
  if (status === '进行中') {
    return SEA.yellow
  }
  return SEA.primary
}

orderStatusColor 函数根据订单状态返回颜色。已完成是绿色,进行中是黄色,待出行是红色。同样的三色体系,同样的语义。

值得注意的是,“待出行"在其他应用中可能用蓝色或其他颜色表示"待处理”,但这里用了红色。这可能是因为"待出行"意味着用户需要行动,用红色提醒用户注意。当然,也可能只是为了保持三色体系的一致性。

3.4 数据查询函数

function activityTitleById(id: number): string {
  for (let i = 0; i < ACTIVITIES.length; i++) {
    if (ACTIVITIES[i].id === id) {
      return ACTIVITIES[i].title
    }
  }
  return '出海活动'
}

activityTitleById 函数根据活动 ID 查询活动标题。它遍历 ACTIVITIES 数组,找到匹配 ID 的活动并返回其标题。如果没有找到,返回默认值"出海活动"。

这类"通过 ID 查询名称"的函数在应用中出现了多次,包括 courseNameById、spotNameById、gearNameById 等。它们的模式完全相同:遍历数组、匹配 ID、返回对应字段、未找到返回默认值。

在数据量较小的情况下(比如只有几条或几十条数据),这种线性查找的性能完全没问题。但如果数据量很大(几千几万条),就应该考虑使用 Map 或对象进行索引,以提高查询效率。

默认返回值的设计很重要。即使因为数据问题找不到匹配项,界面上也不会显示空白,而是显示一个合理的默认值。这是一种防御性编程的思路。

function gearPriceById(id: number): number {
  for (let i = 0; i < EQUIPS.length; i++) {
    if (EQUIPS[i].id === id) {
      return EQUIPS[i].rentPrice
    }
  }
  return 0
}

function gearDepositById(id: number): number {
  for (let i = 0; i < EQUIPS.length; i++) {
    if (EQUIPS[i].id === id) {
      return EQUIPS[i].deposit
    }
  }
  return 0
}

gearPriceById 和 gearDepositById 分别根据装备 ID 查询租金和押金。它们和 activityTitleById 属于同一类函数,只是返回的字段不同。

在装备租借弹窗中,租金小计的计算需要用到装备的日租金,而弹窗组件只存储了装备 ID,因此需要通过 ID 查询价格。这种"ID 引用 + 查询函数"的模式是组件间数据传递的常见方式。

押金数据在租借和归还两个场景中都会用到。租借时显示押金金额提醒用户,归还时告知用户押金将原路退回。押金的存在是为了降低装备损坏或丢失的风险,是租赁业务的标准做法。

3.5 统计计算函数

function totalDives(): number {
  return LOGS.length
}

function avgDuration(): number {
  if (LOGS.length <= 0) {
    return 0
  }
  let t: number = 0
  for (let i = 0; i < LOGS.length; i++) {
    t += LOGS[i].duration
  }
  return Math.round(t / LOGS.length)
}

totalDives 函数返回总潜水次数,即日志数组的长度。avgDuration 函数计算平均潜水时长,它遍历所有日志,累加时长后除以日志数量,再四舍五入取整。

avgDuration 函数中有一个边界检查:当日志数量为 0 时,直接返回 0,避免除以零的错误。这是计算平均值类函数的标准做法。

这些统计函数是日志页面统计卡片的数据源。总潜数、累计深度、最大深度、平均时长,四个核心指标,全面反映了潜水员的经验水平。

function totalDepthSum(): number {
  let t: number = 0
  for (let i = 0; i < LOGS.length; i++) {
    t += LOGS[i].maxDepth
  }
  return Math.round(t)
}

function maxDepthAll(): number {
  let m: number = 0
  for (let i = 0; i < LOGS.length; i++) {
    if (LOGS[i].maxDepth > m) {
      m = LOGS[i].maxDepth
    }
  }
  return m
}

totalDepthSum 函数计算所有日志的最大深度之和,即累计深度。maxDepthAll 函数找出所有日志中的最大深度值。

累计深度是一个很有意思的指标。它不是一个标准的潜水统计量,但在应用中使用可以给用户一种"成就"感。想象一下,累计深度达到几百上千米,就像是在探索深海一样,很有仪式感。

maxDepthAll 函数使用了经典的最大值查找算法:初始化最大值为 0,遍历数组,如果当前元素大于最大值,则更新最大值。这个算法的时间复杂度是 O(n),对于小规模数据完全够用。

function recentLogs(): DiveLog[] {
  return LOGS.slice(0, 6)
}

function maxRecentDepth(): number {
  const list: DiveLog[] = recentLogs()
  let m: number = 0
  for (let i = 0; i < list.length; i++) {
    if (list[i].maxDepth > m) {
      m = list[i].maxDepth
    }
  }
  return m
}

recentLogs 函数返回最近的 6 条日志,用于柱状图的显示。maxRecentDepth 函数计算最近 6 条日志中的最大深度,用于柱状图的高度归一化。

为什么选择 6 条而不是 7 条或 10 条?这可能是基于屏幕宽度的考虑。柱状图的每根柱子需要一定的宽度才能显示清楚,6 根柱子在手机屏幕上正好合适,不会太拥挤也不会太稀疏。

高度归一化是柱状图的常见做法。所有柱子的高度按照最大值进行比例缩放,最高的柱子达到最大高度,其他柱子按比例缩小。这样可以充分利用图表空间,让数据对比更加明显。

function logBarH(depth: number, maxD: number): number {
  if (maxD <= 0) {
    return 4
  }
  let h: number = Math.round(depth / maxD * 90)
  if (h < 4) {
    h = 4
  }
  return h
}

logBarH 函数计算单根柱状图的高度。它接受深度值和最大深度,返回计算后的高度值。最大高度是 90(像素),最小高度是 4(像素)。

最小高度的设置是一个很重要的细节。如果没有最小高度,深度很浅的日志对应的柱子会几乎看不见,用户可能会误以为那里没有数据。设置一个最小高度(4 像素),即使深度很浅,也能看到一个小小的柱子,表示有数据存在。

90 像素的最大高度是根据图表区域的高度(130 像素)来设定的。柱子上方需要显示深度数值,下方需要显示日期,因此柱子本身的高度不能占满整个区域,留出空间给文字标签。

3.6 数据分组函数

function recommendCourses(): Course[] {
  return COURSES.slice(0, 3)
}

recommendCourses 函数返回前 3 门课程作为推荐课程。在首页的课程推荐区域,只展示 3 门课程,避免信息过载。

选择前 3 门作为推荐是一种简单的推荐策略。在实际项目中,推荐算法可能会复杂得多,比如基于用户历史、热门程度、个性化偏好等。但在原型阶段,简单的切片就足够了。

function equipLeft(): Equipment[] {
  const left: Equipment[] = []
  for (let i = 0; i < EQUIPS.length; i += 2) {
    left.push(EQUIPS[i])
  }
  return left
}

function equipRight(): Equipment[] {
  const right: Equipment[] = []
  for (let i = 1; i < EQUIPS.length; i += 2) {
    right.push(EQUIPS[i])
  }
  return right
}

equipLeft 和 equipRight 函数将装备数组分成左右两列。左列包含索引为偶数的装备(0, 2, 4…),右列包含索引为奇数的装备(1, 3, 5…)。

这种分法是为了实现双列网格布局。在鸿蒙的 Column + Row 布局体系中,实现双列网格的一种方式就是创建两个 Column,分别放入左列和右列的数据,然后用一个 Row 将它们并排显示。

为什么不直接使用 Grid 组件?可能是因为 Grid 组件在某些 API 版本中不可用,或者开发者更习惯用 Column + Row 的方式。双 Column 的方式虽然在数据处理上稍显麻烦,但布局更加灵活可控。


四、主入口组件:DiveApp 结构解析

4.1 Tab 枚举与组件声明

enum DiveTab {
  HOME = 0,
  COURSE = 1,
  SPOT = 2,
  GEAR = 3,
  LOG = 4,
  MINE = 5
}

DiveTab 枚举定义了六个 Tab 的编号。使用枚举而不是直接使用数字常量,可以提高代码的可读性和可维护性。

比如 this.activeTab === DiveTab.HOMEthis.activeTab === 0 清晰得多,读者一眼就能明白是在判断首页标签。而且如果未来需要调整 Tab 的顺序,只需要修改枚举的定义,不需要搜索替换所有的数字。

枚举值从 0 开始递增,这与数组索引的习惯一致。每个 Tab 都有一个语义化的名称:HOME(首页)、COURSE(课程)、SPOT(潜点)、GEAR(装备)、LOG(日志)、MINE(我的)。

@Entry
@Component
struct DiveApp {
  @State activeTab: number = 0

DiveApp 是应用的主入口组件,使用 @Entry 装饰器标记。@Entry 表示这是页面的入口组件,一个页面有且仅有一个 @Entry 组件。

@Component 装饰器标记这是一个自定义组件。在鸿蒙 ArkTS 中,所有的 UI 组件都使用 struct 定义,并用 @Component 装饰。struct 与 class 不同,它是值类型,性能更优。

@State 装饰器标记了 activeTab 状态变量。@State 是鸿蒙中最基础的状态管理装饰器,它标记的变量发生变化时,会触发组件的重新渲染。activeTab 的初始值是 0,也就是默认显示首页。

状态管理是声明式 UI 的核心。在传统的命令式 UI 中,我们需要手动获取控件并更新它的状态。而在声明式 UI 中,我们只需要修改状态变量,框架会自动计算差异并更新界面。

4.2 主体布局结构

build() {
    Column() {
      this.appHeader()
      Stack() {
        if (this.activeTab === DiveTab.HOME) {
          HomeTab()
        } else if (this.activeTab === DiveTab.COURSE) {
          CourseTab()
        } else if (this.activeTab === DiveTab.SPOT) {
          SpotTab()
        } else if (this.activeTab === DiveTab.GEAR) {
          GearTab()
        } else if (this.activeTab === DiveTab.LOG) {
          LogTab()
        } else {
          MineTab()
        }
      } .layoutWeight(1) .width('100%')

      Row() {
        this.tabItem('🏠', '首页', DiveTab.HOME)
        this.tabItem('📚', '课程', DiveTab.COURSE)
        this.tabItem('🏝️', '潜点', DiveTab.SPOT)
        this.tabItem('🤿', '装备', DiveTab.GEAR)
        this.tabItem('📓', '日志', DiveTab.LOG)
        this.tabItem('👤', '我的', DiveTab.MINE)
      } .width('100%') .height(56) .backgroundColor(SEA.cardDeep) .padding({ left: 4, right: 4 })
    } .width('100%') .height('100%') .backgroundColor(SEA.bg)
  }

build 方法是组件的核心,它描述了组件的 UI 结构。整个主页面采用了经典的"上中下"三段式布局:顶部头部、中间内容、底部导航。

最外层是一个 Column,纵向排列三个子元素:appHeader(头部)、Stack(内容区)、Row(底部导航)。Column 是鸿蒙中最常用的布局组件之一,它将子元素沿垂直方向排列。

内容区使用了 Stack 布局。Stack 的特点是子元素会堆叠在一起,后渲染的元素会覆盖在先渲染的元素之上。这里用 Stack 包裹 Tab 内容,是因为每个 Tab 的内容都是独立的组件,通过 if-else 判断显示哪个 Tab。虽然这里使用 if-else 每次只显示一个 Tab 组件,实际上并不会有堆叠效果,但 Stack 的布局方式让内容区可以充满剩余空间。

.layoutWeight(1) 是一个关键的属性,它让 Stack 占据剩余的所有空间。在 Column 中,使用 layoutWeight 可以实现"头部和底部固定高度,中间内容区自适应"的效果。这是移动端页面布局的经典模式。

底部导航是一个 Row,横向排列 6 个 tabItem。Row 的高度固定为 56 像素,这是移动端底部导航的标准高度。背景色使用 SEA.cardDeep,比页面背景更深,形成视觉下沉感。

在鸿蒙布局中,Column 和 Row 是最基础也是最重要的两个布局组件。Column 沿垂直方向排列子元素,Row 沿水平方向排列子元素。通过 Column 和 Row 的嵌套组合,可以构建出几乎所有常见的界面布局。

4.3 头部组件 appHeader

@Builder
appHeader() {
    Column() {
      Row() {
        Text('美人鱼潜水').fontSize(18).fontWeight(FontWeight.Bold).fontColor(SEA.text)
        Row() {
          Text('🔍').fontSize(12).fontColor(SEA.primary)
          Text('搜潜点 / 课程 / 装备').fontSize(11).fontColor(SEA.textSub).margin({ left: 5 })
          Column().layoutWeight(1)
          Text('⌕').fontSize(14).fontColor(SEA.primary)
        } .layoutWeight(1) .height(32) .backgroundColor(SEA.card) .borderRadius(16) .margin({ left: 10 })
        Text('✉️').fontSize(17).margin({ left: 10 })
        Text('📷').fontSize(17).margin({ left: 10 })
        Column() {
          Text('🎫').fontSize(16)
          Text('会员卡').fontSize(8).fontColor(SEA.text).margin({ top: 1 })
        } .alignItems(HorizontalAlign.Center) .margin({ left: 10 }) .onClick(() => {
          this.activeTab = DiveTab.MINE
        })
      } .width('100%') .padding({ left: 12, right: 12, top: 8, bottom: 6 })

      Row() {
        Text('🏄 出海船潜').fontSize(10).fontColor(SEA.yellow)
        Text('|').fontSize(10).fontColor(SEA.textSub).margin({ left: 8, right: 8 })
        Text('🤿 装备 8 折').fontSize(10).fontColor(SEA.primary)
        Text('|').fontSize(10).fontColor(SEA.textSub).margin({ left: 8, right: 8 })
        Text('🪪 考证直降 500').fontSize(10).fontColor(SEA.secondary)
        Text('|').fontSize(10).fontColor(SEA.textSub).margin({ left: 8, right: 8 })
        Text('🌊 日志免费记').fontSize(10).fontColor(SEA.primary)
      } .width('92%') .margin({ top: 6, bottom: 4 })
    } .width('100%') .backgroundColor(SEA.cardDeep) .borderRadius({ bottomLeft: 16, bottomRight: 16 }) .padding({ bottom: 10 })
  }

appHeader 是用 @Builder 装饰器定义的自定义构建函数。@Builder 是鸿蒙中非常实用的功能,它可以将一段 UI 代码封装成可复用的函数,避免代码重复。

头部包含两行内容:第一行是 Logo、搜索框、消息、相机、会员卡入口;第二行是一排活动标签。

第一行的布局很有代表性。最左边是 Logo 文字"美人鱼潜水",使用 18 号加粗字体。中间是搜索框,占据了剩余的大部分空间(layoutWeight(1))。搜索框内部也是一个 Row,左边有搜索图标和提示文字,右边有一个搜索图标,中间用 Column().layoutWeight(1) 撑开。

这种"左右固定、中间自适应"的布局技巧在鸿蒙开发中非常常用。通过 layoutWeight(1) 配合空的 Column 或 Row,可以灵活地控制元素之间的间距和对齐方式。

搜索框右边是三个图标按钮:消息(✉️)、相机(📷)、会员卡(🎫)。其中会员卡按钮比较特殊,它是一个 Column,包含图标和文字两部分。点击会员卡会跳转到"我的"Tab,通过修改 activeTab 状态实现。

第二行是活动标签栏,用竖线(|)分隔四个活动提示:出海船潜、装备8折、考证直降、日志免费记。每个标签使用不同的颜色,形成彩虹般的视觉效果。这些标签是吸引用户注意的重要入口。

头部的底部圆角设计很精致:borderRadius({ bottomLeft: 16, bottomRight: 16 }),只有底部有圆角,顶部是直角。这种设计让头部看起来像一个"卡片"从上方延伸下来,增加了层次感。

4.4 底部导航项 tabItem

@Builder
tabItem(icon: string, label: string, tab: number) {
    Column() {
      Text(icon).fontSize(19)
      Text(label).fontSize(10).fontColor(this.activeTab === tab ? SEA.primary : SEA.textSub) .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal).margin({ top: 2 })
      Text('●').fontSize(5).fontColor(this.activeTab === tab ? SEA.primary : 'rgba(0,0,0,0)').margin({ top: 2 })
    } .layoutWeight(1) .justifyContent(FlexAlign.Center) .onClick(() => {
      this.activeTab = tab
    })
  }

tabItem 函数构建单个底部导航项。它接受三个参数:图标(emoji)、标签文字、Tab 编号。

每个导航项是一个 Column,垂直排列三个元素:图标、文字标签、小圆点指示器。Column 使用 layoutWeight(1) 平均分配宽度,六个 Tab 平分底部导航栏的宽度。

justifyContent(FlexAlign.Center) 让子元素在垂直方向上居中对齐。FlexAlign 是 Flex 布局的对齐方式枚举,包括 Start、Center、End、SpaceBetween、SpaceAround、SpaceEvenly 等选项。

选中态和未选中态的区别体现在三个方面:文字颜色(选中是主色,未选中是次要文字色)、字重(选中是加粗,未选中是常规)、小圆点(选中是主色可见,未选中是全透明不可见)。

小圆点指示器是一个很精致的设计。它使用 ‘●’ 字符,通过颜色的透明度变化来显示/隐藏。未选中时颜色是 ‘rgba(0,0,0,0)’,也就是完全透明,虽然看不见,但元素仍然占据空间,保证了布局的一致性。

点击事件修改 activeTab 状态,触发界面重新渲染,实现 Tab 切换。这就是声明式 UI 的状态驱动思想:状态变化 → 界面自动更新。


五、首页 Tab:活动、课程、教练的三重奏

5.1 首页组件结构

@Component
struct HomeTab {
  @State showApply: boolean = false
  @State applyName: string = ''
  @State applyPhone: string = ''
  @State applyDate: string = '09月12日'
  @State applyPeople: number = 2
  @State needGear: boolean = true
  @State selActivity: number = 1

HomeTab 是首页组件,用 @Component 装饰器定义。它包含了多个 @State 状态变量,用于管理报名弹窗的显示状态和表单数据。

showApply 控制报名弹窗的显示与隐藏,初始值为 false(不显示)。applyName、applyPhone、applyDate、applyPeople、needGear 是报名表单的各个字段,都设置了合理的默认值。

selActivity 记录当前选中的活动 ID,用于在弹窗中显示对应的活动标题。当用户点击不同的活动卡片时,selActivity 会被更新为对应活动的 ID。

将弹窗状态和表单数据放在宿主组件中,而不是弹窗组件内部,是鸿蒙开发中的常见模式。这样做的好处是数据集中管理,父子组件之间不需要复杂的状态传递。

build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            // 横幅、活动列表、课程推荐、教练滚动
          }
        } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off) .width('100%') .layoutWeight(1)
      } .width('100%') .height('100%')

      if (this.showApply) {
        this.applyModal()
      }
    } .width('100%') .height('100%')
  }

首页的整体结构是一个 Stack,包含两层:底层是页面内容(Column + Scroll),顶层是弹窗(条件渲染)。

Stack 布局在这里发挥了关键作用。弹窗需要悬浮在页面内容之上,Stack 的堆叠特性正好满足这个需求。当 showApply 为 true 时,applyModal 会被渲染在 Stack 的顶层,覆盖在页面内容之上。

页面内容是一个 Column,内部包含一个 Scroll。Scroll 是可滚动容器,当内容超出屏幕高度时可以上下滚动。scrollable(ScrollDirection.Vertical) 设置滚动方向为垂直方向,scrollBar(BarState.Off) 隐藏滚动条,让界面更加简洁。

Scroll 内部是一个 Column,包含了首页的所有内容区块:活动横幅、出海活动列表、课程推荐、教练团队横向滚动。这些区块按照从上到下的顺序排列,形成一个信息流。

Scroll 组件是长列表页面的基础。在鸿蒙中,Scroll 不仅可以包裹 Column 实现垂直滚动,还可以包裹 Row 实现水平滚动。通过 scrollable 属性控制滚动方向,非常灵活。

5.2 活动横幅

Column() {
    Text('🌊 ' + ACTIVITIES[0].date + ' · 出海船潜').fontSize(11).fontColor('rgba(255,255,255,0.9)')
    Text(ACTIVITIES[0].title).fontSize(19).fontWeight(FontWeight.Bold).fontColor(SEA.white) .margin({ top: 6 }).width('100%')
    Text(ACTIVITIES[0].desc).fontSize(11).fontColor('rgba(255,255,255,0.85)') .width('100%').margin({ top: 6 })
    Row() {
      Text('📍 ' + ACTIVITIES[0].pickUp + ' · ' + ACTIVITIES[0].time).fontSize(10) .fontColor('rgba(255,255,255,0.85)')
      Column().layoutWeight(1)
      Text('仅剩 ' + ACTIVITIES[0].spotsLeft + ' 位').fontSize(10).fontColor(SEA.yellow)
    } .width('100%') .margin({ top: 10 })
    Row() {
      Text(formatPrice(ACTIVITIES[0].price) + '/人').fontSize(13).fontWeight(FontWeight.Bold) .fontColor(SEA.yellow)
      Text(ACTIVITIES[0].gearIncluded ? '✅ 含装备' : '⚠️ 不含装备').fontSize(9) .fontColor('rgba(255,255,255,0.9)').border({ width: 1, color: 'rgba(255,255,255,0.4)' }) .borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 8 })
      Column().layoutWeight(1)
      Text('立即报名 ›').fontSize(12).fontWeight(FontWeight.Bold).fontColor(SEA.cardDeep) .backgroundColor(SEA.yellow).borderRadius(14) .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    } .width('100%') .margin({ top: 10 })
} .width('100%') .padding(16) .linearGradient({ angle: 135, colors: [['#125E8A', 0], ['#0B3B5C', 1]] }) .borderRadius(16) .margin({ top: 12, left: 12, right: 12 }) .onClick(() => {
    this.selActivity = ACTIVITIES[0].id
    this.showApply = true
})

活动横幅是首页最醒目的区域,它展示了第一个出海活动的详细信息。整个横幅使用渐变色背景,圆角 16 像素,左右各 12 像素边距。

渐变色使用 linearGradient 方法设置,角度 135 度(从左上到右下),从 #125E8A(亮蓝)渐变到 #0B3B5C(深海蓝)。渐变背景比纯色背景更有视觉层次感,能够吸引用户的注意力。

横幅内容分为五行:

  1. 第一行:日期和活动类型,小号文字
  2. 第二行:活动标题,大号加粗白色文字,最醒目
  3. 第三行:活动描述,小号文字
  4. 第四行:集合地点、时间 + 剩余名额(左右对齐)
  5. 第五行:价格 + 装备标签 + 报名按钮(左右对齐)

第四行和第五行都使用了"左右两端对齐"的布局技巧:左边放一些内容,中间用 Column().layoutWeight(1) 撑开,右边放一些内容。这是 Row 布局中非常实用的技巧。

"仅剩 X 位"使用黄色高亮显示,制造稀缺感和紧迫感。价格也使用黄色,突出价格信息。"立即报名"按钮使用黄色背景深色文字,是整个横幅的视觉焦点和行动号召(CTA)。

装备标签使用了条件渲染:gearIncluded 为 true 时显示"✅ 含装备",为 false 时显示"⚠️ 不含装备"。三元运算符是 ArkTS 中条件渲染的常用方式。

点击整个横幅区域会打开报名弹窗,同时设置 selActivity 为第一个活动的 ID。这说明横幅和下面的活动列表点击后都打开同一个弹窗,只是选中的活动不同。

5.3 出海活动列表

Row() {
    Text('🏄 出海活动').fontSize(15).fontWeight(FontWeight.Bold).fontColor(SEA.text)
    Column().layoutWeight(1)
    Text('船潜 / 夜潜 / 沉船').fontSize(10).fontColor(SEA.textSub)
} .width('94%') .margin({ top: 14 })

ForEach(ACTIVITIES, (item: OceanActivity) => {
    Row() {
      Text(item.date).fontSize(10).fontColor(SEA.primary).backgroundColor(SEA.primarySoft) .borderRadius(6).padding({ left: 6, right: 6, top: 3, bottom: 3 })
      Column() {
        Text(item.title).fontSize(12).fontWeight(FontWeight.Medium).fontColor(SEA.text)
        Text(item.desc).fontSize(9).fontColor(SEA.textSub).margin({ top: 3 })
      } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 10 })
      Column() {
        Text(formatPrice(item.price)).fontSize(12).fontWeight(FontWeight.Bold) .fontColor(SEA.primary)
        Text('剩 ' + item.spotsLeft + ' 位').fontSize(9).fontColor(SEA.yellow).margin({ top: 2 })
      } .alignItems(HorizontalAlign.End)
    } .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 10 }) .onClick(() => {
      this.selActivity = item.id
      this.showApply = true
    })
}, (item: OceanActivity) => item.id.toString())

出海活动列表由一个标题栏和一个 ForEach 循环组成。标题栏左边是标题文字,右边是分类说明,同样使用了 layoutWeight(1) 的两端对齐技巧。

ForEach 是鸿蒙中用于列表渲染的核心组件。它接受三个参数:数据源数组、子组件生成函数、键值生成函数。ForEach 会遍历数组中的每个元素,为每个元素生成对应的 UI 组件。

ForEach 的第三个参数(键值生成函数)非常重要。它为每个列表项生成一个唯一的 key,帮助框架识别列表项的身份,优化渲染性能。如果不提供 key 生成函数,框架可能无法正确处理列表的增删操作。

每个活动卡片是一个 Row,包含三部分:

  1. 左边:日期标签,红色文字配红色背景
  2. 中间:活动标题和描述,占据剩余空间(layoutWeight(1))
  3. 右边:价格和剩余名额,右对齐

中间的 Column 使用了 alignItems(HorizontalAlign.Start),让文字左对齐。右边的 Column 使用了 alignItems(HorizontalAlign.End),让文字右对齐。alignItems 属性控制子元素在交叉轴上的对齐方式。

价格使用主色(珊瑚红)加粗显示,是卡片的视觉重点之一。剩余名额使用黄色,与横幅中的设计保持一致。

点击卡片会更新 selActivity 并打开报名弹窗。每个卡片点击时都会设置对应的活动 ID,这样弹窗中就能显示正确的活动信息。

5.4 课程推荐

Row() {
    Text('🏊 课程推荐').fontSize(15).fontWeight(FontWeight.Bold).fontColor(SEA.text)
    Column().layoutWeight(1)
    Text('考证季直降 ¥500').fontSize(10).fontColor(SEA.secondary)
} .width('94%') .margin({ top: 16 })

ForEach(recommendCourses(), (item: Course) => {
    Column() {
      Row() {
        Text(item.certOrg).fontSize(9).fontColor(SEA.primary).backgroundColor(SEA.primarySoft) .borderRadius(6).padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Text(item.level).fontSize(9).fontColor(levelColor(item.level)) .backgroundColor(levelBg(item.level)).borderRadius(6) .padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 6 })
      }
      Text(item.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(SEA.text) .width('100%').margin({ top: 8 })
      Text(item.startDate + ' 开班 · ' + item.totalLessons + ' 节课 · 通过率 ' + item.passRate + '%') .fontSize(10).fontColor(SEA.textSub).width('100%').margin({ top: 4 })
      Row() {
        Text(formatPrice(item.origPrice)).fontSize(10).fontColor(SEA.textSub) .decoration({ type: TextDecorationType.LineThrough })
        Text(formatPrice(item.price)).fontSize(16).fontWeight(FontWeight.Bold) .fontColor(SEA.primary).margin({ left: 6 })
        Column().layoutWeight(1)
        Text('去报名').fontSize(11).fontColor(SEA.cardDeep).backgroundColor(SEA.primary) .borderRadius(10).padding({ left: 12, right: 12, top: 5, bottom: 5 })
      } .width('100%') .margin({ top: 10 })
    } .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 10 })
}, (item: Course) => item.id.toString())

课程推荐区域展示 3 门推荐课程,使用 recommendCourses() 函数获取前 3 门课程。标题栏的右边文字"考证季直降 ¥500"使用 secondary 颜色(橙红色),与其他标题栏的设计略有不同,增加了一些变化。

每个课程卡片是一个 Column,包含四行内容:

  1. 标签行:发证机构 + 等级标签
  2. 课程名称:大号加粗文字
  3. 课程信息:开班日期、课时数、通过率
  4. 价格行:原价(删除线)+ 现价 + 报名按钮

标签行的两个标签各有不同的配色:发证机构用红色系,等级标签使用 levelColor 和 levelBg 函数根据等级动态计算颜色。这样用户一眼就能看出课程的等级。

价格行的设计是电商类产品的标准模式:原价用灰色删除线显示,现价用大号加粗红色显示。删除线效果通过 decoration({ type: TextDecorationType.LineThrough }) 实现。

"去报名"按钮使用红色背景深色文字,是卡片的主要行动按钮。不过在首页的课程推荐中,点击"去报名"按钮实际上没有绑定事件(没有 onClick),用户需要切换到课程 Tab 才能报名。这可能是一个设计上的简化。

5.5 教练团队横向滚动

Row() {
    Text('👨‍🏫 教练团队').fontSize(15).fontWeight(FontWeight.Bold).fontColor(SEA.text)
    Column().layoutWeight(1)
    Text(INSTRUCTORS.length + ' 名资深教练').fontSize(10).fontColor(SEA.textSub)
} .width('94%') .margin({ top: 16 })

Scroll() {
    Row() {
      ForEach(INSTRUCTORS, (item: Instructor) => {
        this.instructorCard(item)
      }, (item: Instructor) => item.id.toString())
    } .padding({ left: 8, right: 8 })
} .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .height(140) .width('100%')

教练团队区域使用了横向滚动的设计。标题栏右边显示教练总数,增加了专业感和可信度。

横向滚动通过 Scroll + Row 的组合实现。Scroll 设置 scrollable(ScrollDirection.Horizontal) 为水平滚动方向,scrollBar(BarState.Off) 隐藏滚动条。Scroll 内部是一个 Row,包含所有教练卡片。

横向滚动手势在移动端非常常见,用户可以左右滑动查看更多内容。这种设计的好处是不需要占用太多垂直空间,同时可以展示大量内容。教练卡片、商品推荐、图片轮播等场景都适合使用横向滚动。

Scroll 的高度设置为 140 像素,与教练卡片的高度(128 像素)加上一些边距相匹配。宽度设置为 100%,撑满整个屏幕宽度。

@Builder
instructorCard(item: Instructor) {
    Column() {
      Text(item.avatar).fontSize(28)
      Text(item.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor(SEA.text).margin({ top: 6 })
      Text(item.cert).fontSize(9).fontColor(SEA.yellow).margin({ top: 3 })
      Text(item.years + ' 年 · ' + item.dives + ' 潜').fontSize(9).fontColor(SEA.textSub).margin({ top: 3 })
      Text('⭐ ' + item.rating.toFixed(1)).fontSize(11).fontColor(SEA.yellow).margin({ top: 4 })
    } .width(128) .height(128) .backgroundColor(SEA.card) .borderRadius(14) .alignItems(HorizontalAlign.Center) .margin({ right: 10 })
  }

instructorCard 函数构建单个教练卡片。卡片是一个 128x128 像素的正方形,圆角 14 像素,背景色为卡片色。

卡片内容从上到下依次是:emoji 头像(28 号字体)、教练姓名(13 号中等字重)、教练证书(9 号黄色)、教龄和潜水次数(9 号灰色)、评分(11 号黄色)。

评分使用 item.rating.toFixed(1) 保留一位小数。toFixed 方法将数字转换为字符串并保留指定小数位数,是显示评分的常用方法。

卡片的 alignItems 设置为 HorizontalAlign.Center,所有内容水平居中。教练卡片右边有 10 像素的右边距,形成卡片之间的间隔。最后一张卡片的右边距虽然也存在,但因为是滚动列表的末尾,不会影响视觉效果。

5.6 报名弹窗

@Builder
modalOverlay(onClose: () => void) {
    Column() .width('100%') .height('100%') .backgroundColor('rgba(0,0,0,0.5)') .onClick(onClose)
}

modalOverlay 函数创建弹窗的遮罩层。它是一个全屏的 Column,背景色是半透明的黑色(50% 透明度)。点击遮罩层会触发 onClose 回调,关闭弹窗。

遮罩层有两个作用:一是视觉上突出弹窗内容,让用户的注意力集中在弹窗上;二是阻止用户操作弹窗下方的页面内容。半透明的黑色遮罩是移动端弹窗的标准设计。

onClose 参数是一个函数类型,这是 TypeScript/ArkTS 中的回调函数模式。调用者传入一个函数,遮罩层被点击时执行这个函数。这种设计让 modalOverlay 可以被复用,不同的弹窗可以传入不同的关闭逻辑。

@Builder
applyModal() {
    this.modalOverlay(() => {
      this.showApply = false
    })
    Column() {
      Text('🌊 出海活动报名').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text(activityTitleById(this.selActivity)).fontSize(11).fontColor(SEA.primary) .width('100%').margin({ top: 4 })
      // 表单字段...
      Row() {
        Text('取消').fontSize(13).fontColor(SEA.textSub).border({ width: 1, color: '#2E4A66' }) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14 }) .onClick(() => {
          this.showApply = false
        })
        Text('确认报名').fontSize(13).fontColor(SEA.cardDeep).backgroundColor(SEA.primary) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14, left: 12 }) .onClick(() => {
          this.showApply = false
        })
      } .width('100%')
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16) .constraintSize({ maxHeight: '80%' })
  }

applyModal 函数构建完整的报名弹窗。它由两部分组成:遮罩层和弹窗内容。

弹窗内容是一个 Column,使用 position 属性定位到屏幕中央偏上的位置(x: 10%, y: 18%)。zIndex(999) 确保弹窗显示在最顶层,不会被其他元素遮挡。

弹窗宽度是屏幕的 80%,这是移动端弹窗的常见宽度,既不会太宽显得突兀,也不会太窄影响内容展示。maxHeight 设置为 80%,防止内容过多时弹窗超出屏幕。

弹窗顶部是标题和活动名称。标题加粗 16 号字,活动名称用主色显示,让用户明确当前报名的是哪个活动。

表单部分包含多个字段:姓名(TextInput)、联系电话(TextInput)、活动日期(选项按钮)、出行人数(选项按钮)、装备需求(二选一按钮)。

TextInput 是鸿蒙中的文本输入组件。它的 placeholder 属性设置占位提示文字,onChange 回调在输入内容变化时触发,用于更新状态变量。

日期、人数、装备需求等选项使用 ForEach 渲染选项按钮。选中的选项用主色背景深色文字,未选中的用 chip 背景浅色文字。点击选项时更新对应的状态变量。

底部是两个按钮:取消和确认报名。取消按钮是描边样式(边框 + 透明背景),确认按钮是填充样式(红色背景 + 白色文字)。主按钮用填充样式,次按钮用描边样式,这是按钮设计的经典区分方式。

点击确认按钮后,弹窗关闭。在实际项目中,这里还应该有表单验证和提交数据到服务器的逻辑,但在原型中简化为直接关闭。


六、课程 Tab:考证课程与评价系统

6.1 课程组件概览

@Component
struct CourseTab {
  @State showApply: boolean = false
  @State showReview: boolean = false
  @State applyName: string = ''
  @State applyPhone: string = ''
  @State applyPeriod: string = '9月班'
  @State applyCoach: string = '阿豪'
  @State applyEmergency: string = ''
  @State reviewStars: number = 5
  @State reviewText: string = ''
  @State selCourse: number = 1

CourseTab 是课程页面组件。它比首页多了一个评价弹窗的状态(showReview),以及评价相关的状态变量(reviewStars、reviewText)。

课程报名表单也比活动报名多了几个字段:期望期数、指定教练、紧急联系人。这些额外字段反映了课程报名和活动报名的业务差异:课程需要选择期数和教练,而且潜水课程涉及安全问题,需要紧急联系人信息。

reviewStars 的默认值是 5,也就是默认满分。这是一种微妙的用户体验设计:默认给出最高评价,用户通常不会主动降低评分,从而提高整体评分水平。

build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            // 横幅 + 课程列表
          }
        } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off) .width('100%') .layoutWeight(1)
      } .width('100%') .height('100%')

      if (this.showApply) {
        this.courseApplyModal()
      }
      if (this.showReview) {
        this.reviewModal()
      }
    } .width('100%') .height('100%')
  }

课程页面的整体结构和首页类似:Stack 布局,底层是页面内容,顶层是弹窗。不同的是这里有两个弹窗:报名弹窗和评价弹窗。

两个弹窗都使用条件渲染,根据各自的状态变量决定是否显示。它们是互斥的吗?从代码来看不一定,但在实际使用中,用户不太可能同时打开两个弹窗。即使同时打开,后渲染的那个会显示在上面。

6.2 课程列表

ForEach(COURSES, (item: Course) => {
    Column() {
      Row() {
        Column() {
          Text(item.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(SEA.text)
          Text(item.level + ' · ' + item.certOrg + ' 认证').fontSize(10) .fontColor(levelColor(item.level)).margin({ top: 4 })
        } .alignItems(HorizontalAlign.Start) .layoutWeight(1)
        Column() {
          Text(formatPrice(item.origPrice)).fontSize(9).fontColor(SEA.textSub) .decoration({ type: TextDecorationType.LineThrough })
          Text(formatPrice(item.price)).fontSize(16).fontWeight(FontWeight.Bold) .fontColor(SEA.primary).margin({ top: 2 })
        } .alignItems(HorizontalAlign.End)
      } .width('100%')

      Row() {
        Text('🗓️ ' + item.startDate + ' 开班').fontSize(9).fontColor(SEA.textSub)
        Text('⏱️ ' + item.hours + ' 课时').fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Text('📖 理论 ' + item.theoryCount + ' 次').fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Text('🌊 开放水域 ' + item.openWaterCount + ' 潜').fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
      } .width('100%') .margin({ top: 8 })

      Row() {
        Text('课程进度').fontSize(9).fontColor(SEA.textSub)
        Column().layoutWeight(1)
        Text(item.doneLessons + '/' + item.totalLessons + ' 节课').fontSize(9).fontColor(SEA.primary)
      } .width('100%') .margin({ top: 8 })

      Row() {
        Column().width(courseProgressW(item.doneLessons, item.totalLessons)).height(6) .backgroundColor(SEA.primary).borderRadius(3)
        Column().layoutWeight(1).height(6).backgroundColor('rgba(255,255,255,0.12)').borderRadius(3)
      } .width('100%') .height(6) .margin({ top: 4 })

      Row() {
        Text('✅ 通过率 ' + item.passRate + '%').fontSize(9).fontColor(SEA.green)
        Column().layoutWeight(1)
        Text('报名').fontSize(10).fontColor(SEA.cardDeep).backgroundColor(SEA.primary) .borderRadius(9).padding({ left: 14, right: 14, top: 5, bottom: 5 }) .onClick(() => {
          this.selCourse = item.id
          this.showApply = true
        })
        Text('评价').fontSize(10).fontColor(SEA.text).border({ width: 1, color: '#2E4A66' }) .borderRadius(9).padding({ left: 14, right: 14, top: 5, bottom: 5 }).margin({ left: 8 }) .onClick(() => {
          this.selCourse = item.id
          this.reviewStars = 5
          this.reviewText = ''
          this.showReview = true
        })
      } .width('100%') .margin({ top: 10 })
    } .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 10 })
}, (item: Course) => item.id.toString())

课程列表是课程页面的核心内容,展示了所有 12 门课程。每个课程卡片是一个 Column,包含五行主要内容。

第一行是课程名称和价格,左右对齐。课程名称下方显示等级和发证机构,价格包含原价和现价。价格区域右对齐,突出价格信息。

第二行是课程的基本信息,包括开班日期、课时数、理论课次数、开放水域次数。这些信息用图标 + 文字的形式展示,每个信息之间有 8 像素的左边距。

第三行和第四行组成了进度条区域。第三行是进度文字,左边显示"课程进度",右边显示"X/Y 节课"。第四行是进度条本身,由两个 Column 组成:左边的 Column 宽度由 courseProgressW 函数计算,代表已完成的进度;右边的 Column 使用 layoutWeight(1) 占据剩余空间,代表未完成的进度。

进度条的实现方式非常巧妙:用两个相邻的 Column,分别设置不同的背景色和宽度,模拟进度条效果。已完成部分用主色填充,未完成部分用半透明白色(12% 不透明度)作为底色。高度只有 6 像素,圆角 3 像素,精致小巧。

第五行是通过率和操作按钮。通过率用绿色显示,增加用户信心。右边有两个按钮:"报名"是主按钮(红色填充),"评价"是次按钮(描边样式)。

点击"报名"按钮会设置 selCourse 并打开报名弹窗。点击"评价"按钮会重置评分和评价内容,然后打开评价弹窗。每次打开评价弹窗时重置评分和内容,确保用户看到的是一个干净的表单。

6.3 评价弹窗

@Builder
reviewModal() {
    this.modalOverlay(() => {
      this.showReview = false
    })
    Column() {
      Text('⭐ 课程评价').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text(courseNameById(this.selCourse)).fontSize(11).fontColor(SEA.primary).width('100%').margin({ top: 4 })
      Text('综合评分').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 12 })
      Row() {
        ForEach(STAR_INDEXES, (i: number) => {
          Text('★').fontSize(28).fontColor(starColor(i, this.reviewStars)).margin({ right: 8 }) .onClick(() => {
            this.reviewStars = i
          })
        }, (i: number) => i.toString())
      } .width('100%') .margin({ top: 6 })
      Text(ratingText(this.reviewStars)).fontSize(11).fontWeight(FontWeight.Bold) .fontColor(SEA.yellow).width('100%').margin({ top: 6 })
      Text('评价内容').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 10 })
      TextInput({ placeholder: '分享你的课程体验…' }) .fontSize(12).fontColor(SEA.text).backgroundColor(SEA.chip) .height(60).width('100%').margin({ top: 6 }) .onChange((v: string) => {
        this.reviewText = v
      })
      Text('完成评价可获得 100 积分 · 有助于新学员选课').fontSize(9) .fontColor(SEA.textSub).width('100%').margin({ top: 8 })
      // 底部按钮...
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16)
  }

评价弹窗是课程页面的特色功能。它包含星级评分和文字评价两部分,是用户反馈的重要入口。

星级评分使用 5 个星星字符(★),通过 ForEach 遍历 STAR_INDEXES 数组([1, 2, 3, 4, 5])生成。每个星星的颜色由 starColor 函数决定:索引小于等于当前评分的星星是黄色(点亮),否则是半透明白色(未点亮)。

点击星星时,reviewStars 被设置为对应的索引值,触发界面更新,星星的点亮状态随之变化。这就是声明式 UI 的典型交互模式:用户操作 → 状态更新 → 界面自动刷新。

评分下方有一行文字评价,由 ratingText 函数根据评分返回对应的文字描述(非常满意、满意、一般、不满意、非常不满意)。这行文字用黄色加粗显示,给用户即时的反馈。

评价内容输入框的高度是 60 像素,比普通输入框高一些,因为需要输入多行文字。虽然 TextInput 本身可能只支持单行输入,但增加高度可以让用户看到更多输入的内容。

输入框下方有一行提示文字:“完成评价可获得 100 积分 · 有助于新学员选课”。这是一种激励机制,用积分奖励鼓励用户填写评价。同时提到"有助于新学员选课",从利他的角度说服用户,提高评价的完成率。


七、潜点 Tab:深度数据可视化

7.1 潜点列表与深度条

ForEach(SPOTS, (item: DiveSpot) => {
    Column() {
      Row() {
        Column() {
          Text(item.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(SEA.text)
          Text(item.region + ' · ' + item.type + ' · 最佳 ' + item.bestSeason).fontSize(9) .fontColor(SEA.textSub).margin({ top: 3 })
        } .alignItems(HorizontalAlign.Start) .layoutWeight(1)
        Text('⭐ ' + item.rating.toFixed(1)).fontSize(11).fontWeight(FontWeight.Bold) .fontColor(ratingColor(item.rating))
      } .width('100%')

      Row() {
        Text('最大深度').fontSize(9).fontColor(SEA.textSub)
        Row() {
          Column().width(spotDepthW(item.maxDepth)).height(8) .backgroundColor(diffColor(item.difficulty)).borderRadius(4)
          Column().layoutWeight(1).height(8).backgroundColor('rgba(255,255,255,0.10)') .borderRadius(4)
        } .layoutWeight(1) .height(8) .margin({ left: 8 })
        Text(item.maxDepth + 'm').fontSize(10).fontWeight(FontWeight.Bold) .fontColor(SEA.primary).width(40).textAlign(TextAlign.End)
      } .width('100%') .margin({ top: 10 })

      Row() {
        Text('👁️ 能见度 ' + item.visibility + 'm').fontSize(9).fontColor(SEA.textSub)
        Text('🌊 水流 ' + item.current).fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Text('🌡️ ' + item.temp + '°C').fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Column().layoutWeight(1)
        Text(diffText(item.difficulty)).fontSize(9).fontColor(diffColor(item.difficulty)) .backgroundColor(diffColor(item.difficulty) + '22').borderRadius(8) .padding({ left: 8, right: 8, top: 2, bottom: 2 })
      } .width('100%') .margin({ top: 8 })

      Row() {
        Text('📋 安排计划').fontSize(10).fontColor(SEA.primary).border({ width: 1, color: '#14505C' }) .borderRadius(10).padding({ left: 12, right: 12, top: 5, bottom: 5 }) .onClick(() => {
          // 打开计划弹窗
        })
        Column().layoutWeight(1)
        Text('🗑️ 删除计划').fontSize(10).fontColor(SEA.warn).border({ width: 1, color: '#5A2A2E' }) .borderRadius(10).padding({ left: 12, right: 12, top: 5, bottom: 5 }) .onClick(() => {
          // 打开删除确认
        })
      } .width('100%') .margin({ top: 10 })
    } .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 10 })
}, (item: DiveSpot) => item.id.toString())

潜点列表是潜点页面的核心,每个潜点卡片展示了丰富的信息,包括名称、地区、类型、最佳季节、评分、深度、能见度、水流、水温、难度等。

潜点卡片的设计有一个亮点:深度数据可视化。第二行用一个横向的进度条来表示潜点的最大深度,进度条的颜色随难度等级变化(绿色/黄色/红色),进度条的长度表示深度大小(以 40 米为最大值)。

这种"颜色 + 长度"双重编码的可视化方式,让用户可以非常直观地理解潜点的深度和难度信息。看一眼进度条的长度和颜色,就能大致判断潜点的情况,比纯文字要高效得多。

深度行的布局结构是:左边是"最大深度"文字标签,中间是深度条(Row 嵌套两个 Column),右边是深度数值。中间的 Row 使用 layoutWeight(1) 占据剩余空间,右边的数值固定宽度 40 像素并右对齐。

难度标签使用了动态背景色:diffColor(item.difficulty) + '22'。这里的 ‘22’ 是十六进制的透明度值,大约 13% 的不透明度。也就是说,背景色是难度颜色的低透明度版本,文字是难度颜色本身,形成"同色系深浅搭配"的效果。

底部有两个操作按钮:“安排计划"和"删除计划”。安排计划是红色描边按钮,删除计划是红色警告色描边按钮。它们分别对应两个不同的弹窗:计划编辑弹窗和删除确认弹窗。

7.2 计划编辑弹窗

@Builder
planModal() {
    this.modalOverlay(() => {
      this.showPlan = false
    })
    Column() {
      Text('📋 编辑潜水计划').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text('安排你的下一次出海潜程').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 4 })
      Text('计划日期').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 12 })
      TextInput({ placeholder: '如 09月15日', text: this.planDate }) .fontSize(12).fontColor(SEA.text).backgroundColor(SEA.chip) .height(36).width('100%').margin({ top: 6 }) .onChange((v: string) => {
        this.planDate = v
      })
      Text('选择潜点').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 10 })
      Text(this.planSpot).fontSize(11).fontWeight(FontWeight.Medium).fontColor(SEA.primary) .width('100%').margin({ top: 4 })
      Row() {
        ForEach(SPOT_CHOICES, (s: string) => {
          Text(s).fontSize(10).fontColor(this.planSpot === s ? SEA.cardDeep : SEA.text) .backgroundColor(this.planSpot === s ? SEA.primary : SEA.chip).borderRadius(9) .padding({ left: 10, right: 10, top: 5, bottom: 5 }).margin({ right: 6, top: 6 }) .onClick(() => {
            this.planSpot = s
          })
        }, (s: string) => s)
      } .width('100%') .margin({ top: 2 })
      // 更多字段...
      Row() {
        Text('取消').fontSize(13).fontColor(SEA.textSub).border({ width: 1, color: '#2E4A66' }) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14 }) .onClick(() => {
          this.showPlan = false
        })
        Text('保存计划').fontSize(13).fontColor(SEA.cardDeep).backgroundColor(SEA.primary) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14, left: 12 }) .onClick(() => {
          this.showPlan = false
        })
      } .width('100%')
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16) .constraintSize({ maxHeight: '80%' })
  }

计划编辑弹窗用于创建或编辑潜水计划。它包含以下字段:计划日期、选择潜点、最大深度、计划时长、潜伴选择。

潜点选择的设计比较特别:上方显示当前选中的潜点名称(主色高亮),下方是一排选项按钮。由于潜点名称较长,选项按钮可能会换行(通过 marginTop 控制行间距)。这种多行标签选择器在选项较多时很实用。

TextInput 的 text 属性绑定了状态变量(如 text: this.planDate),这是受控组件的写法。输入框的值由状态变量控制,onChange 回调更新状态变量,形成"状态 → 视图 → 状态"的闭环。

计划时长有三个选项:45分钟、60分钟、90分钟。潜伴选择有三个选项:自己、教练带潜、潜伴同行。这些选项都是通过 ForEach 遍历常量数组生成的,保持了代码的一致性。

点击"保存计划"按钮后,弹窗关闭。在实际应用中,这里应该将数据保存到服务器或本地存储,但在原型中只是关闭弹窗。

7.3 删除确认弹窗

@Builder
deleteConfirmModal() {
    this.modalOverlay(() => {
      this.showDelete = false
    })
    Column() {
      Text('🗑️ 删除计划确认').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text('确认删除「' + spotNameById(this.delSpotId) + '」的潜水计划?') .fontSize(12).fontColor(SEA.text).width('100%').margin({ top: 12 })
      Text('删除后该计划记录不可恢复,请谨慎操作').fontSize(10) .fontColor(SEA.warn).width('100%').margin({ top: 6 })
      Row() {
        Text('取消').fontSize(13).fontColor(SEA.textSub).border({ width: 1, color: '#2E4A66' }) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14 }) .onClick(() => {
          this.showDelete = false
        })
        Text('确认删除').fontSize(13).fontColor(SEA.white).backgroundColor('#E53935') .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14, left: 12 }) .onClick(() => {
          this.showDelete = false
        })
      } .width('100%')
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16)
  }

删除确认弹窗是一种常见的二次确认交互。当用户执行不可逆的危险操作(如删除)时,弹出确认框,防止误操作。

弹窗内容包括:标题、确认信息、警告提示、取消和确认按钮。警告提示用 warn 颜色(亮红色)显示,强调操作的危险性和不可逆性。

"确认删除"按钮使用了纯红色背景(#E53935)和白色文字,比通常的主按钮颜色更红更亮。这是因为删除操作是危险操作,需要更强的视觉警示。使用更鲜艳的红色可以唤起用户的注意,让他们在点击前更加谨慎。

确认按钮的颜色没有使用 SEA.primary,而是直接使用了 #E53935。这说明在设计系统中,"危险操作"和"主操作"使用不同的红色:主操作使用珊瑚红(#FF6B6B),危险操作使用警示红(#E53935)。两者虽然都是红色,但语义完全不同。


八、装备 Tab:双列网格与租借系统

8.1 双列网格布局

Row() {
    Column() {
      ForEach(equipLeft(), (item: Equipment) => {
        this.gearCard(item)
      }, (item: Equipment) => item.id.toString())
    } .layoutWeight(1) .padding({ left: 6, right: 3 })
    Column() {
      ForEach(equipRight(), (item: Equipment) => {
        this.gearCard(item)
      }, (item: Equipment) => item.id.toString())
    } .layoutWeight(1) .padding({ left: 3, right: 6 })
} .width('100%') .alignItems(VerticalAlign.Top) .margin({ top: 2 })

装备页面使用双列网格布局展示所有装备。实现方式是创建两个 Column(左列和右列),用一个 Row 将它们并排显示。每列的宽度都是 layoutWeight(1),即两列等宽。

左列和右列的内边距有所不同:左列左 6 右 3,右列左 3 右 6。这样设计的目的是让两列之间的间距(3+3=6)和列与屏幕边缘的间距(6)保持一致,形成均匀的视觉节奏。

alignItems(VerticalAlign.Top) 是一个重要的属性。因为左右两列的卡片数量可能不同(18 个装备,左列 9 个右列 9 个,正好相等),设置顶部对齐可以确保两列从同一高度开始排列,不会出现底部对齐导致上方空白的情况。

这种双列布局的实现方式虽然简单直接,但也有局限性:如果需要调整列数(比如改成三列),就需要修改代码结构。在实际项目中,如果网格布局比较复杂,可以考虑使用 Grid 组件或更灵活的布局方案。

@Builder
gearCard(item: Equipment) {
    Column() {
      Text(item.category).fontSize(9).fontColor(SEA.primary).margin({ top: 12 })
      Text(item.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor(SEA.text).margin({ top: 4 })
      Text('尺码 ' + item.size).fontSize(9).fontColor(SEA.textSub).margin({ top: 4 })
      Text('库存 ' + item.stock + ' 件').fontSize(9).fontColor(SEA.textSub).margin({ top: 2 })
      Row() {
        Text(formatPrice(item.rentPrice) + '/天').fontSize(13).fontWeight(FontWeight.Bold) .fontColor(SEA.primary)
        Column().layoutWeight(1)
        Text('押金 ' + formatPrice(item.deposit)).fontSize(8).fontColor(SEA.textSub)
      } .width('100%') .margin({ top: 8 })
      Row() {
        Text(item.condition).fontSize(9).fontColor(conditionColor(item.conditionLevel)) .backgroundColor(conditionColor(item.conditionLevel) + '22').borderRadius(8) .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Column().layoutWeight(1)
        Text('租借').fontSize(10).fontColor(SEA.cardDeep).backgroundColor(SEA.primary) .borderRadius(9).padding({ left: 10, right: 10, top: 5, bottom: 5 }) .onClick(() => {
          // 打开租借弹窗
        })
        Text('归还').fontSize(9).fontColor(SEA.text).border({ width: 1, color: '#2E4A66' }) .borderRadius(8).padding({ left: 8, right: 8, top: 4, bottom: 4 }).margin({ left: 6 }) .onClick(() => {
          // 打开归还弹窗
        })
      } .width('100%') .margin({ top: 8 })
    } .width('100%') .height(196) .backgroundColor(SEA.card) .borderRadius(12) .alignItems(HorizontalAlign.Start) .padding({ left: 10, right: 10 }) .margin({ bottom: 10 })
  }

gearCard 函数构建单个装备卡片。卡片高度固定为 196 像素,是一个竖向的长方形。alignItems 设置为 HorizontalAlign.Start,内容左对齐。

卡片内容从上到下依次是:

  1. 分类(9号红色文字)
  2. 装备名称(13号中等字重)
  3. 尺码信息(9号灰色)
  4. 库存数量(9号灰色)
  5. 价格行:日租金 + 押金
  6. 操作行:成色标签 + 租借按钮 + 归还按钮

价格行的设计是左价格右押金。日租金用大号加粗红色显示,是卡片的核心信息。押金用小号灰色文字显示,作为补充信息。

操作行包含三个元素:成色标签、租借按钮、归还按钮。成色标签使用 conditionColor 函数动态计算颜色,背景色是同色系的低透明度版本。租借按钮是主按钮样式(红色填充),归还按钮是次按钮样式(描边)。

点击租借按钮时,会设置装备的 ID、名称等信息,然后打开租借弹窗。点击归还按钮时,设置装备 ID 和名称,打开归还弹窗。

8.2 租借弹窗

@Builder
rentModal() {
    this.modalOverlay(() => {
      this.showRent = false
    })
    Column() {
      Text('🤿 装备租借').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text(this.rentGearName).fontSize(11).fontColor(SEA.primary).width('100%').margin({ top: 4 })
      Text('选择尺码').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 12 })
      Row() {
        ForEach(SIZE_OPTIONS, (s: string) => {
          Text(s).fontSize(10).fontColor(this.rentSize === s ? SEA.cardDeep : SEA.text) .backgroundColor(this.rentSize === s ? SEA.primary : SEA.chip).borderRadius(9) .padding({ left: 14, right: 14, top: 5, bottom: 5 }).margin({ right: 6 }) .onClick(() => {
            this.rentSize = s
          })
        }, (s: string) => s)
      } .width('100%') .margin({ top: 6 })
      Text('租期天数').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 10 })
      Row() {
        ForEach(RENT_DAYS, (d: number) => {
          Text(d + ' 天').fontSize(10).fontColor(this.rentDays === d ? SEA.cardDeep : SEA.text) .backgroundColor(this.rentDays === d ? SEA.primary : SEA.chip).borderRadius(9) .padding({ left: 14, right: 14, top: 5, bottom: 5 }).margin({ right: 6 }) .onClick(() => {
            this.rentDays = d
          })
        }, (d: number) => d.toString())
      } .width('100%') .margin({ top: 6 })
      Row() {
        Text('租借数量').fontSize(12).fontColor(SEA.text)
        Column().layoutWeight(1)
        Text('−').fontSize(17).fontColor(SEA.textSub).backgroundColor(SEA.chip).borderRadius(8) .width(30).height(30).textAlign(TextAlign.Center) .onClick(() => {
          if (this.rentCount > 1) {
            this.rentCount -= 1
          }
        })
        Text(this.rentCount + ' 件').fontSize(13).fontColor(SEA.text).margin({ left: 10, right: 10 })
        Text('+').fontSize(17).fontColor(SEA.cardDeep).backgroundColor(SEA.primary).borderRadius(8) .width(30).height(30).textAlign(TextAlign.Center) .onClick(() => {
          if (this.rentCount < 5) {
            this.rentCount += 1
          }
        })
      } .width('100%') .margin({ top: 12 })
      // 配重选择 + 租金小计 + 按钮...
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16) .constraintSize({ maxHeight: '80%' })
  }

租借弹窗是装备页面的核心交互组件。它包含以下字段:选择尺码、租期天数、租借数量、是否需要配重。

尺码选项有 S、M、L、XL 四个,通过 ForEach 遍历 SIZE_OPTIONS 数组生成。选中的尺码用红色背景深色文字,未选中的用 chip 背景浅色文字。

租期天数有 1、3、7、15 天四个选项。这是一个精心设计的选项设置,覆盖了从短期到中期的租借需求。1 天适合单日体验,3 天适合周末行程,7 天适合一周假期,15 天适合较长的行程。

租借数量使用加减号按钮控制,而不是选项按钮。这是因为数量的可能取值较多(1-5 件),用加减按钮更节省空间。减号按钮是灰色背景,加号按钮是红色背景,视觉上有加号更突出的效果。

减号按钮有最小值限制:rentCount > 1 才能减,确保数量不会小于 1。加号按钮有最大值限制:rentCount < 5 才能加,最多 5 件。这些边界检查是交互设计的基本要求。

租金小计实时计算:日租金 × 天数 × 数量。计算结果用大号加粗红色显示,押金用小号灰色文字显示在旁边。用户调整任何参数,价格都会立即更新,这就是响应式界面的优势。

8.3 归还弹窗

@Builder
returnModal() {
    this.modalOverlay(() => {
      this.showReturn = false
    })
    Column() {
      Text('✅ 归还确认').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text('确认归还「' + this.returnGearName + '」?').fontSize(12) .fontColor(SEA.text).width('100%').margin({ top: 12 })
      Text('归还后押金 ' + formatPrice(gearDepositById(this.returnGearId)) + ' 将在 24 小时内原路退回') .fontSize(10).fontColor(SEA.textSub).width('100%').margin({ top: 6 })
      Text('请在门店营业时间内到店归还 · 支持同城取件').fontSize(10) .fontColor(SEA.warn).width('100%').margin({ top: 6 })
      Row() {
        Text('取消').fontSize(13).fontColor(SEA.textSub).border({ width: 1, color: '#2E4A66' }) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14 }) .onClick(() => {
          this.showReturn = false
        })
        Text('确认归还').fontSize(13).fontColor(SEA.cardDeep).backgroundColor(SEA.green) .borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 }).margin({ top: 14, left: 12 }) .onClick(() => {
          this.showReturn = false
        })
      } .width('100%')
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16)
  }

归还确认弹窗相对简单,主要是确认信息和两个按钮。但它的设计有几个值得关注的细节。

首先,确认按钮使用绿色而不是红色。归还操作是一个正面的、完成的动作,用绿色(成功色)比用红色(主操作色)更符合语义。绿色的确认按钮给用户一种"完成了"的安心感。

其次,弹窗中有两行提示文字:一行是灰色的押金退还说明,另一行是黄色的营业时间提醒。押金退还是用户关心的核心问题(钱什么时候退回来),营业时间提醒是重要的操作指引(什么时候能还)。

押金退还说明使用"24 小时内原路退回"的表述,给出了明确的时间预期。这种明确的时间承诺比"尽快退回"之类的模糊表述更能建立用户信任。

营业时间提醒使用 warn 颜色(黄色),强调这是需要注意的事项。支持同城取件的服务承诺也增加了便利性的感知。


九、日志 Tab:数据统计与 CRUD 操作

9.1 统计卡片与柱状图

Row() {
    this.statCard('总潜数', totalDives().toString(), '次')
    this.statCard('累计深度', totalDepthSum().toString(), 'm')
    this.statCard('最大深度', maxDepthAll().toString(), 'm')
} .width('94%') .margin({ top: 12 })

日志页面顶部是三个统计卡片,横向排列在一个 Row 中。三个卡片分别展示总潜数、累计深度、最大深度三个核心指标。

统计卡片的设计非常简洁:上方是指标名称(小号灰色),下方是数值 + 单位(大号红色 + 小号灰色)。数值是卡片的视觉重点,用最大号的字体和主色显示。

三个卡片使用 layoutWeight(1) 平分宽度,每个卡片左右各有 4 像素的边距,形成均匀的三列布局。

@Builder
statCard(title: string, value: string, unit: string) {
    Column() {
      Text(title).fontSize(10).fontColor(SEA.textSub)
      Row() {
        Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(SEA.primary)
        Text(unit).fontSize(10).fontColor(SEA.textSub).margin({ left: 2 })
      } .margin({ top: 6 })
    } .layoutWeight(1) .height(62) .backgroundColor(SEA.card) .borderRadius(12) .alignItems(HorizontalAlign.Center) .margin({ left: 4, right: 4 })
  }

statCard 函数构建单个统计卡片。它接受三个参数:标题、数值、单位。卡片高度 62 像素,圆角 12 像素,背景色为卡片色。

数值和单位放在同一个 Row 中,数值用 18 号加粗红色,单位用 10 号灰色,两者底部对齐。这种"数值 + 单位"的排版方式在数据展示中非常常见,数值是主体,单位是补充。

卡片使用 alignItems(HorizontalAlign.Center) 让内容水平居中,符合统计卡片的常规设计。

Column() {
    Text('📊 近 6 次潜水深度').fontSize(13).fontWeight(FontWeight.Bold) .fontColor(SEA.text).width('100%')
    Text('最近记录最深 ' + maxRecentDepth() + 'm · 柱高按最大值归一').fontSize(9) .fontColor(SEA.textSub).width('100%').margin({ top: 2 })
    Row() {
      ForEach(recentLogs(), (item: DiveLog) => {
        Column() {
          Text(item.maxDepth + 'm').fontSize(8).fontColor(SEA.textSub)
          Column().width(16).height(logBarH(item.maxDepth, maxRecentDepth())) .backgroundColor(barColor(item.id)).borderRadius(6).margin({ top: 4 })
          Text(item.date.slice(3, 5)).fontSize(8).fontColor(SEA.textSub).margin({ top: 4 })
        } .width('16.6%') .height(118) .justifyContent(FlexAlign.End) .alignItems(HorizontalAlign.Center)
      }, (item: DiveLog) => item.id.toString())
    } .width('100%') .height(130) .alignItems(VerticalAlign.Bottom) .margin({ top: 8 })
} .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 12 })

柱状图是日志页面最有特色的部分。它展示了最近 6 次潜水的深度数据,用柱状图的形式直观地呈现深度变化趋势。

每根柱子是一个 Column,宽度占 16.6%(约 1/6),高度 118 像素。Column 使用 justifyContent(FlexAlign.End) 让内容从底部开始排列,这样柱子就会"向上生长"。

柱子本身是一个 Column,宽度 16 像素,高度由 logBarH 函数计算得出。柱子颜色由 barColor 函数根据 ID 返回不同的颜色,形成彩虹般的彩色柱状图。

barColor 函数使用 ID 对 6 取模的方式,从 6 种颜色的数组中选择颜色。这样每个柱子都有不同的颜色,增加了图表的视觉丰富度。虽然颜色本身没有特定的含义(只是区分不同柱子),但彩色图表比单色图表更吸引人。

柱子上方显示深度数值,下方显示日期(只显示日期中的日数字,通过 slice(3, 5) 截取)。日期格式是"08月24日",slice(3,5) 会得到"24",也就是日期的日部分。这是一种简单的日期格式化方式。

柱状图的外层 Row 设置了 alignItems(VerticalAlign.Bottom),确保所有柱子底部对齐。这是柱状图的关键布局属性,如果没有这个设置,柱子可能会顶部对齐或居中,就不像柱状图了。

9.2 日志列表

ForEach(LOGS, (item: DiveLog) => {
    Column() {
      Row() {
        Column() {
          Text(item.date + ' · ' + item.weather).fontSize(12) .fontWeight(FontWeight.Medium).fontColor(SEA.text)
          Text(item.spot).fontSize(10).fontColor(SEA.textSub).margin({ top: 3 })
        } .alignItems(HorizontalAlign.Start) .layoutWeight(1)
        Text(item.maxDepth + 'm').fontSize(15).fontWeight(FontWeight.Bold) .fontColor(SEA.primary)
      } .width('100%')
      Divider().color('rgba(255,255,255,0.10)').margin({ top: 8, bottom: 8 })
      Row() {
        Text('⏱️ ' + item.duration + '分钟').fontSize(9).fontColor(SEA.textSub)
        Text('🫧 ' + item.tank).fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Text('🌡️ ' + item.waterTemp + '°C').fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Text('👁️ ' + item.visibility + 'm').fontSize(9).fontColor(SEA.textSub).margin({ left: 8 })
        Column().layoutWeight(1)
      } .width('100%')
      Text('📝 ' + item.note).fontSize(9).fontColor(SEA.textSub) .width('100%').margin({ top: 6 }).maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis })
      Row() {
        Text('👤 潜伴:' + item.buddy).fontSize(9).fontColor(SEA.green)
        Column().layoutWeight(1)
        Text('编辑').fontSize(9).fontColor(SEA.primary).border({ width: 1, color: '#14505C' }) .borderRadius(8).padding({ left: 10, right: 10, top: 4, bottom: 4 }) .onClick(() => {
          this.openEditLog(item)
        })
        Text('删除').fontSize(9).fontColor(SEA.warn).border({ width: 1, color: '#5A2A2E' }) .borderRadius(8).padding({ left: 10, right: 10, top: 4, bottom: 4 }).margin({ left: 8 }) .onClick(() => {
          this.setDeleteLog(item.id)
        })
      } .width('100%') .margin({ top: 8 })
    } .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 10 })
}, (item: DiveLog) => item.id.toString())

日志列表展示了所有潜水日志的详细信息。每个日志卡片的信息密度很高,包含了日期、天气、潜点、深度、时长、气瓶、水温、能见度、备注、潜伴等多个字段。

卡片的第一行是头部信息:左边是日期和天气 + 潜点,右边是最大深度(大号加粗红色)。深度数据在日志中是核心指标,因此用最大号的字体和最醒目的颜色显示。

第一行和第二行之间有一条分隔线(Divider)。Divider 是鸿蒙中的分割线组件,它的 color 属性设置颜色。这里使用了半透明白色(10% 不透明度),形成一条淡淡的分割线,既分隔了内容,又不会太突兀。

第二行是潜水数据行,包括时长、气瓶、水温、能见度四个指标,每个指标都有对应的 emoji 图标。这些图标增加了视觉趣味,也帮助用户快速识别不同类型的信息。

第三行是备注信息。备注使用 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 限制只显示一行,超出部分用省略号表示。这样可以保持卡片高度的一致性,不会因为备注太长而导致卡片高度参差不齐。

第四行是潜伴和操作按钮。潜伴信息用绿色显示,是日志中的重要社交元素。右边有两个操作按钮:编辑(红色描边)和删除(红色警告描边)。

点击编辑按钮会调用 openEditLog 方法,将当前日志的数据填充到编辑表单中,然后打开编辑弹窗。点击删除按钮会调用 setDeleteLog 方法,记录要删除的日志 ID,然后打开删除确认弹窗。

9.3 日志的增删改方法

openEditLog(item: DiveLog): void {
    this.editId = item.id
    this.editDate = item.date
    this.editSpot = item.spot
    this.editDepth = item.maxDepth.toString()
    this.editDuration = item.duration + '分钟'
    this.editTank = item.tank
    this.editTemp = item.waterTemp.toString()
    this.editNote = item.note
    this.showEdit = true
  }

  setDeleteLog(id: number): void {
    this.delLogId = id
    this.showDelete = true
  }

openEditLog 和 setDeleteLog 是两个普通的成员方法(不是 @Builder),它们用于处理用户操作后的状态更新。

openEditLog 方法接受一个 DiveLog 对象作为参数,将日志的各个字段赋值给编辑表单的状态变量,然后设置 showEdit 为 true 打开编辑弹窗。

注意数据类型的转换:maxDepth 是 number 类型,需要转换为 string 才能赋值给 editDepth(TextInput 的值是 string)。duration 也是 number 类型,需要拼接"分钟"字符串才能匹配 editDuration 的格式。

这些数据转换工作在打开编辑弹窗时完成,确保表单显示的格式与用户输入的格式一致。如果不做转换,直接将数字赋值给字符串变量,可能会导致显示异常。

setDeleteLog 方法更简单,只需要记录要删除的日志 ID,然后打开删除确认弹窗。删除操作只需要 ID 就足够了,不需要其他信息。

将操作逻辑封装成方法而不是直接写在 onClick 回调中,是一种良好的代码组织方式。它有几个好处:一是回调函数更简洁,二是逻辑可以复用,三是更容易测试和维护。


十、我的 Tab:会员体系与订单管理

10.1 会员卡横幅

Column() {
    Row() {
      Text('🐬 美人鱼潜水会员卡').fontSize(15).fontWeight(FontWeight.Bold).fontColor(SEA.white)
      Text('SVIP').fontSize(9).fontColor(SEA.cardDeep).backgroundColor(SEA.yellow) .borderRadius(6).padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 8 })
    } .width('100%')
    Text('¥1,280.00').fontSize(26).fontWeight(FontWeight.Bold) .fontColor(SEA.white).width('100%').margin({ top: 12 })
    Text('账户余额 · 会员日潜水装备 8.8 折').fontSize(9) .fontColor('rgba(255,255,255,0.8)').width('100%').margin({ top: 2 })
    Row() {
      Text('🎁 积分 2,150').fontSize(9).fontColor('rgba(255,255,255,0.9)')
      Text('🛡️ 潜水保险在保').fontSize(9).fontColor('rgba(255,255,255,0.9)').margin({ left: 12 })
      Column().layoutWeight(1)
      Text('立即充值 ›').fontSize(11).fontWeight(FontWeight.Bold).fontColor(SEA.cardDeep) .backgroundColor(SEA.yellow).borderRadius(12) .padding({ left: 14, right: 14, top: 6, bottom: 6 }) .onClick(() => {
        this.rechargeId = 2
        this.rechargePay = '微信支付'
        this.showRecharge = true
      })
    } .width('100%') .margin({ top: 14 })
} .width('100%') .padding(16) .linearGradient({ angle: 135, colors: [['#FF6B6B', 0], ['#B84A5A', 1]] }) .borderRadius(16) .margin({ top: 12, left: 12, right: 12 })

"我的"页面顶部是一张会员卡横幅,采用珊瑚红渐变背景(从 #FF6B6B 到 #B84A5A),与其他页面的蓝色渐变横幅形成鲜明对比,突出会员专属感。

会员卡的信息层次非常清晰:

  1. 第一行:会员卡名称 + SVIP 标签
  2. 第二行:账户余额(最大号字体,最醒目)
  3. 第三行:余额说明 + 会员权益提示
  4. 第四行:积分 + 保险状态 + 充值按钮

余额是会员卡的核心信息,用 26 号加粗白色大字显示,占据视觉中心位置。SVIP 标签使用黄色背景深色文字,是会员身份的象征。

积分和潜水保险是会员的两个重要权益。积分可以兑换服务或商品,潜水保险是潜水活动的必要保障。将这两个信息放在显眼位置,可以增强会员的价值感知。

"立即充值"按钮使用黄色背景深色文字,是卡片上的主要行动号召。点击按钮会打开充值弹窗,默认选中 200 元档位(rechargeId = 2)和微信支付。

10.2 证书进阶路线

ForEach(CERTS, (item: CertLevel) => {
    Column() {
      Row() {
        Text(item.icon).fontSize(24)
        Column() {
          Text(item.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(SEA.text)
          Text(item.target + ' · ' + item.level).fontSize(9) .fontColor(SEA.textSub).margin({ top: 2 })
        } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 10 })
        Text(item.status).fontSize(9).fontColor(certStatusColor(item.status)) .backgroundColor(certStatusBg(item.status)).borderRadius(8) .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      } .width('100%')

      Row() {
        Column().width(courseProgressW(item.lessonsDone, item.lessonsTotal)).height(6) .backgroundColor(certStatusColor(item.status)).borderRadius(3)
        Column().layoutWeight(1).height(6).backgroundColor('rgba(255,255,255,0.12)') .borderRadius(3)
      } .width('100%') .height(6) .margin({ top: 10 })

      Row() {
        Text(item.lessonsDone + '/' + item.lessonsTotal + ' 节完成').fontSize(9) .fontColor(SEA.textSub)
        Text('进度 ' + courseProgressW(item.lessonsDone, item.lessonsTotal)).fontSize(9) .fontColor(SEA.primary).margin({ left: 8 })
        Column().layoutWeight(1)
        Text('📅 ' + item.date).fontSize(9).fontColor(SEA.textSub)
      } .width('100%') .margin({ top: 6 })
    } .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .padding(12) .margin({ top: 10 })
}, (item: CertLevel) => item.id.toString())

证书进阶路线展示了用户的潜水考证历程,从 OW 到 AOW 到救援到 DM,形成一条清晰的成长路径。

每个证书卡片包含三部分:头部信息(图标 + 名称 + 状态)、进度条、底部信息(课时完成数 + 进度百分比 + 日期)。

图标使用 24 号 emoji,不同等级有不同的图标:🏅(OW)、🥈(AOW)、🛟(救援)、🗺️(DM)。这些图标不仅是装饰,也帮助用户快速识别不同的证书等级。

状态标签使用 certStatusColor 和 certStatusBg 函数动态计算颜色。已获得是绿色,学习中是黄色,计划中是红色。进度条的颜色也与状态颜色保持一致,形成统一的视觉语言。

进度条下方的文字信息提供了更精确的数据:已完成/总课时数、进度百分比、目标日期。这些信息让用户清楚地知道自己的学习进度和下一步目标。

日期信息显示了证书获得的时间或计划完成的时间。已获得的证书显示获得日期,学习中的显示预计完成日期,计划中的显示计划开始日期。这为用户的学习规划提供了时间参考。

10.3 订单列表

Column() {
    ForEach(ORDERS, (item: OrderInfo) => {
      Column() {
        Row() {
          Text(item.title).fontSize(12).fontWeight(FontWeight.Medium).fontColor(SEA.text) .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(item.status).fontSize(9).fontColor(orderStatusColor(item.status))
        } .width('100%')
        Row() {
          Text(item.date + ' · ' + item.orderNo).fontSize(9).fontColor(SEA.textSub)
          Column().layoutWeight(1)
          Text(formatPrice(item.amount)).fontSize(13).fontWeight(FontWeight.Bold) .fontColor(SEA.primary)
        } .width('100%') .margin({ top: 6 })
      } .width('100%') .padding({ left: 12, right: 12, top: 10, bottom: 10 }) .onClick(() => {
        this.openOrder(item)
      })
      Divider().color('rgba(255,255,255,0.08)')
    }, (item: OrderInfo) => item.id.toString())
} .width('94%') .backgroundColor(SEA.card) .borderRadius(12) .margin({ top: 10 })

订单列表展示了用户的历史订单。与其他列表不同,订单列表的卡片之间没有间距,而是用分隔线(Divider)分隔,形成一个整体的列表容器。

每个订单项包含两行:第一行是订单标题 + 状态,第二行是日期/订单号 + 金额。标题使用 maxLines(1) 和 textOverflow 限制单行显示,超出部分省略。

订单状态使用 orderStatusColor 函数动态计算颜色:已完成是绿色,进行中是黄色,待出行是红色。颜色编码让用户可以快速识别订单状态。

点击订单项会调用 openOrder 方法,将订单数据填充到订单详情的状态变量中,然后打开订单详情弹窗。这是一种常见的"列表 → 详情"交互模式。

openOrder(item: OrderInfo): void {
    this.orderTitle = item.title
    this.orderNo = item.orderNo
    this.orderDate = item.date
    this.orderAmount = item.amount
    this.orderStatus = item.status
    this.orderItems = item.items
    this.orderPay = item.payMethod
    this.showOrder = true
  }

openOrder 方法将订单对象的各个字段赋值给组件的状态变量,然后打开订单详情弹窗。这种"数据传递"方式是组件间通信的常见模式。

10.4 充值弹窗

@Builder
rechargeModal() {
    this.modalOverlay(() => {
      this.showRecharge = false
    })
    Column() {
      Text('💰 会员卡充值').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text('当前余额 ¥1,280 · 充值多送多').fontSize(10).fontColor(SEA.textSub) .width('100%').margin({ top: 4 })
      ForEach(RECHARGES, (item: RechargeOption) => {
        Row() {
          Text(item.icon).fontSize(18)
          Column() {
            Text(formatPrice(item.amount) + ' 档').fontSize(12).fontWeight(FontWeight.Medium) .fontColor(SEA.text)
            Text(item.desc + ' · 赠 ' + item.bonus).fontSize(9) .fontColor(SEA.textSub).margin({ top: 2 })
          } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 10 })
          Text(item.tag).fontSize(8).fontColor(item.hot ? SEA.cardDeep : SEA.textSub) .backgroundColor(item.hot ? SEA.yellow : SEA.chip).borderRadius(6) .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          Text(this.rechargeId === item.id ? '✓' : '○').fontSize(13) .fontColor(this.rechargeId === item.id ? SEA.primary : SEA.textSub).margin({ left: 8 })
        } .width('100%') .borderRadius(10) .border({ width: 1, color: this.rechargeId === item.id ? SEA.primary : 'rgba(255,255,255,0.10)' }) .padding(10) .margin({ top: 8 }) .onClick(() => {
          this.rechargeId = item.id
        })
      }, (item: RechargeOption) => item.id.toString())
      // 支付方式 + 底部按钮...
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16) .constraintSize({ maxHeight: '80%' })
  }

充值弹窗是会员系统的重要功能。它的设计有几个亮点:

首先,充值档位使用列表式布局,每个档位是一个带边框的 Row。选中的档位边框会变成主色(红色),未选中的是半透明白色边框。边框颜色的变化比背景色的变化更加微妙和精致。

每个档位的内容包括:图标、档位名称、到账说明、赠送金额、标签、选中指示器。图标使用 emoji,增加了视觉趣味。档位名称和到账说明形成主次信息层次,用户一眼就能看到充多少钱、到账多少。

标签(tag)字段有不同的样式:热门标签用黄色背景深色文字,非常醒目;其他标签用 chip 背景灰色文字。"热门"标签是一种营销手段,引导用户选择推荐的档位。

选中状态的指示器使用了字符:选中是 ✓(对勾),未选中是 ○(圆圈)。这种设计比单纯的颜色变化更明确,用户可以清晰地看到自己选中了哪个档位。

Text('支付方式').fontSize(11).fontColor(SEA.textSub).width('100%').margin({ top: 10 })
Row() {
    ForEach(PAY_OPTIONS, (p: string) => {
        Text(p).fontSize(10).fontColor(this.rechargePay === p ? SEA.cardDeep : SEA.text) .backgroundColor(this.rechargePay === p ? SEA.primary : SEA.chip).borderRadius(9) .padding({ left: 12, right: 12, top: 5, bottom: 5 }).margin({ right: 6 }) .onClick(() => {
            this.rechargePay = p
        })
    }, (p: string) => p)
} .width('100%') .margin({ top: 6 })

支付方式选择是充值弹窗中的另一个重要部分。目前支持两种支付方式:微信支付和支付宝。这两种支付方式在国内市场占据主导地位,覆盖了绝大多数用户的支付需求。

支付方式的选择器使用了与其他选项相同的设计模式:选中的是红色背景深色文字,未选中的是 chip 背景浅色文字。这种一致性的设计让用户可以快速理解交互方式。

底部的按钮行包含三个元素:到账金额、取消按钮、确认充值按钮。到账金额实时计算(充值金额 + 赠送金额),用大号加粗红色显示,让用户清楚地知道最终能获得多少。

到账金额放在左边,按钮放在右边,这种布局方式与其他弹窗的底部按钮行略有不同。其他弹窗的按钮是左右两端对齐(取消在左,确认在右),而充值弹窗在左边增加了到账金额信息,取消按钮移到了靠右的位置。

10.5 订单详情弹窗

@Builder
orderDetailModal() {
    this.modalOverlay(() => {
      this.showOrder = false
    })
    Column() {
      Text('📦 订单详情').fontSize(16).fontWeight(FontWeight.Bold).fontColor(SEA.text)
      Text(this.orderNo).fontSize(9).fontColor(SEA.textSub).width('100%').margin({ top: 4 })
      Column() {
        Row() {
          Text('订单名称').fontSize(11).fontColor(SEA.textSub).width(70)
          Text(this.orderTitle).fontSize(11).fontColor(SEA.text).layoutWeight(1).textAlign(TextAlign.End)
        } .width('100%') .margin({ top: 8 })
        Row() {
          Text('下单日期').fontSize(11).fontColor(SEA.textSub).width(70)
          Text(this.orderDate).fontSize(11).fontColor(SEA.text).layoutWeight(1).textAlign(TextAlign.End)
        } .width('100%') .margin({ top: 8 })
        Row() {
          Text('订单金额').fontSize(11).fontColor(SEA.textSub).width(70)
          Text(formatPrice(this.orderAmount)).fontSize(13).fontWeight(FontWeight.Bold) .fontColor(SEA.primary).layoutWeight(1).textAlign(TextAlign.End)
        } .width('100%') .margin({ top: 8 })
        Row() {
          Text('商品明细').fontSize(11).fontColor(SEA.textSub).width(70)
          Text(this.orderItems).fontSize(11).fontColor(SEA.text).layoutWeight(1).textAlign(TextAlign.End)
        } .width('100%') .margin({ top: 8 })
        Row() {
          Text('支付方式').fontSize(11).fontColor(SEA.textSub).width(70)
          Text(this.orderPay).fontSize(11).fontColor(SEA.text).layoutWeight(1).textAlign(TextAlign.End)
        } .width('100%') .margin({ top: 8 })
        Row() {
          Text('订单状态').fontSize(11).fontColor(SEA.textSub).width(70)
          Text(this.orderStatus).fontSize(11).fontColor(orderStatusColor(this.orderStatus)) .layoutWeight(1).textAlign(TextAlign.End)
        } .width('100%') .margin({ top: 8 })
      } .width('100%') .backgroundColor(SEA.chip) .borderRadius(10) .padding(10) .margin({ top: 12 })
      // 底部按钮...
    } .position({ x: '10%', y: '18%' }) .zIndex(999) .width('80%') .backgroundColor(SEA.card) .borderRadius(16) .padding(16)
  }

订单详情弹窗展示了订单的完整信息。它的布局采用了"标签 + 值"的键值对形式,每行左边是标签(固定宽度 70 像素),右边是值(右对齐)。

这种键值对布局是详情页的经典设计模式。标签使用灰色,值使用白色或彩色,形成清晰的视觉层次。订单金额使用红色加粗显示,是详情页的重点信息。

订单状态使用 orderStatusColor 函数动态着色,与订单列表中的颜色保持一致。绿色表示已完成,黄色表示进行中,红色表示待出行。颜色编码系统贯穿整个应用,用户无需阅读文字就能快速理解状态含义。

所有的详情字段都放在一个带背景的容器中,背景色使用 SEA.chip,比卡片背景稍亮一些。这个容器将详情信息聚合在一起,形成一个信息区块,与弹窗标题和底部按钮区分开来。

底部按钮是"关闭"和"再次预约"。关闭按钮是描边样式,再次预约是填充样式。"再次预约"按钮为用户提供了便捷的复购入口,点击后可以快速重新下单。这是一种提升转化率的常用设计手法。

订单详情的键值对布局中,layoutWeight(1)textAlign(TextAlign.End) 的组合值得注意。layoutWeight 让值的区域占据剩余空间,textAlign(End) 让文字右对齐,两者配合实现了"标签左对齐、值右对齐"的经典表单布局。


十一、鸿蒙核心技术点深度总结

11.1 声明式 UI 与状态管理

这款潜水俱乐部应用全面采用了鸿蒙 ArkTS 的声明式 UI 开发范式。与传统的命令式 UI 不同,声明式 UI 的核心思想是"描述界面应该是什么样子",而不是"一步步告诉系统怎么构建界面"。

@Component 装饰器标记的 struct 是声明式组件的基本单位。每个组件都有自己的 build 方法,在 build 方法中描述组件的 UI 结构。当组件的状态发生变化时,框架会自动重新执行 build 方法,计算新的 UI 树,并更新界面。

@State 装饰器是状态管理的基础。被 @State 标记的变量是组件的内部状态,当这些变量的值改变时,会触发组件的重新渲染。在这款应用中,Tab 切换、弹窗显示、表单输入等所有交互都依赖于 @State 状态变量。

状态管理的基本原则是"单一数据源"。在这款应用中,每个弹窗的状态和表单数据都存储在对应的 Tab 组件中,而不是分散在各个子组件中。这样做的好处是数据流清晰,状态变更的来源唯一,调试和维护都更加方便。

渲染错误: Mermaid 渲染失败: Parse error on line 2: ... LR A[用户交互] --> B[更新@State变量] B ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

这张流程图展示了声明式 UI 的基本工作流程。用户操作(如点击按钮、输入文字)会触发状态变量的更新,状态变化会驱动界面自动更新。整个过程中,开发者只需要关注状态和 UI 的映射关系,不需要手动操作 DOM 或控件。

11.2 布局系统:Column、Row、Stack

鸿蒙 ArkTS 的布局系统基于 Flex 布局思想,提供了 Column、Row、Stack 三种基础布局组件。

Column 是垂直方向的线性布局,子元素从上到下排列。Column 是应用中使用最频繁的布局组件,几乎每个组件的 build 方法都以 Column 开头。Column 的 alignItems 属性控制子元素在水平方向的对齐方式,justifyContent 属性控制子元素在垂直方向的对齐方式。

Row 是水平方向的线性布局,子元素从左到右排列。Row 常用于构建横向的内容行,比如标题栏、按钮组、列表项等。Row 的 alignItems 属性控制垂直对齐,justifyContent 属性控制水平对齐。

Stack 是堆叠布局,子元素按照先后顺序堆叠在一起,后渲染的元素覆盖在先渲染的元素之上。Stack 在弹窗、遮罩层、浮动按钮等场景中非常有用。在这款应用中,所有的弹窗都是通过 Stack 实现的,弹窗内容堆叠在页面内容之上。

layoutWeight 是一个非常重要的布局属性。它可以让子元素占据父容器的剩余空间,实现自适应布局。在 Column 中使用 layoutWeight(1) 可以让某个子元素占据剩余的垂直空间,在 Row 中使用则占据剩余的水平空间。

FlexAlign 是 Flex 布局的对齐方式枚举,包括 Start、Center、End、SpaceBetween、SpaceAround、SpaceEvenly 等。其中 SpaceBetween 在两端对齐的场景中非常常用,它会让第一个元素和最后一个元素贴边,其余元素均匀分布。

11.3 @Builder 与组件复用

@Builder 装饰器是鸿蒙 ArkTS 的特色功能之一。它可以将一段 UI 代码封装成一个函数,在 build 方法中像调用普通函数一样调用它。

在这款应用中,@Builder 被广泛用于封装各种可复用的 UI 片段:appHeader(头部)、tabItem(底部导航项)、instructorCard(教练卡片)、gearCard(装备卡片)、statCard(统计卡片)、modalOverlay(弹窗遮罩)、以及各种弹窗组件。

@Builder 的好处主要有三点:
第一,减少代码重复。相同的 UI 结构只需要写一次,多处调用。比如 modalOverlay 在每个弹窗中都会用到,封装成 @Builder 后,每个弹窗只需要调用一次。
第二,提高代码可读性。将复杂的 UI 结构拆分成多个有意义的 @Builder 函数,build 方法的结构会更加清晰,读者可以快速理解组件的组成部分。
第三,便于维护和修改。如果需要修改某个 UI 片段,只需要修改对应的 @Builder 函数,所有调用它的地方都会自动更新。

@Builder 函数可以接受参数,实现更灵活的复用。比如 tabItem 接受 icon、label、tab 三个参数,可以构建不同的导航项;instructorCard 接受一个 Instructor 对象,构建不同教练的卡片。

11.4 ForEach 与列表渲染

ForEach 是鸿蒙中用于列表渲染的核心组件。它可以根据数组数据批量生成子组件,是构建列表的首选方式。

ForEach 接受三个参数:第一个是数据源数组,第二个是子组件生成函数(数组中的每个元素对应一个子组件),第三个是键值生成函数(为每个子组件生成唯一的 key)。

键值生成函数是 ForEach 的重要组成部分。它的作用是为每个列表项提供唯一的标识,帮助框架识别列表项的身份。当列表数据发生变化时(增删改),框架可以根据 key 来判断哪些项是新增的、哪些是删除的、哪些是移动的,从而进行高效的 DOM 更新。

在这款应用中,几乎所有的列表都使用了 ForEach 来渲染:活动列表、课程列表、潜点列表、装备列表、日志列表、教练滚动、订单列表、证书列表、充值档位等等。ForEach 的应用场景非常广泛。

ForEach 不仅可以用于垂直列表(包裹在 Column 中),也可以用于水平列表(包裹在 Row 中,再嵌套在水平 Scroll 中)。教练团队的横向滚动就是通过 Scroll + Row + ForEach 的组合实现的。

11.5 Scroll 与滚动容器

Scroll 是鸿蒙中的可滚动容器组件。当内容的尺寸超出容器的可视区域时,用户可以通过滑动来查看更多内容。

Scroll 的 scrollable 属性用于设置滚动方向。ScrollDirection.Vertical 是垂直滚动,适用于长列表页面;ScrollDirection.Horizontal 是水平滚动,适用于横向滑动的内容区域。

scrollBar 属性用于控制滚动条的显示。BarState.Off 表示隐藏滚动条,这在移动端设计中非常常见。隐藏滚动条可以让界面更加简洁,但也会牺牲一些可发现性。在大多数情况下,用户通过内容的截断和滑动习惯就能判断是否可以滚动。

在这款应用中,每个 Tab 页面的主体内容都包裹在一个垂直 Scroll 中,确保内容超出屏幕高度时可以滚动查看。教练团队区域使用了水平 Scroll,实现了横向滑动的教练卡片列表。

Scroll 的使用有一些注意事项。首先,Scroll 必须有确定的高度,否则无法计算滚动区域。在应用中,Scroll 通过 layoutWeight(1) 获得剩余空间的高度。其次,Scroll 内部只能有一个根组件,通常是 Column 或 Row。


十二、六大模块功能对比总结

为了更清晰地展示六大功能模块的设计特点和技术实现,下面通过表格的形式进行全面对比。

对比维度首页 Tab课程 Tab潜点 Tab装备 Tab日志 Tab我的 Tab
核心功能活动展示+课程推荐+教练介绍课程列表+报名+评价潜点展示+计划管理装备展示+租借+归还数据统计+日志CRUD会员+证书+订单+充值
弹窗数量1个2个2个2个3个2个
弹窗类型活动报名课程报名+课程评价计划编辑+删除确认装备租借+装备归还新增+编辑+删除日志会员充值+订单详情
列表形式活动列表+课程列表课程列表潜点列表双列网格日志列表证书列表+订单列表
特色组件横向滚动教练卡片进度条+星级评分深度数据条双列装备网格柱状图+统计卡片会员卡横幅+充值档位
表单字段数5个5个5个4个7个2个
状态变量数7个9个7个10个17个9个
数据数组数3个1个1个1个1个3个
主色调使用珊瑚红珊瑚红珊瑚红珊瑚红珊瑚红珊瑚红渐变
交互复杂度中等中等中等中等偏高中等

从表格中可以看出,日志 Tab 的状态变量数最多(17 个),这是因为它需要管理新增、编辑、删除三种弹窗的表单数据,每种弹窗都有独立的状态变量。装备 Tab 次之(10 个),因为租借表单的字段较多。

我的 Tab 使用了三个数据数组(证书、订单、充值档位),是数据来源最丰富的模块。这也符合"个人中心"类页面的特点:信息密度高、功能入口多。

从弹窗数量来看,日志 Tab 有三个弹窗(新增、编辑、删除),是弹窗最多的模块。这反映了 CRUD(增删改查)类功能的特点:每种操作对应一个弹窗。其他模块大多有两个弹窗,形成了"主操作 + 次操作"的双弹窗模式。

六大模块虽然功能各不相同,但它们的整体结构高度一致:都是 Stack 布局 + Scroll 内容区 + 条件渲染弹窗。这种一致性的架构设计降低了开发和维护成本,用户在使用不同模块时也能获得一致的交互体验。


十三、全面总结

通过对这款美人鱼潜水俱乐部应用的逐段分析,我们可以看到一个完整的鸿蒙 ArkTS 应用是如何从数据层到组件层、从布局到交互一步步构建起来的。

在数据层,应用使用接口(interface)定义了 9 种业务数据结构,包括活动、课程、教练、潜点、装备、日志、证书、充值档位、订单信息。这些接口不仅提供了类型安全,也清晰地描述了业务领域的核心概念和关系。常量数据以数组的形式存储,为界面渲染提供数据源。调色板常量集中管理了 18 种颜色,确保整个应用的视觉一致性。

在工具函数层,应用封装了数十个纯函数,涵盖价格格式化、进度计算、颜色映射、数据查询、统计计算、数据分组等多种类型。这些纯函数没有副作用,输入相同则输出相同,非常易于测试和复用。颜色映射函数(如 diffColor、levelColor、conditionColor 等)构建了一套完整的状态色彩体系,让用户可以通过颜色快速理解信息含义。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============================================================
// 主题:深海蓝 #0B3B5C × 珊瑚红 #FF6B6B × 海沫白 #EAF6F5
// 6 Tab 单排:首页 / 课程 / 潜点 / 装备 / 日志 / 我的
// API24 · ArkTS · 单文件自包含 · 无动画 · 数据写死
// ============================================================

// ---------- 接口定义 ----------

interface SeaPalette {
  bg: string
  primary: string
  primarySoft: string
  secondary: string
  secondarySoft: string
  card: string
  cardDeep: string
  cardLight: string
  text: string
  textSub: string
  chip: string
  warn: string
  warnSoft: string
  green: string
  greenSoft: string
  yellow: string
  yellowSoft: string
  white: string
}

interface OceanActivity {
  id: number
  title: string
  date: string
  time: string
  price: number
  spotsLeft: number
  totalSpots: number
  target: string
  pickUp: string
  gearIncluded: boolean
  desc: string
}

interface Course {
  id: number
  name: string
  level: string
  hours: number
  theoryCount: number
  openWaterCount: number
  price: number
  origPrice: number
  certOrg: string
  startDate: string
  doneLessons: number
  totalLessons: number
  passRate: number
}

interface Instructor {
  id: number
  name: string
  cert: string
  years: number
  dives: number
  specialty: string
  rating: number
  courses: string
  avatar: string
}

interface DiveSpot {
  id: number
  name: string
  region: string
  maxDepth: number
  visibility: number
  current: string
  difficulty: number
  rating: number
  type: string
  bestSeason: string
  temp: number
}

interface Equipment {
  id: number
  name: string
  category: string
  size: string
  rentPrice: number
  deposit: number
  condition: string
  conditionLevel: number
  stock: number
  note: string
}

interface DiveLog {
  id: number
  date: string
  spot: string
  maxDepth: number
  duration: number
  tank: string
  waterTemp: number
  visibility: number
  buddy: string
  note: string
  weather: string
}

interface CertLevel {
  id: number
  name: string
  level: string
  progress: number
  lessonsDone: number
  lessonsTotal: number
  target: string
  status: string
  icon: string
  price: number
  date: string
}

interface RechargeOption {
  id: number
  amount: number
  bonus: number
  tag: string
  icon: string
  desc: string
  savePercent: number
  hot: boolean
}

interface OrderInfo {

在这里插入图片描述

在组件架构层,应用采用了"主入口 + 六个 Tab 组件"的经典结构。主入口组件 DiveApp 负责整体布局和 Tab 切换,每个 Tab 组件独立管理自己的状态和弹窗。这种模块化的设计让每个功能模块都可以独立开发和维护,模块之间通过 Tab 切换进行松耦合的关联。

在布局技术方面,应用熟练运用了 Column、Row、Stack 三大基础布局组件,配合 layoutWeight、alignItems、justifyContent 等属性,构建了丰富多样的界面布局。从简单的垂直列表到复杂的双列网格,从两端对齐的标题栏到堆叠显示的弹窗,鸿蒙的布局系统展现了强大的表达能力和灵活性。

在状态管理方面,应用全部使用 @State 装饰器进行组件内状态管理。虽然对于更复杂的应用可能需要使用 @Provide/@Consume 或全局状态管理方案,但在这种中等规模的单页应用中,@State 已经足够满足需求。每个组件管理自己的状态,数据流清晰,调试方便。

在组件复用方面,@Builder 装饰器发挥了重要作用。从头部、底部导航项到卡片组件、弹窗遮罩,@Builder 将可复用的 UI 片段封装成函数,减少了代码重复,提高了开发效率。@Builder 的参数化能力让它可以应对各种复用场景。

在列表渲染方面,ForEach 组件配合键值生成函数,实现了高效的列表渲染。无论是垂直列表还是水平滚动列表,ForEach 都能很好地胜任。它的声明式语法让列表渲染变得简单直观,开发者只需要描述"数据是什么"和"每个元素渲染成什么",框架会处理好更新逻辑。

在交互设计方面,应用的弹窗系统设计得相当完善。每个弹窗都包含遮罩层、标题、表单内容和操作按钮,结构一致,交互统一。遮罩层点击关闭的设计符合用户的直觉预期。表单中的选项按钮采用了统一的选中/未选中样式,用户学习一次就能在所有地方使用。

在视觉设计方面,深海蓝与珊瑚红的配色方案营造了独特的海洋主题氛围。深色背景配合高对比度的文字和元素,保证了可读性。绿-黄-红三色状态体系贯穿整个应用,形成了统一的视觉语言。渐变色横幅、圆角卡片、emoji 图标等设计元素共同构建了精致而有特色的界面风格。

Logo

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

更多推荐