装备平台深海蓝主题HarmonyOS 6多层级 Tab 架构与潮汐可视化深度解析,弹窗内部使用 TextInput 组件接收用户输入,通过 onChange 回调实时更新对应的 @State 变量
技术背景

在当今移动端应用开发领域,声明式 UI 范式已经逐步取代了传统的命令式编程模式,成为构建复杂界面的主流选择。声明式 UI 的核心哲学在于"状态驱动视图"——开发者只需要描述界面在任何给定状态下的样子,而框架负责在状态变化时自动高效地更新对应的 DOM 节点。这种范式极大减少了样板代码的编写量,让开发者能够将精力集中在业务逻辑本身,而非繁琐的视图同步操作上。在潜水运动这一垂直领域,由于需要展示大量动态数据(如潮汐变化、潜点信息、装备状态等),声明式 UI 的优势更加明显——每一次数据更新都能精准地反映到界面上,无需手动操作 DOM。
ArkTS 作为鸿蒙生态中的核心编程语言,在 TypeScript 的基础上进行了深度扩展与类型强化。它不仅保留了 TypeScript 的静态类型检查能力,还引入了 @Component、@Entry、@Builder、@State、@Observed 等装饰器系统,形成了一套完整的声明式 UI 开发体系。这些装饰器各司其职:@Entry 标记页面入口组件,@Component 声明自定义组件,@Builder 封装可复用的 UI 片段,@State 管理组件内部响应式状态,@Observed 则使类实例具备深度可观察性。在本潜水平台应用中,这些装饰器被大量使用,共同构建了一个从数据模型到视图渲染的完整链路。
鸿蒙操作系统(HarmonyOS)为应用开发者提供了丰富的原生组件库和统一的渲染引擎。其 Flex 布局系统、Scroll 滚动容器、Stack 层叠容器等基础组件,能够满足从简单列表到复杂嵌套导航的各种场景需求。同时,鸿蒙的动画框架支持属性动画、定时器驱动的状态切换等多种动效形式,在不依赖第三方库的情况下即可实现流畅的视觉效果。本应用中的"气泡上升特效"就是通过 setInterval 定时器驱动的轻量级动画实现,在不影响主要交互的前提下为界面增添了海洋运动的氛围感。
@Observed 装饰器是 ArkTS 中实现数据深度响应的关键机制。当一个类被 @Observed 标记后,该类的实例在作为 @State 变量使用时,其内部属性的变化也能触发视图的重新渲染。这在潜水平台的装备管理、组队邀约等模块中至关重要——用户编辑装备名称或状态时,列表中的对应项需要实时更新;删除组队邀约时,列表需要立即移除对应卡片。@Observed 配合 @State 使用,形成了一条从用户操作到数据变更再到视图刷新的闭环链路,确保界面始终保持与数据的一致性。
从行业视角来看,潜水运动应用具有独特的 UI 设计特征。首先,数据维度极为丰富——每一个潜点都包含深度、能见度、水温、等级要求等多个指标,需要以紧凑而清晰的方式呈现。其次,潮汐信息是潜水安全的核心数据,需要通过可视化图表(如柱状图)直观展示潮位变化趋势。再者,潜水装备管理涉及状态流转(良好→待保养→损坏),需要颜色编码来帮助用户快速识别。最后,组队潜水是潜水社交的重要形式,约伴卡片需要突出显示关键信息(潜点、日期、缺人数量、等级要求)。这些行业特征共同塑造了本应用独特的界面布局和交互模式。
项目概述

蓝鳍·潜水考证与装备平台是一个面向潜水爱好者和专业潜水员的综合性移动应用,涵盖潜水运动的全生命周期需求。平台采用底部 4 主 Tab 架构(首页、课程、日志、我的),其中首页进一步嵌套 6 个内容 Tab(潜点、课程、装备、潮汐、生物、组队),形成两层导航体系。这种设计既保证了底部导航的简洁性,又通过内嵌 Tab 的方式在首页内承载了丰富的功能模块。
具体功能模块包括:
- 潜点浏览:以信息流大卡形式展示 14 个精选潜点,每个潜点包含深度、能见度、水温、等级要求和热门标签
- 课程进度:阶梯式进度列表展示 10 门潜水课程,从 OW 初级到 DM 潜水长,配以 10 段进度条可视化
- 装备管理:三列网格展示 12 件潜水装备,支持点击编辑装备名称和状态(良好/待保养/损坏)
- 潮汐可视化:柱状图展示 12 小时潮位曲线,配合三个关键时段卡片(低潮、高潮、低潮)和最佳下水建议
- 生物图鉴:图文混排列表展示 10 种海洋生物,包含稀有度颜色编码和栖息地信息
- 组队约伴:可删除的约伴卡片列表,展示 10 条组队邀约,包含潜点、日期、缺人数、等级和领队信息
- 潜水日志:累计统计卡片(潜水次数/水下分钟/足迹潜点)+ 可新增的潜水日志列表,支持表单弹窗录入
- 个人中心:用户档案 + 功能菜单列表 + 成就徽章墙
整体采用深海蓝(#0B2A44/#1877B8)为主色调,珊瑚橙(#FF7A59)为强调色,搭配沙色(#F2E8D5)和浅蓝(#DCEAF5)形成层次分明的海洋色调体系。
架构设计与整体技术选型

本应用在架构上采用了清晰的分层设计模式。最底层是数据模型层,通过 @Observed 装饰器标记的类(DiveSpot、CourseItem、DiveGear、CreatureItem、TeamItem、LogItem)定义业务实体结构,每个类都拥有完整的属性集合和构造函数,确保数据初始化的类型安全。中间层是常量数据层,通过 const 数组(SPOT_INIT、COURSE_INIT、GEAR_INIT、TIDE_BARS、CREATURE_INIT、TEAM_INIT、LOG_INIT)预填充模拟数据,这些数据在组件实例化时即被引用为初始状态。
在视图层,应用通过 @Builder 装饰器将复杂的 UI 拆分为多个可复用的构建方法。每个 @Builder 方法负责一个独立功能区域的渲染逻辑,如 header() 负责头部区域、spotTab() 负责潜点列表、tideTab() 负责潮汐图表等。这种拆分方式使得 build() 方法本身保持简洁,只需根据 mainTab 和 homeTab 状态变量进行条件路由即可。在状态管理上,@State 变量涵盖了当前选中的主 Tab、内嵌 Tab、各类弹窗的显示状态、以及可变列表(装备列表、组队列表、日志列表)的引用。定时器驱动的 glow 状态变量则单独管理气泡特效的显示与隐藏。
在交互层面,三个弹窗(新增日志、编辑装备、删除组队)共享统一的 modalOverlay 遮罩构建器,通过传入不同的关闭回调函数实现弹窗关闭逻辑的复用。弹窗内部使用 TextInput 组件接收用户输入,通过 onChange 回调实时更新对应的 @State 变量,在确认操作时将变更写入数据列表。这种"弹窗表单→状态更新→列表刷新"的模式贯穿了应用的整个交互流程。
逐段代码深度分析

第一段:ColorPalette 接口定义与色彩体系
interface ColorPalette {
bg: string;
card: string;
deep: string;
blue: string;
blueD: string;
blueL: string;
coral: string;
coralL: string;
sand: string;
title: string;
sub: string;
text3: string;
gold: string;
green: string;
red: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
这段代码定义了一个名为 ColorPalette 的接口,它是整个应用色彩系统的类型契约。在 ArkTS 的类型系统中,interface 用于定义对象的形状约束,这里将所有颜色变量以 string 类型声明,确保了每个颜色值都是字符串形式的颜色描述(如十六进制色值或 rgba 表达式)。
这个接口的设计体现了"语义化色彩"的理念——每个属性名不是简单的 color1、color2,而是具有明确语义的 bg(背景)、card(卡片背景)、deep(深海色)、coral(珊瑚色)、sand(沙色)等。这种命名方式让开发者在引用颜色时能够立即理解其用途,减少了心智负担。特别是 blueD(蓝色加深)和 blueL(蓝色变浅)的区分,为同一色系提供了层次变化,使得渐变效果和状态对比成为可能。
接口中还包含了功能性色彩:title(标题文字色)、sub(次要文字色)、text3(三级文字色)构成了文字的三级层次体系;gold、green、red 用于状态标识和稀有度编码;line 用于分割线;mask 用于弹窗遮罩的半透明效果;chip 用于标签背景的微透明效果。这套完整的色彩体系覆盖了从背景到前景、从文字到装饰元素的所有视觉层次。
第二段:COLORS 常量实例化与主题色值

const COLORS: ColorPalette = {
bg: '#EFF6FB',
card: '#FFFFFF',
deep: '#0B2A44',
blue: '#1877B8',
blueD: '#0E558A',
blueL: '#DCEAF5',
coral: '#FF7A59',
coralL: '#FFE9E2',
sand: '#F2E8D5',
title: '#123047',
sub: '#5E7B93',
text3: '#A8BDCC',
gold: '#D9A441',
green: '#2E9E6C',
red: '#D9534F',
line: '#DCE7F0',
tabOn: '#1877B8',
mask: 'rgba(11,42,68,0.55)',
chip: 'rgba(24,119,184,0.10)'
};
这里将 ColorPalette 接口实例化为 COLORS 常量,赋予了具体的色值。整个配色方案围绕"深海蓝 + 珊瑚橙"的双主色体系展开,营造出浓郁的海洋氛围。
背景色 #EFF6FB 是一种极浅的蓝灰色,模拟海面反射的天空色调,既不会过于刺眼,又能与白色卡片 #FFFFFF 形成微妙的层次对比。深海色 #0B2A44 是近乎墨蓝的深邃色调,用于渐变背景和遮罩,营造水下深处的视觉感受。主蓝色 #1877B8 是标准的海洋蓝,用于主要交互元素和选中态;其加深变体 #0E558A 用于渐变终止色和按压态;变浅变体 #DCEAF5 则用于浅色背景和图标容器。
珊瑚橙 #FF7A59 是整个配色中的强调色,用于价格、标签、提示文字等需要吸引用户注意的元素。其浅色变体 #FFE9E2 用于标签背景。沙色 #F2E8D5 用于组队卡片的图标容器,模拟沙滩色调,增添了温暖的自然感。文字色三级体系 #123047→#5E7B93→##A8BDCC 从深到浅,对应标题→正文→辅助文字的层次。
遮罩色 rgba(11,42,68,0.55) 使用了深海色的半透明变体,在弹窗出现时提供柔和的暗化效果。标签色 rgba(24,119,184,0.10) 则是主蓝色的极低透明度变体,用于 Chip 标签的微妙背景着色。这些 rgba 颜色与 hex 颜色的混合使用,体现了在不同场景下对透明度精度的需求差异。
第三段:TabMeta 接口与底部导航数据

interface TabMeta {
icon: string;
label: string;
}
const BOTTOM_TABS: TabMeta[] = [
{ icon: '🏖️', label: '首页' },
{ icon: '🎓', label: '课程' },
{ icon: '📓', label: '日志' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了 Tab 项的最小数据结构——一个 emoji 图标和一个文本标签。这种简洁的设计让 Tab 数据的维护变得极其简单,新增或修改 Tab 只需要修改数组内容即可,无需改动任何 UI 代码。
BOTTOM_TABS 常量数组定义了应用底部的 4 个主导航项:首页(🏖️ 沙滩)、课程(🎓 毕业帽)、日志(📓 笔记本)、我的(👤 人形)。4 个 Tab 的数量选择是经过深思熟虑的——在移动端设计中,底部 Tab 数量在 3-5 之间时用户体验最佳,过少则导航功能不足,过多则每个 Tab 的点击区域过小。4 个 Tab 既保证了主要功能的快速切换,又留出了充足的触摸空间。
值得注意的是,首页 Tab 使用了沙滩 emoji 而非传统的房屋 emoji,这一选择与应用的海洋主题高度契合。每个 Tab 的图标都是 Emoji 字符而非图片资源,这意味着无需加载任何图标文件,减少了应用体积和网络请求。在鸿蒙系统中,Emoji 的渲染由系统字体引擎统一处理,保证了跨设备的一致性。
第四段:首页内嵌 Tab 数据

const HOME_TABS: TabMeta[] = [
{ icon: '📍', label: '潜点' },
{ icon: '🎓', label: '课程' },
{ icon: '🤿', label: '装备' },
{ icon: '🌊', label: '潮汐' },
{ icon: '🐠', label: '生物' },
{ icon: '🤝', label: '组队' }
];
HOME_TABS 定义了首页内嵌的 6 个内容 Tab,复用了与 BOTTOM_TABS 相同的 TabMeta 接口。这 6 个 Tab 分别对应潜水平台的六大核心功能模块:潜点浏览(📍 定位针)、课程进度(🎓 毕业帽,与底部 Tab 共享图标但含义不同——这里指课程进度详情)、装备管理(🤿 潜水呼吸器)、潮汐可视化(🌊 海浪)、生物图鉴(🐠 热带鱼)、组队约伴(🤝 握手)。
6 个 Tab 的设计体现了潜水运动的多元需求——从探索潜点、提升技能、管理装备、了解潮汐、认识海洋生物到结伴出行,覆盖了潜水爱好者的完整活动链路。这种将多种功能整合在首页内嵌 Tab 中的做法,既避免了底部 Tab 过多的问题,又通过水平滚动的 Tab 条实现了功能的快速切换。当 Tab 数量超过屏幕宽度时,Scroll 容器会自动启用横向滚动,用户可以滑动查看更多的 Tab 选项。
第五段:DiveSpot 潜点数据模型

@Observed export class DiveSpot {
id: number = 0;
name: string = '';
icon: string = '';
area: string = '';
depth: number = 0;
vis: number = 0;
temp: number = 0;
level: string = '';
hot: string = '';
constructor(id: number, name: string, icon: string, area: string, depth: number, vis: number, temp: number, level: string, hot: string) {
this.id = id; this.name = name; this.icon = icon; this.area = area;
this.depth = depth; this.vis = vis; this.temp = temp; this.level = level; this.hot = hot;
}
}
DiveSpot 类是潜点信息的实体模型,被 @Observed 装饰器标记,意味着当其实例作为 @State 变量使用时,内部属性的变化能够触发视图更新。每个属性都有默认初始值(id 为 0,字符串属性为空串,数值属性为 0),这是 ArkTS 的良好实践——确保即使构造函数参数缺失,对象也不会处于 undefined 状态。
属性设计紧贴潜水行业需求:id 为唯一标识;name 是潜点的名称(如"蓝洞探秘");icon 是代表性 Emoji 图标;area 是所在海域区域;depth 是最大深度(米);vis 是能见度(米);temp 是水温(摄氏度);level 是所需潜水等级(OW/AOW/专家);hot 是热门标签文案(如"🔥 热门"、“新手友好”)。
构造函数采用了紧凑的单行赋值风格,将所有参数在一条语句中完成赋值。这种写法虽然牺牲了一些可读性,但在数据模型类这种"纯数据载体"中是可接受的,因为这类类的逻辑本身就很简单——只是数据容器。export 关键字使得该类可以被其他模块引用,虽然在本文件中所有类都在同一文件内,但导出声明为未来的模块拆分留下了空间。
第六段:SPOT_INIT 潜点初始数据
const SPOT_INIT: DiveSpot[] = [
new DiveSpot(1, '蓝洞探秘', '🌊', '东礁环', 28, 25, 24, 'AOW', '🔥 热门'),
new DiveSpot(2, '沉船花园', '⚓', '北湾沉船区', 32, 18, 22, 'AOW', '🔥 经典'),
new DiveSpot(3, '珊瑚台地', '🪸', '浅滩公园', 12, 30, 26, 'OW', '新手友好'),
new DiveSpot(4, '蝠鲼清洁站', '🦈', '深礁脊', 26, 20, 23, 'AOW', '🔥 网红'),
new DiveSpot(5, '海龟草场', '🐢', '南湾草甸', 9, 15, 26, 'OW', '海龟常驻'),
new DiveSpot(6, '夜潜礁壁', '🌙', '西礁崖', 18, 12, 24, 'AOW', '限夜间'),
new DiveSpot(7, '锤头鲨大道', '🦈', '外海峡谷', 40, 28, 21, '专家', '🔥 震撼'),
new DiveSpot(8, '微距天堂', '🔍', '沙底斜坡', 16, 22, 25, 'OW', '海兔天堂'),
new DiveSpot(9, '海扇秘境', '🪸', '流角', 24, 19, 23, 'AOW', '海扇巨大'),
new DiveSpot(10, '沙丁鱼风暴', '🐟', '回旋湾', 22, 14, 24, 'OW', '🔥 群游'),
new DiveSpot(11, '火山口温泉', '🌋', '东北火山', 19, 10, 29, 'AOW', '地质奇观'),
new DiveSpot(12, '灯塔暗礁', '🗼', '灯塔东侧', 14, 21, 25, 'OW', '光影绝佳'),
new DiveSpot(13, '海草丛·海马', '🐴', '浅滩东', 7, 13, 27, 'OW', '海马之家'),
new DiveSpot(14, '峭壁大蓝水', '🏔️', '远洋崖', 36, 30, 22, '专家', '能见度王')
];
SPOT_INIT 常量数组预填充了 14 个精选潜点数据。这些数据的丰富性和多样性是本应用的核心亮点之一——每一个潜点都代表了潜水世界中一种独特的体验类型。
从深度维度看,数据覆盖了从 7 米(海草丛·海马)到 40 米(锤头鲨大道)的完整深度范围,对应从体验潜水到专家级深潜的所有等级。从等级要求看,包含了 OW(开放水域潜水员,最深 18 米)、AOW(进阶开放水域潜水员,最深 30 米)和专家级三个层次,为不同水平的潜水者提供了选择参考。
从体验类型看,数据涵盖了蓝洞探险(蓝洞探秘)、沉船穿越(沉船花园)、珊瑚观赏(珊瑚台地)、大型生物互动(蝠鲼清洁站、海龟草场、锤头鲨大道)、夜潜体验(夜潜礁壁)、微距摄影(微距天堂)、地质奇观(火山口温泉)等多种潜水活动类型。每个潜点的 hot 标签提供了直观的推荐理由,如"🔥 热门"表示高人气、"新手友好"适合入门者、"限夜间"需要特殊时段、"海龟常驻"强调生物确定性等。这些标签通过后续的 levelColor 函数进行颜色编码,使得用户能够通过色彩快速筛选适合自己等级和兴趣的潜点。
第七段:CourseItem 课程数据模型
@Observed export class CourseItem {
id: number = 0;
name: string = '';
icon: string = '';
level: string = '';
days: number = 0;
dives: number = 0;
price: string = '';
progress: number = 0;
constructor(id: number, name: string, icon: string, level: string, days: number, dives: number, price: string, progress: number) {
this.id = id; this.name = name; this.icon = icon; this.level = level;
this.days = days; this.dives = dives; this.price = price; this.progress = progress;
}
}
CourseItem 类定义了潜水课程的数据模型,同样被 @Observed 标记。其属性设计反映了潜水考证体系的标准结构:id 为课程编号;name 为课程名称;icon 为课程图标;level 为课程级别分类(入门/进阶/专业/专长/职业);days 为课程天数;dives 为包含的潜水次数;price 为课程价格(字符串形式,含货币符号);progress 为学习进度百分比(0-100)。
值得注意的是 price 被设计为 string 类型而非 number。这一选择是因为价格需要直接显示在界面上(如"¥2680"),使用字符串避免了在 UI 层进行格式化拼接的额外操作。而 progress 作为 number 类型则是因为它需要参与数值计算(如进度条的段数计算 c.progress / 10),数值类型更适合这种场景。
第八段:COURSE_INIT 课程初始数据
const COURSE_INIT: CourseItem[] = [
new CourseItem(1, '开放水域 OW 初级', '🤿', '入门', 4, 5, '¥2680', 100),
new CourseItem(2, '进阶开放水域 AOW', '🏅', '进阶', 3, 5, '¥2280', 100),
new CourseItem(3, '救援潜水员', '🚑', '专业', 4, 6, '¥3180', 60),
new CourseItem(4, '高氧空气专长', '💨', '专长', 1, 2, '¥980', 30),
new CourseItem(5, '深潜专长', '🌊', '专长', 2, 4, '¥1480', 0),
new CourseItem(6, '夜潜专长', '🌙', '专长', 2, 3, '¥1280', 0),
new CourseItem(7, '水下摄影专长', '📷', '专长', 2, 4, '¥1880', 0),
new CourseItem(8, '干衣潜水专长', '🧥', '专长', 2, 3, '¥1680', 0),
new CourseItem(9, '侧挂专长', '🎒', '专长', 3, 5, '¥2080', 0),
new CourseItem(10, '潜水长 DM 课程', '🦸', '职业', 14, 40, '¥8880', 0)
];
COURSE_INIT 预填充了 10 门潜水课程,完整覆盖了从入门到职业级别的 PADI/SSI 标准考证体系。数据设计严格遵循了国际潜水认证机构的课程结构:
入门级课程(OW 开放水域)是潜水员的起点,4 天 5 潜的安排符合国际标准。进阶级(AOW)在 OW 基础上进一步提升,3 天 5 潜的设置体现了精简高效的特点。专业级(救援潜水员)是向职业方向发展的重要里程碑,4 天 6 潜中包含了救援场景模拟训练。
专长课程系列涵盖了潜水运动的各个细分领域:高氧空气(延长水下时间)、深潜(突破深度限制)、夜潜(探索夜间海洋生态)、水下摄影(记录海底奇观)、干衣潜水(冷水域必备技能)、侧挂(技术潜水配置)。每门专长课程的天数和潜水次数都根据实际培训需求设定,从 1 天 2 潜到 3 天 5 潜不等。
最终的潜水长(DM)课程是职业级认证,14 天 40 潜的强度反映了这一等级的专业性。进度数据(progress)的设计模拟了一个真实用户的学习状态:OW 和 AOW 已完成(100%)、救援进行中(60%)、高氧刚开始(30%)、其余尚未开始(0%),这种分布使得进度条的可视化展示更加丰富和真实。
第九段:DiveGear 装备数据模型
@Observed export class DiveGear {
id: number = 0;
name: string = '';
icon: string = '';
price: string = '';
status: string = '';
level: number = 0;
tag: string = '';
constructor(id: number, name: string, icon: string, price: string, status: string, level: number, tag: string) {
this.id = id; this.name = name; this.icon = icon; this.price = price;
this.status = status; this.level = level; this.tag = tag;
}
}
DiveGear 类定义了潜水装备的数据模型,被 @Observed 标记以支持装备编辑后的视图自动刷新。属性包括:id(装备编号)、name(装备名称)、icon(Emoji 图标)、price(价格)、status(使用状态:良好/待保养/损坏)、level(重要级别 1-5)、tag(功能标签:核心/保暖/面镜/安全/夜潜/配重/工具/摄影/推进)。
status 属性的设计是装备管理的核心——它直接映射到 statusColor 函数进行颜色编码,良好显示绿色、待保养显示珊瑚橙色、损坏显示红色。这种色彩编码让用户一眼就能识别需要关注的装备。level 属性用数值表示装备的重要程度,数值越高代表越核心,这一信息虽然未在界面上直接显示,但在数据层面为未来按重要度排序留出了空间。
第十段:GEAR_INIT 装备初始数据
const GEAR_INIT: DiveGear[] = [
new DiveGear(1, '调节器套装', '🔧', '¥3680', '良好', 5, '核心'),
new DiveGear(2, 'BCD 浮力背心', '🎽', '¥2280', '良好', 4, '核心'),
new DiveGear(3, '潜水服 5mm', '🧥', '¥880', '待保养', 3, '保暖'),
new DiveGear(4, '面镜+呼吸管', '👓', '¥420', '良好', 3, '面镜'),
new DiveGear(5, '潜水电脑表', '⌚', '¥3299', '良好', 5, '安全'),
new DiveGear(6, '蛙鞋', '🦶', '¥680', '良好', 3, '推进'),
new DiveGear(7, '潜水手电', '🔦', '¥399', '待保养', 3, '夜潜'),
new DiveGear(8, '头灯套装', '💡', '¥259', '良好', 2, '夜潜'),
new DiveGear(9, '配重带', '⚖️', '¥180', '良好', 2, '配重'),
new DiveGear(10, '潜水刀', '🔪', '¥230', '良好', 2, '工具'),
new DiveGear(11, '水下相机壳', '📷', '¥2980', '良好', 4, '摄影'),
new DiveGear(12, '浮力袋', '🛟', '¥360', '良好', 3, '安全')
];
GEAR_INIT 预填充了 12 件潜水装备,涵盖了潜水员从入门到进阶所需的完整装备清单。数据组织体现了潜水装备的层次结构:
核心装备(level 4-5)包括调节器套装(一级+二级调节器,潜水呼吸的核心设备)、BCD 浮力背心(控制浮力的关键装备)、潜水电脑表(实时监测深度/时间/气压的安全设备)。这三件装备被称为"潜水三大件",是任何潜水活动的必备品。其价格也相应较高(¥2280-¥3680),反映了其技术含量和安全性要求。
保暖装备(潜水服 5mm)的状态被设为"待保养",这一设计是有意为之——它演示了 statusColor 函数的珊瑚橙色输出,让用户在浏览装备列表时能够看到不同状态的视觉差异。5mm 厚度适合 24-26°C 的水温(与潜点数据中的水温一致),体现了数据的内部一致性。
安全装备包括浮力袋(水面标识用)和潜水电脑表,功能标签为"安全"。夜潜装备包括潜水手电(也被标记为"待保养")和头灯套装。摄影装备为水下相机壳。辅助装备包括配重带、潜水刀、蛙鞋等。这些装备的 tag 属性为按功能筛选提供了分类依据,虽然当前界面未使用筛选功能,但数据层面的分类为未来扩展预留了接口。
第十一段:BarData 潮汐数据结构与 TIDE_BARS
interface BarData {
label: string;
value: number;
type: string;
}
const TIDE_BARS: BarData[] = [
{ label: '05:00', value: 2.4, type: '退' },
{ label: '06:00', value: 1.8, type: '退' },
{ label: '07:00', value: 1.2, type: '退' },
{ label: '08:00', value: 0.9, type: '低' },
{ label: '09:00', value: 1.4, type: '涨' },
{ label: '10:00', value: 2.1, type: '涨' },
{ label: '11:00', value: 2.8, type: '涨' },
{ label: '12:00', value: 3.2, type: '高' },
{ label: '13:00', value: 2.6, type: '退' },
{ label: '14:00', value: 1.9, type: '退' },
{ label: '15:00', value: 1.3, type: '退' },
{ label: '16:00', value: 0.8, type: '低' }
];
BarData 接口定义了潮汐柱状图的单个数据点结构:label 是时间标签(如"05:00"),value 是潮位高度(米),type 是潮汐状态(涨/退/高/低)。这种结构设计简洁而信息完整,足以支撑柱状图的所有可视化需求。
TIDE_BARS 数组提供了 12 小时(05:00-16:00)的潮汐数据,完整描绘了一个潮汐周期的变化曲线。数据遵循了典型的半日潮规律:从 05:00 的退潮开始,水位持续下降至 08:00 的低潮(0.9m),然后转为涨潮,水位上升至 12:00 的高潮(3.2m),再次退潮至 16:00 的低潮(0.8m)。这种"退→低→涨→高→退→低"的双峰曲线是半日潮海域的典型特征。
type 属性的设计体现了对潮汐状态语义化的追求——不简单地用"上升/下降"描述数值变化,而是用专业术语"涨/退/高/低"来表达潮汐的不同阶段。这些类型值通过后续的 tideColor 函数映射为不同颜色:高潮和低潮显示金色(#D9A441,标识极值点)、涨潮显示蓝色(#1877B8,表示水位上升)、退潮显示浅灰蓝(#7A9BB5,表示水位下降)。这种颜色编码让用户能够通过色彩直观判断潮汐趋势,无需仔细阅读数值。
第十二段:CreatureItem 生物数据模型与初始数据
@Observed export class CreatureItem {
id: number = 0;
name: string = '';
icon: string = '';
kind: string = '';
place: string = '';
rarity: string = '';
note: string = '';
constructor(id: number, name: string, icon: string, kind: string, place: string, rarity: string, note: string) {
this.id = id; this.name = name; this.icon = icon; this.kind = kind;
this.place = place; this.rarity = rarity; this.note = note;
}
}
const CREATURE_INIT: CreatureItem[] = [
new CreatureItem(1, '绿海龟', '🐢', '爬行类', '南湾草场', '常见', '草场常客'),
new CreatureItem(2, '蝠鲼', '🪁', '软骨鱼', '深礁脊', '少见', '清洁站巡游'),
new CreatureItem(3, '锤头鲨', '🦈', '软骨鱼', '外海峡谷', '稀有', '结群巡游'),
new CreatureItem(4, '小丑鱼', '🐠', '硬骨鱼', '珊瑚台地', '常见', '海葵共生'),
new CreatureItem(5, '海兔', '🐌', '软体类', '沙底斜坡', '少见', '颜色多样'),
new CreatureItem(6, '沙丁鱼群', '🐟', '硬骨鱼', '回旋湾', '常见', '群游风暴'),
new CreatureItem(7, '海马', '🐴', '硬骨鱼', '海草丛', '稀有', '栖息海草'),
new CreatureItem(8, '章鱼', '🐙', '软体类', '夜潜礁壁', '少见', '夜行变色'),
new CreatureItem(9, '狮子鱼', '🦁', '硬骨鱼', '沉船花园', '少见', '色彩艳丽'),
new CreatureItem(10, '鹦嘴鱼', '🦜', '硬骨鱼', '海扇秘境', '常见', '啃食珊瑚')
];
CreatureItem 类定义了海洋生物图鉴的数据模型,属性包括:name(生物名称)、icon(Emoji 图标)、kind(生物分类:爬行类/软骨鱼/硬骨鱼/软体类)、place(常见栖息地,与潜点数据中的名称对应)、rarity(稀有度:常见/少见/稀有)、note(行为特征描述)。
CREATURE_INIT 预填充了 10 种海洋生物,其设计精妙之处在于与潜点数据建立了交叉引用关系:绿海龟栖息于"南湾草场"(对应潜点5的海龟草场)、锤头鲨出没于"外海峡谷"(对应潜点7的锤头鲨大道)、小丑鱼生活在"珊瑚台地"(对应潜点3)、章鱼在"夜潜礁壁"出没(对应潜点6)、海马栖息于"海草丛"(对应潜点13)。这种数据间的关联性使得用户在浏览潜点和生物图鉴时能够形成认知闭环——在潜点看到的生物特征可以在图鉴中找到详细信息,反之亦然。
稀有度分为三级:常见(灰色 #5E7B93)、少见(珊瑚橙 #FF7A59)、稀有(红色 #D9534F),通过 rarityColor 函数进行颜色映射。这种分级体系帮助潜水者快速识别哪些生物是"可遇不可求"的,合理管理预期。锤头鲨和海马被标记为"稀有",符合实际潜水经验——锤头鲨需要外海深潜且季节性出现,海马则需要细心搜索海草丛才能发现。
第十三段:TeamItem 组队数据模型与初始数据
@Observed export class TeamItem {
id: number = 0;
title: string = '';
icon: string = '';
spot: string = '';
date: string = '';
need: string = '';
level: string = '';
leader: string = '';
constructor(id: number, title: string, icon: string, spot: string, date: string, need: string, level: string, leader: string) {
this.id = id; this.title = title; this.icon = icon; this.spot = spot;
this.date = date; this.need = need; this.level = level; this.leader = leader;
}
}
const TEAM_INIT: TeamItem[] = [
new TeamItem(1, '周末双潜拼船', '🚤', '珊瑚台地+海龟草场', '3月29日 08:30', '缺 2 人', 'OW+', '老船长'),
new TeamItem(2, '蓝洞摄影团', '📷', '蓝洞探秘', '4月2日 07:00', '缺 1 人', 'AOW', '光影猎人'),
new TeamItem(3, '夜潜专场', '🌙', '夜潜礁壁', '4月4日 19:00', '缺 3 人', 'AOW', '夜潜向导'),
new TeamItem(4, '锤头鲨远征', '🦈', '外海峡谷', '4月8日 06:30', '缺 4 人', '专家', '远洋舰队'),
new TeamItem(5, '微距摄影课', '🔍', '沙底斜坡', '4月10日 09:00', '缺 2 人', 'OW+', '微距大师'),
new TeamItem(6, '沉船进阶团', '⚓', '沉船花园', '4月12日 08:00', '缺 3 人', 'AOW', '沉船专家'),
new TeamItem(7, '新手体验日', '🤿', '珊瑚台地', '4月13日 10:00', '缺 5 人', '体验', '教练小蓝'),
new TeamItem(8, '海龟保育探访', '🐢', '南湾草甸', '4月15日 09:30', '缺 2 人', 'OW+', '保育员'),
new TeamItem(9, '沙丁风暴追拍', '🐟', '回旋湾', '4月17日 07:30', '缺 3 人', 'OW+', '追鱼人'),
new TeamItem(10, '峭壁深潜挑战', '🏔️', '峭壁大蓝水', '4月19日 06:00', '缺 2 人', '专家', '深蓝小队')
];
TeamItem 类定义了组队约伴的数据模型,被 @Observed 标记以支持删除操作后的列表自动更新。属性设计紧扣组队场景的核心信息需求:title(活动标题)、icon(活动类型图标)、spot(目标潜点)、date(出发时间)、need(缺人数)、level(等级要求)、leader(领队昵称)。
TEAM_INIT 预填充了 10 条组队邀约,设计上再次体现了与潜点数据的交叉引用:珊瑚台地、蓝洞探秘、夜潜礁壁、外海峡谷、沙底斜坡、沉船花园、珊瑚台地、南湾草甸、回旋湾、峭壁大蓝水——每条邀约的目标潜点都能在 SPOT_INIT 中找到对应。等级要求的设计也层次分明:从"体验"(新手体验日,无需证书)到"OW+“(有一定经验)到"AOW”(进阶潜水员)再到"专家"(锤头鲨远征和峭壁深潜挑战),满足不同水平潜水者的组队需求。
need 属性的格式统一为"缺 N 人",这一信息在界面上以珊瑚橙色加粗显示,是组队卡片中最吸引用户注意的元素——它直接告诉用户"这个活动还需要多少人才能成行"。leader 属性使用个性化的昵称(老船长、光影猎人、夜潜向导等),增添了社区氛围和信任感。组队列表支持删除操作,当用户点击垃圾桶图标时,会弹出确认弹窗,确认后通过 splice 方法从列表中移除对应项。
第十四段:LogItem 潜水日志数据模型与初始数据
@Observed export class LogItem {
id: number = 0;
date: string = '';
spot: string = '';
depth: number = 0;
minutes: number = 0;
buddy: string = '';
note: string = '';
constructor(id: number, date: string, spot: string, depth: number, minutes: number, buddy: string, note: string) {
this.id = id; this.date = date; this.spot = spot; this.depth = depth;
this.minutes = minutes; this.buddy = buddy; this.note = note;
}
}
const LOG_INIT: LogItem[] = [
new LogItem(1, '03-24', '珊瑚台地', 14, 42, '阿飞', '海龟同游'),
new LogItem(2, '03-15', '海龟草场', 9, 38, '小蓝', '看到海马'),
new LogItem(3, '03-08', '沉船花园', 30, 45, '老船长', '船舱内部'),
new LogItem(4, '02-28', '蓝洞探秘', 26, 40, '阿飞', '光线绝美'),
new LogItem(5, '02-19', '沙底斜坡', 16, 50, '微距大师', '海兔成群'),
new LogItem(6, '02-11', '夜潜礁壁', 18, 35, '夜潜向导', '章鱼变色'),
new LogItem(7, '01-30', '回旋湾', 22, 44, '追鱼人', '沙丁风暴'),
new LogItem(8, '01-18', '海扇秘境', 24, 48, '老船长', '巨大海扇'),
new LogItem(9, '01-05', '灯塔暗礁', 14, 39, '小蓝', '光影绝佳'),
new LogItem(10, '12-22', '外海峡谷', 40, 52, '远洋舰队', '锤头鲨!')
];
LogItem 类定义了潜水日志的数据模型,这是潜水员记录每次潜水活动的核心数据结构。属性包括:date(潜水日期)、spot(潜点名称)、depth(最大深度,米)、minutes(水下时间,分钟)、buddy(潜伴姓名)、note(备注/亮点描述)。
LOG_INIT 预填充了 10 条潜水日志,按时间倒序排列(从 03-24 到 12-22),模拟了一个活跃潜水员近 3 个月的潜水记录。数据设计与潜点数据再次形成交叉引用:每条日志的 spot 都对应 SPOT_INIT 中的潜点名称。buddy 属性的值与 TEAM_INIT 中的 leader 名称部分重叠(老船长、夜潜向导、追鱼人等),暗示了这些潜水活动是通过组队约伴完成的。
note 属性的设计很有特色——它不是干巴巴的"水深X米,能见度Y米"式记录,而是用简短生动的文字描述了每次潜水的亮点:“海龟同游”、“看到海马”、“光线绝美”、“海兔成群”、“章鱼变色”、“沙丁风暴”、“锤头鲨!”。这种记录方式让日志不仅是数据存档,更成为了一本充满故事感的"海底日记"。特别是最后一条"锤头鲨!"仅一个感叹号就传达了潜水员看到稀有生物时的激动心情。这些备注在界面上以珊瑚橙色显示,成为日志列表中最引人注目的文字元素。
第十五段:全局纯函数 - tideColor
function tideColor(t: string): string {
if (t === '高' || t === '低') { return '#D9A441'; }
if (t === '涨') { return '#1877B8'; }
return '#7A9BB5';
}
tideColor 函数是潮汐柱状图的核心配色逻辑。它接收潮汐类型字符串(“高”/“低”/“涨”/“退”),返回对应的颜色值。这一设计体现了"语义→视觉"的映射理念:高潮和低潮是潮汐的极值点,使用金色(#D9A441)突出显示,提醒用户这是需要关注的时刻;涨潮代表水位上升,使用主蓝色(#1877B8)与海洋主题一致;退潮则使用较浅的灰蓝色(#7A9BB5),视觉上弱化但仍然可辨。
作为全局函数(而非类方法),tideColor 的设计选择有其优势:它不依赖任何实例状态,是纯函数,可以在任何上下文中调用,也便于单元测试。函数逻辑简洁明了,通过两个 if 判断覆盖了三种情况,最后的 return 作为默认值处理"退"潮类型。这种"显式匹配→默认返回"的模式确保了即使传入未预期的值,函数也不会返回 undefined。
第十六段:全局纯函数 - levelColor、statusColor、rarityColor
function levelColor(lv: string): string {
if (lv === '专家') { return '#D9534F'; }
if (lv === 'AOW') { return '#FF7A59'; }
return '#1877B8';
}
function statusColor(s: string): string {
if (s === '待保养') { return '#FF7A59'; }
if (s === '损坏') { return '#D9534F'; }
return '#2E9E6C';
}
function rarityColor(r: string): string {
if (r === '稀有') { return '#D9534F'; }
if (r === '少见') { return '#FF7A59'; }
return '#5E7B93';
}
这三个函数分别处理潜水等级、装备状态和生物稀有度的颜色映射,它们的结构高度一致——都采用"if 判断→返回特定色值→默认返回"的模式。这种一致性降低了维护成本,开发者在阅读任何一个函数时都能快速理解其逻辑。
levelColor 函数将潜水等级映射为颜色:专家级显示红色(#D9534F,暗示高风险/高要求)、AOW 显示珊瑚橙色(#FF7A59,中间级别的警示色)、OW 及以下显示蓝色(#1877B8,入门级别的安全色)。这种"红→橙→蓝"的递减体系在视觉上形成了从危险到安全的直观感知梯度。
statusColor 函数将装备状态映射为颜色:待保养显示珊瑚橙色(提示需要关注)、损坏显示红色(警示需维修或更换)、良好显示绿色(#2E9E6C,表示状态正常)。这是经典的"红黄绿"三色状态系统在潜水装备管理中的应用。
rarityColor 函数将生物稀有度映射为颜色:稀有显示红色(强调难得一见)、少见显示珊瑚橙色(提示有一定概率)、常见显示灰蓝色(#5E7B93,低调表示容易遇到)。这种颜色编码让用户在浏览生物图鉴时能够通过色彩快速筛选稀有物种。
第十七段:Page827 组件状态定义
@Entry
@Component
struct Page827 {
@State mainTab: number = 0;
@State homeTab: number = 0;
@State glow: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addSpot: string = '';
@State addDepth: string = '';
@State addNote: string = '';
@State editName: string = '';
@State editStatus: string = '良好';
@State gearList: DiveGear[] = GEAR_INIT;
@State teamList: TeamItem[] = TEAM_INIT;
@State logList: LogItem[] = LOG_INIT;
timer: number = 0;
Page827 是应用的入口组件,被 @Entry 和 @Component 装饰器标记。其状态变量集合可以分为四个功能组:
导航状态组:mainTab(当前底部 Tab 索引,0-3)和 homeTab(当前首页内嵌 Tab 索引,0-5),这两个变量控制着整个应用的内容路由。初始值都为 0,即默认显示首页的潜点列表。
弹窗状态组:addModal(新增日志弹窗)、editModal(编辑装备弹窗)、delModal(删除组队确认弹窗),三个布尔值独立控制三个弹窗的显示与隐藏。editIdx 用于记录当前编辑/删除的列表项索引。
表单数据组:addSpot、addDepth、addNote 用于新增日志弹窗的输入字段;editName、editStatus 用于编辑装备弹窗的输入字段。这些变量通过 TextInput 的 onChange 回调实时更新,在用户点击保存时被写入数据列表。
可变列表组:gearList、teamList、logList 分别引用 GEAR_INIT、TEAM_INIT、LOG_INIT 初始数据。由于这些列表绑定的类都被 @Observed 标记,当列表项的属性被修改(如装备编辑)或列表本身被增删(如日志新增、组队删除)时,对应的 UI 区域会自动刷新。
timer 变量不是 @State 变量,因为它不需要触发视图更新——它只是 setInterval 的返回值句柄,在 aboutToDisappear 时用于清除定时器。将 timer 定义为普通属性而非 @State 是正确的做法,避免了不必要的响应式开销。
第十八段:生命周期管理 - aboutToAppear 与 aboutToDisappear
aboutToAppear() {
this.timer = setInterval(() => { this.glow = !this.glow; }, 750);
}
aboutToDisappear() {
clearInterval(this.timer);
}
这两个方法是 ArkTS 组件生命周期的核心钩子。aboutToAppear 在组件创建后、渲染前调用,aboutToDisappear 在组件销毁前调用。
aboutToAppear 中启动了一个 750 毫秒间隔的定时器,周期性地切换 glow 布尔值(true↔false)。由于 glow 是 @State 变量,每次切换都会触发依赖 glow 的视图区域重新渲染——即气泡特效层的显示与隐藏。750 毫秒的间隔选择是一个平衡点:太短(如 200ms)会导致频繁渲染影响性能,太长(如 2000ms)则动效显得迟钝不自然。750ms 的间隔让气泡的闪烁节奏接近海浪中气泡上升的自然频率。
aboutToDisappear 中调用 clearInterval 清除定时器,这是防止内存泄漏的关键步骤。如果不清理,组件销毁后定时器仍会运行,持续修改一个已经不存在于视图树中的 @State 变量,可能导致运行时错误或内存泄漏。这种"启动→清理"的成对模式是定时器管理的最佳实践。
值得注意的是,整个应用只有一个定时器驱动一个 @State 变量。这种"单定时器+单状态变量"的极简方案避免了对性能的影响——每次只有气泡特效层在重新渲染,而非整个页面。通过将特效限制在页面顶部的一个 Row 中(通过 position 定位),实现了"不覆盖头部"的视觉效果。
第十九段:头部构建器 - header()
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🔍').fontSize(14)
Text('搜索潜点 / 课程 / 装备').fontSize(12).fontColor(COLORS.sub)
}
.width('100%').padding({ left: 14, right: 14, top: 9, bottom: 9 })
.borderRadius(20).backgroundColor('#FFFFFF')
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('水温').fontSize(9).fontColor(COLORS.blueL)
Text('26°C').fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('能见度').fontSize(9).fontColor(COLORS.blueL)
Text('24m').fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('浪高').fontSize(9).fontColor(COLORS.blueL)
Text('0.6m').fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('适合潜水').fontSize(9).fontColor(COLORS.coralL)
Text('✅ 适宜').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(14).borderRadius(16)
.linearGradient({ angle: 135, colors: [['#1877B8', 0], ['#0E558A', 1]] })
header() 构建器负责渲染首页顶部的三个区域:搜索栏、今日潜水条件大卡、热门潜点横滚。
搜索栏采用白色圆角胶囊形设计(borderRadius(20)),内部放置搜索图标和占位文本"搜索潜点 / 课程 / 装备"。这一设计虽然目前是静态展示(未实现搜索功能),但视觉上为用户提供了"可搜索"的心理预期,符合现代移动应用的设计语言。fontColor(COLORS.sub) 使占位文字呈现柔和的灰蓝色,与白色背景形成适当的对比度。
今日潜水条件大卡是头部的视觉重心。它使用 linearGradient 创建了一个 135 度角的渐变背景,从主蓝色(#1877B8)渐变到深蓝色(#0E558A),营造出深海的视觉氛围。卡片内包含四个等宽数据列(通过 layoutWeight(1) 实现):水温 26°C、能见度 24m、浪高 0.6m、适合潜水 ✅ 适宜。每列的结构一致——上方是 9px 的标签文字(浅蓝色 COLORS.blueL),下方是 18px 的加粗数值(白色),唯独"适合潜水"列的标签使用了珊瑚浅色(COLORS.coralL)以区分,数值也换成了"✅ 适宜"的文字描述而非纯数值。这种在统一布局中通过细节变化制造视觉层次的手法,是优秀 UI 设计的体现。
这些数据值(26°C 水温、24m 能见度、0.6m 浪高)与潜点数据中的典型值保持一致,体现了数据的内部逻辑自洽。26°C 的水温对应珊瑚台地(26°C)和海龟草场(26°C),24m 的能见度对应蓝洞探秘(25m)的近似值,0.6m 的浪高属于安全潜水的范围(低于 1m 即可安全下水),"适宜"的判断基于这三个指标的综合评估。
第二十段:头部热门潜点横滚
Scroll() {
Row({ space: 8 }) {
ForEach(SPOT_INIT, (s: DiveSpot) => {
Column({ space: 2 }) {
Text(s.icon).fontSize(18)
Text(s.name).fontSize(9).fontColor(COLORS.sub)
}
.width(58).padding({ top: 8, bottom: 6 }).borderRadius(10)
.backgroundColor('#FFFFFF').alignItems(HorizontalAlign.Center)
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
}
热门潜点横滚是头部的第三个区域。它使用 Scroll 容器包裹一个水平排列的 Row,通过 scrollable(ScrollDirection.Horizontal) 启用横向滚动,scrollBar(BarState.Off) 隐藏滚动条以保持视觉简洁。
ForEach 遍历 SPOT_INIT 数组的全部 14 个潜点,为每个潜点生成一个 58px 宽的白色圆角小卡。卡片内包含 18px 的 Emoji 图标和 9px 的潜点名称。由于 14 个潜点的总宽度远超屏幕宽度,用户需要横向滑动才能查看全部潜点,这种"信息溢出→滚动探索"的设计增加了交互的趣味性。
整个头部被包裹在一个 Column 中,space: 10 提供了三个区域之间的垂直间距。外层的 padding 和 backgroundColor(COLORS.bg) 确保头部区域与页面背景融为一体。值得注意的是,头部只在 mainTab === 0(首页)时显示,其他 Tab 页面使用简化版的标题栏——这一逻辑在后续的 build() 方法中实现。
第二十一段:首页内嵌 Tab 条 - homeTabBar()
@Builder
homeTabBar() {
Scroll() {
Row({ space: 6 }) {
ForEach(HOME_TABS, (t: TabMeta, i: number) => {
Column({ space: 1 }) {
Text(t.icon).fontSize(14)
Text(t.label).fontSize(9)
}
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(10)
.backgroundColor(this.homeTab === i ? COLORS.blue : '#FFFFFF')
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.homeTab = i; })
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
homeTabBar() 构建了首页内的 6 个内容 Tab 切换条。与头部热门潜点横滚类似,它也使用了 Scroll + Row 的横向滚动布局,但每个 Tab 项增加了点击交互和选中态视觉反馈。
选中态的视觉区分通过背景色变化实现:当前选中的 Tab(this.homeTab === i)显示主蓝色背景(COLORS.blue),未选中的 Tab 显示白色背景。由于 Tab 内的文字和图标没有颜色变化,选中态的蓝色背景与白色文字形成对比,而未选中态的白色背景与文字色(默认黑色)形成对比。这种"背景色反转"的选中态指示方式简洁有效,且与底部 Tab 的文字颜色变化方式形成了互补。
每个 Tab 项的 onClick 回调简单地设置 this.homeTab = i,这一操作会触发 homeContent() 构建器的条件分支重新求值,从而切换显示对应的内容区域。6 个 Tab 的布局结构完全不同,从信息流大卡到阶梯进度列表、从三列网格到柱状图、从图文混排到可删除卡片,每个 Tab 都提供了独特的浏览体验。
第二十二段:首页内容切换 - homeContent()
@Builder
homeContent() {
Column({ space: 0 }) {
this.homeTabBar()
Scroll() {
Column({ space: 10 }) {
if (this.homeTab === 0) {
this.spotTab()
} else if (this.homeTab === 1) {
this.courseTab()
} else if (this.homeTab === 2) {
this.gearTab()
} else if (this.homeTab === 3) {
this.tideTab()
} else if (this.homeTab === 4) {
this.creatureTab()
} else {
this.teamTab()
}
}
.width('100%')
}
.scrollBar(BarState.Off)
.align(Alignment.Top)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%')
}
homeContent() 是首页内容区的核心路由构建器。它的结构非常清晰:顶部是 homeTabBar()(Tab 切换条),下方是一个 Scroll 容器,内部通过 if-else 链根据 homeTab 的值调用对应的子构建器。
这种"if-else 条件链+子构建器调用"的模式是 ArkTS 中实现多视图切换的标准做法。当 homeTab 变化时,ArkTS 框架会重新评估条件链,销毁前一个构建器的 UI 树并创建新构建器的 UI 树。这一过程是高效的,因为每个子构建器都是独立的 UI 单元,切换时无需保留前一个视图的状态。
Scroll 容器设置了 layoutWeight(1),这意味着它会占据 Tab 条之后的所有剩余空间。align(Alignment.Top) 确保内容从顶部开始排列,当内容高度超过容器高度时自动启用滚动。scrollBar(BarState.Off) 隐藏滚动条,保持界面的简洁。外层 Column 的 height('100%') 确保内容区填满头部和底部 Tab 之间的所有空间。
第二十三段:潜点信息流大卡 - spotTab()
@Builder
spotTab() {
Column({ space: 8 }) {
ForEach(SPOT_INIT, (s: DiveSpot) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Column() {
Text(s.icon).fontSize(22)
}
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.blueL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(s.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(s.area).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(s.hot).fontSize(8).fontColor(COLORS.red)
Text(s.level).fontSize(9).fontColor(levelColor(s.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%')
spotTab() 构建器将 14 个潜点渲染为信息流大卡列表。每个卡片由两个主要行组成:上方是潜点标题行,下方是数据指标行。
标题行的布局从左到右依次为:44x44 的圆角图标容器(浅蓝背景 COLORS.blueL,居中显示潜点 Emoji)、潜点名称和区域(左侧对齐,名称 12px 加粗,区域 9px 次要色)、热门标签(8px 红色,如"🔥 热门")、等级标签(9px,通过 levelColor 函数着色,带有 Chip 背景的胶囊形标签)。layoutWeight(1) 应用在名称区域上,使其占据图标和标签之间的所有空间,确保标签始终贴右排列。
这种"图标+标题+标签"的三段式布局是信息流卡片的经典设计。图标提供视觉识别、标题传递核心信息、标签快速传达关键属性(等级和热度),用户在快速滑动浏览时能够高效获取每个潜点的核心信息。等级标签的颜色编码(蓝色 OW→橙色 AOW→红色专家)让潜水者能够通过色彩快速筛选适合自己等级的潜点。
第二十四段:潜点数据指标行
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('深度').fontSize(8).fontColor(COLORS.text3)
Text(s.depth.toString() + ' m').fontSize(12).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('能见度').fontSize(8).fontColor(COLORS.text3)
Text(s.vis.toString() + ' m').fontSize(12).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('水温').fontSize(8).fontColor(COLORS.text3)
Text(s.temp.toString() + ' °C').fontSize(12).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('看详情').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.blue)
}
.width('100%')
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
数据指标行是潜点卡片的下半部分,包含三个等宽的数据列和一个"看详情"按钮。三个数据列(深度、能见度、水温)的布局完全一致:8px 的标签文字(三级灰色 COLORS.text3)在上,12px 的加粗数值(主蓝色)在下,通过 layoutWeight(1) 等分宽度并居中对齐。
数值使用了 toString() 方法进行字符串拼接(如 s.depth.toString() + ' m'),这是因为 ArkTS 的 Text 组件只接受字符串内容,数值需要显式转换。单位分别使用了"m"(米)和"°C"(摄氏度),格式清晰专业。
"看详情"按钮使用主蓝色背景和白色文字,通过 padding 控制按钮大小,borderRadius(8) 提供圆角。虽然当前未实现实际的详情跳转(onClick 未绑定),但按钮的视觉存在为用户提供了"可查看更多信息"的预期。整个卡片使用 COLORS.card(白色)背景和 12px 圆角,配合 10px 的内边距,在浅蓝灰色背景上呈现出清晰的白色信息卡片。
第二十五段:课程阶梯进度列表 - courseTab()
@Builder
courseTab() {
Column({ space: 8 }) {
ForEach(COURSE_INIT, (c: CourseItem) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(c.icon).fontSize(16)
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.level).fontSize(8).fontColor(COLORS.coral)
.padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6)
.backgroundColor(COLORS.coralL)
Column().layoutWeight(1)
Text(c.price).fontSize(11).fontColor(COLORS.coral).fontWeight(FontWeight.Bold)
}
.width('100%')
courseTab() 将 10 门潜水课程渲染为阶梯式进度列表。每张课程卡片的布局分为三层:标题行、信息行、进度条行。
标题行从左到右排列:课程图标(16px Emoji)、课程名称(12px 加粗标题色)、级别标签(8px 珊瑚色文字,珊瑚浅色背景的胶囊标签,如"入门"、“进阶”、“专长”)、弹性空白(Column().layoutWeight(1) 用于推开右侧元素)、价格(11px 珊瑚色加粗,如"¥2680")。级别标签和价格都使用珊瑚橙色系,在以蓝色为主的配色中形成了视觉焦点,吸引用户关注课程级别和价格这两个关键决策信息。
第二十六段:课程进度条实现
Row({ space: 8 }) {
Text(c.days.toString() + ' 天 · ' + c.dives.toString() + ' 潜').fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text(c.progress.toString() + '%').fontSize(9).fontColor(c.progress === 100 ? COLORS.green : COLORS.sub)
}
.width('100%')
Row({ space: 4 }) {
ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (i: number) => {
Column().layoutWeight(1).height(5).borderRadius(3)
.backgroundColor(i <= (c.progress / 10) ? (c.progress === 100 ? COLORS.green : COLORS.blue) : COLORS.line)
})
}
.width('100%')
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
进度条行的设计是课程列表的视觉亮点。它使用了 10 个等宽的 Column 段(通过 layoutWeight(1) 等分),每段高度 5px,圆角 3px。每段的背景色通过条件判断决定:如果段索引 i 小于等于 c.progress / 10(即进度百分比除以 10 得到应填充的段数),则显示进度色;否则显示浅色(COLORS.line)。
进度色还有一层条件:如果进度为 100%(完成),使用绿色(COLORS.green)表示完成状态;否则使用蓝色(COLORS.blue)表示进行中状态。这种"蓝色进行中→绿色已完成"的色彩语义与信息行中的百分比文字颜色变化(c.progress === 100 ? COLORS.green : COLORS.sub)保持一致。
以具体数据为例:救援潜水员课程进度为 60%,则前 6 段显示蓝色,后 4 段显示浅灰色;高氧空气专长进度为 30%,前 3 段蓝色,后 7 段灰色;OW 和 AOW 进度 100%,全部 10 段显示绿色。这种"分段式进度条"相比传统的连续进度条,在视觉上更加清晰,每一段代表 10% 的进度,用户可以通过数段快速估算进度。
信息行中"X 天 · Y 潜"的格式简洁地传达了课程的时间投入(如"4 天 · 5 潜"),让用户在决定报名前能够快速评估课程的时间成本。
第二十七段:装备三列网格 - gearTab()
@Builder
gearTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
ForEach(this.gearList.slice(0, 9), (g: DiveGear, gi: number) => {
Column({ space: 4 }) {
Text(g.icon).fontSize(20)
Text(g.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(g.price).fontSize(8).fontColor(COLORS.coral)
Text(g.status).fontSize(7).fontColor(statusColor(g.status))
Text('✏️').fontSize(11)
.onClick(() => { this.editIdx = gi; this.editName = g.name; this.editStatus = g.status; this.editModal = true; })
}
.layoutWeight(1).padding({ top: 10, bottom: 10, left: 4, right: 4 }).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Text('共 ' + this.gearList.length.toString() + ' 件装备 · 点 ✏️ 可编辑').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
}
gearTab() 将装备渲染为三列网格布局。这里使用了一个巧妙的技巧:通过 ForEach 遍历 this.gearList.slice(0, 9)(取前 9 件),结合每个项的 layoutWeight(1),实现了每行 3 列的网格效果(9÷3=3 行)。虽然技术上这是一个单行 Row 中的 9 个等宽元素,但由于 layoutWeight(1) 会根据容器宽度自动计算每个元素的宽度,当屏幕宽度有限时,9 个元素会自然排列为多行——每行 3 个。
每个装备卡片内部从上到下依次显示:20px 的装备图标、9px 的装备名称(加粗,单行显示溢出省略 maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }))、8px 的价格(珊瑚色)、7px 的状态文字(通过 statusColor 函数着色)、11px 的编辑图标(✏️)。
编辑图标的 onClick 回调是装备管理功能的核心入口:它先将当前装备的索引 gi 存入 editIdx,将装备名称存入 editName,将状态存入 editStatus,然后打开编辑弹窗(editModal = true)。这种"先暂存编辑上下文→打开弹窗→弹窗内修改→确认后写回"的模式是表单编辑的标准流程。
底部还有一行提示文字"共 12 件装备 · 点 ✏️ 可编辑",使用 this.gearList.length.toString() 动态显示装备总数。这行文字不仅提供了统计信息,还作为编辑功能的操作提示,提升了界面的可学习性。
第二十八段:潮汐柱状图 - tideTab()
@Builder
tideTab() {
Column({ space: 10 }) {
Column({ space: 8 }) {
Text('🌊 今日潮位曲线(m)').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Scroll() {
Row({ space: 6 }) {
ForEach(TIDE_BARS, (t: BarData) => {
Column({ space: 4 }) {
Text(t.value.toString()).fontSize(8).fontColor(COLORS.sub)
Column().width(18).height(t.value * 16).borderRadius(5)
.backgroundColor(tideColor(t.type))
Text(t.label).fontSize(7).fontColor(COLORS.sub)
Text(t.type).fontSize(7).fontColor(tideColor(t.type))
}
.alignItems(HorizontalAlign.Center)
})
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
tideTab() 的核心是潮汐柱状图,这是整个应用中最复杂的数据可视化组件。柱状图的实现不依赖任何图表库,而是通过纯 ArkTS 组件构建。
每个柱子由一个 Column 容器表示,内部从上到下排列四个元素:数值标签(8px,显示潮位高度如"3.2")、柱体(Column 宽度 18px,高度为 t.value * 16,圆角 5px,背景色通过 tideColor 函数确定)、时间标签(7px,如"12:00")、类型标签(7px,如"高",使用与柱体相同的颜色)。
柱体高度的计算 t.value * 16 是一个简单而有效的高度映射:最高潮位 3.2m 对应 51.2px 的柱高,最低潮位 0.8m 对应 12.8px 的柱高。16 倍的缩放系数确保了柱子在 18px 宽度内有足够的视觉高度差异,同时不会超出卡片容器的可用空间。这种"数值×固定系数"的映射方式虽然简单,但在数据范围已知且有限的情况下效果良好。
柱状图被包裹在 Scroll 容器中启用横向滚动,因为 12 个柱子加上间距的总宽度可能超过屏幕宽度。scrollBar(BarState.Off) 隐藏滚动条保持简洁。外层卡片使用白色背景和 12px 圆角,标题"🌊 今日潮位曲线(m)"以加粗标题色显示在顶部。
第二十九段:潮汐时段卡片
Row({ space: 6 }) {
ForEach([['🌅', '06:24', '低潮 0.9m', '适合沙滩观察'], ['☀️', '11:58', '高潮 3.2m', '最佳下水时段'], ['🌇', '16:42', '低潮 0.8m', '潮池生物多']], (w: string[]) => {
Column({ space: 4 }) {
Text(w[0]).fontSize(16)
Text(w[1]).fontSize(11).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text(w[2]).fontSize(8).fontColor(COLORS.sub)
Text(w[3]).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
}
.width('100%')
}
在柱状图下方,tideTab() 还渲染了三个关键时段卡片,分别对应低潮、高潮和低潮三个时间节点。每个卡片通过 layoutWeight(1) 等分宽度,内部从上到下显示:时段图标(🌅 日出/☀️ 正午/🌇 日落)、时间(11px 蓝色加粗)、潮位描述(8px 次要色,如"高潮 3.2m")、建议(8px 三级色,如"最佳下水时段")。
这三张卡片的设计精妙之处在于将抽象的潮汐数据转化为具体的行动建议。"适合沙滩观察"对应低潮时段,此时海水退去露出沙滩,适合在潮间带探索;"最佳下水时段"对应高潮时段,此时水深最大、能见度好,适合进行潜水活动;"潮池生物多"对应另一个低潮时段,退潮后潮池中残留的海水孕育了丰富的微小生物。
时间值(06:24、11:58、16:42)与柱状图中的时间点(08:00 低潮、12:00 高潮、16:00 低潮)略有差异,这种差异模拟了实际潮汐预报的精确时间——潮汐不会恰好在整点达到极值,而是有几分钟到几十分钟的偏差。这种数据细节增加了应用的真实感。
第三十段:生物图鉴 - creatureTab()
@Builder
creatureTab() {
Column({ space: 8 }) {
ForEach(CREATURE_INIT, (c: CreatureItem) => {
Row({ space: 10 }) {
Column() {
Text(c.icon).fontSize(24)
}
.width(50).height(50).borderRadius(25).backgroundColor(COLORS.blueL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.kind).fontSize(8).fontColor(COLORS.sub)
}
Text(c.place + ' · ' + c.note).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(c.rarity).fontSize(9).fontColor(rarityColor(c.rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
creatureTab() 将 10 种海洋生物渲染为图文混排列表。每张卡片的布局是水平排列的三段式:左侧是 50x50 的圆形图标容器(浅蓝背景,居中显示 24px 的生物 Emoji),中间是生物名称+分类+栖息地+描述(左侧对齐,layoutWeight(1) 占据中间空间),右侧是稀有度标签(通过 rarityColor 函数着色,Chip 背景的胶囊标签)。
中间区域内部又分为两行:第一行是名称(12px 加粗标题色)和分类(8px 次要色,如"软骨鱼"、“软体类”),第二行是栖息地和描述的组合文本(9px 次要色,如"深礁脊 · 清洁站巡游")。这种将多个信息字段拼接为一条文本的设计,在有限的卡片高度内最大化了信息密度,同时通过" · "分隔符保持了可读性。
稀有度标签的颜色编码是生物图鉴的核心视觉特征:常见(灰蓝色 #5E7B93)、少见(珊瑚橙 #FF7A59)、稀有(红色 #D9534F),用户在快速浏览列表时能够通过标签颜色立即识别哪些生物值得期待。锤头鲨和海马被标记为"稀有"(红色),在列表中会格外醒目。
第三十一段:组队约伴卡片 - teamTab()
@Builder
teamTab() {
Column({ space: 8 }) {
ForEach(this.teamList, (t: TeamItem, idx: number) => {
Row({ space: 10 }) {
Column() {
Text(t.icon).fontSize(20)
}
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.sand)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(t.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(t.spot + ' · ' + t.date).fontSize(9).fontColor(COLORS.sub)
Text('领队 ' + t.leader + ' · ' + t.level).fontSize(8).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 6 }) {
Text(t.need).fontSize(9).fontColor(COLORS.coral).fontWeight(FontWeight.Bold)
Text('🗑️').fontSize(13)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
teamTab() 将组队邀约渲染为可删除的卡片列表。每张卡片是水平三段式布局:左侧是 44x44 的圆角图标容器(沙色背景 COLORS.sand,是本应用中唯一使用沙色背景的地方,为组队卡片增添了温暖的自然感),中间是活动信息(标题 12px 加粗、潜点+日期 9px、领队+等级 8px),右侧是操作区(缺人数 9px 珊瑚色加粗、删除图标 13px)。
ForEach 的第二个参数 idx 是列表项的索引,在删除操作中用于定位要删除的项。删除图标的 onClick 回调将 idx 存入 editIdx,然后打开删除确认弹窗(delModal = true)。这种"先暂存索引→弹窗确认→确认后执行删除"的两步操作流程,相比直接删除,增加了一层安全确认,避免了误操作。
值得注意的是 this.teamList 直接绑定了 @State 变量,且 TeamItem 被 @Observed 标记。当用户在确认弹窗中点击"确认撤下"时,this.teamList.splice(this.editIdx, 1) 会直接修改数组,ArkTS 框架会检测到 @State 数组的变化并自动刷新 ForEach 列表,被删除的卡片会从界面上消失。这就是 @Observed + @State 的响应式力量。
第三十二段:课程主 Tab - courseMain()
@Builder
courseMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 8 }) {
ForEach([['🎓', 'OW', '已获证'], ['🏅', 'AOW', '已获证'], ['🚑', '救援', '进行中'], ['📷', '摄影', '未开始']], (c: string[]) => {
Column({ space: 4 }) {
Text(c[0]).fontSize(18)
Text(c[1]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c[2]).fontSize(8).fontColor(c[2] === '已获证' ? COLORS.green : (c[2] === '进行中' ? COLORS.coral : COLORS.text3))
}
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
courseMain() 是底部 Tab"课程"的主页面。与首页内嵌的课程 Tab(courseTab() 展示进度条列表)不同,这个页面提供了更宏观的课程概览。页面顶部是一个 4 列的状态摘要行,展示用户已获证和正在进行的课程:OW 已获证(绿色)、AOW 已获证(绿色)、救援进行中(珊瑚橙)、摄影未开始(灰色)。
状态文字的颜色使用了一个嵌套三元表达式:c[2] === '已获证' ? COLORS.green : (c[2] === '进行中' ? COLORS.coral : COLORS.text3)。这种写法虽然在一行内完成了三种状态的判断,但可读性较差。在实际项目中,这种逻辑通常会封装为一个函数,但在此处的单次使用场景下,内联表达式也是可接受的。
第三十三段:课程列表精简版
Text('📜 全部课程(' + COURSE_INIT.length.toString() + ')').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
ForEach(COURSE_INIT, (c: CourseItem) => {
Row({ space: 8 }) {
Text(c.icon).fontSize(15)
Column({ space: 2 }) {
Text(c.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.days.toString() + ' 天 · ' + c.dives.toString() + ' 潜 · ' + c.price).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(c.progress === 100 ? '✅ 完成' : (c.progress > 0 ? '进行中' : '报名')).fontSize(8).fontColor(c.progress === 100 ? COLORS.green : COLORS.blue)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
与首页内嵌课程 Tab 的详细进度条不同,课程主 Tab 的列表采用了精简的单行布局。每行显示:课程图标、课程名称+信息(天数、潜水次数、价格拼接为一条文本)、状态标签(“✅ 完成”/“进行中”/“报名”)。
标题"📜 全部课程(10)“使用 COURSE_INIT.length.toString() 动态显示课程总数。状态标签的颜色根据进度值变化:100% 显示绿色"✅ 完成”、大于 0% 显示蓝色"进行中"、等于 0% 显示蓝色"报名"。这种文字状态标签比进度条更加节省垂直空间,适合在概览页面使用。
两种课程展示方式(首页内嵌的详细进度条 vs 课程主 Tab 的精简列表)的设计选择,体现了"同一数据在不同上下文中以不同粒度展示"的设计理念:首页内嵌的课程 Tab 供用户仔细查看每门课程的进度详情,而课程主 Tab 供用户快速浏览所有课程的整体状态。
第三十四段:日志主 Tab - logMain()
@Builder
logMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('86').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('累计潜水').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('3120').fontSize(18).fontColor(COLORS.coral).fontWeight(FontWeight.Bold)
Text('水下分钟').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('26').fontSize(18).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('足迹潜点').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
logMain() 是底部 Tab"日志"的主页面,顶部是一个三列统计摘要卡片:累计潜水 86 次(蓝色)、水下分钟 3120 分钟(珊瑚色)、足迹潜点 26 个(绿色)。三个数据分别使用不同的颜色,让用户能够通过色彩区分不同的统计维度。
这些数值的设计有内在的逻辑关系:86 次潜水×平均 36 分钟≈3120 分钟(86×36=3096,接近 3120),26 个潜点 < 86 次潜水(说明有些潜点被重复造访),这些数据模拟了一个经验丰富的 AOW 潜水员的活动记录。在列表中实际显示了 10 条日志,说明这 10 条只是精选记录,完整 86 次潜水的日志可能在更深层的数据源中。
第三十五段:日志新增按钮与日志列表
Row({ space: 8 }) {
Text('📓 潜水日志').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Column() { Text('+ 新增').fontSize(10).fontColor('#FFFFFF') }
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.blue)
.onClick(() => { this.addModal = true; })
}
.width('100%')
ForEach(this.logList, (l: LogItem) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(l.date).fontSize(11).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('潜水').fontSize(8).fontColor(COLORS.text3)
}
.width(46).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(l.spot).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('深度 ' + l.depth.toString() + ' m · ' + l.minutes.toString() + ' min · 潜伴 ' + l.buddy).fontSize(9).fontColor(COLORS.sub)
Text(l.note).fontSize(9).fontColor(COLORS.coral)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
日志列表的标题行右侧有一个蓝色"+ 新增"按钮,点击后打开新增日志弹窗(addModal = true)。这个按钮的设计简洁明了——白色文字、蓝色背景、圆角胶囊形,通过 layoutWeight(1) 的空白 Column 将其推到行右端。
日志列表的每条记录是两段式布局:左侧是 46px 宽的日期列(蓝色加粗日期如"03-24",下方"潜水"标签),右侧是日志详情(潜点名称、深度/时间/潜伴的拼接文本、珊瑚色的备注)。备注文本使用珊瑚橙色显示,在以蓝灰色为主的日志卡片中形成了温暖的视觉焦点,让每条日志的"故事性"元素(如"海龟同游"、“锤头鲨!”)得到突出。
深度、时间、潜伴被拼接为一条文本(如"深度 14 m · 42 min · 潜伴 阿飞"),这种设计在有限高度内最大化了信息密度。三个数据字段通过" · "分隔符保持可读性,用户一眼就能获取每次潜水的核心参数。
第三十六段:个人中心 - mineContent()
@Builder
mineContent() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column() {
Text('🤿').fontSize(26)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.blueL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text('深海蓝鳍').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('AOW 进阶潜水员 · 教练候补').fontSize(9).fontColor(COLORS.coral)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('编辑').fontSize(9).fontColor(COLORS.blue)
}
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
mineContent() 是底部 Tab"我的"的主页面。顶部是用户档案卡片:56x56 的圆形头像容器(浅蓝背景,显示潜水呼吸器 Emoji)、用户昵称"深海蓝鳍"(14px 加粗)、身份描述"AOW 进阶潜水员 · 教练候补"(9px 珊瑚色)、右侧的"编辑"按钮(蓝色文字,Chip 背景)。
用户昵称"深海蓝鳍"与应用名"蓝鳍"形成了呼应,暗示这是应用的默认用户账号。身份描述中的"AOW 进阶潜水员"与课程数据中 AOW 已完成(100%)保持一致,"教练候补"则暗示该用户正在或即将开始 DM(潜水长)课程——这一信息在课程列表中对应"潜水长 DM 课程"进度为 0%(未开始),数据间的逻辑自洽性再次得到体现。
第三十七段:个人中心功能菜单
ForEach([['📕', '装备清单', '12 件'], ['📋', '我的课程', '4 门'], ['🎫', '约伴记录', '6 次'], ['🏅', '徽章墙', '9 枚'], ['🛟', '安全档案', '体检合格']], (m: string[]) => {
Row({ space: 10 }) {
Text(m[0]).fontSize(15)
Text(m[1]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(m[2]).fontSize(9).fontColor(COLORS.text3)
Text('›').fontSize(13).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
功能菜单是个人中心的主体部分,包含 5 个入口项:装备清单(12 件,与 GEAR_INIT 的 12 件一致)、我的课程(4 门,对应已获证 2 门+进行中 1 门+未开始 1 门=4 门,与状态摘要一致)、约伴记录(6 次)、徽章墙(9 枚)、安全档案(体检合格)。
每行菜单项的布局是标准设置列表样式:左侧图标、菜单名称、弹性空白、右侧值/状态、箭头符号"›"。这种布局是移动端设置页面的通用模式,用户能够凭直觉知道点击可以进入对应的详情页面。虽然当前实现中菜单项没有绑定 onClick 回调,但视觉上提供了完整的导航预期。
"安全档案"项的右侧显示"体检合格"而非数量,这是因为安全档案不是一个可以量化为数字的项目,而是一个状态——体检是否合格决定了用户是否可以进行潜水活动。"体检合格"的显示给用户传达了安全确认的信息。
第三十八段:成就徽章墙
Column({ space: 6 }) {
Text('🎖️ 成就徽章').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Row({ space: 8 }) {
ForEach(['🤿', '🦈', '📷', '🌙', '⛑️'], (b: string) => {
Column() {
Text(b).fontSize(20)
Text('已解锁').fontSize(7).fontColor(COLORS.green)
}
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
个人中心底部是成就徽章墙,展示 5 枚已解锁的徽章:潜水呼吸器(🤿,代表获得潜水证书)、鲨鱼(🦈,代表与大型海洋生物同游)、相机(📷,代表完成水下摄影)、月亮(🌙,代表完成夜潜)、头盔(⛑️,代表完成救援潜水员课程)。
每枚徽章在 20px Emoji 下方显示"已解锁"(7px 绿色),背景使用 COLORS.bg(浅蓝灰)而非白色,与卡片白色背景形成微妙的层次。5 枚徽章通过 layoutWeight(1) 等分宽度排列。这些徽章与课程数据形成了对应关系:🤿 对应 OW/AOW 已完成、🦈 对应锤头鲨日志记录、📷 对应摄影专长/微距摄影课、🌙 对应夜潜专长/夜潜礁壁日志、⛑️ 对应救援潜水员课程进行中。这种徽章与活动数据的关联设计,让成就系统不仅是装饰,而是用户潜水经历的可视化认证。
第三十九段:底部 Tab 栏 - bottomBar()
@Builder
bottomBar() {
Row({ space: 4 }) {
ForEach(BOTTOM_TABS, (t: TabMeta, i: number) => {
Column({ space: 2 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.mainTab === i ? COLORS.blue : COLORS.text3)
.fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = i; })
})
}
.width('100%').padding({ left: 8, right: 8, top: 4, bottom: 6 })
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: COLORS.line })
}
bottomBar() 构建了底部的 4 个主 Tab 导航栏。每个 Tab 项由 18px 的 Emoji 图标和 9px 的文字标签组成,通过 layoutWeight(1) 等分宽度。选中态通过文字颜色和字重的双重变化来指示:选中 Tab 的文字使用主蓝色(COLORS.blue)和加粗字体,未选中 Tab 使用三级灰色(COLORS.text3)和常规字体。这种双维度(颜色+字重)的选中态指示比单一维度的变化更加醒目。
底部栏的顶部有一道 1px 的分割线(border({ width: { top: 1 }, color: COLORS.line })),将底部栏与上方内容区域视觉分离。背景使用纯白色(#FFFFFF),与页面背景的浅蓝灰色形成对比,使底部栏在视觉上"锚定"在屏幕底部。
onClick 回调简单地设置 this.mainTab = i,触发主内容区域的条件分支重新求值,切换显示对应的主 Tab 页面。这种"点击→状态变更→条件渲染"的模式是整个应用导航的核心机制。
第四十段:弹窗遮罩复用 - modalOverlay()
@Builder
modalOverlay(onClose: () => void) {
Column() {
}
.width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
modalOverlay() 是一个参数化的 @Builder 方法,它接收一个 onClose 回调函数作为参数。这一设计是 ArkTS 中 @Builder 方法参数化的高级用法——通过传入不同的回调函数,同一个构建器可以为不同的弹窗提供遮罩层。
遮罩本身是一个空的 Column(内容为空),设置了全屏宽高和半透明深海色背景(COLORS.mask = 'rgba(11,42,68,0.55)')。onClick 回调调用传入的 onClose 函数,实现"点击遮罩区域关闭弹窗"的交互模式。三个弹窗(新增日志、编辑装备、删除组队)都复用了这个遮罩构建器,只是传入了不同的关闭回调(() => { this.addModal = false; }、() => { this.editModal = false; }、() => { this.delModal = false; })。
这种参数化遮罩的设计减少了重复代码——如果不使用参数化构建器,每个弹窗都需要单独写一个遮罩 Column,包含相同的全屏宽高和背景色设置。通过 modalOverlay(onClose) 的一行调用,即可获得完整的遮罩层和点击关闭功能。
第四十一段:build() 根装配 - 头部与主内容路由
build() {
Column() {
if (this.mainTab === 0) {
this.header()
} else {
Row({ space: 8 }) {
Text('🤿').fontSize(14)
Text(this.mainTab === 1 ? '潜水课程' : (this.mainTab === 2 ? '潜水日志' : '个人中心')).fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
}
.width('100%').padding({ left: 12, right: 12, top: 12, bottom: 10 })
.backgroundColor(COLORS.bg)
}
if (this.mainTab === 0) {
this.homeContent()
} else if (this.mainTab === 1) {
this.courseMain()
} else if (this.mainTab === 2) {
this.logMain()
} else {
this.mineContent()
}
this.bottomBar()
build() 方法是整个组件的根装配点。它采用自上而下的结构:头部区域→主内容区域→底部 Tab 栏→弹窗层→特效层。
头部区域的逻辑是条件渲染:当 mainTab === 0(首页)时,显示完整的 header() 构建器(包含搜索栏、潜水条件卡、热门潜点横滚);其他 Tab 显示一个简化版的标题栏(潜水呼吸器 Emoji + 对应的页面标题)。这种设计使得首页拥有更丰富的头部信息展示,而其他功能页面的头部则保持简洁,将更多垂直空间留给内容区域。
主内容区域的 if-else 链实现了四路路由:首页→homeContent()、课程→courseMain()、日志→logMain()、我的→mineContent()。每个分支调用对应的子构建器,子构建器内部又有自己的布局逻辑。这种"根装配→子构建器→孙构建器"的层级调用模式,使得代码结构清晰、职责分明。
第四十二段:气泡特效层
if (this.glow) {
Row({ space: 40 }) {
Text('🫧').fontSize(9).opacity(0.5)
Text('○').fontSize(6).opacity(0.4)
Text('🫧').fontSize(7).opacity(0.35)
Text('○').fontSize(10).opacity(0.45)
}
.width('100%').height(14).position({ x: 0, y: 96 }).zIndex(1)
}
气泡特效层是 build() 中的一个条件渲染区域——当 glow 为 true 时显示,为 false 时隐藏。由于 glow 每 750ms 切换一次,这个气泡层会以 750ms 的频率闪现和消失。
特效层是一个 Row,包含 4 个气泡元素:两个 Emoji 气泡(🫧)和两个圆形字符(○),大小和透明度各不相同(9px/0.5、6px/0.4、7px/0.35、10px/0.45)。这种大小和透明度的随机分布模拟了真实气泡的视觉特征——大气泡透明度高(更可见)、小气泡透明度低(更隐蔽)。
position({ x: 0, y: 96 }) 将特效层固定在距顶部 96px 的位置——这一位置正好在头部区域之下、内容区域之上,实现了"不覆盖头部"的设计要求。zIndex(1) 确保特效层浮在内容区域之上(默认 zIndex 为 0),但由于特效层高度仅 14px 且透明度较低(0.35-0.5),不会实质性地遮挡内容。整个特效层不绑定 onClick,因此不会拦截用户的点击事件。
第四十三段:新增潜水日志弹窗
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('📓 新增潜水日志').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('潜点名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 珊瑚台地', text: this.addSpot })
.onChange((v: string) => { this.addSpot = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
新增日志弹窗是三个弹窗中最复杂的一个。它使用 Stack 容器实现遮罩+弹窗的层叠效果:底层是 modalOverlay() 遮罩,上层是弹窗内容 Column。alignContent: Alignment.Center 确保弹窗内容居中显示。
弹窗内部结构从上到下依次为:标题行("📓 新增潜水日志"标题 + 弹性空白 + ✕ 关闭按钮)、分割线、潜点名称输入框、最大深度输入框、潜水备注输入框、操作按钮行。三个 TextInput 组件都通过 onChange 回调将输入值实时同步到对应的 @State 变量(addSpot、addDepth、addNote),使用 text 参数绑定了当前值,实现受控输入。
TextInput 的样式设计也很讲究:fontSize(12) 确保输入文字清晰可读,padding(10) 提供足够的触摸区域,borderRadius(8) 和 backgroundColor(COLORS.bg) 使输入框在白色弹窗背景中以浅蓝灰色凸出显示。fontColor(COLORS.title) 确保输入文字使用深色标题色,与占位文字的次要色形成区分。
第四十四段:新增日志保存逻辑
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('保存日志').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.blue).alignItems(HorizontalAlign.Center)
.onClick(() => {
let d: number = 12;
if (this.addDepth !== '') {
d = parseInt(this.addDepth);
}
this.logList.splice(0, 0, new LogItem(this.logList.length + 1, '03-27', this.addSpot === '' ? '新潜点' : this.addSpot, d, 40, '我', this.addNote === '' ? '潜水愉快' : this.addNote));
this.addSpot = ''; this.addDepth = ''; this.addNote = '';
this.addModal = false;
})
}
.width('100%')
保存按钮的 onClick 回调包含了完整的新增日志逻辑:
首先是深度值的解析。默认深度设为 12 米,如果用户输入了深度值,则使用 parseInt(this.addDepth) 解析为整数。这种"默认值+条件覆盖"的模式确保了即使用户不输入深度,日志也能以合理默认值创建。
然后是日志项的创建和插入。this.logList.splice(0, 0, new LogItem(...)) 使用 splice 方法在数组开头插入新条目——splice(0, 0, item) 表示在索引 0 处不删除任何元素并插入一个新元素,效果等同于 unshift。新 LogItem 的构造参数中:id 使用 this.logList.length + 1 确保 ID 唯一;日期固定为"03-27"(模拟当前日期);潜点名称在用户未输入时使用默认值"新潜点";深度使用解析后的值;时间固定为 40 分钟;潜伴固定为"我";备注在用户未输入时使用默认值"潜水愉快"。
最后是状态清理:清空三个输入变量(addSpot、addDepth、addNote)并关闭弹窗(addModal = false)。这一步确保了下次打开弹窗时输入框是空的,不会残留上次的数据。由于 logList 是 @State 变量且 LogItem 被 @Observed 标记,splice 操作会触发 ForEach 列表的自动刷新,新日志会立即出现在列表顶部。
第四十五段:编辑装备弹窗
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('✏️ 编辑装备').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('装备名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入新的名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
编辑装备弹窗的结构与新增日志弹窗类似,但第二个输入字段被替换为状态选择器。弹窗打开时,editName 和 editStatus 已经在 gearTab() 的编辑图标 onClick 回调中被赋值为当前装备的值(this.editName = g.name; this.editStatus = g.status),因此 TextInput 的 text 参数会显示当前装备名称,状态选择器也会高亮当前状态——这种"预填充当前值"的设计让用户可以基于现有信息进行修改,而非从零开始输入。
第四十六段:装备状态选择器
Text('使用状态').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['良好', '待保养', '损坏'], (s: string) => {
Text(s).fontSize(10)
.fontColor(this.editStatus === s ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.editStatus === s ? COLORS.blue : COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.onClick(() => { this.editStatus = s; })
})
}
.width('100%')
状态选择器是编辑装备弹窗的核心交互组件。它通过 ForEach 遍历三个状态选项(“良好”、“待保养”、“损坏”),每个选项都是一个可点击的 Text 标签。选中态通过双重条件判断实现:当前选中状态的文字显示白色(#FFFFFF)和蓝色背景(COLORS.blue),未选中的状态显示次要色文字和浅色背景。
用户点击某个状态选项时,onClick 回调更新 this.editStatus = s,由于 editStatus 是 @State 变量,状态选择器会立即重新渲染,更新选中态的视觉反馈。这种"即点即变"的交互模式比传统的下拉选择器更加直观和高效。
三个状态的排列顺序(良好→待保养→损坏)按照严重程度递增排列,符合用户的直觉认知。蓝色选中态与白色未选中态的对比清晰,用户能够一眼看出当前选中的是哪个状态。
第四十七段:编辑装备保存逻辑
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.blue).alignItems(HorizontalAlign.Center)
.onClick(() => {
if (this.editIdx < this.gearList.length) {
this.gearList[this.editIdx].name = this.editName;
this.gearList[this.editIdx].status = this.editStatus;
}
this.editModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
保存按钮的 onClick 回调首先进行边界检查(this.editIdx < this.gearList.length),确保索引不越界。然后直接修改 gearList 数组中对应索引项的 name 和 status 属性。由于 DiveGear 被 @Observed 标记,且 gearList 是 @State 变量,这些属性修改会触发 ForEach 列表中对应卡片的自动刷新——装备名称和状态文字(包括通过 statusColor 函数计算的颜色)会立即更新。
弹窗容器的 constraintSize({ maxHeight: '70%' }) 限制了弹窗最大高度不超过屏幕的 70%,防止内容过多时弹窗溢出屏幕。zIndex(999) 确保弹窗浮在所有其他内容之上。
第四十八段:删除组队邀约确认弹窗
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 10 }) {
Text('🤝').fontSize(34)
Text('撤下这条组队邀约?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('撤下后其他潜友将无法加入').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认撤下').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.teamList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
删除确认弹窗是三个弹窗中最简洁的一个,它不包含任何输入字段,仅展示确认信息。弹窗顶部是 34px 的握手 Emoji(🤝),下方是确认问题"撤下这条组队邀约?“(14px 加粗标题色)、警示提示"撤下后其他潜友将无法加入”(10px 红色,使用 COLORS.red 突出风险),以及两个操作按钮。
两个按钮的颜色设计形成对比:取消按钮使用浅色背景(COLORS.bg)和次要色文字,确认按钮使用红色背景(COLORS.red)和白色文字。红色背景传达了"危险操作"的视觉警示,让用户在点击前三思。
确认按钮的 onClick 回调执行 this.teamList.splice(this.editIdx, 1)——从数组中删除索引 editIdx 处的一个元素。由于 teamList 是 @State 变量且 TeamItem 被 @Observed 标记,删除操作会触发 ForEach 列表的自动刷新,被删除的约伴卡片会从列表中消失。
弹窗宽度设为 76%,比新增日志弹窗(82%)和编辑装备弹窗(82%)更窄,这是有意为之——确认弹窗的内容较少,较窄的宽度使弹窗在视觉上更加聚焦和紧凑。alignItems(HorizontalAlign.Center) 使弹窗内容居中排列,与确认弹窗的"聚焦决策"语义一致。
流程图
技术对比表
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 色彩系统 | interface + const 常量对象 | 21 个语义化颜色变量,深海蓝+珊瑚橙双主色 | 统一引用,避免散落的硬编码色值 |
| 导航架构 | 4 底部 Tab + 6 内嵌 Tab 两层导航 | 底部简洁,首页内容丰富,条件路由 | 单一 mainTab/homeTab 变量控制,切换成本低 |
| 数据模型 | @Observed 装饰器标记 6 个类 | 每个类含构造函数和默认值,类型安全 | 属性变化精准触发视图更新 |
| 初始数据 | const 数组预填充 6 组数据 | 14 潜点+10 课程+12 装备+12 潮汐+10 生物+10 组队+10 日志 | 页面加载即有数据,无需网络请求 |
| 颜色映射函数 | 4 个全局纯函数 (tideColor/levelColor/statusColor/rarityColor) | 语义→颜色映射,红黄蓝三色体系 | 纯函数无副作用,调用开销极小 |
| 头部设计 | 搜索栏+渐变条件卡+横向滚动潜点条 | 三段式布局,渐变背景突出今日条件 | 条件卡使用 linearGradient,GPU 渲染 |
| Tab 选中态 | 背景色反转(内嵌 Tab)+ 文字色变化(底部 Tab) | 两种选中态模式适应不同布局场景 | 仅 CSS 属性变化,无 DOM 重排 |
| 潜点卡片 | 图标+标题+标签 三段式 + 三数据列+按钮 | 信息密度高,色彩编码等级和热度 | ForEach 静态数据,无动态开销 |
| 课程进度条 | 10 段等宽 Column + 条件背景色 | 分段式进度,每段=10%,蓝/绿双色 | 10×Column 节点,轻量级渲染 |
| 装备网格 | slice(0,9) + layoutWeight(1) 实现三列 | 前端分页,3 行×3 列展示 | 只渲染 9 项,控制 DOM 节点数 |
| 潮汐柱状图 | ForEach + Column 高度=value×16 | 纯组件构建,无图表库依赖 | 12 个柱子节点,横向 Scroll |
| 生物图鉴 | 圆形图标+文字+稀有度标签 | 图文混排,稀有度颜色编码 | 静态数据,渲染稳定 |
| 组队卡片 | 可删除+沙色图标背景 | 突出缺人数和领队信息 | splice 触发列表局部刷新 |
| 日志列表 | 可新增+splice(0,0) 插入顶部 | 统计摘要+表单弹窗+记录列表 | 新增后自动排序,无需重排 |
| 弹窗系统 | Stack+modalOverlay 参数化复用 | 三弹窗共享遮罩,差异化内容 | zIndex(999) 层叠,条件渲染 |
| 气泡特效 | 单 setInterval+单 @State glow | 750ms 闪烁,position 定位不遮头部 | 单定时器单变量,最小化性能影响 |
| 生命周期 | aboutToAppear 启动定时器 + aboutToDisappear 清除 | 成对管理,防止内存泄漏 | 组件销毁即清理,无泄漏风险 |
| 表单输入 | TextInput + onChange + @State 双向绑定 | text 参数受控,onChange 实时同步 | 输入即更新状态,无防抖需求 |
| 数据一致性 | 潜点↔生物↔组队↔日志 交叉引用 | 四组数据互相关联,逻辑自洽 | 无需关联查询,数据预填充 |
| 删除操作 | splice + @Observed + @State 联动 | 两步确认(点击→弹窗→确认),防误操作 | splice 原地修改,列表自动刷新 |
总结与工程实践启示
第一,声明式 UI 的分层构建哲学在本应用中得到了充分体现。 从 @Entry 标记的入口组件到 @Builder 封装的十几个子构建器,再到 ForEach 内联的列表项模板,整个 UI 树被系统地拆分为三个层级。build() 方法作为根装配点仅负责条件路由和层级堆叠,具体的 UI 逻辑全部委托给子构建器。这种"根装配→子构建器→孙模板"的层级结构使得每个方法保持简洁(大多数构建器在 30-50 行之间),代码的可读性和可维护性大幅提升。在复杂应用开发中,合理拆分构建器是控制代码复杂度的关键手段。
第二,@Observed + @State 的响应式数据流设计是本应用交互能力的基石。 六个数据模型类全部被 @Observed 标记,三个可变列表(装备、组队、日志)作为 @State 变量绑定到 ForEach 列表。这一设计使得用户在编辑装备名称时,对应卡片的文字立即更新;在删除组队邀约时,对应卡片从列表中消失;在新增日志时,新条目出现在列表顶部。整个数据变更到视图刷新的过程完全由框架自动处理,开发者无需手动操作 DOM 或调用刷新方法。这种"数据驱动视图"的编程范式是声明式 UI 的核心价值。
第三,行业数据的深度建模与交叉引用是本应用的突出亮点。 14 个潜点、10 门课程、12 件装备、12 小时潮汐、10 种生物、10 条组队、10 条日志——这些数据不是随机的占位文本,而是经过精心设计的、具有内在逻辑关系的有机整体。潜点数据中的"珊瑚台地"在生物图鉴中对应小丑鱼的栖息地,在组队列表中对应新手体验日的目标潜点,在潜水日志中对应"海龟同游"的记录。这种交叉引用使得应用在浏览时呈现出真实的生态系统的感觉,而非孤立的列表堆叠。在实际产品开发中,这种数据建模的深度直接决定了应用的专业度和用户黏性。
第四,纯组件构建数据可视化的实践值得借鉴。 潮汐柱状图完全由 ArkTS 原生组件(Column、Text、Scroll)构建,没有依赖任何第三方图表库。柱体高度通过 value * 16 的简单数学映射实现,颜色通过 tideColor 函数语义化分配。这种做法的好处是显而易见的:零依赖、轻量、可定制、渲染性能可预期。在数据维度有限(12 个柱子)且可视化需求相对简单的情况下,自建图表是比引入图表库更优的选择。当然,如果需求扩展到折线图、饼图、动态动画等复杂场景,则应考虑使用专业图表库。
第五,参数化 @Builder 的复用模式提升了代码效率。 modalOverlay(onClose) 通过接收一个回调函数参数,使三个弹窗的遮罩层共用同一构建器,每个弹窗只需传入不同的关闭回调。这种参数化构建器的模式在需要多处复用类似 UI 结构但行为略有不同的场景中特别有效。在传统的命令式 UI 开发中,这种复用通常通过组件继承或组合实现,而在声明式 UI 中,参数化构建器提供了更轻量的复用方案。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 827 蓝鳍 · 潜水考证与装备平台(现代行业:潜水运动 / 海洋旅游)
// 头部样式:海洋运动头部(搜索栏 + 今日潜水条件大卡 + 热门潜点横滚)无动画
// 布局风格:底部 4 主 tab(首页/课程/日志/我的),首页内嵌 6 内容 tab
// 潜点=信息流大卡 / 课程=阶梯进度列表 / 装备=三列网格(可编辑)
// 潮汐=柱状图+时段表 / 生物图鉴=图文混排 / 组队=约伴卡片(可删除)
// 弹框:新增潜水日志 / 编辑装备 / 删除组队邀约
// 特效:气泡上升(setInterval 驱动,不覆盖头部)
// 主题:深海蓝 + 珊瑚橙 亮底
// ============================================================
interface ColorPalette {
bg: string;
card: string;
deep: string;
blue: string;
blueD: string;
blueL: string;
coral: string;
coralL: string;
sand: string;
title: string;
sub: string;
text3: string;
gold: string;
green: string;
red: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#EFF6FB',
card: '#FFFFFF',
deep: '#0B2A44',
blue: '#1877B8',
blueD: '#0E558A',
blueL: '#DCEAF5',
coral: '#FF7A59',
coralL: '#FFE9E2',
sand: '#F2E8D5',
title: '#123047',
sub: '#5E7B93',
text3: '#A8BDCC',
gold: '#D9A441',
green: '#2E9E6B',
red: '#D9534F',
line: '#DCE7F0',
tabOn: '#1877B8',
mask: 'rgba(11,42,68,0.55)',
chip: 'rgba(24,119,184,0.10)'
};
interface TabMeta {
icon: string;
label: string;
}
const BOTTOM_TABS: TabMeta[] = [
{ icon: '🏖️', label: '首页' },
{ icon: '🎓', label: '课程' },
{ icon: '📓', label: '日志' },
{ icon: '👤', label: '我的' }
];
const HOME_TABS: TabMeta[] = [
{ icon: '📍', label: '潜点' },
{ icon: '🎓', label: '课程' },
{ icon: '🤿', label: '装备' },
{ icon: '🌊', label: '潮汐' },
{ icon: '🐠', label: '生物' },
{ icon: '🤝', label: '组队' }
];
@Observed export class DiveSpot {
id: number = 0;
name: string = '';
icon: string = '';
area: string = '';
depth: number = 0;
vis: number = 0;
temp: number = 0;
level: string = '';
hot: string = '';
constructor(id: number, name: string, icon: string, area: string, depth: number, vis: number, temp: number, level: string, hot: string) {
this.id = id; this.name = name; this.icon = icon; this.area = area;
this.depth = depth; this.vis = vis; this.temp = temp; this.level = level; this.hot = hot;
}
}
const SPOT_INIT: DiveSpot[] = [
new DiveSpot(1, '蓝洞探秘', '🌊', '东礁环', 28, 25, 24, 'AOW', '🔥 热门'),
new DiveSpot(2, '沉船花园', '⚓', '北湾沉船区', 32, 18, 22, 'AOW', '🔥 经典'),
new DiveSpot(3, '珊瑚台地', '🪸', '浅滩公园', 12, 30, 26, 'OW', '新手友好'),
new DiveSpot(4, '蝠鲼清洁站', '🦈', '深礁脊', 26, 20, 23, 'AOW', '🔥 网红'),
new DiveSpot(5, '海龟草场', '🐢', '南湾草甸', 9, 15, 26, 'OW', '海龟常驻'),
new DiveSpot(6, '夜潜礁壁', '🌙', '西礁崖', 18, 12, 24, 'AOW', '限夜间'),
new DiveSpot(7, '锤头鲨大道', '🦈', '外海峡谷', 40, 28, 21, '专家', '🔥 震撼'),
new DiveSpot(8, '微距天堂', '🔍', '沙底斜坡', 16, 22, 25, 'OW', '海兔天堂'),
new DiveSpot(9, '海扇秘境', '🪸', '流角', 24, 19, 23, 'AOW', '海扇巨大'),
new DiveSpot(10, '沙丁鱼风暴', '🐟', '回旋湾', 22, 14, 24, 'OW', '🔥 群游'),
new DiveSpot(11, '火山口温泉', '🌋', '东北火山', 19, 10, 29, 'AOW', '地质奇观'),
new DiveSpot(12, '灯塔暗礁', '🗼', '灯塔东侧', 14, 21, 25, 'OW', '光影绝佳'),
new DiveSpot(13, '海草丛·海马', '🐴', '浅滩东', 7, 13, 27, 'OW', '海马之家'),
new DiveSpot(14, '峭壁大蓝水', '🏔️', '远洋崖', 36, 30, 22, '专家', '能见度王')
];
@Observed export class CourseItem {
id: number = 0;
name: string = '';
icon: string = '';
level: string = '';
days: number = 0;
dives: number = 0;
price: string = '';
progress: number = 0;
constructor(id: number, name: string, icon: string, level: string, days: number, dives: number, price: string, progress: number) {
this.id = id; this.name = name; this.icon = icon; this.level = level;
this.days = days; this.dives = dives; this.price = price; this.progress = progress;
}
}
const COURSE_INIT: CourseItem[] = [
new CourseItem(1, '开放水域 OW 初级', '🤿', '入门', 4, 5, '¥2680', 100),
new CourseItem(2, '进阶开放水域 AOW', '🏅', '进阶', 3, 5, '¥2280', 100),
new CourseItem(3, '救援潜水员', '🚑', '专业', 4, 6, '¥3180', 60),
new CourseItem(4, '高氧空气专长', '💨', '专长', 1, 2, '¥980', 30),
new CourseItem(5, '深潜专长', '🌊', '专长', 2, 4, '¥1480', 0),
new CourseItem(6, '夜潜专长', '🌙', '专长', 2, 3, '¥1280', 0),
new CourseItem(7, '水下摄影专长', '📷', '专长', 2, 4, '¥1880', 0),
new CourseItem(8, '干衣潜水专长', '🧥', '专长', 2, 3, '¥1680', 0),
new CourseItem(9, '侧挂专长', '🎒', '专长', 3, 5, '¥2080', 0),
new CourseItem(10, '潜水长 DM 课程', '🦸', '职业', 14, 40, '¥8880', 0)
];
@Observed export class DiveGear {
id: number = 0;
name: string = '';
icon: string = '';
price: string = '';
status: string = '';
level: number = 0;
tag: string = '';
constructor(id: number, name: string, icon: string, price: string, status: string, level: number, tag: string) {
this.id = id; this.name = name; this.icon = icon; this.price = price;
this.status = status; this.level = level; this.tag = tag;
}
}
const GEAR_INIT: DiveGear[] = [
new DiveGear(1, '调节器套装', '🔧', '¥3680', '良好', 5, '核心'),
new DiveGear(2, 'BCD 浮力背心', '🎽', '¥2280', '良好', 4, '核心'),
new DiveGear(3, '潜水服 5mm', '🧥', '¥880', '待保养', 3, '保暖'),
new DiveGear(4, '面镜+呼吸管', '👓', '¥420', '良好', 3, '面镜'),
new DiveGear(5, '潜水电脑表', '⌚', '¥3299', '良好', 5, '安全'),
new DiveGear(6, '蛙鞋', '🦶', '¥680', '良好', 3, '推进'),
new DiveGear(7, '潜水手电', '🔦', '¥399', '待保养', 3, '夜潜'),
new DiveGear(8, '头灯套装', '💡', '¥259', '良好', 2, '夜潜'),
new DiveGear(9, '配重带', '⚖️', '¥180', '良好', 2, '配重'),
new DiveGear(10, '潜水刀', '🔪', '¥230', '良好', 2, '工具'),
new DiveGear(11, '水下相机壳', '📷', '¥2980', '良好', 4, '摄影'),
new DiveGear(12, '浮力袋', '🛟', '¥360', '良好', 3, '安全')
];
interface BarData {
label: string;
value: number;
type: string;
}
const TIDE_BARS: BarData[] = [
{ label: '05:00', value: 2.4, type: '退' },
{ label: '06:00', value: 1.8, type: '退' },
{ label: '07:00', value: 1.2, type: '退' },
{ label: '08:00', value: 0.9, type: '低' },
{ label: '09:00', value: 1.4, type: '涨' },
{ label: '10:00', value: 2.1, type: '涨' },
{ label: '11:00', value: 2.8, type: '涨' },
{ label: '12:00', value: 3.2, type: '高' },
{ label: '13:00', value: 2.6, type: '退' },
{ label: '14:00', value: 1.9, type: '退' },
{ label: '15:00', value: 1.3, type: '退' },
{ label: '16:00', value: 0.8, type: '低' }
];
@Observed export class CreatureItem {
id: number = 0;
name: string = '';
icon: string = '';
kind: string = '';
place: string = '';
rarity: string = '';
note: string = '';
constructor(id: number, name: string, icon: string, kind: string, place: string, rarity: string, note: string) {
this.id = id; this.name = name; this.icon = icon; this.kind = kind;
this.place = place; this.rarity = rarity; this.note = note;
}
}
const CREATURE_INIT: CreatureItem[] = [
new CreatureItem(1, '绿海龟', '🐢', '爬行类', '南湾草场', '常见', '草场常客'),
new CreatureItem(2, '蝠鲼', '🪁', '软骨鱼', '深礁脊', '少见', '清洁站巡游'),
new CreatureItem(3, '锤头鲨', '🦈', '软骨鱼', '外海峡谷', '稀有', '结群巡游'),
new CreatureItem(4, '小丑鱼', '🐠', '硬骨鱼', '珊瑚台地', '常见', '海葵共生'),
new CreatureItem(5, '海兔', '🐌', '软体类', '沙底斜坡', '少见', '颜色多样'),
new CreatureItem(6, '沙丁鱼群', '🐟', '硬骨鱼', '回旋湾', '常见', '群游风暴'),
new CreatureItem(7, '海马', '🐴', '硬骨鱼', '海草丛', '稀有', '栖息海草'),
new CreatureItem(8, '章鱼', '🐙', '软体类', '夜潜礁壁', '少见', '夜行变色'),
new CreatureItem(9, '狮子鱼', '🦁', '硬骨鱼', '沉船花园', '少见', '色彩艳丽'),
new CreatureItem(10, '鹦嘴鱼', '🦜', '硬骨鱼', '海扇秘境', '常见', '啃食珊瑚')
];
@Observed export class TeamItem {
id: number = 0;
title: string = '';
icon: string = '';
spot: string = '';
date: string = '';
need: string = '';
level: string = '';
leader: string = '';
constructor(id: number, title: string, icon: string, spot: string, date: string, need: string, level: string, leader: string) {
this.id = id; this.title = title; this.icon = icon; this.spot = spot;
this.date = date; this.need = need; this.level = level; this.leader = leader;
}
}
const TEAM_INIT: TeamItem[] = [
new TeamItem(1, '周末双潜拼船', '🚤', '珊瑚台地+海龟草场', '3月29日 08:30', '缺 2 人', 'OW+', '老船长'),
new TeamItem(2, '蓝洞摄影团', '📷', '蓝洞探秘', '4月2日 07:00', '缺 1 人', 'AOW', '光影猎人'),
new TeamItem(3, '夜潜专场', '🌙', '夜潜礁壁', '4月4日 19:00', '缺 3 人', 'AOW', '夜潜向导'),
new TeamItem(4, '锤头鲨远征', '🦈', '外海峡谷', '4月8日 06:30', '缺 4 人', '专家', '远洋舰队'),
new TeamItem(5, '微距摄影课', '🔍', '沙底斜坡', '4月10日 09:00', '缺 2 人', 'OW+', '微距大师'),
new TeamItem(6, '沉船进阶团', '⚓', '沉船花园', '4月12日 08:00', '缺 3 人', 'AOW', '沉船专家'),
new TeamItem(7, '新手体验日', '🤿', '珊瑚台地', '4月13日 10:00', '缺 5 人', '体验', '教练小蓝'),
new TeamItem(8, '海龟保育探访', '🐢', '南湾草甸', '4月15日 09:30', '缺 2 人', 'OW+', '保育员'),
new TeamItem(9, '沙丁风暴追拍', '🐟', '回旋湾', '4月17日 07:30', '缺 3 人', 'OW+', '追鱼人'),
new TeamItem(10, '峭壁深潜挑战', '🏔️', '峭壁大蓝水', '4月19日 06:00', '缺 2 人', '专家', '深蓝小队')
];
@Observed export class LogItem {
id: number = 0;
date: string = '';
spot: string = '';
depth: number = 0;
minutes: number = 0;
buddy: string = '';
note: string = '';
constructor(id: number, date: string, spot: string, depth: number, minutes: number, buddy: string, note: string) {
this.id = id; this.date = date; this.spot = spot; this.depth = depth;
this.minutes = minutes; this.buddy = buddy; this.note = note;
}
}
const LOG_INIT: LogItem[] = [
new LogItem(1, '03-24', '珊瑚台地', 14, 42, '阿飞', '海龟同游'),
new LogItem(2, '03-15', '海龟草场', 9, 38, '小蓝', '看到海马'),
new LogItem(3, '03-08', '沉船花园', 30, 45, '老船长', '船舱内部'),
new LogItem(4, '02-28', '蓝洞探秘', 26, 40, '阿飞', '光线绝美'),
new LogItem(5, '02-19', '沙底斜坡', 16, 50, '微距大师', '海兔成群'),
new LogItem(6, '02-11', '夜潜礁壁', 18, 35, '夜潜向导', '章鱼变色'),
new LogItem(7, '01-30', '回旋湾', 22, 44, '追鱼人', '沙丁风暴'),
new LogItem(8, '01-18', '海扇秘境', 24, 48, '老船长', '巨大海扇'),
new LogItem(9, '01-05', '灯塔暗礁', 14, 39, '小蓝', '光影绝佳'),
new LogItem(10, '12-22', '外海峡谷', 40, 52, '远洋舰队', '锤头鲨!')
];
// ============ 全局纯函数 ============
function tideColor(t: string): string {
if (t === '高' || t === '低') { return '#D9A441'; }
if (t === '涨') { return '#1877B8'; }
return '#7A9BB5';
}
function levelColor(lv: string): string {
if (lv === '专家') { return '#D9534F'; }
if (lv === 'AOW') { return '#FF7A59'; }
return '#1877B8';
}
function statusColor(s: string): string {
if (s === '待保养') { return '#FF7A59'; }
if (s === '损坏') { return '#D9534F'; }
return '#2E9E6B';
}
function rarityColor(r: string): string {
if (r === '稀有') { return '#D9534F'; }
if (r === '少见') { return '#FF7A59'; }
return '#5E7B93';
}
// ============================================================
@Entry
@Component
struct Page827 {
@State mainTab: number = 0;
@State homeTab: number = 0;
@State glow: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addSpot: string = '';
@State addDepth: string = '';
@State addNote: string = '';
@State editName: string = '';
@State editStatus: string = '良好';
@State gearList: DiveGear[] = GEAR_INIT;
@State teamList: TeamItem[] = TEAM_INIT;
@State logList: LogItem[] = LOG_INIT;
timer: number = 0;
aboutToAppear() {
this.timer = setInterval(() => { this.glow = !this.glow; }, 750);
}
aboutToDisappear() {
clearInterval(this.timer);
}
// ============ 头部(海洋运动电商风格,无动画) ============
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🔍').fontSize(14)
Text('搜索潜点 / 课程 / 装备').fontSize(12).fontColor(COLORS.sub)
}
.width('100%').padding({ left: 14, right: 14, top: 9, bottom: 9 })
.borderRadius(20).backgroundColor('#FFFFFF')
// 今日潜水条件大卡
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('水温').fontSize(9).fontColor(COLORS.blueL)
Text('26°C').fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('能见度').fontSize(9).fontColor(COLORS.blueL)
Text('24m').fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('浪高').fontSize(9).fontColor(COLORS.blueL)
Text('0.6m').fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('适合潜水').fontSize(9).fontColor(COLORS.coralL)
Text('✅ 适宜').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(14).borderRadius(16)
.linearGradient({ angle: 135, colors: [['#1877B8', 0], ['#0E558A', 1]] })
// 热门潜点横滚
Scroll() {
Row({ space: 8 }) {
ForEach(SPOT_INIT, (s: DiveSpot) => {
Column({ space: 2 }) {
Text(s.icon).fontSize(18)
Text(s.name).fontSize(9).fontColor(COLORS.sub)
}
.width(58).padding({ top: 8, bottom: 6 }).borderRadius(10)
.backgroundColor('#FFFFFF').alignItems(HorizontalAlign.Center)
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
}
// ============ 首页 6 内容 tab 横条 ============
@Builder
homeTabBar() {
Scroll() {
Row({ space: 6 }) {
ForEach(HOME_TABS, (t: TabMeta, i: number) => {
Column({ space: 1 }) {
Text(t.icon).fontSize(14)
Text(t.label).fontSize(9)
}
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(10)
.backgroundColor(this.homeTab === i ? COLORS.blue : '#FFFFFF')
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.homeTab = i; })
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
// ============ 首页内容切换 ============
@Builder
homeContent() {
Column({ space: 0 }) {
this.homeTabBar()
Scroll() {
Column({ space: 10 }) {
if (this.homeTab === 0) {
this.spotTab()
} else if (this.homeTab === 1) {
this.courseTab()
} else if (this.homeTab === 2) {
this.gearTab()
} else if (this.homeTab === 3) {
this.tideTab()
} else if (this.homeTab === 4) {
this.creatureTab()
} else {
this.teamTab()
}
}
.width('100%')
}
.scrollBar(BarState.Off)
.align(Alignment.Top)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%')
}
// ============ 潜点:信息流大卡 ============
@Builder
spotTab() {
Column({ space: 8 }) {
ForEach(SPOT_INIT, (s: DiveSpot) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Column() {
Text(s.icon).fontSize(22)
}
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.blueL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(s.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(s.area).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(s.hot).fontSize(8).fontColor(COLORS.red)
Text(s.level).fontSize(9).fontColor(levelColor(s.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('深度').fontSize(8).fontColor(COLORS.text3)
Text(s.depth.toString() + ' m').fontSize(12).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('能见度').fontSize(8).fontColor(COLORS.text3)
Text(s.vis.toString() + ' m').fontSize(12).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('水温').fontSize(8).fontColor(COLORS.text3)
Text(s.temp.toString() + ' °C').fontSize(12).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('看详情').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.blue)
}
.width('100%')
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 课程:阶梯进度列表 ============
@Builder
courseTab() {
Column({ space: 8 }) {
ForEach(COURSE_INIT, (c: CourseItem) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(c.icon).fontSize(16)
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.level).fontSize(8).fontColor(COLORS.coral)
.padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6)
.backgroundColor(COLORS.coralL)
Column().layoutWeight(1)
Text(c.price).fontSize(11).fontColor(COLORS.coral).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 8 }) {
Text(c.days.toString() + ' 天 · ' + c.dives.toString() + ' 潜').fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text(c.progress.toString() + '%').fontSize(9).fontColor(c.progress === 100 ? COLORS.green : COLORS.sub)
}
.width('100%')
Row({ space: 4 }) {
ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (i: number) => {
Column().layoutWeight(1).height(5).borderRadius(3)
.backgroundColor(i <= (c.progress / 10) ? (c.progress === 100 ? COLORS.green : COLORS.blue) : COLORS.line)
})
}
.width('100%')
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 装备:三列网格(可编辑) ============
@Builder
gearTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
ForEach(this.gearList.slice(0, 9), (g: DiveGear, gi: number) => {
Column({ space: 4 }) {
Text(g.icon).fontSize(20)
Text(g.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(g.price).fontSize(8).fontColor(COLORS.coral)
Text(g.status).fontSize(7).fontColor(statusColor(g.status))
Text('✏️').fontSize(11)
.onClick(() => { this.editIdx = gi; this.editName = g.name; this.editStatus = g.status; this.editModal = true; })
}
.layoutWeight(1).padding({ top: 10, bottom: 10, left: 4, right: 4 }).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Text('共 ' + this.gearList.length.toString() + ' 件装备 · 点 ✏️ 可编辑').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
}
// ============ 潮汐:柱状图 + 时段表 ============
@Builder
tideTab() {
Column({ space: 10 }) {
Column({ space: 8 }) {
Text('🌊 今日潮位曲线(m)').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Scroll() {
Row({ space: 6 }) {
ForEach(TIDE_BARS, (t: BarData) => {
Column({ space: 4 }) {
Text(t.value.toString()).fontSize(8).fontColor(COLORS.sub)
Column().width(18).height(t.value * 16).borderRadius(5)
.backgroundColor(tideColor(t.type))
Text(t.label).fontSize(7).fontColor(COLORS.sub)
Text(t.type).fontSize(7).fontColor(tideColor(t.type))
}
.alignItems(HorizontalAlign.Center)
})
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
Row({ space: 6 }) {
ForEach([['🌅', '06:24', '低潮 0.9m', '适合沙滩观察'], ['☀️', '11:58', '高潮 3.2m', '最佳下水时段'], ['🌇', '16:42', '低潮 0.8m', '潮池生物多']], (w: string[]) => {
Column({ space: 4 }) {
Text(w[0]).fontSize(16)
Text(w[1]).fontSize(11).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text(w[2]).fontSize(8).fontColor(COLORS.sub)
Text(w[3]).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
}
.width('100%')
}
// ============ 生物图鉴:图文混排 ============
@Builder
creatureTab() {
Column({ space: 8 }) {
ForEach(CREATURE_INIT, (c: CreatureItem) => {
Row({ space: 10 }) {
Column() {
Text(c.icon).fontSize(24)
}
.width(50).height(50).borderRadius(25).backgroundColor(COLORS.blueL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.kind).fontSize(8).fontColor(COLORS.sub)
}
Text(c.place + ' · ' + c.note).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(c.rarity).fontSize(9).fontColor(rarityColor(c.rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 组队:约伴卡片(可删除) ============
@Builder
teamTab() {
Column({ space: 8 }) {
ForEach(this.teamList, (t: TeamItem, idx: number) => {
Row({ space: 10 }) {
Column() {
Text(t.icon).fontSize(20)
}
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.sand)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(t.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(t.spot + ' · ' + t.date).fontSize(9).fontColor(COLORS.sub)
Text('领队 ' + t.leader + ' · ' + t.level).fontSize(8).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 6 }) {
Text(t.need).fontSize(9).fontColor(COLORS.coral).fontWeight(FontWeight.Bold)
Text('🗑️').fontSize(13)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 课程主 tab:横向进度 + 明细 ============
@Builder
courseMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 8 }) {
ForEach([['🎓', 'OW', '已获证'], ['🏅', 'AOW', '已获证'], ['🚑', '救援', '进行中'], ['📷', '摄影', '未开始']], (c: string[]) => {
Column({ space: 4 }) {
Text(c[0]).fontSize(18)
Text(c[1]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c[2]).fontSize(8).fontColor(c[2] === '已获证' ? COLORS.green : (c[2] === '进行中' ? COLORS.coral : COLORS.text3))
}
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Text('📜 全部课程(' + COURSE_INIT.length.toString() + ')').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
ForEach(COURSE_INIT, (c: CourseItem) => {
Row({ space: 8 }) {
Text(c.icon).fontSize(15)
Column({ space: 2 }) {
Text(c.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.days.toString() + ' 天 · ' + c.dives.toString() + ' 潜 · ' + c.price).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(c.progress === 100 ? '✅ 完成' : (c.progress > 0 ? '进行中' : '报名')).fontSize(8).fontColor(c.progress === 100 ? COLORS.green : COLORS.blue)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
// ============ 日志主 tab:潜水记录 ============
@Builder
logMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('86').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('累计潜水').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('3120').fontSize(18).fontColor(COLORS.coral).fontWeight(FontWeight.Bold)
Text('水下分钟').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('26').fontSize(18).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('足迹潜点').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
Row({ space: 8 }) {
Text('📓 潜水日志').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Column() { Text('+ 新增').fontSize(10).fontColor('#FFFFFF') }
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.blue)
.onClick(() => { this.addModal = true; })
}
.width('100%')
ForEach(this.logList, (l: LogItem) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(l.date).fontSize(11).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('潜水').fontSize(8).fontColor(COLORS.text3)
}
.width(46).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(l.spot).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('深度 ' + l.depth.toString() + ' m · ' + l.minutes.toString() + ' min · 潜伴 ' + l.buddy).fontSize(9).fontColor(COLORS.sub)
Text(l.note).fontSize(9).fontColor(COLORS.coral)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
// ============ 我的:个人中心 ============
@Builder
mineContent() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column() {
Text('🤿').fontSize(26)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.blueL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text('深海蓝鳍').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('AOW 进阶潜水员 · 教练候补').fontSize(9).fontColor(COLORS.coral)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('编辑').fontSize(9).fontColor(COLORS.blue)
}
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
ForEach([['📕', '装备清单', '12 件'], ['📋', '我的课程', '4 门'], ['🎫', '约伴记录', '6 次'], ['🏅', '徽章墙', '9 枚'], ['🛟', '安全档案', '体检合格']], (m: string[]) => {
Row({ space: 10 }) {
Text(m[0]).fontSize(15)
Text(m[1]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(m[2]).fontSize(9).fontColor(COLORS.text3)
Text('›').fontSize(13).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
Column({ space: 6 }) {
Text('🎖️ 成就徽章').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Row({ space: 8 }) {
ForEach(['🤿', '🦈', '📷', '🌙', '⛑️'], (b: string) => {
Column() {
Text(b).fontSize(20)
Text('已解锁').fontSize(7).fontColor(COLORS.green)
}
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
// ============ 底部 4 主 tab ============
@Builder
bottomBar() {
Row({ space: 4 }) {
ForEach(BOTTOM_TABS, (t: TabMeta, i: number) => {
Column({ space: 2 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.mainTab === i ? COLORS.blue : COLORS.text3)
.fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = i; })
})
}
.width('100%').padding({ left: 8, right: 8, top: 4, bottom: 6 })
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: COLORS.line })
}
// ============ 弹框遮罩 ============
@Builder
modalOverlay(onClose: () => void) {
Column() {
}
.width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
build() {
Column() {
// 头部
if (this.mainTab === 0) {
this.header()
} else {
Row({ space: 8 }) {
Text('🤿').fontSize(14)
Text(this.mainTab === 1 ? '潜水课程' : (this.mainTab === 2 ? '潜水日志' : '个人中心')).fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
}
.width('100%').padding({ left: 12, right: 12, top: 12, bottom: 10 })
.backgroundColor(COLORS.bg)
}
// 主内容区
if (this.mainTab === 0) {
this.homeContent()
} else if (this.mainTab === 1) {
this.courseMain()
} else if (this.mainTab === 2) {
this.logMain()
} else {
this.mineContent()
}
// 底部 tab
this.bottomBar()
// 气泡上升特效层
if (this.glow) {
Row({ space: 40 }) {
Text('🫧').fontSize(9).opacity(0.5)
Text('○').fontSize(6).opacity(0.4)
Text('🫧').fontSize(7).opacity(0.35)
Text('○').fontSize(10).opacity(0.45)
}
.width('100%').height(14).position({ x: 0, y: 96 }).zIndex(1)
}
// ===== 新增潜水日志弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('📓 新增潜水日志').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('潜点名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 珊瑚台地', text: this.addSpot })
.onChange((v: string) => { this.addSpot = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('最大深度 (m)').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 18', text: this.addDepth })
.onChange((v: string) => { this.addDepth = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('潜水备注').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '记录看到的生物', text: this.addNote })
.onChange((v: string) => { this.addNote = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('保存日志').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.blue).alignItems(HorizontalAlign.Center)
.onClick(() => {
let d: number = 12;
if (this.addDepth !== '') {
d = parseInt(this.addDepth);
}
this.logList.splice(0, 0, new LogItem(this.logList.length + 1, '03-27', this.addSpot === '' ? '新潜点' : this.addSpot, d, 40, '我', this.addNote === '' ? '潜水愉快' : this.addNote));
this.addSpot = ''; this.addDepth = ''; this.addNote = '';
this.addModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 编辑装备弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('✏️ 编辑装备').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('装备名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入新的名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('使用状态').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['良好', '待保养', '损坏'], (s: string) => {
Text(s).fontSize(10)
.fontColor(this.editStatus === s ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.editStatus === s ? COLORS.blue : COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.onClick(() => { this.editStatus = s; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.blue).alignItems(HorizontalAlign.Center)
.onClick(() => {
if (this.editIdx < this.gearList.length) {
this.gearList[this.editIdx].name = this.editName;
this.gearList[this.editIdx].status = this.editStatus;
}
this.editModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 删除组队邀约确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 10 }) {
Text('🤝').fontSize(34)
Text('撤下这条组队邀约?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('撤下后其他潜友将无法加入').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认撤下').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.teamList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
—
第六,单定时器驱动特效的极简方案在性能与效果间取得了良好平衡。 整个应用只有一个 setInterval(750ms)和一个 @State 变量(glow)驱动气泡特效。每次 glow 切换只触发气泡特效层的条件渲染(显示/隐藏),不会导致整个页面的重绘。通过 position 定位将特效层固定在头部下方的特定位置,实现了"不覆盖头部"的视觉要求。这种方案虽然在视觉效果上比较简单(闪烁而非平滑上升),但在性能开销上几乎可以忽略不计。在实际开发中,如果需要更复杂的动画效果(如气泡持续上升),可以考虑使用 ArkTS 的 animateTo API 或 Animator 组件,但这会增加实现复杂度。
第七,弹窗系统的一致性设计提升了用户体验和开发效率。 三个弹窗虽然功能不同(表单输入、属性编辑、删除确认),但共享一致的结构模式:Stack 容器→modalOverlay 遮罩→Column 弹窗内容。弹窗内部的标题行(标题+✕ 关闭)、分割线、内容区域、操作按钮行(取消+确认)的布局模式也保持一致。这种一致性使用户在操作不同弹窗时能够凭直觉使用,无需学习每个弹窗的交互方式。同时,对开发者而言,新增弹窗只需复制现有模式并修改内容,大幅降低了开发成本。弹窗的 zIndex(999) 确保其浮在所有内容之上,constraintSize 限制了最大高度防止内容溢出,这些细节处理体现了对边界情况的周全考虑。
更多推荐



所有评论(0)