引言

在移动互联网与街头文化深度融合的当下,垂直社区类应用正在经历从信息聚合向沉浸式体验转型的关键阶段。华为推出的 HarmonyOS 6.1.1 作为鸿蒙操作系统的重要演进版本,不仅在前代基础上进一步强化了底层的渲染管线效率和内存调度机制,更通过 HarmonyOS ArkTS API 24 为开发者提供了一套完整的声明式 UI 编程框架,使得构建具有丰富视觉表现力和复杂交互逻辑的原生应用成为可能。基于 HarmonyOS API 24 的开发能力,开发者可以充分利用系统级的状态管理追踪、细粒度的差分渲染算法以及高效的组件复用机制来构建出在高帧率下流畅运行的高品质移动应用。本文将以一个完整的城市滑板社区应用页面为切入点,深入剖析 HarmonyOS ArkTS API 24 在单文件页面架构下的完整工程实践,从类型系统设计、响应式数据建模、纯函数业务逻辑到组件化构建和多弹框状态管理,全面解读每一行代码背后的技术决策与设计哲学。

滑板运动作为街头文化的标志性载体,具有其独特的用户群体特征和产品需求维度。一个面向滑板爱好者的社区应用不仅需要覆盖场地发现(Spot)、技巧教学、装备选购、赛事报名和滑手排行等核心功能模块,还需要通过视觉设计传达街头潮流文化的美学语言——这包括霓虹色调的暗色主题、涂鸦风格的自定义元素、以及大量动态反馈效果。这样一个业务场景复杂且视觉表现力要求极高的应用,恰好能够全面检验 HarmonyOS ArkTS API 24 在多 Tab 内容管理、横向滚动列表、瀑布流布局、自定义日历组件、进度条可视化、定时器驱动的动画层、条件渲染弹框系统以及底部导航交互等方面的综合能力。通过本文的逐段代码分析,读者将深入理解如何运用 ArkTS 的 @Entry@Component@State@Observed@Builder 等核心装饰器来组织一个具有生产级复杂度的单文件应用架构。

声明式 UI 编程范式的核心哲学是"状态即视图",即开发者只需要声明界面在任意状态下的最终形态,而由框架负责在状态变化时高效地计算差异并更新渲染树。HarmonyOS ArkTS API 24 在这一范式上进行了深度的工程优化,包括基于依赖追踪的精确状态更新、ForEach 列表渲染的 key 优化、以及 @Builder 构建器的内联展开机制。在本文所分析的城市滑板社区应用中,我们可以清晰地看到这一范式的实际应用:当用户在七个内容 Tab 之间切换时,curTab 状态的变化会自动驱动 build 方法中条件分支的重新求值,只渲染当前选中页面的内容区域;当定时器每 120 毫秒递增 tick 状态时,全屏浮动 emoji 特效层会根据新的 tick 值重新计算每个 emoji 的位置、旋转角度和透明度,实现持续流动的背景动画;当用户点击 Spot 卡片打开约板弹框时,bizOpen 状态从 false 变为 truemodalOverlay 中的条件渲染分支被激活,弹框从透明遮罩中浮现。这些场景共同构成了对声明式 UI 框架响应式渲染能力的全面验证。

在架构设计层面,本文所分析的应用采用了"接口层—数据模型层—设计令牌层—静态数据层—纯函数层—组件状态层—Builder 视图层"的七层分离架构。接口层由一组 TypeScript interface 定义构成,为整个应用提供类型安全的数据契约;数据模型层通过 @Observed 装饰的 class implements 模式将接口实例化为可观察的响应式对象;设计令牌层集中管理所有颜色常量、配置映射和枚举选项,实现视觉语言的统一管控;静态数据层模拟后端 API 返回的业务数据;纯函数层包含所有不依赖组件状态的逻辑函数,如星级格式化、难度标签计算、数字缩写等,具有高复用性和可测试性;组件状态层通过 @State 装饰器建立状态与视图的绑定关系;Builder 视图层通过一系列 @Builder 函数将状态和数据转化为结构化的 UI 组件树。这种分层设计使代码职责清晰、关注点分离,在应对需求变更和功能扩展时展现出极高的架构韧性。

此外,本文还将重点关注 HarmonyOS ArkTS API 24 中多弹框条件渲染系统的设计模式。该应用实现了四种不同风格的弹框——新增 Spot 录入弹框、装备编辑工单弹框、删除确认警示弹框和约板组局弹框——它们共享同一个 modalOverlay 容器,通过四个独立的布尔状态变量控制各自的条件渲染分支。这种设计不仅避免了多个弹框同时显示的冲突问题,还使得点击遮罩区域时可以统一关闭所有弹框,提供了清晰一致的交互逻辑。同时,每个弹框内部的视觉风格截然不同——从荧光渐变标题条到贴纸涂鸦风格再到黄黑斜纹警示条——展示了如何在统一的架构框架下实现差异化的视觉表达。基于 HarmonyOS 6.1.1 系统的渲染加速支持,这些丰富的视觉元素能够在保证帧率的前提下呈现精细的视觉效果,为用户带来沉浸式的街头潮流体验。

一、系统整体架构与接口设计

1.1 项目架构总览

在深入每一行代码之前,我们首先从宏观层面理解整个城市滑板社区应用的架构设计。该应用采用了 HarmonyOS ArkTS API 24 所推崇的单文件页面架构,即将接口定义、数据模型、设计令牌、静态数据、纯函数逻辑和 UI 组件全部组织在一个源文件中。这种架构选择在中小型项目中具有显著优势:它消除了跨文件依赖管理的复杂度,使开发者能够在一个编辑器视图中纵览整个页面的全貌,同时也便于代码审查和快速迭代。对于一个包含七个内容 Tab 和四种弹框交互的复杂页面而言,单文件架构在保持代码内聚性的同时,通过良好的分层组织确保了可读性。

类型约束

类型约束

视觉引用

被调用

数据注入

接口定义层
11 个 TypeScript 接口

数据模型层
6 个 @Observed class

设计令牌层
COLORS + 8 组配置映射

静态数据层
12 Spot + 10 Trick + 12 Gear
8 Contest + 10 Skater + 12 Moment

纯函数层
starsOf / diffLabel / fmtCount 等

组件状态层
12 个 @State 变量 + 定时器

Builder 视图层
7 页面 + 4 弹框 + 辅助层

build 主入口
Stack 布局组装

从上述架构图可以清晰看到,整个系统从下至上分为七个层次。最底层的接口定义层为整个系统提供了类型安全的契约,确保数据在各层之间传递时不会出现类型不匹配的问题。数据模型层通过 @Observed 装饰器将接口实例化为可观察的响应式类,使数据对象本身具备参与响应式渲染的能力。设计令牌层集中管理所有颜色常量和配置映射,实现视觉语言的统一管控和主题切换能力。静态数据层模拟后端 API 返回的业务数据,使前端开发能够在不依赖后端服务的情况下独立推进。纯函数层是整个系统的业务逻辑核心,它不持有任何组件状态,仅负责接收输入并返回确定的输出。组件状态层和 Builder 视图层共同构成了视图层,它们通过 @State 装饰器建立状态与视图之间的绑定关系,最终在 build 方法中通过 Stack 布局组装成完整的页面。

1.2 ColorPalette 接口:色彩系统的类型契约

interface ColorPalette {
  bg: string
  card: string
  ink: string
  sub: string
  dark: string
  darkCard: string
  darkField: string
  neon: string
  neonOrange: string
  neonPurple: string
  neonPink: string
  neonGreen: string
  neonBlue: string
  gold: string
  silver: string
  bronze: string
  warnYellow: string
  stripeBlack: string
  line: string
  chipBg: string
}

在这里插入图片描述

ColorPalette 接口定义了整个应用的色彩系统类型契约,这是整个设计系统中最基础的一环。在 HarmonyOS ArkTS API 24 中,接口不仅用于约束对象的结构,更在编译期提供了类型安全保障,使得任何使用 ColorPalette 类型的变量都必须包含全部二十个颜色字段,任何缺失都会在编译阶段被立即捕获。这种强类型约束在大型项目中尤为重要,它能够在编译期就消除大量因颜色字段拼写错误或遗漏导致的运行时崩溃。

这里定义了二十个颜色字段,每一个都有其明确的语义用途,构成了一个层次分明的暗色霓虹色彩体系。bg 代表页面背景色,card 代表卡片背景色,darkdarkCarddarkField 是深色变体,分别用于更深层的背景层级——如弹框背景、弹框内嵌区域和输入框背景。ink 是主文本颜色(对应白色系),sub 是次要文本颜色(对应灰色系),这两个字段构成了文本颜色的二级层次体系。neon 及其五个变体(neonOrangeneonPurpleneonPinkneonGreenneonBlue)是整个应用的视觉灵魂——霓虹色系,分别用于不同语义场景的强调色:橙色用于主品牌色和难度指示,紫色用于滑手积分和进阶标识,粉色用于街拍互动和装备标签,绿色用于成功状态和进度指示,蓝色用于信息提示和约板入口。goldsilverbronze 三色专门用于滑手榜单的金银铜奖牌配色,体现了体育竞技的仪式感。warnYellow 用于警示信息和特殊标注,stripeBlack 用于黄黑斜纹警示条的黑底色,line 是分隔线颜色,chipBg 是标签胶囊的背景色。

通过将颜色定义抽象为接口而非直接使用字符串字面量,整个应用获得了一个关键优势:色彩变更的集中管理。如果未来需要支持浅色主题或自定义主题,只需提供不同的 ColorPalette 实现即可,而不需要修改任何组件代码。这种设计在 HarmonyOS ArkTS API 24 的声明式 UI 框架中尤为重要,因为 UI 组件通过属性链式调用设置颜色(如 .fontColor(COLORS.neonOrange)),如果颜色散落在各处,主题切换将变成一场灾难。同时,类型化的颜色常量还能获得 IDE 的自动补全支持,大幅提升开发效率。

1.3 TabMeta 接口:内容标签元数据

interface TabMeta {
  label: string
  icon: string
}

TabMeta 接口定义了顶部内容 Tab 的元数据结构,包含 label(标签文字)和 icon(图标 emoji)两个字段。这个接口虽然结构简单,但它在整个应用的导航系统中扮演着关键角色。在 HarmonyOS ArkTS API 24 的声明式 UI 中,ForEach 组件用于循环渲染列表元素,它需要接收一个数据数组和一个构建函数。通过将 Tab 元数据抽象为接口,CONTENT_TABS 常量可以以类型安全的方式存储所有 Tab 信息,tabBar 构建器中的 ForEach 遍历时也能获得完整的类型推导。

这种将 UI 元数据从组件代码中分离出来的设计模式具有多重好处。首先,它使得添加或修改 Tab 变得非常简单——只需修改 CONTENT_TABS 数组即可,无需触碰组件代码。其次,它保证了 Tab 数据的一致性:每个 Tab 都必须包含 label 和 icon 两个字段,不会出现某个 Tab 缺少图标的情况。最后,它使得 Tab 数据可以被多个组件共享复用,例如顶部 Tab 条和底部导航栏可以引用不同的子集。

1.4 GroundMeta 接口:地面类型元数据

interface GroundMeta {
  label: string
  icon: string
  color: string
}

在这里插入图片描述

GroundMeta 接口定义了滑板场地地面类型的元数据结构。在滑板运动中,地面材质直接影响滑行体验和动作表现:混凝土提供良好的抓地力但硬度高,大理石地面平滑如镜适合 flip 动作,沥青路面适合刷街巡航,木地板多见于室内碗池场地。这个接口通过 label(中文标签)、icon(emoji 图标)和 color(主题色)三个字段,将地面类型的视觉表现和语义信息统一封装。

GROUND_CONFIG 常量中,这个接口被实例化为一个 Record<string, GroundMeta> 类型的映射表,将中文地面名称映射到完整的元数据对象。这种设计使得在 Spot 卡片渲染时,可以通过 GROUND_CONFIG[s.ground]?.icon 这样的可选链表达式安全地获取地面图标,即使传入了一个未知的地面类型也不会导致运行时崩溃——可选链会在属性不存在时返回 undefined,而后面的 ?? '🛣' 空值合并操作符会提供一个默认的 fallback 图标。这是 HarmonyOS ArkTS API 24 类型安全设计的典型应用场景。

1.5 WearOpt 与 LevelOpt 接口:选项配置模型

interface WearOpt {
  label: string
  color: string
  desc: string
}

interface LevelOpt {
  label: string
  icon: string
  color: string
  desc: string
}

在这里插入图片描述

WearOpt 接口定义了滑板板面磨损状态的选项模型,包含 label(状态名称)、color(状态颜色)和 desc(状态描述)三个字段。在装备编辑弹框中,磨损状态以一组彩色胶囊按钮的形式呈现,用户通过点击选择当前板面的磨损程度。每个选项都有自己的颜色编码——新板用绿色、轻微磨损用蓝色、中度战损用橙色、退役边缘用粉色——这种颜色编码使用户能够一眼判断装备的健康状态,无需阅读文字描述。

LevelOpt 接口在 WearOpt 的基础上增加了 icon 字段,用于定义约板水平要求的选项模型。在约板组局弹框中,水平要求以分组单选列表的形式呈现,每个选项包含一个 emoji 图标(如 🐣 表示新手友好、🔥 表示进阶刷街、💀 表示大神乱杀)、标签名称、描述文字和主题色。这种结构化的选项配置使得 UI 渲染逻辑可以完全数据驱动——只需修改 LEVEL_OPTSWEAR_OPTS 数组即可增删选项,无需修改组件代码。

1.6 StatusMeta 接口:赛事状态元数据

interface StatusMeta {
  label: string
  color: string
  bg: string
}

StatusMeta 接口定义了赛事状态的三色元数据,包含 label(状态文字)、color(文字颜色)和 bg(背景颜色)三个字段。在赛事日历页面中,每场赛事都有一个状态标签(报名中、进行中、已结束),这个标签需要同时设置文字颜色和背景颜色来形成色彩对比。通过将这三个值封装在一个接口中,可以确保状态标签的配色方案始终保持一致——不会出现文字颜色和背景颜色对比度不足的情况。

STATUS_CONFIG 常量中,三个状态分别对应不同的配色方案:报名中使用绿色文字配深绿背景(传达"可以参与"的积极信号),进行中使用蓝色文字配深蓝背景(传达"正在进行中"的动态感),已结束使用灰色文字配深灰背景(传达"已结束"的弱化感)。这种语义化的色彩设计使用户能够在不阅读文字的情况下,仅凭颜色就判断赛事的当前状态,这在信息密集的列表场景中极大地提升了信息获取效率。

1.7 SpotData 接口:场地数据契约

interface SpotData {
  id: number
  name: string
  area: string
  diff: number
  ground: string
  heat: number
  riders: number
  desc: string
  emoji: string
  color: string
}

在这里插入图片描述

SpotData 接口定义了滑板场地(Spot)的完整数据契约,包含十个字段。id 是唯一标识符,用于列表渲染的 key 追踪;name 是场地名称;area 是地理位置描述;diff 是难度等级(1-5 的整数);ground 是地面类型(对应 GROUND_CONFIG 中的键);heat 是人气值(0-100 的整数,用于进度条展示);riders 是当前在场滑手数;desc 是场地描述文案;emoji 是场地的视觉图标;color 是场地的主题色(用于卡片左侧色块)。

这个接口的设计体现了几个重要的工程考量。首先,diff 使用数字而非字符串,使得后续的难度比较和颜色计算可以通过纯函数直接处理数值大小关系,而无需解析字符串。其次,ground 使用字符串而非枚举,虽然牺牲了一些类型安全性,但获得了与 GROUND_CONFIG 映射表直接对接的灵活性。最后,emojicolor 字段将视觉元素与数据绑定,使得每个场地都有自己独特的视觉标识——这种设计在内容密集的列表中帮助用户快速区分不同场地。

1.8 TrickData 接口:技巧数据契约

interface TrickData {
  id: number
  name: string
  en: string
  diff: number
  steps: string[]
  mastered: number
  tip: string
  emoji: string
  color: string
}

在这里插入图片描述

TrickData 接口定义了滑板技巧的数据契约,包含九个字段。与 SpotData 相比,这里增加了 en(英文名称)和 steps(动作分解步骤数组)两个字段。en 字段体现了滑板文化的国际化特征——几乎所有滑板技巧都有标准的英文术语(如 Ollie、Kickflip、Pop Shove-it),在中文社区中同时展示中英文名称是行业惯例。steps 是一个字符串数组,存储技巧的动作分解步骤,每个步骤是一个独立的字符串元素,这允许在 UI 中以编号列表的形式逐步展示教学步骤。

mastered 字段记录用户已掌握的步骤数量,这个值与 steps.length 配合使用,可以在 UI 中计算并展示学习进度。例如,当 steps.length 为 5 且 mastered 为 3 时,步骤列表中前三步会显示为绿色已完成状态(带删除线),后两步显示为灰色待完成状态,同时底部会显示 3/5 已掌握 的进度文字和圆点进度指示器。这种将进度数据嵌入技巧数据模型的设计,使得进度展示可以直接从数据驱动,无需额外的状态管理。

1.9 GearData 接口:装备数据契约

interface GearData {
  id: number
  name: string
  brand: string
  cat: string
  price: number
  oldPrice: number
  rating: number
  specs: string[]
  emoji: string
  barPct: number
  color: string
}

在这里插入图片描述

GearData 接口定义了滑板装备的数据契约,包含十一个字段,是所有数据接口中字段最多的一个。brand 是品牌名称,cat 是分类(板面/轮子/桥),priceoldPrice 构成价格对比——当前价格和划线原价,在 UI 中原价会添加删除线装饰来突出折扣信息。rating 是 1-5 的整数评分,通过 starsOf 纯函数转换为 ★☆ 字符串展示。specs 是规格参数数组,每项是一个字符串(如"宽度 8.25""、"枫木 7层"等),在卡片右侧以无序列表形式逐条展示。

barPct 字段是装备的"指数百分比",在板面价格对比横条图中用作柱状图的高度比例值。这个字段的设计体现了将可视化数据预计算并存储在数据模型中的思路——而非在渲染时动态计算,这减少了运行时的计算开销,同时允许运营人员在后台直接配置每个装备的展示指数。color 字段与 SpotData 中的用法一致,用于装备左侧色块和价格对比柱状图的颜色编码。

1.10 ContestData 接口:赛事数据契约

interface ContestData {
  id: number
  name: string
  month: number
  day: number
  week: string
  time: string
  loc: string
  ctype: string
  prize: string
  entries: number
  cap: number
  status: string
  color: string
}

ContestData 接口定义了赛事的数据契约,包含十三个字段。日期信息被拆分为 month(月)、day(日)、week(星期)三个独立字段而非使用一个日期字符串,这种设计使得在赛事卡片中可以灵活地组织日期展示格式——左侧色块中显示"8月"和"15"和"周六"三行信息,层次分明。ctype 是赛事类型(碗池赛、街式赛、垂直赛等),prize 是奖品描述,entriescap 分别是当前报名人数和容量上限,这两个值的比值用于计算报名进度条的填充百分比。

status 字段是一个字符串键,对应 STATUS_CONFIG 映射表中的键,通过可选链表达式 STATUS_CONFIG[c.status]?.label 可以安全地获取状态标签。这种"数据中存储键、配置中查找值"的设计模式,将数据与展示逻辑分离——如果未来需要修改状态标签的配色方案,只需修改 STATUS_CONFIG 而无需触碰数据。

1.11 SkaterData 接口:滑手数据契约

interface SkaterData {
  rank: number
  name: string
  nick: string
  style: string
  score: number
  best: string
  avatar: string
  trend: number
  color: string
}

在这里插入图片描述

SkaterData 接口定义了滑手排行榜的数据契约,包含九个字段。rank 是排名序号,name 是真实姓名,nick 是社交媒体昵称(以 @ 前缀展示),style 是滑手风格分类(街式 STREET、碗池 BOWL、平地 FLAT、刷街 CRUISE、速降 DOWNHILL),score 是赛季积分,best 是招牌动作描述,avatar 是 emoji 头像,trend 是积分环比涨幅百分比(正数表示上升)。

trend 字段的设计值得关注——它存储的是一个已经计算好的百分比数值而非原始的积分变化数据,这简化了前端渲染逻辑(直接在数字前加"▲"符号即可),但牺牲了一些灵活性(如果需要展示跌幅则需要额外处理)。在当前应用的上下文中,所有滑手的 trend 都是正数(表示积分上升),所以这个设计是合理的。

1.12 MomentData 接口:街拍动态数据契约

interface MomentData {
  id: number
  title: string
  author: string
  likes: number
  emoji: string
  tag: string
  h: number
  color: string
  color2: string
}

MomentData 接口定义了街拍动态(moment)的数据契约,包含九个字段。likes 是点赞数,通过 fmtCount 纯函数格式化为缩写形式(如 3218 显示为 “3.2k”)。tag 是话题标签(如"日落"、“首清”、"平地"等),在卡片左下角以胶囊标签形式展示。h 是卡片图片区域的高度(像素值),这个字段的存在使得瀑布流布局中每张卡片的高度可以不同,形成参差错落的视觉效果——这是瀑布流布局的核心特征。

colorcolor2 是渐变色的起止颜色,用于卡片图片区域的线性渐变背景。每条 moment 都有自己独特的渐变配色方案,这使得瀑布流中的每张卡片在视觉上都有独特的辨识度。配合不同高度的 h 值,整个瀑布流呈现出丰富的层次感和节奏感,模拟了小红书等社交平台的街拍信息流体验。

二、响应式数据模型设计

2.1 @Observed 装饰器与 class implements 模式

@Observed
class SpotItem implements SpotData {
  id: number = 0
  name: string = ''
  area: string = ''
  diff: number = 1
  ground: string = '混凝土'
  heat: number = 0
  riders: number = 0
  desc: string = ''
  emoji: string = '🛹'
  color: string = '#333333'

  constructor(id: number, name: string, area: string, diff: number, ground: string, heat: number, riders: number, desc: string, emoji: string, color: string) {
    this.id = id; this.name = name; this.area = area; this.diff = diff
    this.ground = ground; this.heat = heat; this.riders = riders
    this.desc = desc; this.emoji = emoji; this.color = color
  }
}

@Observed 是 HarmonyOS ArkTS API 24 中用于声明可观察类的装饰器。当一个类被 @Observed 修饰时,其实例对象的属性变更可以被 ArkUI 框架追踪,从而在属性值变化时自动触发依赖该属性的 UI 组件重新渲染。这种机制是 HarmonyOS 响应式 UI 系统的核心——不同于传统的命令式 UI 需要手动调用 setStateforceUpdate@Observed 类的属性变更本身就是 UI 更新的触发信号。

SpotItem 类通过 implements SpotData 语法实现了 SpotData 接口,这意味着它必须包含接口中定义的所有字段。这种"接口定义契约 + 类实现契约"的双重保障设计具有显著的工程价值:接口提供了纯粹的类型契约,可以在不实例化类的情况下使用(如在函数参数类型标注中);而类提供了具体的实例化能力,包括构造函数和默认值设置。在 HarmonyOS ArkTS API 24 中,interface 不能有构造函数,只能定义结构;而 class 可以有构造函数,可以实例化。通过 implements 关键字将两者关联,既获得了接口的类型安全约束,又获得了类的实例化能力。

值得注意的是,SpotItem 的所有属性都有默认值初始值(如 id: number = 0name: string = '')。这是 HarmonyOS ArkTS API 24 的一个重要语法要求——在 ArkTS 中,类的属性必须显式初始化,不能仅声明类型而不赋值。这个要求比标准 TypeScript 更严格,因为 ArkTS 编译器会在编译期检查所有属性是否已初始化,未初始化的属性会导致编译错误。这种严格的设计可以避免运行时访问未初始化属性导致的 undefined 问题。

构造函数接收全部十个参数,并通过一行紧凑的赋值语句完成初始化。这种将所有参数放在一个构造函数中的设计虽然参数列表较长,但在静态数据初始化时非常直观——new SpotItem(1, '滨江碗池', '浦东滨江 · 3号驿站', 4, '混凝土', 96, 1240, '6尺深transitions,泵道丝滑,落日时分的全场高光', '🌀', '#3B2A6E') 这样的调用语句可以直接看到每个字段的值,便于数据审查和维护。

2.2 TrickItem:技巧数据模型

@Observed
class TrickItem implements TrickData {
  id: number = 0
  name: string = ''
  en: string = ''
  diff: number = 1
  steps: string[] = []
  mastered: number = 0
  tip: string = ''
  emoji: string = '🛹'
  color: string = '#333333'

  constructor(id: number, name: string, en: string, diff: number, steps: string[], mastered: number, tip: string, emoji: string, color: string) {
    this.id = id; this.name = name; this.en = en; this.diff = diff
    this.steps = steps; this.mastered = mastered; this.tip = tip
    this.emoji = emoji; this.color = color
  }
}

在这里插入图片描述

TrickItem 类实现了 TrickData 接口,用于表示一个滑板技巧的完整数据。与 SpotItem 相比,它增加了 en(英文名称)、steps(步骤数组)和 mastered(已掌握步骤数)三个字段。steps 属性的类型是 string[],默认值初始化为空数组 [],这确保了即使构造函数传入的 steps 数组为空,属性也不会是 undefined,在 ForEach 渲染时不会因遍历 undefined 而崩溃。

mastered 的默认值为 0,表示一个全新的技巧尚未开始练习。在实际数据中,这个值从 0 到 5 不等,表示用户已经掌握了多少个步骤。在 UI 渲染时,这个值会被用于条件判断——步骤索引 idx 小于 mastered 的步骤显示为绿色已完成状态,否则显示为灰色待完成状态。这种将进度数据嵌入数据模型的设计,使得进度展示可以直接从数据驱动,无需额外的状态管理。

2.3 GearItem:装备数据模型

@Observed
class GearItem implements GearData {
  id: number = 0
  name: string = ''
  brand: string = ''
  cat: string = ''
  price: number = 0
  oldPrice: number = 0
  rating: number = 5
  specs: string[] = []
  emoji: string = '🛹'
  barPct: number = 50
  color: string = '#333333'

  constructor(id: number, name: string, brand: string, cat: string, price: number, oldPrice: number, rating: number, specs: string[], emoji: string, barPct: number, color: string) {
    this.id = id; this.name = name; this.brand = brand; this.cat = cat
    this.price = price; this.oldPrice = oldPrice; this.rating = rating
    this.specs = specs; this.emoji = emoji; this.barPct = barPct; this.color = color
  }
}

GearItem 类实现了 GearData 接口,用于表示一件滑板装备的完整数据。构造函数接收十一个参数,是所有数据模型中参数最多的。rating 属性的默认值为 5(满分),这意味着如果在创建实例时忘记传入评分参数,装备会默认显示为五星好评——这是一个安全默认值的选择,因为展示一个高评分的装备比展示零评分更符合商业展示逻辑。

barPct 属性默认值为 50,这是一个中间值,在价格对比横条图中会渲染为一个半满的柱状条。这种默认值选择确保了即使数据缺失,UI 也不会出现空白的柱状条或过长的柱状条,保持了视觉上的合理性。

2.4 ContestItem、SkaterItem 与 MomentItem

@Observed
class ContestItem implements ContestData {
  id: number = 0
  name: string = ''
  month: number = 8
  day: number = 1
  week: string = '周六'
  time: string = ''
  loc: string = ''
  ctype: string = ''
  prize: string = ''
  entries: number = 0
  cap: number = 64
  status: string = '报名中'
  color: string = '#333333'

  constructor(id: number, name: string, month: number, day: number, week: string, time: string, loc: string, ctype: string, prize: string, entries: number, cap: number, status: string, color: string) {
    this.id = id; this.name = name; this.month = month; this.day = day
    this.week = week; this.time = time; this.loc = loc; this.ctype = ctype
    this.prize = prize; this.entries = entries; this.cap = cap
    this.status = status; this.color = color
  }
}

ContestItem 类实现了 ContestData 接口。month 默认值为 8,day 默认值为 1,week 默认值为"周六",cap 默认值为 64,status 默认值为"报名中"——这些默认值的选择都基于赛事数据的常见特征:大多数滑板赛事在周末举行,报名容量通常为 32-64 人,初始状态为"报名中"。通过设置合理的默认值,即使构造函数的某些参数被省略,实例对象也不会出现不合理的字段值。

@Observed
class SkaterItem implements SkaterData {
  rank: number = 0
  name: string = ''
  nick: string = ''
  style: string = ''
  score: number = 0
  best: string = ''
  avatar: string = '🛹'
  trend: number = 0
  color: string = '#333333'

  constructor(rank: number, name: string, nick: string, style: string, score: number, best: string, avatar: string, trend: number, color: string) {
    this.rank = rank; this.name = name; this.nick = nick; this.style = style
    this.score = score; this.best = best; this.avatar = avatar
    this.trend = trend; this.color = color
  }
}

SkaterItem 类实现了 SkaterData 接口。构造函数接收九个参数,按排名、姓名、昵称、风格、积分、招牌动作、头像、趋势、颜色的顺序排列。在实际数据初始化中,每个滑手的 color 值与其奖牌颜色或排名相关——第一名用金色 #FFD54A,第二名用银色 #CFD8DC,第三名用铜色 #E0955A,后续名次使用霓虹色系中的不同颜色。

@Observed
class MomentItem implements MomentData {
  id: number = 0
  title: string = ''
  author: string = ''
  likes: number = 0
  emoji: string = '🛹'
  tag: string = ''
  h: number = 160
  color: string = '#333333'
  color2: string = '#111111'

  constructor(id: number, title: string, author: string, likes: number, emoji: string, tag: string, h: number, color: string, color2: string) {
    this.id = id; this.title = title; this.author = author; this.likes = likes
    this.emoji = emoji; this.tag = tag; this.h = h
    this.color = color; this.color2 = color2
  }
}

MomentItem 类实现了 MomentData 接口。h 默认值为 160,这是一个适中的卡片图片高度,确保即使数据中未指定高度,卡片也不会过高或过低。color2 默认值为 #111111(接近黑色),作为渐变色的终止颜色,配合 color 的起始颜色形成从亮到暗的渐变效果。在实际数据中,每条 moment 的 colorcolor2 都是精心配对的颜色组合,如 #5C3A6E#1C1230 的紫色渐变、#6E2A2A#2A1010 的暗红渐变等,每种配色都呼应了街拍内容的不同主题氛围。

三、设计令牌系统

3.1 COLORS 色彩常量

const COLORS: ColorPalette = {
  bg: '#121216',
  card: '#1C1C24',
  ink: '#F5F5F7',
  sub: '#9A9AA6',
  dark: '#0A0A0F',
  darkCard: '#101014',
  darkField: '#1B1B22',
  neon: '#FF5A1F',
  neonOrange: '#FF5A1F',
  neonPurple: '#B24BF3',
  neonPink: '#FF3D81',
  neonGreen: '#2FFF6E',
  neonBlue: '#38D9FF',
  gold: '#FFD54A',
  silver: '#CFD8DC',
  bronze: '#E0955A',
  warnYellow: '#FFD400',
  stripeBlack: '#151515',
  line: '#2A2A34',
  chipBg: '#24242E'
}

COLORS 常量是整个应用的色彩系统单例,它将 ColorPalette 接口实例化为一组具体的十六进制颜色值。这个常量在整个应用的生命周期中不会被修改(const 声明确保了引用不可变),所有 UI 组件都通过 COLORS.xxx 的方式引用颜色值。这种集中化的色彩管理方式具有几个显著的工程优势。

首先是色彩一致性。在整个应用中,所有卡片背景色都使用 COLORS.card,所有主文本都使用 COLORS.ink,所有次要文本都使用 COLORS.sub——不会出现不同组件中"差不多但又不完全一样"的颜色值。这种一致性在视觉上表现为整个应用具有统一的色彩语言,在工程上表现为色彩变更只需修改一处即可全局生效。

其次是暗色主题的系统性设计。从 bg#121216)到 dark#0A0A0F)再到 darkCard#101014),背景色形成了一个从浅到深的层次体系,每个层级之间有明确的亮度差异,使得在暗色主题下不同层级的 UI 区域仍然可以清晰区分。霓虹色系(neonOrangeneonBlue)的高饱和度色彩在暗色背景上具有极强的视觉冲击力,这正是街头潮流风格的设计语言核心。

值得注意的是 neonneonOrange 的值相同(都是 #FF5A1F),这是因为 neon 是一个通用名称,而 neonOrange 是一个语义化名称——在实际使用中,开发者可以根据语义选择更贴切的字段名,而不用担心颜色值不一致。这种"别名"设计在不增加额外颜色的情况下提供了更好的代码可读性。

3.2 GROUND_CONFIG 地面类型映射

const GROUND_CONFIG: Record<string, GroundMeta> = {
  '混凝土': { label: '混凝土', icon: '🧱', color: '#C58F6A' },
  '大理石': { label: '大理石', icon: '⚪', color: '#B0C4D8' },
  '沥青': { label: '沥青', icon: '🛣', color: '#8E9BA8' },
  '木地板': { label: '木地板', icon: '🪵', color: '#D8A45C' }
}

GROUND_CONFIG 常量使用 Record<string, GroundMeta> 类型,将中文地面名称映射到完整的地面元数据对象。Record<K, V> 是 TypeScript/ArkTS 中的泛型工具类型,等价于 { [key: string]: GroundMeta },表示一个以字符串为键、以 GroundMeta 为值的索引类型对象。使用 Record 而非普通对象类型的好处是类型安全——编译器会确保所有值的结构符合 GroundMeta 接口定义。

这个映射表在 Spot 卡片中通过可选链表达式 GROUND_CONFIG[s.ground]?.icon 被调用。当 s.ground 的值是"混凝土"时,表达式返回 '🧱';当 s.ground 的值是一个不在映射表中的未知字符串时,GROUND_CONFIG[s.ground] 返回 undefined,可选链 ?. 使得 undefined.icon 不再抛出运行时错误而是返回 undefined,随后的空值合并操作符 ?? '🛣' 提供了一个默认的 fallback 图标。这种防御性编程模式确保了即使数据中出现未知的地面类型,UI 也不会崩溃,而是优雅地降级显示。

3.3 STATUS_CONFIG 赛事状态映射

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '报名中': { label: '报名中', color: '#2FFF6E', bg: '#12291A' },
  '进行中': { label: '进行中', color: '#38D9FF', bg: '#12232B' },
  '已结束': { label: '已结束', color: '#77777F', bg: '#222229' }
}

STATUS_CONFIG 常量将赛事状态字符串映射到包含文字颜色和背景颜色的元数据对象。三个状态分别对应三种不同的配色方案,传达不同的语义信息。报名中(绿色)传达积极信号——用户可以参与;进行中(蓝色)传达动态感——赛事正在发生;已结束(灰色)传达弱化感——赛事已经过去。这种语义化的色彩设计使得用户在浏览赛事列表时,可以仅凭颜色快速筛选自己关心的赛事类型。

背景色 bg 的设计遵循了"暗色背景上的亮色文字"原则——每种状态的背景色都是其文字色的极暗版本。例如,报名中的文字色是 #2FFF6E(亮绿),背景色是 #12291A(极暗绿),两者在色相上一致但亮度差异巨大,形成了高对比度的可读性。这种配色方案在 HarmonyOS ArkTS API 24 的暗色主题应用中非常常见。

3.4 WEAR_OPTS 与 LEVEL_OPTS 选项数组

const WEAR_OPTS: WearOpt[] = [
  { label: '新板', color: '#2FFF6E', desc: '刚贴砂纸' },
  { label: '轻微磨损', color: '#38D9FF', desc: '板尾起毛' },
  { label: '中度战损', color: '#FF5A1F', desc: '板面开裂' },
  { label: '退役边缘', color: '#FF3D81', desc: '即将收藏' }
]

WEAR_OPTS 常量是一个 WearOpt 类型的数组,定义了四种板面磨损状态选项。颜色从绿色(新板)到蓝色(轻微)到橙色(中度)到粉色(退役),形成了一个从"健康"到"危险"的颜色梯度,直观地传达了装备的退化程度。每个选项还有一句简短的描述文案(如"刚贴砂纸"、“板尾起毛”),帮助用户理解每个状态的具体含义。

const LEVEL_OPTS: LevelOpt[] = [
  { label: '新手友好', icon: '🐣', color: '#2FFF6E', desc: '带教学 · 慢速刷街' },
  { label: '进阶刷街', icon: '🔥', color: '#FF5A1F', desc: '道具场地 · 中等强度' },
  { label: '大神乱杀', icon: '💀', color: '#B24BF3', desc: '大台阶 · 高强度' }
]

LEVEL_OPTS 常量是一个 LevelOpt 类型的数组,定义了三种约板水平要求选项。每个选项除了 label、color、desc 之外还有 icon 字段——🐣 表示新手友好(可爱、温和),🔥 表示进阶刷街(热情、有挑战),💀 表示大神乱杀(极限、硬核)。这些 emoji 图标不仅是视觉装饰,更是语义编码——它们在滑板文化中有约定俗成的含义,能够帮助用户快速判断约板活动的强度水平。

3.5 CONTENT_TABS 内容标签配置

const CONTENT_TABS: TabMeta[] = [
  { label: '热门Spot', icon: '🔥' },
  { label: '技巧研究所', icon: '🧪' },
  { label: '装备车间', icon: '🧰' },
  { label: '赛事日历', icon: '📅' },
  { label: '滑手榜', icon: '🏆' },
  { label: '街拍moment', icon: '📸' },
  { label: '我的板', icon: '🛹' }
]

CONTENT_TABS 常量定义了应用顶部内容区域的七个 Tab 标签。每个 Tab 由一个 emoji 图标和中文标签组成,从"热门Spot"到"我的板"覆盖了滑板社区的核心功能维度。这七个 Tab 的排列顺序也体现了产品设计的思路——从内容发现(Spot、技巧、装备)到活动参与(赛事、滑手榜、街拍)再到个人中心(我的板),形成了一个从公共到私人的信息流。

这个数组在 tabBar 构建器中被 ForEach 遍历渲染为一组水平排列的 Tab 按钮。当前选中的 Tab 使用 COLORS.neonOrange 背景色和深色文字,未选中的 Tab 使用 COLORS.card 背景色和灰色文字。通过将 Tab 配置抽象为数组常量,添加或删除 Tab 只需修改数组即可,无需触碰组件代码——这是数据驱动 UI 设计的典型实践。

3.6 其他设计令牌与常量

const GROUND_TYPES: string[] = ['混凝土', '大理石', '沥青', '木地板']

const DECK_SIZES: string[] = ['7.75', '8.0', '8.125', '8.25', '8.5']

const FX_EMOJIS: string[] = ['🛹', '🔥', '⚡', '🏆', '🛹', '💥', '🎯', '⚡', '🔥', '🛹', '✨', '🏆']

GROUND_TYPES 定义了地面类型的有序数组,用于新增 Spot 弹框中的地面类型胶囊选择器。与 GROUND_CONFIG 映射表不同,这是一个有序数组,定义了选项在 UI 中的排列顺序。DECK_SIZES 定义了板面宽度的可选尺寸(英寸),从 7.75" 到 8.5",涵盖了街式滑板最常见的尺寸范围。

FX_EMOJIS 是一个包含十二个滑板相关 emoji 的数组,用于全屏浮动特效层。这些 emoji 包括滑板(🛹)、火焰(🔥)、闪电(⚡)、奖杯(🏆)、爆炸(💥)、靶心(🎯)、闪光(✨)等,它们以不同的速度、旋转角度和透明度在屏幕上漂浮,营造出街头文化的活力氛围。数组中有意重复了一些 emoji(如 🛹 出现三次、🔥 出现两次),确保特效层中滑板和火焰元素出现频率更高,强化视觉主题。

const CAL_ROWS: number[][] = [
  [0, 0, 0, 0, 0, 1, 2],
  [3, 4, 5, 6, 7, 8, 9],
  [10, 11, 12, 13, 14, 15, 16],
  [17, 18, 19, 20, 21, 22, 23],
  [24, 25, 26, 27, 28, 29, 30],
  [31, 0, 0, 0, 0, 0, 0]
]

const CAL_MARKS: number[] = [8, 15, 16, 22, 23, 29]

const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日']

const BIZ_AVATARS: string[] = ['😎', '🧢', '🔥', '🐯', '🦌', '👽', '🐻', '🦊']

CAL_ROWS 定义了赛事日历页面的日历网格数据——一个 6 行 7 列的二维数组,每个元素是一个日期数字(0 表示空格,1-31 表示日期)。这个数组按照周一到周日的排列方式组织,第一行从周五开始(1 号和 2 号),最后一行只有 31 号。通过将日历数据预定义为常量数组,渲染时只需两层 ForEach 遍历即可生成完整的日历网格,无需在组件中编写复杂的日期计算逻辑。

CAL_MARKS 定义了日历中有赛事标记的日期(8、15、16、22、23、29 号),这些日期会在日历中显示一个紫色圆点标记。WEEK_LABELS 定义了日历顶部的星期标签。BIZ_AVATARS 定义了约板弹框中已报名滑手的头像 emoji 列表,共八个不同的 emoji,代表八位已报名的滑手。

3.7 MY_BOARD_SPECS 与 WEEK_KM 个人数据

const MY_BOARD_SPECS: string[] = ['板面 Santa Cruz 8.25 尖叫手掌', '轮子 Spitfire F4 52mm 99a', '桥 Independent 139 十一代', '轴承 Bones Reds 陶瓷版']

const WEEK_KM: number[] = [12, 18, 9, 22, 26, 31, 15]

MY_BOARD_SPECS 定义了用户当前滑板的装备配置清单,包含板面、轮子、桥和轴承四个核心部件的详细规格信息。这些信息在"我的板"页面以清单列表的形式展示,每项前面有一个滑板 emoji 图标和绿色对勾标记。

WEEK_KM 定义了用户本周每天的滑行里程(公里),从周一到周日。这组数据在"我的板"页面的里程统计区域以柱状图形式展示——每天一个柱状条,高度与里程值成正比(WEEK_KM[d] * 2.4 像素高度)。周六(索引 5)的柱状条使用 COLORS.neonOrange(橙色)高亮,其他天使用 COLORS.line(灰色),使最高里程日(周六 31km)在视觉上突出。

四、静态数据层

4.1 SPOTS:十二个热门场地

const SPOTS: SpotItem[] = [
  new SpotItem(1, '滨江碗池', '浦东滨江 · 3号驿站', 4, '混凝土', 96, 1240, '6尺深transitions,泵道丝滑,落日时分的全场高光', '🌀', '#3B2A6E'),
  new SpotItem(2, '老厂房台阶', '桃浦创意园 B区', 5, '混凝土', 88, 860, '7级大台阶+侧扶手,全城大神的试炼场', '🏭', '#5C3A21'),
  new SpotItem(3, '人民广场大理石', '人民广场地下层', 2, '大理石', 92, 2100, '地面如镜面,平地动作圣地,flip党的家', '⬜', '#2E4A5C'),
  // ... 共12个场地
]

SPOTS 常量是一个 SpotItem 类型的数组,包含十二个滑板场地的完整数据。每个 SpotItem 实例通过 new SpotItem(...) 构造函数创建,传入 id、名称、位置、难度、地面类型、人气值、滑手数、描述、emoji 和主题色十个参数。这十二个场地覆盖了上海地区不同类型的滑板 Spot——从滨江碗池到老厂房台阶,从商场下沉广场到高架桥下空间,每个场地都有其独特的地形特征和滑手文化氛围。

数据设计上,难度值从 1(新手福音)到 5(地狱级)分布,人气值从 65 到 96 不等,滑手数从 360 到 2100 范围。每个场地的 color 值都是精心选择的暗色调颜色,在卡片左侧色块中作为背景色使用,配合白色 emoji 图标形成视觉标识。这些颜色虽然各不相同,但都遵循暗色系的设计语言,与整体应用的霓虹暗色主题保持和谐。

4.2 TRICKS:十个技巧教学

const TRICKS: TrickItem[] = [
  new TrickItem(1, '豚跳', 'Ollie', 2, ['后脚猛踩板尾起跳', '前脚外翻刷板砂', '肩膀带动整体上提', '双腿收拢贴板飞行', '落地屈膝缓冲'], 5, '一切街式动作的地基,每天热身先来20个', '🦘', '#FF5A1F'),
  new TrickItem(2, '尖翻', 'Kickflip', 3, ['稳定Ollie起跳', '前脚脚尖勾板刃', '脚尖快速向外带板', '板在空中纵轴旋转', '等板面回正踩落'], 4, '勾板要快,眼睛盯住贴纸图案', '🌀', '#B24BF3'),
  // ... 共10个技巧
]

TRICKS 常量包含十个滑板技巧的完整教学数据。每个技巧都有中英文名称、难度等级、动作分解步骤数组、已掌握步骤数、教练提示和主题色。这些技巧从最基础的 Ollie(豚跳)到最高难度的 360 Flip(三百六十翻),覆盖了街式滑板的核心动作体系。

steps 数组的设计特别值得注意。每个技巧被分解为四到五个独立步骤,每个步骤是一句简洁的动作要领描述(如"后脚猛踩板尾起跳"、"前脚外翻刷板砂"等)。在 UI 中,这些步骤以编号列表形式展示,已完成的步骤(索引小于 mastered)用绿色圆形编号和删除线文字标记,未完成的步骤用灰色圆形编号和正常文字标记。这种将复杂技巧分解为可追踪步骤的设计,不仅提升了教学内容的可操作性,还为用户提供了清晰的进度追踪体验。

mastered 值在十个技巧中从 0 到 5 不等,表示用户已掌握的步骤数。360 Flip 的 mastered 为 0,表示用户尚未开始练习这个最高难度的技巧;而 Ollie 和 Pop Shove-it 的 mastered 为 5,表示用户已经完全掌握这两个基础动作。这种进度分布合理地反映了滑板学习的典型路径——从基础动作到高级技巧的渐进过程。

4.3 DECKS、WHEELS、TRUCKS:装备数据

const DECKS: GearItem[] = [
  new GearItem(1, '尖叫手掌经典款', 'Santa Cruz', '板面', 469, 529, 5, ['宽度 8.25"', '枫木 7层', '板尾上翘 18mm', '重量 1180g'], '🖐', 88, '#FF5A1F'),
  new GearItem(2, '布道者暗黑系', 'Baker', '板面', 399, 449, 5, ['宽度 8.0"', '枫木 7层', '弹性和脚感偏硬', '重量 1150g'], '🃏', 75, '#B24BF3'),
  new GearItem(3, '粉色旋风签名款', 'Girl', '板面', 429, 469, 4, ['宽度 8.125"', '枫木 7层', '中凹适中', '重量 1165g'], '🌸', 80, '#FF3D81'),
  new GearItem(4, '碳纤维轻量版', 'Almost', '板面', 599, 699, 5, ['宽度 8.0"', '碳纤维加强层', '超弹超轻', '重量 1050g'], '🪶', 95, '#38D9FF')
]

装备数据被分为三组——DECKS(板面,4 件)、WHEELS(轮子,4 件)和 TRUCKS(桥,4 件),共十二件装备。每组使用 GearItem 类型,通过 cat 字段区分分类。这种将同类型数据分组为独立常量的设计,使得在装备车间页面中可以通过分别调用 gearSectionTitlegearCardBuilder 来渲染不同分类的装备区域,代码结构清晰。

每组装备都涵盖了该分类下的主流品牌和不同价位——板面从 ¥399(Baker)到 ¥599(Almost 碳纤维),轮子从 ¥219(OJ Hot Juice 软轮)到 ¥268(Spitfire F4 经典),桥从 ¥359(Venture)到 ¥469(Independent 锻造)。每个装备的 specs 数组包含四条规格参数(宽度、硬度、材质、重量等),在卡片右侧以无序列表形式展示。barPct 值用于价格对比横条图中柱状条的高度比例,碳纤维轻量版的 barPct 为 95,表示其在价格指数中处于最高位。

4.4 CONTESTS、SKATERS、MOMENTS 数据

const CONTESTS: ContestItem[] = [
  new ContestItem(1, '滨江碗池Jam Session', 8, 15, '周六', '16:00-19:00', '滨江碗池', '碗池赛', '奖金池 ¥8,000', 42, 48, '报名中', '#FF5A1F'),
  new ContestItem(2, '城市街式公开赛·预选', 8, 16, '周日', '13:00-18:00', '西岸滑板公园', '街式赛', '晋级秋季总决赛', 96, 96, '已结束', '#B24BF3'),
  // ... 共8场赛事
]

CONTESTS 常量包含八场赛事的完整数据,涵盖碗池赛、街式赛、垂直赛、平地赛、速降赛、大招赛和团队赛等多种赛事类型。赛事时间分布在 8 月和 9 月,每场赛事都有具体的日期、时间、地点和奖品信息。entriescap 的比值用于计算报名进度条——如第一场赛事 42/48 表示已报名 42 人,容量 48 人,进度条填充约 87.5%。第二场赛事 96/96 表示已满员,且状态为"已结束"。

const SKATERS: SkaterItem[] = [
  new SkaterItem(1, '板神老K', 'KaiTheShred', '街式 STREET', 9821, '360 Flip 下7级', '😎', 12, '#FFD54A'),
  new SkaterItem(2, '阿疯', 'FengRaw', '碗池 BOWL', 9104, 'U池 540转体', '🔥', 8, '#CFD8DC'),
  new SkaterItem(3, '小鹿', 'LuFlip', '平地 FLAT', 8870, '连续尖翻 ×23', '🦌', 15, '#E0955A'),
  // ... 共10位滑手
]

SKATERS 常量包含十位滑手的排行榜数据,积分从 9821(第一名"板神老K")到 6873(第十名"学生会长")。前三名分别使用金色 #FFD54A、银色 #CFD8DC、铜色 #E0955A 作为主题色,在领奖台卡片中以不同高度的徽章卡形式展示。第四到第十名使用霓虹色系的不同颜色,在普通行列表中以紧凑的行布局展示。每个滑手都有独特的 emoji 头像和风格分类,反映了不同滑手的个性特征。

const MOMENTS: MomentItem[] = [
  new MomentItem(1, '傍晚的滨江碗池,剪影绝了', '板神老K', 3218, '🌅', '#日落', 210, '#5C3A6E', '#1C1230'),
  new MomentItem(2, '老厂房7级台阶首清!', '台阶收割机', 2865, '🪜', '#首清', 160, '#6E2A2A', '#2A1010'),
  // ... 共12条街拍
]

MOMENTS 常量包含十二条街拍动态数据,每条都有独特的渐变配色方案和不同的图片高度。h 值从 140 到 230 不等,使得两列交错瀑布流布局中每张卡片的高度不同,形成参差错落的视觉效果。likes 值从 890 到 3218,通过 fmtCount 函数格式化为 “3.2k” 这样的缩写形式展示。

五、纯函数工具层

5.1 starsOf:星级格式化函数

function starsOf(n: number): string {
  let s: string = ''
  for (let i: number = 0; i < 5; i++) {
    if (i < n) {
      s += '★'
    } else {
      s += '☆'
    }
  }
  return s
}

starsOf 函数将一个 1-5 的数字转换为五个星形字符的字符串——前 n 个是实心星(★),剩余的是空心星(☆)。例如 starsOf(4) 返回 '★★★★☆'starsOf(5) 返回 '★★★★★'。这个函数在应用中被多处使用——Spot 卡片的难度星级、技巧卡片的难度星级、装备卡片的评分星级都调用了它。

这个函数虽然逻辑简单,但它的存在体现了纯函数设计的核心价值。首先,它是无状态的——接收一个数字输入,返回一个确定的字符串输出,不依赖任何外部状态或副作用。其次,它是可复用的——任何需要星级展示的地方都可以直接调用,无需重复编写循环逻辑。第三,它是可测试的——可以独立于 UI 组件进行单元测试,验证各种边界情况(如 starsOf(0) 返回全空星、starsOf(5) 返回全实心星)。

在 HarmonyOS ArkTS API 24 中,纯函数必须声明在 @Component@Builder 之外,因为 ArkTS 规定 Builder 函数体内不能声明变量或定义函数。这个约束实际上促进了更好的代码组织——将可复用的逻辑提取到组件外部作为纯函数,使得组件代码更专注于 UI 声明。

5.2 diffLabel 与 diffColor:难度映射函数

function diffLabel(n: number): string {
  if (n >= 5) {
    return '地狱'
  } else if (n >= 4) {
    return '硬核'
  } else if (n >= 3) {
    return '高手'
  } else if (n >= 2) {
    return '进阶'
  }
  return '新手'
}

function diffColor(n: number): string {
  if (n >= 5) {
    return '#E53935'
  } else if (n >= 4) {
    return COLORS.neonOrange
  } else if (n >= 3) {
    return COLORS.neonPurple
  }
  return COLORS.neonGreen
}

diffLabeldiffColor 是一对配合使用的纯函数,分别将难度数字映射为中文标签和颜色值。diffLabel 使用阶梯式条件判断——5 及以上返回"地狱",4 及以上返回"硬核",3 及以上返回"高手",2 及以上返回"进阶",1 及以下返回"新手"。diffColor 使用相同的阶梯逻辑,但返回颜色值——5 返回红色(#E53935),4 返回橙色,3 返回紫色,2 及以下返回绿色。

这对函数的设计体现了几个重要的工程考量。首先,它们是一对"语义-视觉"映射函数——同一难度值通过这两个函数分别获得文字标签和颜色值,确保了语义和视觉的一致性。其次,它们的阶梯条件是一致的——都使用 >= 判断,且阶梯点相同(5、4、3、2),这意味着同一难度值在两个函数中会落入同一档位。第三,颜色梯度从绿色(安全)到紫色到橙色到红色(危险),形成了一个直观的"难度递增=颜色变暖"的视觉编码。

在实际使用中,这两个函数经常成对出现——Spot 卡片中 Text(diffLabel(s.diff))Text(starsOf(s.diff)).fontColor(diffColor(s.diff)) 同时调用,使得难度标签的文字和星级颜色保持一致。在新增 Spot 弹框中,diffLabel(this.formDiff) 被用于实时显示当前选择的难度等级文字。

5.3 fmtCount:数字缩写函数

function fmtCount(n: number): string {
  if (n >= 1000) {
    return (n / 1000).toFixed(1) + 'k'
  }
  return n.toString()
}

fmtCount 函数将数字格式化为缩写形式——大于等于 1000 的数字除以 1000 并保留一位小数后加 “k” 后缀,小于 1000 的数字直接转为字符串。例如 fmtCount(3218) 返回 '3.2k'fmtCount(890) 返回 '890'。这个函数在街拍 moment 卡片的点赞数展示中使用,使得大数字在有限的卡片空间内以紧凑形式展示。

使用 toFixed(1) 而非 toPrecisiontoExponential 的选择是基于 UI 展示的考虑——toFixed(1) 保证结果始终有一位小数(如 3.2k),而 toPrecision 可能返回不同位数的结果,在 UI 中会导致对齐不整齐。同时,toString() 作为小于 1000 的情况的 fallback 保留了原始数字的精确展示,不会让小数字也带上不必要的小数位。

5.4 heatText 与计数函数

function heatText(h: number): string {
  return '人气 ' + h
}

function getSpotCount(): number {
  return SPOTS.length
}

function getTrickCount(): number {
  return TRICKS.length
}

function getContestCount(): number {
  return CONTESTS.length
}

function bizSignCount(): number {
  return 8
}

heatText 函数将人气数值包装为带前缀的展示文本——heatText(96) 返回 '人气 96'。虽然逻辑极其简单,但将其提取为纯函数的好处在于:如果未来需要修改展示格式(如改为"热度 96°"或"96 人气"),只需修改一处即可全局生效。

getSpotCountgetTrickCountgetContestCount 三个函数分别返回静态数据数组的长度——SPOTS.length(12)、TRICKS.length(10)、CONTESTS.length(8)。这些函数在页面的标题行中被调用,如 Text('共' + getSpotCount() + '处') 显示"共12处"。虽然直接使用 SPOTS.length 也可以达到相同效果,但通过函数封装获得了更好的语义表达——函数名 getSpotCount 比属性访问 SPOTS.length 更能表达"获取场地数量"的意图。

bizSignCount 函数返回硬编码的数字 8,表示约板弹框中已报名滑手的数量。这个函数的存在是为了保持函数式风格的一致性——所有在 Builder 中使用的数字都通过函数获取,而非直接使用字面量。虽然这里返回的是固定值而非动态计算的值,但在真实应用中,这个函数可以从后端 API 获取实时报名人数。

六、入口组件状态管理

6.1 @Entry 与 @Component 装饰器

@Entry
@Component
struct SkateApp {
  @State curTab: number = 0
  @State mainTab: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  @State tick: number = 0
  @State formSpotName: string = ''
  @State formSpotLoc: string = ''
  @State formDiff: number = 3
  @State formGround: string = '混凝土'
  @State formDeck: string = '8.25'
  @State formWear: string = '中度战损'
  @State formNote: string = ''
  @State bizLevel: string = '进阶刷街'
  @State bizSpot: string = '滨江碗池'
  private timer: number = -1
  // ...
}

@Entry 装饰器标记 SkateApp 结构体为应用的入口页面——整个应用启动后将首先渲染这个组件。@Component 装饰器标记它为一个自定义组件,使其可以在其他组件中被引用(虽然作为入口组件,它通常不会被其他组件引用)。这两个装饰器的组合是 HarmonyOS ArkTS API 24 中定义页面的标准模式。

@State 装饰器是 ArkTS 响应式状态系统的核心。被 @State 修饰的变量在其值变化时会自动触发依赖于该变量的 UI 组件重新渲染。SkateApp 组件定义了十六个 @State 变量,可以分为四组。

第一组是导航状态:curTab 控制当前选中的内容 Tab(0-6 对应七个页面),mainTab 是底部导航的同步状态。当 curTab 变化时,build 方法中的条件分支会重新求值,渲染对应页面的内容。这是整个应用最核心的状态变量——它决定了用户当前看到的是哪个页面。

第二组是弹框状态:addOpeneditOpendelOpenbizOpen 分别控制四种弹框的显示与隐藏。这四个变量都是布尔类型,默认值为 false(弹框关闭)。当某个变量变为 true 时,modalOverlay 构建器中对应的条件渲染分支被激活,弹框浮现。

第三组是表单状态:formSpotNameformSpotLocformDiffformGround 用于新增 Spot 弹框的表单数据;formDeckformWearformNote 用于编辑装备弹框的表单数据;bizLevelbizSpot 用于约板弹框的表单数据。这些状态变量与弹框中的 TextInput、选择器组件双向绑定——用户输入时通过 onChange 回调更新状态,状态变化时 UI 自动反映新值。

第四组是动画状态:tick 是一个每 120 毫秒自动递增的数字,用于驱动全屏浮动 emoji 特效层的动画。每次 tick 变化时,fxLayer 构建器中的 emoji 位置、旋转角度和透明度都会根据新的 tick 值重新计算,实现持续流动的背景动画。

6.2 生命周期管理

aboutToAppear() {
  this.timer = setInterval(() => { this.tick++ }, 120)
}

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

aboutToAppearaboutToDisappear 是 HarmonyOS ArkTS API 24 组件生命周期的两个关键回调。aboutToAppear 在组件实例创建后、build 方法执行前被调用,通常用于初始化状态、启动定时器或发起网络请求。aboutToDisappear 在组件实例销毁前被调用,通常用于清理资源、取消定时器或取消网络请求。

aboutToAppear 中,通过 setInterval 创建了一个每 120 毫秒执行一次的定时器,每次执行时递增 this.tick。定时器的 ID 被保存在 this.timer 属性中(private timer: number = -1),以便在组件销毁时可以清除。this.tick 的递增会触发 @State tick 的状态变更通知,依赖 tickfxLayer 构建器会重新渲染,实现 emoji 位置的持续更新。

aboutToDisappear 中,通过 clearInterval(this.timer) 清除定时器。检查 this.timer >= 0 是一个防御性措施——如果定时器因某种原因未成功创建(setInterval 返回 -1 或抛出异常),clearInterval 不会被调用,避免了清除无效定时器 ID 可能导致的未定义行为。

120 毫秒的间隔选择是一个性能与流畅度的平衡点。更短的间隔(如 60ms 或 16ms)会使动画更流畅,但会增加 @State 更新和 UI 重新渲染的频率,可能导致性能问题。120ms 间隔意味着每秒约 8 次更新,对于背景装饰层的漂浮 emoji 动画来说,这个频率足够提供"缓慢流动"的视觉效果,同时不会对主线程造成过大压力。

七、全屏浮动特效层

7.1 fxLayer 构建器

@Builder fxLayer() {
  Stack() {
    ForEach(FX_EMOJIS, (e: string, i: number) => {
      Text(e)
        .fontSize(14 + (i % 3) * 7)
        .opacity(0.18 + ((this.tick + i * 7) % 5) * 0.06)
        .rotate({ angle: (this.tick * (i % 2 === 0 ? 3 : -3) + i * 30) % 360 })
        .position({
          x: ((this.tick * (2 + (i % 4) * 2) + i * 47) % 100) + '%',
          y: ((this.tick * (3 + (i % 3) * 2) + i * 83) % 100) + '%'
        })
    })
  }
  .width('100%') .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer 构建器创建了一个全屏的浮动 emoji 特效层,这是整个应用视觉体验的点睛之笔。这个构建器通过 @Builder 装饰器声明,意味着它是一个可内联展开的 UI 片段——在 build 方法中被调用时,其内容会被直接展开到调用位置,而不是创建一个独立的组件实例。这种内联展开机制在 HarmonyOS ArkTS API 24 中是性能优化的关键手段,它避免了不必要的组件实例创建开销。

Stack 容器作为特效层的根节点,其 width('100%')height('100%') 确保它覆盖整个屏幕。hitTestBehavior(HitTestMode.None) 是一个关键的交互设计——它将这个 Stack 的点击测试行为设为 None,意味着这个层不会拦截任何触摸事件,所有触摸事件会穿透到下方的 UI 组件。这使得浮动 emoji 层成为纯粹的视觉装饰,不会干扰用户与下方内容的交互。

ForEach 遍历 FX_EMOJIS 数组(十二个 emoji),为每个 emoji 创建一个 Text 组件。每个 Text 的样式通过 i(索引)和 this.tick(定时器计数)计算得出,确保十二个 emoji 的位置、旋转和透明度各不相同且随时间变化。

7.2 动画参数的数学设计

.fontSize(14 + (i % 3) * 7)
.opacity(0.18 + ((this.tick + i * 7) % 5) * 0.06)
.rotate({ angle: (this.tick * (i % 2 === 0 ? 3 : -3) + i * 30) % 360 })
.position({
  x: ((this.tick * (2 + (i % 4) * 2) + i * 47) % 100) + '%',
  y: ((this.tick * (3 + (i % 3) * 2) + i * 83) % 100) + '%'
})

这些动画参数的数学设计是整个特效层的核心智慧。每个参数都通过 i(emoji 索引)和 this.tick(时间计数)的组合计算,确保十二个 emoji 以不同的节奏和路径漂浮。

字体大小 14 + (i % 3) * 7 通过取模运算将十二个 emoji 分为三组大小——14px、21px、28px,循环出现。这种分组使得特效层中的 emoji 大小有层次感而非全部一致,增加了视觉丰富度。

透明度 0.18 + ((this.tick + i * 7) % 5) * 0.06 的计算更为精妙。(this.tick + i * 7) % 5 产生 0-4 的循环值,乘以 0.06 后得到 0-0.24 的范围,加上基础值 0.18 后最终透明度在 0.18-0.42 之间循环变化。i * 7 的偏移量确保不同 emoji 的透明度变化相位不同——当第一个 emoji 透明度为最大值 0.42 时,其他 emoji 可能处于不同的透明度阶段。这种相位差异使得整个特效层的 emoji 不会同时变亮或变暗,而是呈现出此起彼伏的闪烁效果。

旋转角度 (this.tick * (i % 2 === 0 ? 3 : -3) + i * 30) % 360 使用了方向交替的设计——偶数索引的 emoji 顺时针旋转(乘以 +3),奇数索引的逆时针旋转(乘以 -3)。i * 30 的初始偏移量确保十二个 emoji 的初始角度各不相同,% 360 确保角度值始终在 0-359 度范围内。这种一半顺时针一半逆时针的设计使得特效层看起来更有动感和混乱感,而非整齐划一地朝同一方向旋转。

位置计算是最复杂的部分。X 坐标 ((this.tick * (2 + (i % 4) * 2) + i * 47) % 100) + '%' 中,i % 4 将十二个 emoji 分为四组,每组有不同的水平移动速度(2、4、6、8 像素/帧)。i * 47 的初始偏移量确保不同 emoji 的水平起始位置不同。% 100 确保位置始终在 0-100% 范围内,超出后从左侧重新出现,实现循环漂浮的效果。Y 坐标使用类似的逻辑,但速度系数不同(3 + (i % 3) * 2),偏移量也不同(i * 83),确保水平和垂直的运动模式不相关,每个 emoji 的运动轨迹都是独特的。

这种通过纯数学计算驱动的动画系统有几个显著优势。首先,它不需要任何动画框架或缓动函数库——所有运动都是通过简单的算术运算生成的,性能开销极低。其次,它是确定性的——给定相同的 tick 值,每个 emoji 的位置、旋转和透明度都是确定的,不会出现随机抖动。第三,它天然支持循环——% 取模运算确保所有值都在有限范围内循环,动画可以无限持续而不会出现越界问题。

八、渐变头部设计

8.1 header 构建器

@Builder header() {
  Column() {
    Row() {
      Column() {
        Text('CITY SKATE')
          .fontSize(24) .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF') .letterSpacing(3)
        Text('城市滑板社区 · STREET NEVER SLEEPS')
          .fontSize(10) .fontColor('rgba(255,255,255,0.62)')
          .letterSpacing(1) .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      Column()
        .layoutWeight(1)
      Column() {
        Text('+')
          .fontSize(22) .fontWeight(FontWeight.Bold)
          .fontColor('#0A0A0F')
      }
      .width(38) .height(38) .borderRadius(12)
      .justifyContent(FlexAlign.Center)
      .linearGradient({
        angle: 120,
        colors: [[COLORS.neonOrange, 0], [COLORS.neonPurple, 1]]
      })
      .onClick(() => { this.addOpen = true })
    }
    .width('100%') .alignItems(VerticalAlign.Center)
    // ...
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 14 })
  .linearGradient({
    angle: 135,
    colors: [['#0A0A0F', 0], ['#221033', 0.48], ['#FF5A1F', 0.86], ['#B24BF3', 1]]
  })
}

header 构建器创建了应用顶部的品牌头部区域。整个头部采用 Column 布局,包含两行内容——第一行是品牌标识和新增按钮,第二行是状态标签条。

头部的视觉核心是它的四色线性渐变背景。linearGradient 属性配置了 angle: 135(从左上到右下的135度角)和四个颜色停止点——#0A0A0F(近黑,0%位置)、#221033(深紫,48%位置)、#FF5A1F(霓虹橙,86%位置)、#B24BF3(霓虹紫,100%位置)。这种从暗到亮再到霓虹色的渐变设计,使得头部区域呈现出从深沉到炽热的视觉过渡,呼应了"STREET NEVER SLEEPS"(街头永不眠)的品牌口号。

第一行的左侧是品牌标识区域,包含"CITY SKATE"主标题和"城市滑板社区 · STREET NEVER SLEEPS"副标题。主标题使用 24px 粗体白色字体,配合 letterSpacing(3) 的字间距设置,营造出街头品牌的排版风格。副标题使用 10px 的半透明白色字体(rgba(255,255,255,0.62)),字间距为 1,在视觉上作为主标题的补充说明。

右侧的"+"按钮使用了一个 38x38 像素的圆角方块(borderRadius(12)),背景使用从橙色到紫色的双色渐变。这个按钮的 onClick 事件将 this.addOpen 设置为 true,触发新增 Spot 弹框的显示。按钮的深色字体(#0A0A0F)在亮色渐变背景上具有高对比度,使得"+"符号清晰可辨。

8.2 状态标签条

Row() {
  Text('🔥 今日打卡 328')
    .fontSize(10) .fontColor('rgba(255,255,255,0.85)')
    .backgroundColor('rgba(255,255,255,0.14)')
    .borderRadius(10)
    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
  Text('🛹 在线滑手 92')
    .fontSize(10) .fontColor('rgba(255,255,255,0.85)')
    .backgroundColor('rgba(255,255,255,0.14)')
    .borderRadius(10)
    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .margin({ left: 6 })
  Text('📍 本周新Spot ' + 5)
    .fontSize(10) .fontColor('rgba(255,255,255,0.85)')
    .backgroundColor('rgba(255,255,255,0.14)')
    .borderRadius(10)
    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .margin({ left: 6 })
  Column()
    .layoutWeight(1)
  Text('VOL.27')
    .fontSize(10) .fontWeight(FontWeight.Bold)
    .fontColor('#0A0A0F')
    .backgroundColor(COLORS.neonGreen) .borderRadius(8)
    .padding({ left: 6, right: 6, top: 3, bottom: 3 })
}
.width('100%') .margin({ top: 10 })
.alignItems(VerticalAlign.Center)

头部的第二行是状态标签条,由四个标签组成——三个半透明白色背景的统计标签和一个绿色背景的期号标签。前三个标签分别显示"今日打卡 328"、“在线滑手 92”、“本周新Spot 5”,使用 rgba(255,255,255,0.14) 的半透明白色背景和 rgba(255,255,255,0.85) 的白色文字,在渐变背景上呈现出玻璃质感的胶囊效果。每个标签之间使用 margin({ left: 6 }) 保持间距。

第四个标签"VOL.27"使用 COLORS.neonGreen(亮绿色)背景和深色文字,在视觉上与其他三个标签形成对比,突出期号信息。Column().layoutWeight(1) 在标签和期号之间创建了一个弹性间距,将期号推到右侧。

三个统计标签的内容是硬编码的固定值(328、92、5),在实际应用中这些值应该从后端 API 动态获取。但在当前的单文件演示版本中,使用固定值可以确保 UI 的稳定性,同时通过文字内容传达应用的活跃氛围。

九、内容 Tab 条设计

9.1 tabBar 构建器

@Builder tabBar() {
  Scroll() {
    Row() {
      ForEach(CONTENT_TABS, (t: TabMeta, i: number) => {
        if (this.curTab === i) {
          Text(t.icon + ' ' + t.label)
            .fontSize(12) .fontWeight(FontWeight.Bold)
            .fontColor('#0A0A0F')
            .backgroundColor(COLORS.neonOrange)
            .borderRadius(16)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .margin({ left: 4, right: 4 })
            .onClick(() => { this.curTab = i })
        } else {
          Text(t.icon + ' ' + t.label)
            .fontSize(12) .fontColor(COLORS.sub)
            .backgroundColor(COLORS.card) .borderRadius(16)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .margin({ left: 4, right: 4 })
            .onClick(() => { this.curTab = i })
        }
      })
    }
    .padding({ left: 12, right: 12 })
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off) .width('100%') .height(46)
  .align(Alignment.Center)
}

tabBar 构建器创建了顶部内容区域上方的 Tab 导航条。由于七个 Tab 的总宽度可能超过屏幕宽度,整个 Tab 条被包裹在一个水平滚动的 Scroll 容器中。scrollable(ScrollDirection.Horizontal) 启用水平滚动,scrollBar(BarState.Off) 隐藏滚动条,使 Tab 条看起来更像一组可滑动的胶囊按钮而非传统的滚动区域。

ForEach 遍历 CONTENT_TABS 数组,为每个 Tab 渲染一个 Text 按钮。这里使用了条件渲染——当 this.curTab === i 时(当前选中),Tab 按钮使用橙色背景(COLORS.neonOrange)和深色粗体文字;否则使用暗色卡片背景(COLORS.card)和灰色文字。两种状态的 borderRadius(16)paddingmargin 保持一致,确保切换 Tab 时按钮的尺寸不会变化,避免布局抖动。

每个 Tab 按钮的 onClick 事件将 this.curTab 设置为对应的索引值 i。这个状态变化会触发 @State curTab 的变更通知,导致 build 方法中的条件分支重新求值——之前选中页面的 Builder 不再被渲染,新选中页面的 Builder 被渲染。同时,Tab 条本身也会重新渲染——之前选中的 Tab 按钮从橙色变为灰色,新选中的从灰色变为橙色。

这种条件渲染模式是 HarmonyOS ArkTS API 24 声明式 UI 的典型应用——开发者只需声明"当 curTab === 0 时渲染 pageSpot,当 curTab === 1 时渲染 pageTrick",框架会自动在状态变化时执行切换。无需手动管理页面的显示和隐藏,无需手动清理旧页面的 DOM 节点,一切由框架的差分渲染算法自动处理。

十、热门 Spot 页面

10.1 spotCardBuilder:场地卡片构建器

@Builder spotCardBuilder(s: SpotItem) {
  Row() {
    Column() {
      Text(s.emoji)
        .fontSize(26)
      Text(diffLabel(s.diff))
        .fontSize(9) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
        .backgroundColor(COLORS.neonGreen) .borderRadius(6)
        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
        .margin({ top: 5 })
    }
    .width(64) .height(76) .borderRadius(14)
    .backgroundColor(s.color)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    Column() {
      Row() {
        Text(s.name)
          .fontSize(15) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
        Text(s.area)
          .fontSize(10) .fontColor(COLORS.sub)
          .margin({ left: 8 }) .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%') .alignItems(VerticalAlign.Bottom)
      // ...
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
    .padding({ left: 12, right: 8 })
    Column() {
      Text(fmtCount(s.riders))
        .fontSize(17) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonPurple)
      Text('滑手在此')
        .fontSize(9) .fontColor(COLORS.sub)
        .margin({ top: 2 })
      Text('约板 ▸')
        .fontSize(10) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonGreen)
        .backgroundColor('#12291A') .borderRadius(10)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .margin({ top: 8 })
    }
    .alignItems(HorizontalAlign.Center)
  }
  .width('100%') .backgroundColor(COLORS.card)
  .borderRadius(16) .padding(12) .margin({ top: 10 })
  .alignItems(VerticalAlign.Center)
  .onClick(() => {
    this.bizSpot = s.name
    this.bizOpen = true
  })
}

spotCardBuilder 构建器是热门 Spot 页面中单条场地卡片的渲染模板。它接收一个 SpotItem 参数 s,通过 Row 水平布局将卡片分为三个区域——左侧色块、中间信息区和右侧操作区。

左侧色块是一个 64x76 像素的圆角矩形(borderRadius(14)),背景色为 s.color(每个场地有自己独特的暗色调颜色),内部居中显示场地的 emoji 图标(fontSize(26))和难度标签(如"新手"、“进阶”、"硬核"等,通过 diffLabel 函数生成)。难度标签使用绿色背景和深色文字的胶囊样式,在彩色色块上形成醒目的难度指示。

中间信息区使用 layoutWeight(1) 占据剩余空间,包含场地名称、位置、星级评分、地面类型、描述和人气进度条等信息。场地名称使用 15px 粗体白色字体,位置信息使用 10px 灰色字体并限制单行显示(maxLines(1) + textOverflow({ overflow: TextOverflow.Ellipsis })),超出部分以省略号截断。这种文本截断设计确保了长名称不会破坏卡片的布局结构。

右侧操作区显示当前在场滑手数(通过 fmtCount 函数格式化)和"约板 ▸"按钮。按钮使用绿色背景和深色文字,点击后触发整个卡片的 onClick 事件——将 this.bizSpot 设置为场地名称,然后打开约板弹框(this.bizOpen = true)。这种"整卡可点击 + 按钮内嵌"的设计使得用户可以点击卡片的任何位置打开约板弹框,提供了更大的点击区域。

10.2 人气进度条的实现

Row() {
  Column() {
    Row() {
      Column()
        .width(s.heat + '%') .height(5)
        .backgroundColor(COLORS.neonOrange) .borderRadius(3)
      Column()
        .layoutWeight(1)
    }
    .width('100%') .height(5)
    .backgroundColor(COLORS.line) .borderRadius(3)
  }
  .layoutWeight(1)
  Text(heatText(s.heat))
    .fontSize(9) .fontColor(COLORS.neonOrange)
    .margin({ left: 8 })
}
.width('100%') .margin({ top: 8 })
.alignItems(VerticalAlign.Center)

人气进度条是 Spot 卡片中一个精细的视觉组件。它由两层 Column 嵌套组成——外层是一个灰色背景的容器(backgroundColor(COLORS.line),5px 高度,圆角3),内层是一个橙色填充条(backgroundColor(COLORS.neonOrange)),其宽度为 s.heat + '%'(如人气值为 96 时宽度为 96%)。

这种双层嵌套的进度条实现方式是 HarmonyOS ArkTS API 24 声明式 UI 中的常见模式。外层 Column 提供了进度条的背景轨道(灰色),内层的第一个 Column 提供了进度条的填充部分(橙色),第二个 Column().layoutWeight(1) 占据剩余空间,使得填充条始终从左对齐开始。通过设置填充条的 width 为百分比字符串,进度条的长度会根据人气值动态变化——人气越高,橙色填充越长。

进度条右侧通过 heatText(s.heat) 函数显示"人气 96"这样的文字标签,使用与填充条相同的橙色字体,形成视觉一致性。这种"进度条 + 数值文字"的组合展示方式,既提供了直观的视觉长度感知,又提供了精确的数值信息。

10.3 pageSpot:热门 Spot 页面组装

@Builder pageSpot() {
  Column() {
    // ---- 街拍大片首图卡:深色叠层上白字,杂志封面感 ----
    Stack({ alignContent: Alignment.BottomStart }) {
      Column() {
        Text('🛹')
          .fontSize(120) .opacity(0.32)
          .rotate({ angle: -14 })
          .position({ x: '56%', y: '16%' })
        Text('🔥')
          .fontSize(40) .opacity(0.5)
          .position({ x: '10%', y: '12%' })
        Text('⚡')
          .fontSize(30) .opacity(0.45)
          .position({ x: '84%', y: '58%' })
      }
      .width('100%') .height('100%')
      .linearGradient({
        angle: 160,
        colors: [['#4A3560', 0], ['#20122E', 0.45], ['#0A0A0F', 1]]
      })
      Text('VOL.27 · 街拍特辑')
        .fontSize(10) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
        .backgroundColor(COLORS.warnYellow) .borderRadius(8)
        .padding({ left: 7, right: 7, top: 3, bottom: 3 })
        .position({ x: '60%', y: 14 })
      Column() {
        Text('🔥 今日最热Spot')
          .fontSize(10) .fontWeight(FontWeight.Bold)
          .fontColor('#0A0A0F')
          .backgroundColor(COLORS.neonOrange) .borderRadius(9)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        Text('滨江碗池')
          .fontSize(32) .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF') .letterSpacing(2)
          .margin({ top: 8 })
        Text('BINJIANG BOWL · 6尺transitions · 落日泵道')
          .fontSize(11) .fontColor('rgba(255,255,255,0.72)')
          .letterSpacing(1) .margin({ top: 4 })
        // ...
      }
      .alignItems(HorizontalAlign.Start)
      .padding({ left: 18, right: 18, bottom: 16 })
    }
    .width('100%') .height(238) .borderRadius(20)
    .clip(true) .margin({ top: 10 })
    // ...
  }
}

pageSpot 构建器组装了整个热门 Spot 页面。页面顶部是一个 238px 高的杂志封面式首图卡片,使用 Stack 布局将装饰元素和文字内容叠层放置。

首图卡片的背景层是一个 Column,设置了从紫色到深暗色的三色渐变(#4A3560#20122E#0A0A0F),并在其中以绝对定位方式放置了三个大尺寸 emoji——🛹(120px,旋转-14度,透明度0.32)、🔥(40px,透明度0.5)、⚡(30px,透明度0.45)。这三个 emoji 以不同的位置和透明度分布在卡片上,作为装饰性背景元素,营造出街头杂志的视觉氛围。

在背景层之上,通过 position 绝对定位放置了"VOL.27 · 街拍特辑"期号标签(黄色背景,定位在右上角),以及在 StackBottomStart 对齐位置放置了主要内容区域——"今日最热Spot"标签、"滨江碗池"大标题、英文描述行和难度/地面/人气信息行。大标题使用 32px 粗体白色字体配合 letterSpacing(2) 的字间距,形成杂志封面般的视觉冲击力。

clip(true) 是一个关键属性——它确保 Stack 内超出圆角边界的内容被裁剪。由于 Stack 设置了 borderRadius(20) 的圆角,但内部的渐变 Column 和 emoji 装饰元素可能延伸到圆角区域之外,clip(true) 确保这些超出部分被裁剪掉,使卡片呈现为完整的圆角矩形。

10.4 排序筛选行与列表渲染

Row() {
  Text('🗺 全城Spot地图')
    .fontSize(15) .fontWeight(FontWeight.Bold)
    .fontColor(COLORS.ink)
  Column()
    .layoutWeight(1)
  Text('共' + getSpotCount() + '处')
    .fontSize(10) .fontColor(COLORS.sub)
  Text('按人气 ▾')
    .fontSize(10) .fontColor(COLORS.neonOrange)
    .backgroundColor(COLORS.card) .borderRadius(9)
    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .margin({ left: 8 })
}
.width('100%') .margin({ top: 16 })
.alignItems(VerticalAlign.Center)
// ---- 12条竖排Spot列表 ----
this.spotCardBuilder(SPOTS[0])
this.spotCardBuilder(SPOTS[1])
// ...共12个
this.spotCardBuilder(SPOTS[11])

在首图卡片下方,是排序筛选行和十二个场地卡片的竖排列表。排序筛选行使用 Row 布局,左侧是"全城Spot地图"标题,右侧是场地总数(通过 getSpotCount() 函数获取)和"按人气 ▾"排序按钮。Column().layoutWeight(1) 在标题和右侧信息之间创建弹性间距。

十二个场地卡片通过连续调用 this.spotCardBuilder(SPOTS[0])this.spotCardBuilder(SPOTS[11]) 渲染。这里没有使用 ForEach 而是手动展开十二次调用,这是一个有趣的实现选择。在 HarmonyOS ArkTS API 24 中,ForEach 通常用于列表渲染,但 @Builder 函数的直接调用也可以实现列表效果。手动展开的好处是每次调用的参数都是明确的数组索引,编译器可以进行更好的内联优化;缺点是代码冗长,且如果数组长度变化需要手动增删调用。

列表底部是一个"滑手守则"提示卡片,使用绿色标题和灰色描述文字,提醒用户在 Spot 场地注意安全和尊重。

十一、技巧研究所页面

11.1 trickCardBuilder:技巧卡片构建器

@Builder trickCardBuilder(t: TrickItem) {
  Column() {
    Row() {
      Column() {
        Text(t.emoji)
          .fontSize(28)
      }
      .width(52) .height(52) .borderRadius(14)
      .backgroundColor(t.color)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      Column() {
        Text(t.name)
          .fontSize(17) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
        Text(t.en)
          .fontSize(10) .fontColor(COLORS.sub)
          .letterSpacing(1) .margin({ top: 2 })
      }
      .layoutWeight(1) .alignItems(HorizontalAlign.Start)
      .padding({ left: 12 })
      Text(starsOf(t.diff))
        .fontSize(12) .fontColor(diffColor(t.diff))
    }
    .width('100%') .alignItems(VerticalAlign.Center)
    // 分步教学
    Text('📋 动作分解 · ' + t.steps.length + '步')
      .fontSize(11) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.neonOrange) .margin({ top: 14 })
    // ...
  }
  .width(272) .backgroundColor(COLORS.card)
  .borderRadius(18) .padding(14)
  .margin({ left: 6, right: 6 })
  .alignItems(HorizontalAlign.Start)
}

trickCardBuilder 构建器是技巧研究所页面中单条技巧卡片的渲染模板。与 Spot 卡片不同,技巧卡片使用固定宽度(272px)而非 100% 宽度,这是因为它被设计为横向滚动列表中的元素——多个卡片水平排列在一个 Scroll 容器中,用户可以左右滑动查看不同技巧。

卡片顶部是技巧的头部区域——左侧是 52x52 像素的彩色圆角方块,内含技巧的 emoji 图标(28px);中间是技巧的中英文名称,中文名 17px 粗体,英文名 10px 灰色配合字间距 1;右侧是难度星级,通过 starsOf(t.diff) 生成五个星形字符,颜色通过 diffColor(t.diff) 根据难度等级动态选择。

11.2 分步教学的条件渲染

Column() {
  ForEach(t.steps, (st: string, idx: number) => {
    Row() {
      Text((idx + 1).toString())
        .fontSize(10) .fontWeight(FontWeight.Bold)
        .fontColor(idx < t.mastered ? '#0A0A0F' : COLORS.sub)
        .backgroundColor(idx < t.mastered ? COLORS.neonGreen : COLORS.line)
        .width(20) .height(20) .borderRadius(10)
        .textAlign(TextAlign.Center)
      Text(st)
        .fontSize(11)
        .fontColor(idx < t.mastered ? COLORS.ink : COLORS.sub)
        .layoutWeight(1) .margin({ left: 10 })
        .decoration({
          type: idx < t.mastered ? TextDecorationType.LineThrough : TextDecorationType.None
        })
    }
    .width('100%') .alignItems(VerticalAlign.Center)
    .padding({ top: 6, bottom: 6 })
    Divider()
      .color(COLORS.line) .margin({ left: 30 })
  })
}
.width('100%') .backgroundColor(COLORS.darkCard)
.borderRadius(12)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.margin({ top: 8 })

分步教学区域是技巧卡片的核心功能模块。ForEach 遍历 t.steps 数组,为每个步骤渲染一个 Row,包含编号圆球、步骤文字和分隔线。这里最值得关注的是条件渲染的设计——每个步骤的视觉状态取决于 idx < t.mastered 的判断结果。

当步骤索引 idx 小于已掌握步骤数 t.mastered 时,该步骤被视为"已完成"——编号圆球使用绿色背景(COLORS.neonGreen)和深色文字,步骤文字使用白色字体(COLORS.ink),并添加删除线装饰(TextDecorationType.LineThrough)。当步骤索引大于等于 t.mastered 时,该步骤被视为"待完成"——编号圆球使用灰色背景(COLORS.line)和灰色文字,步骤文字使用灰色字体,无装饰线。

这种通过条件表达式驱动视觉状态的设计,是声明式 UI 的核心优势之一。开发者不需要手动操作 DOM 来添加或删除 CSS 类——只需声明"当条件为真时使用绿色,当条件为假时使用灰色",框架会自动在条件变化时更新渲染。在这里,由于 t.mastered 是静态数据中的固定值,条件渲染的结果在首次渲染时就确定了。但在实际应用中,如果 t.mastered 是一个 @State 变量,当用户完成一个新步骤时,对应的步骤项会自动从灰色变为绿色,文字添加删除线,无需任何手动 UI 更新代码。

11.3 步骤圆点进度指示器

Row() {
  ForEach(t.steps, (st: string, idx: number) => {
    Column()
      .width(idx < t.mastered ? 12 : 8)
      .height(idx < t.mastered ? 12 : 8) .borderRadius(6)
      .backgroundColor(idx < t.mastered ? t.color : COLORS.line)
      .margin({ left: 3, right: 3 })
  })
  Column()
    .layoutWeight(1)
  Text(t.mastered + '/' + t.steps.length + ' 已掌握')
    .fontSize(10) .fontColor(COLORS.neonGreen)
}
.width('100%') .margin({ top: 12 })
.alignItems(VerticalAlign.Center)

在分步教学列表下方,技巧卡片还包含一个圆点进度指示器。ForEach 再次遍历 t.steps 数组,为每个步骤渲染一个小圆点。已完成步骤的圆点更大(12x12 像素)且使用技巧的主题色(t.color),未完成步骤的圆点更小(8x8 像素)且使用灰色(COLORS.line)。这种大小和颜色的双重差异使得已完成和未完成的步骤在视觉上有明确的区分。

圆点指示器右侧是"3/5 已掌握"这样的文字进度信息,使用绿色字体。layoutWeight(1) 的空 Column 在圆点和文字之间创建弹性间距。这种"圆点 + 文字"的进度展示方式比传统的线性进度条更适合分步场景——每个圆点对应一个具体的步骤,用户可以直观地看到自己完成了哪些步骤。

11.4 pageTrick:横向滚动与挑战卡

@Builder pageTrick() {
  Column() {
    Row() {
      Column() {
        Text('🧪 技巧研究所')
          .fontSize(17) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
        Text('TRICK LAB · 左右滑动查看 ' + getTrickCount() + ' 个动作分解')
          .fontSize(10) .fontColor(COLORS.sub)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start) .layoutWeight(1)
      Text('新手路线 ▸')
        .fontSize(10) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonPurple)
        .backgroundColor(COLORS.card) .borderRadius(9)
        .padding({ left: 9, right: 9, top: 4, bottom: 4 })
    }
    .width('100%') .alignItems(VerticalAlign.Center)
    // 横向分步技巧条
    Scroll() {
      Row() {
        this.trickCardBuilder(TRICKS[0])
        this.trickCardBuilder(TRICKS[1])
        // ...共10个
        this.trickCardBuilder(TRICKS[9])
      }
      .padding({ left: 8, right: 8 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off) .width('100%')
    .margin({ top: 12 })
    // ...
  }
}

pageTrick 构建器组装了技巧研究所页面。页面顶部是标题行,左侧是"技巧研究所"中文标题和"TRICK LAB"英文副标题,右侧是"新手路线 ▸"按钮。标题中通过 getTrickCount() 函数动态获取技巧总数并展示在副标题中。

核心内容区域是一个水平滚动的 Scroll 容器,内部 Row 中排列了十个技巧卡片(通过 this.trickCardBuilder(TRICKS[0])this.trickCardBuilder(TRICKS[9]) 调用)。由于每个技巧卡片的固定宽度为 272px,十个卡片的总宽度远超屏幕宽度,用户需要左右滑动查看所有技巧。scrollBar(BarState.Off) 隐藏了滚动条,使得滑动体验更加沉浸。

11.5 本周挑战卡与进度总览

Stack({ alignContent: Alignment.BottomStart }) {
  Column() {
    Text('🎯')
      .fontSize(56) .opacity(0.35)
      .position({ x: '68%', y: '10%' })
  }
  .width('100%') .height('100%')
  .linearGradient({
    angle: 135,
    colors: [['#B24BF3', 0], ['#5C2A8E', 0.6], ['#1A1024', 1]]
  })
  Column() {
    Text('本周挑战 · 连续 10 个尖翻')
      .fontSize(15) .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
    Text('已有 217 位滑手参与,最高纪录 23 连 · 完成可得限定贴纸')
      .fontSize(10) .fontColor('rgba(255,255,255,0.7)')
      .margin({ top: 5 })
    Row() {
      Text('我要挑战 ▸')
        .fontSize(11) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
        .backgroundColor(COLORS.neonGreen) .borderRadius(11)
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .onClick(() => { this.bizOpen = true; this.bizSpot = '人民广场大理石' })
      Text(' challengers 217')
        .fontSize(10) .fontColor('rgba(255,255,255,0.6)')
        .margin({ left: 10 })
    }
    .margin({ top: 10 })
    .alignItems(VerticalAlign.Center)
  }
  .padding({ left: 16, right: 16, bottom: 14 })
  .alignItems(HorizontalAlign.Start)
}
.width('100%') .height(128) .borderRadius(18)
.clip(true) .margin({ top: 14 })

在横向技巧列表下方,页面包含一个"本周挑战"卡片,使用紫色渐变背景和 Stack 叠层布局。背景层的右上角放置了一个大尺寸的 🎯 emoji(56px,透明度 0.35)作为装饰。前景内容包含挑战标题、参与说明和"我要挑战 ▸"按钮。

"我要挑战"按钮的 onClick 事件同时设置了两个状态——this.bizOpen = true 打开约板弹框,this.bizSpot = '人民广场大理石' 设置约板地点。这种在一个事件回调中同时修改多个状态的写法在 ArkTS 中是完全合法的——框架会批量处理这些状态变更,在事件回调结束后统一触发 UI 更新。

页面底部是"我的招式库"进度总览卡片,包含四个统计数字(已掌握 26、练习中 4、目标招 2、今日 Ollie 318)和一个 72% 的完成度进度条。这个卡片展示了用户的技巧学习概况,帮助用户追踪自己的进步。

十二、装备车间页面

12.1 gearCardBuilder:装备卡片构建器

@Builder gearCardBuilder(g: GearItem) {
  Row() {
    // 左:装备图块
    Column() {
      Text(g.emoji)
        .fontSize(30)
      Text(g.cat)
        .fontSize(9) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
        .backgroundColor(COLORS.neonGreen) .borderRadius(6)
        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
        .margin({ top: 6 })
    }
    .width(78) .height(96) .borderRadius(14)
    .backgroundColor(g.color)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    // 右:参数区
    Column() {
      Row() {
        Text(g.brand)
          .fontSize(9) .fontColor(COLORS.neonOrange)
          .letterSpacing(1)
        Text('官方认证')
          .fontSize(8) .fontColor(COLORS.neonBlue)
          .backgroundColor('#12232B') .borderRadius(6)
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
          .margin({ left: 6 })
        Column()
          .layoutWeight(1)
        Text(starsOf(g.rating))
          .fontSize(10) .fontColor(COLORS.warnYellow)
      }
      .width('100%') .alignItems(VerticalAlign.Center)
      Text(g.name)
        .fontSize(15) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink) .margin({ top: 3 })
      Column() {
        ForEach(g.specs, (sp: string) => {
          Row() {
            Text('· ' + sp)
              .fontSize(10) .fontColor(COLORS.sub)
              .layoutWeight(1)
          }
          .width('100%') .padding({ top: 2, bottom: 2 })
        })
      }
      .width('100%') .margin({ top: 4 })
      Row() {
        Text('¥' + g.price)
          .fontSize(16) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.neonOrange)
        Text('¥' + g.oldPrice)
          .fontSize(10) .fontColor(COLORS.sub)
          .decoration({ type: TextDecorationType.LineThrough })
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('加入板单')
          .fontSize(10) .fontWeight(FontWeight.Bold)
          .fontColor('#0A0A0F')
          .backgroundColor(COLORS.neonGreen) .borderRadius(10)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .onClick(() => { this.editOpen = true })
      }
      .width('100%') .margin({ top: 6 })
      .alignItems(VerticalAlign.Center)
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
    .padding({ left: 12 })
  }
  .width('100%') .backgroundColor(COLORS.card)
  .borderRadius(16) .padding(12) .margin({ top: 10 })
  .alignItems(VerticalAlign.Center)
}

gearCardBuilder 构建器是装备车间页面中单件装备卡片的渲染模板。卡片使用 Row 水平布局,左侧是 78x96 像素的彩色装备图块,右侧是参数信息区。

右侧参数区的顶部是品牌行——品牌名称使用橙色字体配合字间距 1,"官方认证"标签使用蓝色背景和蓝色文字的胶囊样式,星级评分使用黄色星形字符通过 starsOf(g.rating) 生成。品牌名称和认证标签之间通过 Column().layoutWeight(1) 创建弹性间距,将星级评分推到右侧。

装备名称下方是规格参数列表,通过 ForEach(g.specs, ...) 遍历 specs 数组,每项以"· "前缀的灰色文字形式展示。这种使用 ForEach 渲染列表项的方式是 HarmonyOS ArkTS API 24 中的标准模式。

底部是价格行——当前价格使用 16px 粗体橙色字体,原价使用 10px 灰色字体并添加删除线装饰(TextDecorationType.LineThrough),"加入板单"按钮使用绿色背景和深色文字。按钮的 onClick 事件触发编辑装备弹框(this.editOpen = true)。

12.2 gearSectionTitle:分区标题构建器

@Builder gearSectionTitle(icon: string, cn: string, en: string, count: string) {
  Row() {
    Text(icon)
      .fontSize(16)
    Text(cn)
      .fontSize(15) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.ink) .margin({ left: 6 })
    Text(en)
      .fontSize(9) .fontColor(COLORS.sub)
      .letterSpacing(1) .margin({ left: 6 })
    Column()
      .layoutWeight(1)
    Text(count)
      .fontSize(10) .fontColor(COLORS.neonOrange)
      .backgroundColor(COLORS.chipBg) .borderRadius(9)
      .padding({ left: 8, right: 8, top: 3, bottom: 3 })
  }
  .width('100%') .margin({ top: 18 })
  .alignItems(VerticalAlign.Center)
}

gearSectionTitle 构建器是一个可复用的分区标题组件,接收四个参数:icon(emoji 图标)、cn(中文标题)、en(英文副标题)和 count(数量标签)。通过参数化的方式,同一个构建器可以在装备车间的三个分区中复用——this.gearSectionTitle('🛹', '板面 DECKS', '枫木与碳纤', '4款热销')this.gearSectionTitle('⚙', '轮子 WHEELS', '直径与硬度', '4款热销')this.gearSectionTitle('🔧', '桥 TRUCKS', '转向与重量', '4款热销')

这种参数化构建器的设计体现了 @Builder 的核心价值——代码复用。如果不使用参数化构建器,三个分区标题需要重复编写三次几乎相同的 Row 代码,每次只是文字内容不同。通过提取为带参数的构建器,代码量减少了三分之二,且修改标题样式时只需修改一处。

12.3 板面价格对比横条图

Column() {
  Text('📊 板面价格对比')
    .fontSize(13) .fontWeight(FontWeight.Bold)
    .fontColor(COLORS.ink) .width('100%')
  Column() {
    ForEach(DECKS, (g: GearItem) => {
      Column() {
        Row() {
          Text(g.brand)
            .fontSize(10) .fontColor(COLORS.ink)
          Text('¥' + g.price)
            .fontSize(10) .fontWeight(FontWeight.Bold)
            .fontColor(g.color) .margin({ left: 8 })
          Column()
            .layoutWeight(1)
          Text(g.barPct + '%指数')
            .fontSize(9) .fontColor(COLORS.sub)
        }
        .width('100%') .alignItems(VerticalAlign.Center)
        Row() {
          Column()
            .width(g.barPct + '%') .height(8)
            .backgroundColor(g.color) .borderRadius(4)
          Column()
            .layoutWeight(1)
        }
        .width('100%') .height(8)
        .backgroundColor(COLORS.line) .borderRadius(4)
        .margin({ top: 5 })
      }
      .width('100%') .margin({ top: 10 })
      .alignItems(HorizontalAlign.Start)
    })
  }
  .width('100%') .margin({ top: 4 })
}

装备车间页面在板面列表下方包含一个"板面价格对比"横条图。这个图表不是使用 Canvas 绘制的,而是使用 Row + Column 的声明式布局模拟的——每个装备对应一个横向柱状条,柱状条的宽度为 g.barPct + '%',颜色为 g.color

这种使用布局组件模拟图表的方式在 HarmonyOS ArkTS API 24 中是一种常见的轻量级数据可视化方案。相比于使用 Canvas 绘制柱状图,声明式布局方案有几个优势:首先,它天然支持响应式更新——当 g.barPct 变化时,柱状条宽度会自动更新;其次,它可以使用所有 UI 属性(如圆角、背景色、margin 等)进行样式定制,无需调用 Canvas API;第三,它的性能开销更低,不需要创建 CanvasRenderingContext2D 和执行绘制命令。

每个柱状条上方还有一个信息行,显示品牌名称、价格和指数百分比。价格使用 g.color 作为字体颜色,与柱状条颜色一致,形成视觉关联。layoutWeight(1) 的空 Column 在价格和指数之间创建弹性间距。

12.4 装备保养工单

Column() {
  Text('🧴 装备保养工单')
    .fontSize(12) .fontWeight(FontWeight.Bold)
    .fontColor(COLORS.neonGreen) .width('100%')
  Row() {
    Text('轴承')
      .fontSize(10) .fontColor(COLORS.ink) .width(52)
    Row() {
      Column()
        .width('40%') .height(5)
        .backgroundColor(COLORS.neonOrange) .borderRadius(3)
      Column()
        .layoutWeight(1)
    }
    .layoutWeight(1) .height(5)
    .backgroundColor(COLORS.line) .borderRadius(3)
    Text('剩余寿命 40%')
      .fontSize(9) .fontColor(COLORS.sub)
      .margin({ left: 8 })
  }
  .width('100%') .margin({ top: 10 })
  .alignItems(VerticalAlign.Center)
  // ... 砂纸剩余寿命 65%
  Text('提示:轮子每两周换位一次可显著延缓偏磨,轴承进水后立即拆洗上油。')
    .fontSize(10) .fontColor(COLORS.sub) .lineHeight(15)
    .margin({ top: 10 })
}

装备车间页面底部是一个"装备保养工单"卡片,显示轴承和砂纸的剩余寿命进度条。轴承的剩余寿命为 40%(橙色进度条),砂纸为 65%(紫色进度条)。进度条使用与人气进度条相同的双层嵌套 Column 实现——外层灰色背景轨道,内层彩色填充条。

底部的保养提示文字使用 lineHeight(15) 设置行高,使多行文字的行间距更舒适。这种细节处理体现了对阅读体验的关注——默认行高在 10px 字号下可能过于紧凑,适当增加行高可以提升可读性。

十三、赛事日历页面

13.1 contestCardBuilder:赛事卡片构建器

@Builder contestCardBuilder(c: ContestItem) {
  Row() {
    // 日期块
    Column() {
      Text(c.month + '月')
        .fontSize(10) .fontColor('rgba(255,255,255,0.75)')
      Text(c.day.toString())
        .fontSize(26) .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text(c.week)
        .fontSize(10) .fontColor('rgba(255,255,255,0.75)')
    }
    .width(64) .height(84) .borderRadius(14)
    .backgroundColor(c.color)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    // 赛事信息
    Column() {
      Text(c.name)
        .fontSize(14) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink) .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Row() {
        Text(c.ctype)
          .fontSize(9) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.neonOrange)
          .backgroundColor(COLORS.chipBg) .borderRadius(7)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Text(c.prize)
          .fontSize(9) .fontColor(COLORS.warnYellow)
          .backgroundColor('#241C0A') .borderRadius(7)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .margin({ left: 6 })
      }
      .margin({ top: 5 })
      // ...
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
    .padding({ left: 12, right: 8 })
    // 状态与约板入口
    Column() {
      Text(STATUS_CONFIG[c.status]?.label ?? '报名中')
        .fontSize(10) .fontWeight(FontWeight.Bold)
        .fontColor(STATUS_CONFIG[c.status]?.color ?? COLORS.neonGreen)
        .backgroundColor(STATUS_CONFIG[c.status]?.bg ?? '#12291A')
        .borderRadius(9)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      Text('约板\n组局 ▸')
        .fontSize(10) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonBlue)
        .backgroundColor('#12232B') .borderRadius(10)
        .padding({ left: 8, right: 8, top: 6, bottom: 6 })
        .margin({ top: 8 }) .textAlign(TextAlign.Center)
    }
    .alignItems(HorizontalAlign.Center)
  }
  .width('100%') .backgroundColor(COLORS.card)
  .borderRadius(16) .padding(12) .margin({ top: 10 })
  .alignItems(VerticalAlign.Center)
  .onClick(() => {
    this.bizSpot = c.loc
    this.bizOpen = true
  })
}

contestCardBuilder 构建器是赛事日历页面中单场赛事卡片的渲染模板。卡片使用 Row 水平三栏布局——左侧日期块、中间赛事信息、右侧状态与操作。

左侧日期块是一个 64x84 像素的彩色圆角方块,内含三行信息——月份(10px 半透明白色)、日期数字(26px 粗体白色)、星期(10px 半透明白色)。这种将日期信息分层展示的设计使得日期块具有日历撕页的视觉效果,赛事日期一目了然。

中间赛事信息区包含赛事名称、类型和奖品标签、时间地点行和报名进度条。赛事类型标签使用橙色字体和暗色背景,奖品标签使用黄色字体和暗黄色背景,两个标签并排排列。报名进度条通过 c.entries / c.cap * 100 计算填充百分比,如 42/48 计算为 87.5%,toFixed(0) 取整为 88%。

右侧的状态标签使用了可选链和空值合并操作符的防御性编程模式:STATUS_CONFIG[c.status]?.label ?? '报名中'。这种三重保障确保了即使 c.status 的值不在 STATUS_CONFIG 映射表中,状态标签也不会显示为 undefined,而是降级为默认的"报名中"。同理,颜色和背景色也使用了相同的防御性模式。

13.2 日历网格的实现

Column() {
  ForEach(CAL_ROWS, (row: number[]) => {
    Row() {
      ForEach(row, (d: number) => {
        Column() {
          if (d === 15) {
            Text(d.toString())
              .fontSize(12) .fontWeight(FontWeight.Bold)
              .fontColor('#0A0A0F')
              .backgroundColor(COLORS.neonOrange) .width(26)
              .height(26) .borderRadius(13)
              .textAlign(TextAlign.Center)
          } else if (CAL_MARKS.indexOf(d) >= 0) {
            Text(d.toString())
              .fontSize(12) .fontColor(COLORS.ink)
            Column()
              .width(4) .height(4) .borderRadius(2)
              .backgroundColor(COLORS.neonPurple)
              .margin({ top: 2 })
          } else if (d === 0) {
            Text(' ')
              .fontSize(12) .fontColor(COLORS.line)
          } else {
            Text(d.toString())
              .fontSize(12) .fontColor(COLORS.sub)
            Column()
              .width(4) .height(4) .borderRadius(2)
              .backgroundColor(COLORS.line) .margin({ top: 2 })
          }
        }
        .layoutWeight(1) .alignItems(HorizontalAlign.Center)
        .padding({ top: 5, bottom: 3 })
      })
    }
    .width('100%')
  })
}

赛事日历页面的核心组件是一个 6 行 7 列的日历网格。这个网格通过两层 ForEach 嵌套实现——外层遍历 CAL_ROWS 的六行,内层遍历每行的七个日期数字。每个日期单元格使用条件渲染分为四种状态。

第一种状态:当 d === 15 时(15 号,即赛事"滨江碗池Jam Session"的日期),日期数字使用橙色背景圆形高亮显示——这是"今日"或"赛事日"的视觉标记。26x26 像素的圆形背景配合居中的粗体深色文字,在日历中形成醒目的视觉焦点。

第二种状态:当 CAL_MARKS.indexOf(d) >= 0 时(日期在赛事标记数组中,即 8、15、16、22、23、29 号),日期数字使用白色字体,下方有一个紫色小圆点标记。这些圆点提示用户这些日期有赛事安排。

第三种状态:当 d === 0 时(空格日,即月份开头或结尾不足七天的空位),显示一个空格占位符。

第四种状态:其他普通日期,数字使用灰色字体,下方有一个灰色小圆点。这种设计使得每个日期下方都有一个圆点(无论是紫色还是灰色),保持了网格的视觉一致性。

这种四重条件渲染的设计展示了 HarmonyOS ArkTS API 24 声明式 UI 中条件分支的能力——开发者可以通过 if-else if-else if-else 链式条件为同一个列表项渲染完全不同的 UI 结构,无需使用策略模式或模板模式等复杂的设计模式。

13.3 pageContest:赛事日历页面组装

@Builder pageContest() {
  Column() {
    // ---- 日历格子头 ----
    Column() {
      Row() {
        Text('‹')
          .fontSize(18) .fontColor(COLORS.sub)
        Column()
          .layoutWeight(1)
        Text('2026年8月')
          .fontSize(16) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
        Text('· 赛事 ' + getContestCount() + ' 场')
          .fontSize(10) .fontColor(COLORS.neonOrange)
          .margin({ left: 6 })
        Column()
          .layoutWeight(1)
        Text('›')
          .fontSize(18) .fontColor(COLORS.sub)
      }
      .width('100%') .alignItems(VerticalAlign.Center)
      Row() {
        ForEach(WEEK_LABELS, (w: string) => {
          Text(w)
            .fontSize(10) .fontColor(COLORS.sub)
            .layoutWeight(1) .textAlign(TextAlign.Center)
        })
      }
      .width('100%') .margin({ top: 12 })
      // 日历网格
      // ...
    }
    .width('100%') .backgroundColor(COLORS.card)
    .borderRadius(18) .padding(14) .margin({ top: 10 })
    .alignItems(HorizontalAlign.Start)
    // ---- 赛事条目列表 ----
    Row() {
      Text('📅 赛事列表')
        .fontSize(15) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
      Column()
        .layoutWeight(1)
      Text('提醒我 ▾')
        .fontSize(10) .fontColor(COLORS.neonOrange)
        .backgroundColor(COLORS.card) .borderRadius(9)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    }
    .width('100%') .margin({ top: 16 })
    .alignItems(VerticalAlign.Center)
    this.contestCardBuilder(CONTESTS[0])
    // ...共8场
    this.contestCardBuilder(CONTESTS[7])
    // ---- 赛规小卡 ----
    // ...
  }
}

pageContest 构建器组装了赛事日历页面。页面顶部是日历卡片,包含月份导航行(‹ 2026年8月 · 赛事 8 场 ›)、星期标签行(一到日)和日历网格。月份导航行使用两个 layoutWeight(1) 的空 Column 实现居中——左右箭头分别在最外侧,月份信息在中间居中。

日历卡片下方是赛事列表,通过连续调用 this.contestCardBuilder(CONTESTS[0])this.contestCardBuilder(CONTESTS[7]) 渲染八场赛事。列表底部是赛规说明卡片,包含四条赛事规则文字,使用 lineHeight(17) 增加行间距以提升多行文本的可读性。

十四、滑手榜页面

14.1 rankBadgeCard:领奖台徽章卡

@Builder rankBadgeCard(s: SkaterItem, medal: string, medalColor: string, cardHeight: number) {
  Column() {
    Stack({ alignContent: Alignment.TopEnd }) {
      Column() {
        Text(s.avatar)
          .fontSize(34)
      }
      .width(66) .height(66) .borderRadius(33)
      .backgroundColor(s.color)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      Text(medal)
        .fontSize(18) .position({ x: 44, y: -6 })
        .rotate({ angle: 14 })
    }
    .width(66) .height(62) .margin({ top: 14 })
    Text(s.name)
      .fontSize(15) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.ink) .margin({ top: 6 })
      .maxLines(1)
    Text('@' + s.nick)
      .fontSize(9) .fontColor(COLORS.sub)
      .margin({ top: 2 }) .maxLines(1)
    Text(s.style)
      .fontSize(9) .fontColor(medalColor)
      .backgroundColor(COLORS.chipBg) .borderRadius(7)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .margin({ top: 5 })
    Text(s.score.toString())
      .fontSize(20) .fontWeight(FontWeight.Bold)
      .fontColor(medalColor) .margin({ top: 8 })
    Text('赛季积分')
      .fontSize(8) .fontColor(COLORS.sub)
    Text('招牌 · ' + s.best)
      .fontSize(9) .fontColor(COLORS.sub) .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .margin({ top: 5 })
    Row() {
      Text('▲ ' + s.trend + '%')
        .fontSize(9) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonGreen)
    }
    .margin({ top: 5 })
  }
  .layoutWeight(1) .height(cardHeight)
  .backgroundColor(COLORS.card) .borderRadius(18)
  .border({
    width: 1.5,
    color: medalColor
  })
  .margin({ left: 4, right: 4, top: 12 })
  .alignItems(HorizontalAlign.Center)
}

rankBadgeCard 构建器是滑手榜页面中领奖台前三名滑手的徽章卡片渲染模板。它接收四个参数:s(滑手数据)、medal(奖牌 emoji,如 🥇🥈🥉)、medalColor(奖牌颜色,如金色/银色/铜色)和 cardHeight(卡片高度)。

卡片的视觉亮点是顶部的头像区域——使用 Stack 布局将一个 66x66 像素的圆形头像(borderRadius(33))和奖牌 emoji 叠放。奖牌 emoji 通过 position({ x: 44, y: -6 }) 定位在头像的右上角,并使用 rotate({ angle: 14 }) 旋转 14 度,模拟实体奖牌倾斜挂在头像上的效果。这种使用 position 绝对定位和 rotate 旋转组合来模拟物理世界物体的设计,展示了声明式 UI 在创意表达方面的灵活性。

卡片还使用 border({ width: 1.5, color: medalColor }) 为整个卡片添加了 1.5 像素宽的奖牌色边框——金、银、铜三种颜色的边框使得前三名滑手的卡片在视觉上有明确的等级区分。积分数字和风格标签也使用 medalColor 作为字体颜色,强化了奖牌色系的视觉统一性。

14.2 领奖台布局:银-金-铜

Row() {
  this.rankBadgeCard(SKATERS[1], '🥈', COLORS.silver, 232)
  this.rankBadgeCard(SKATERS[0], '🥇', COLORS.gold, 252)
  this.rankBadgeCard(SKATERS[2], '🥉', COLORS.bronze, 232)
}
.width('100%') .alignItems(VerticalAlign.Bottom)

领奖台布局是滑手榜页面最具仪式感的视觉设计。三张徽章卡片在 Row 中水平排列,使用 alignItems(VerticalAlign.Bottom) 底部对齐。值得注意的是排列顺序——第二名(银牌)在左、第一名(金牌)在中间、第三名(铜牌)在右,且第一名卡片的 cardHeight 为 252px(比两侧的 232px 高出 20px),使得金牌卡片在视觉上"站"在最高的领奖台上。

这种"银-金-铜"的排列方式而非"金-银-铜"的顺序排列,是体育赛事领奖台的传统视觉布局——金牌得主站在中央最高的位置,银牌和铜牌分别站在两侧较低的位置。通过为金牌卡片设置更大的高度,应用在声明式 UI 中精准地还原了这一传统布局。

14.3 rankRowBuilder:普通排名行

@Builder rankRowBuilder(s: SkaterItem) {
  Row() {
    Text(s.rank.toString())
      .fontSize(15) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.sub) .width(30)
      .textAlign(TextAlign.Center)
    Column() {
      Text(s.avatar)
        .fontSize(20)
    }
    .width(42) .height(42) .borderRadius(21)
    .backgroundColor(s.color)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    Column() {
      Row() {
        Text(s.name)
          .fontSize(13) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
        Text('@' + s.nick)
          .fontSize(9) .fontColor(COLORS.sub)
          .margin({ left: 6 })
      }
      .alignItems(VerticalAlign.Bottom)
      Row() {
        Text(s.style)
          .fontSize(9) .fontColor(COLORS.neonBlue)
        Text('招牌 · ' + s.best)
          .fontSize(9) .fontColor(COLORS.sub)
          .margin({ left: 8 }) .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .margin({ top: 3 })
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
    .padding({ left: 10 })
    Column() {
      Text(s.score.toString())
        .fontSize(14) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
      Text('▲ ' + s.trend + '%')
        .fontSize(8) .fontColor(COLORS.neonGreen)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
    Text('关注')
      .fontSize(10) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.neonOrange)
      .backgroundColor(COLORS.chipBg) .borderRadius(10)
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .margin({ left: 10 })
  }
  .width('100%') .backgroundColor(COLORS.card)
  .borderRadius(14)
  .padding({ left: 10, right: 12, top: 10, bottom: 10 })
  .margin({ top: 8 })
  .alignItems(VerticalAlign.Center)
}

rankRowBuilder 构建器是第四到第十名滑手的排名行渲染模板。与领奖台徽章卡的垂直布局不同,排名行使用紧凑的 Row 水平布局——排名序号、头像、姓名信息、积分和关注按钮在一行中排列。

排名序号使用 15px 粗体灰色字体,固定宽度 30px 并居中对齐。头像是一个 42x42 像素的圆形(borderRadius(21)),使用滑手主题色作为背景,内含 20px 的 emoji 头像。姓名和昵称在同一行中排列——姓名使用 13px 粗体白色,昵称使用 9px 灰色并以 @ 前缀展示,两者通过 alignItems(VerticalAlign.Bottom) 底部对齐,使得姓名和昵称的基线在同一水平线上。

右侧的积分使用 14px 粗体白色,趋势涨幅使用 8px 绿色字体。最右侧的"关注"按钮使用橙色字体和暗色背景的胶囊样式,margin({ left: 10 }) 保持与前方的积分区域间距。

14.4 pageRank:滑手榜页面组装

@Builder pageRank() {
  Column() {
    Row() {
      Column() {
        Text('🏆 滑手榜')
          .fontSize(17) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
        Text('SEASON RANKING · 每周一凌晨结算')
          .fontSize(10) .fontColor(COLORS.sub)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start) .layoutWeight(1)
      Text('我的排名 #87')
        .fontSize(10) .fontColor(COLORS.neonGreen)
        .backgroundColor(COLORS.card) .borderRadius(9)
        .padding({ left: 9, right: 9, top: 4, bottom: 4 })
    }
    .width('100%') .alignItems(VerticalAlign.Center)
    // ---- 领奖台:银-金-铜 ----
    Row() {
      this.rankBadgeCard(SKATERS[1], '🥈', COLORS.silver, 232)
      this.rankBadgeCard(SKATERS[0], '🥇', COLORS.gold, 252)
      this.rankBadgeCard(SKATERS[2], '🥉', COLORS.bronze, 232)
    }
    .width('100%') .alignItems(VerticalAlign.Bottom)
    // ---- 4-10名普通行 ----
    Text('— TOP 4-10 —')
      .fontSize(10) .fontColor(COLORS.sub)
      .letterSpacing(2) .margin({ top: 18 })
    this.rankRowBuilder(SKATERS[3])
    // ... 共7行
    this.rankRowBuilder(SKATERS[9])
    // ---- 榜单说明 ----
    // ...
  }
}

pageRank 构建器组装了滑手榜页面。页面顶部是标题行,左侧是"滑手榜"标题和"SEASON RANKING · 每周一凌晨结算"副标题,右侧是"我的排名 #87"标签。领奖台区域通过三个 rankBadgeCard 调用渲染银-金-铜三位滑手。领奖台下方是"— TOP 4-10 —"分隔标题,使用 letterSpacing(2) 增加字间距,形成仪式感的分隔效果。然后是七行排名行,通过连续调用 rankRowBuilder 渲染第四到第十名滑手。

页面底部是"积分怎么涨?"说明卡片,解释了积分获取规则——完成赛事名次加分 > 发布街拍获赞 > 每日 Spot 打卡 > 约板组局参与。这些规则文字使用 lineHeight(16) 设置行高,确保多行文字的阅读舒适度。

十五、街拍 moment 页面

15.1 momentCardBuilder:瀑布流卡片构建器

@Builder momentCardBuilder(m: MomentItem) {
  Column() {
    Stack({ alignContent: Alignment.BottomStart }) {
      Column() {
        Text(m.emoji)
          .fontSize(52) .opacity(0.5)
          .position({ x: '58%', y: '22%' })
      }
      .width('100%') .height(m.h)
      .linearGradient({
        angle: 150,
        colors: [[m.color, 0], [m.color2, 1]]
      })
      Column() {
        Text('#' + m.tag)
          .fontSize(9) .fontWeight(FontWeight.Bold)
          .fontColor('#0A0A0F')
          .backgroundColor(COLORS.neonGreen) .borderRadius(7)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Row() {
          Text('❤ ' + fmtCount(m.likes))
            .fontSize(10) .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .margin({ top: 6 })
      }
      .padding(10) .alignItems(HorizontalAlign.Start)
    }
    .width('100%') .height(m.h) .borderRadius(14)
    .clip(true)
    Text(m.title)
      .fontSize(12) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.ink) .maxLines(2)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .margin({ top: 8 })
    Row() {
      Text('👤 ' + m.author)
        .fontSize(9) .fontColor(COLORS.sub) .layoutWeight(1)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('♡')
        .fontSize(16) .fontColor(COLORS.neonPink)
    }
    .width('100%') .margin({ top: 6 })
    .alignItems(VerticalAlign.Center)
  }
  .width('100%') .backgroundColor(COLORS.card)
  .borderRadius(16) .padding(10) .margin({ top: 10 })
  .alignItems(HorizontalAlign.Start)
}

momentCardBuilder 构建器是街拍 moment 页面中单张瀑布流卡片的渲染模板。卡片的图片区域使用 Stack 布局,背景是一个从 m.colorm.color2 的双色渐变 Column,高度为 m.h(每条动态有不同的高度值),内部通过 position 绝对定位放置了一个大尺寸的 emoji(52px,透明度 0.5)作为装饰性图标。

渐变背景上叠放了两个信息元素——左下角的 # 话题标签(绿色胶囊背景)和点赞数(白色心形 + 数字,通过 fmtCount 函数格式化)。StackalignContent: Alignment.BottomStart 确保信息内容在图片区域的左下角对齐。clip(true) 确保图片区域的内容被圆角裁剪。

图片区域下方是标题文字(最多两行,超出省略)和作者行。作者行使用 layoutWeight(1) 将作者名推到左侧,心形 ♡ 图标在右侧,使用粉色字体呼应社交平台的点赞视觉语言。

15.2 两列交错瀑布流布局

Row() {
  Column() {
    this.momentCardBuilder(MOMENTS[0])
    this.momentCardBuilder(MOMENTS[2])
    this.momentCardBuilder(MOMENTS[4])
    this.momentCardBuilder(MOMENTS[6])
    this.momentCardBuilder(MOMENTS[8])
    this.momentCardBuilder(MOMENTS[10])
  }
  .layoutWeight(1) .alignItems(HorizontalAlign.Start)
  Column() {
    this.momentCardBuilder(MOMENTS[1])
    this.momentCardBuilder(MOMENTS[3])
    this.momentCardBuilder(MOMENTS[5])
    this.momentCardBuilder(MOMENTS[7])
    this.momentCardBuilder(MOMENTS[9])
    this.momentCardBuilder(MOMENTS[11])
  }
  .layoutWeight(1) .alignItems(HorizontalAlign.Start)
  .margin({ top: 22 })
}
.width('100%') .alignItems(VerticalAlign.Top)

街拍 moment 页面的核心布局是两列交错的瀑布流。十二条动态数据被分为两组——偶数索引(0、2、4、6、8、10)放在左列,奇数索引(1、3、5、7、9、11)放在右列。右列通过 margin({ top: 22 }) 向下偏移 22 像素,使得两列的卡片在垂直方向上错开排列,形成参差错落的瀑布流视觉效果。

这种交错布局的关键在于每张卡片的高度不同(通过 m.h 字段控制)。在左列中,六张卡片的高度分别为 210、185、175、195、140、185 像素;在右列中,六张卡片的高度分别为 160、230、155、150、200、185 像素。由于每张卡片的高度不同,两列在垂直方向上的总高度也不同,配合右列的 22px 顶部偏移,使得两列的卡片在视觉上形成自然的错位效果,模拟了小红书、Pinterest 等社交平台的瀑布流信息流体验。

两列都使用 layoutWeight(1) 等分宽度,alignItems(HorizontalAlign.Start) 确保卡片在列内左对齐。外层 Row 使用 alignItems(VerticalAlign.Top) 确保两列从顶部开始排列。

15.3 街拍之星横滑头图

Scroll() {
  Row() {
    ForEach(SKATERS, (s: SkaterItem) => {
      Column() {
        Column() {
          Text(s.avatar)
            .fontSize(22)
        }
        .width(52) .height(52) .borderRadius(26)
        .backgroundColor(s.color)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)
        Text(s.name)
          .fontSize(8) .fontColor(COLORS.sub)
          .margin({ top: 4 }) .maxLines(1)
      }
      .margin({ left: 7, right: 7 })
      .alignItems(HorizontalAlign.Center)
    })
  }
  .padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off) .width('100%')

街拍 moment 页面顶部是一个横向滚动的"街拍之星"头像列表。ForEach 遍历 SKATERS 数组(十位滑手),为每位滑手渲染一个圆形头像(52x52 像素,borderRadius(26))和下方的名字标签(8px 灰色字体,单行省略)。头像使用滑手的主题色作为背景,内含 22px 的 emoji 头像。

这个横滑头图在功能上起到了"关注的好友"或"热门创作者"的入口作用——用户可以快速浏览社区中的知名滑手,并可能点击进入他们的个人主页。在当前的实现中,头像只是展示性的,没有 onClick 事件绑定,但在实际应用中可以为每个头像添加点击事件,跳转到对应的滑手详情页。

十六、我的板页面

16.1 个人档案卡

Stack({ alignContent: Alignment.BottomStart }) {
  Column() {
    Text('🛹')
      .fontSize(110) .opacity(0.25) .rotate({ angle: 12 })
      .position({ x: '62%', y: '6%' })
  }
  .width('100%') .height('100%')
  .linearGradient({
    angle: 140,
    colors: [['#5C2A8E', 0], ['#B24BF3', 0.5], ['#1A1024', 1]]
  })
  Column() {
    Row() {
      Column() {
        Text('😎')
          .fontSize(30)
      }
      .width(60) .height(60) .borderRadius(30)
      .backgroundColor('#0A0A0F')
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      Column() {
        Text('板仔阿凯')
          .fontSize(18) .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('@KaiShreds · 街式玩家 · 滑龄 4年')
          .fontSize(10) .fontColor('rgba(255,255,255,0.7)')
          .margin({ top: 3 })
        Row() {
          Text('Lv.7 街头老炮')
            .fontSize(9) .fontWeight(FontWeight.Bold)
            .fontColor('#0A0A0F')
            .backgroundColor(COLORS.warnYellow) .borderRadius(7)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          Text('全城Spot 9/12')
            .fontSize(9) .fontColor('#FFFFFF')
            .backgroundColor('rgba(255,255,255,0.18)')
            .borderRadius(7)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .margin({ left: 6 })
        }
        .margin({ top: 5 })
      }
      .layoutWeight(1) .alignItems(HorizontalAlign.Start)
      .padding({ left: 14 })
    }
    .width('100%') .alignItems(VerticalAlign.Center)
  }
  .padding({ left: 16, right: 16, bottom: 14 })
  .alignItems(HorizontalAlign.Start)
}
.width('100%') .height(140) .borderRadius(20)
.clip(true) .margin({ top: 10 })

pageMine 构建器的顶部是用户个人档案卡片,使用 Stack 布局叠放紫色渐变背景和用户信息内容。背景层包含一个超大尺寸的 🛹 emoji(110px,透明度 0.25,旋转 12 度),通过 position 定位在卡片的右上方,作为装饰性水印效果。

前景内容是一个 Row 布局——左侧是 60x60 像素的黑色圆形头像(内含 😎 emoji),右侧是用户名"@板仔阿凯"(18px 粗体白色)、社交信息"@KaiShreds · 街式玩家 · 滑龄 4年"(10px 半透明白色)和两个标签——等级标签"Lv.7 街头老炮"(黄色背景)和成就标签"全城 Spot 9/12"(半透明白色背景)。

layoutWeight(1) 使信息区域占据头像右侧的所有剩余空间,padding({ left: 14 }) 在头像和信息之间创建间距。整个卡片使用 clip(true) 确保超出圆角边界的内容被裁剪。

16.2 里程统计大数字

Column() {
  Text('🛹 里程统计')
    .fontSize(13) .fontWeight(FontWeight.Bold)
    .fontColor(COLORS.ink) .width('100%')
  Row() {
    Column() {
      Text('1286.5')
        .fontSize(32) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonOrange)
      Text('累计里程 km')
        .fontSize(9) .fontColor(COLORS.sub)
        .margin({ top: 2 })
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
    Column() {
      Text('486')
        .fontSize(32) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonPurple)
      Text('滑行时长 h')
        .fontSize(9) .fontColor(COLORS.sub)
        .margin({ top: 2 })
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
    Column() {
      Text('32918')
        .fontSize(32) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.neonGreen)
      Text('Ollie 累计次')
        .fontSize(9) .fontColor(COLORS.sub)
        .margin({ top: 2 })
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Start)
  }
  .width('100%') .margin({ top: 12 })
  // ...
}

里程统计区域展示了三个大数字——累计里程 1286.5km(橙色)、滑行时长 486h(紫色)、Ollie 累计 32918 次(绿色)。三个数字使用 32px 粗体字体,配合不同的霓虹色系颜色,在暗色卡片背景上形成强烈的视觉冲击力。每个数字下方有 9px 灰色的小标题说明数字的含义。

三个统计项使用 layoutWeight(1) 等分宽度,左对齐排列。这种"大数字 + 小标签"的设计是数据仪表盘的经典模式——大数字传达核心信息,小标签提供语义说明,用户一眼就能获取关键数据。

16.3 本周里程柱状图

Text('本周里程 · 合计 133 km')
  .fontSize(10) .fontColor(COLORS.sub)
  .margin({ top: 14 })
Row() {
  ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
    Column() {
      Text(WEEK_KM[d] + '')
        .fontSize(8) .fontColor(COLORS.sub)
        .margin({ bottom: 3 })
      Column()
        .width(16) .height(WEEK_KM[d] * 2.4)
        .backgroundColor(d === 5 ? COLORS.neonOrange : COLORS.line)
        .borderRadius(4)
      Text(WEEK_LABELS[d])
        .fontSize(8) .fontColor(COLORS.sub)
        .margin({ top: 3 })
    }
    .layoutWeight(1) .alignItems(HorizontalAlign.Center)
  })
}
.width('100%') .margin({ top: 6 })

本周里程柱状图使用声明式布局模拟了一个简单的柱状图。ForEach 遍历 [0, 1, 2, 3, 4, 5, 6] 七个索引值,为每天渲染一个柱状条。每个柱状条由三部分组成——顶部的数值标签(WEEK_KM[d],8px 灰色)、中间的柱状条(Column,宽度 16px,高度为 WEEK_KM[d] * 2.4 像素)、底部的星期标签(WEEK_LABELS[d],8px 灰色)。

柱状条的高度通过 WEEK_KM[d] * 2.4 计算——例如周一里程为 12km,柱状条高度为 28.8px;周六里程为 31km,柱状条高度为 74.4px。这种将数据值映射为像素高度的简单线性变换,是最基础的数据可视化方法。2.4 这个系数是通过调试确定的——它使得最大值(31km)对应的柱状条高度(74.4px)在卡片内不会过高,同时最小值(9km)对应的柱状条高度(21.6px)也有足够的可见性。

周六(索引 5)的柱状条使用 COLORS.neonOrange(橙色)高亮,其他天使用 COLORS.line(灰色)。这种设计使得最高里程日在视觉上突出,帮助用户快速识别自己的高峰训练日。

16.4 我的板配置与成就徽章

Column() {
  Row() {
    Text('🔧 我的板 · 当前配置')
      .fontSize(13) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.ink)
    Column()
      .layoutWeight(1)
    Text('编辑配置')
      .fontSize(10) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.neonBlue)
      .backgroundColor('#12232B') .borderRadius(9)
      .padding({ left: 9, right: 9, top: 4, bottom: 4 })
      .onClick(() => { this.editOpen = true })
  }
  .width('100%') .alignItems(VerticalAlign.Center)
  Column() {
    ForEach(MY_BOARD_SPECS, (sp: string) => {
      Row() {
        Text('🛹')
          .fontSize(11)
        Text(sp)
          .fontSize(11) .fontColor(COLORS.ink)
          .layoutWeight(1) .margin({ left: 8 })
        Text('✓')
          .fontSize(11) .fontColor(COLORS.neonGreen)
      }
      .width('100%') .padding({ top: 7, bottom: 7 })
      .alignItems(VerticalAlign.Center)
    })
  }
  .width('100%') .backgroundColor(COLORS.darkCard)
  .borderRadius(12)
  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
  .margin({ top: 10 })
  Row() {
    Text('磨损状态:中度战损 · 板面有裂纹')
      .fontSize(10) .fontColor(COLORS.neonOrange)
      .layoutWeight(1)
    Text('退役这块板')
      .fontSize(10) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.warnYellow)
      .backgroundColor('#241C0A') .borderRadius(9)
      .padding({ left: 9, right: 9, top: 4, bottom: 4 })
      .onClick(() => { this.delOpen = true })
  }
  .width('100%') .margin({ top: 10 })
  .alignItems(VerticalAlign.Center)
}

"我的板"配置卡片展示了用户当前滑板的四件装备配置——板面、轮子、桥和轴承。每项配置通过 ForEach(MY_BOARD_SPECS, ...) 渲染为一个清单行——🛹 图标 + 规格描述 + 绿色 ✓ 标记。"编辑配置"按钮的 onClick 触发编辑装备弹框(this.editOpen = true)。

配置清单下方是磨损状态行——左侧显示"磨损状态:中度战损 · 板面有裂纹"(橙色文字),右侧是"退役这块板"按钮(黄色文字配暗黄背景),点击触发删除确认弹框(this.delOpen = true)。

页面最底部是成就徽章区域,展示五个成就——🌅 日落滑手(黄色高亮)、🪜 7 级首清、🌀 尖翻达成、⚡ 磨桥火花(黄色高亮)、🔒 神秘成就(灰色锁定)。每个徽章由一个 22px 的 emoji 和 8px 的成就名称组成,使用 layoutWeight(1) 等分宽度居中排列。已解锁的成就使用黄色或正常色文字,未解锁的(如"神秘成就")使用灰色文字,在视觉上区分已解锁和未解锁状态。

十七、弹框遮罩系统

17.1 modalOverlay:统一遮罩层

@Builder modalOverlay() {
  Column() {
    if (this.addOpen) {
      this.modalBodyAdd()
    }
    if (this.editOpen) {
      this.modalBodyEdit()
    }
    if (this.delOpen) {
      this.modalBodyDel()
    }
    if (this.bizOpen) {
      this.modalBodyBiz()
    }
  }
  .width('100%') .height('100%')
  .backgroundColor('rgba(8,8,12,0.68)')
  .onClick(() => {
    this.addOpen = false
    this.editOpen = false
    this.delOpen = false
    this.bizOpen = false
  })
}

modalOverlay 构建器是整个弹框系统的统一遮罩层。它使用一个全屏 Column 作为遮罩容器,背景色为 rgba(8,8,12,0.68)(半透明深色,68% 不透明度),覆盖在整个页面上方。遮罩容器内通过四个独立的 if 条件渲染语句控制四种弹框的显示——addOpentrue 时渲染新增 Spot 弹框,editOpentrue 时渲染编辑装备弹框,以此类推。

这种设计有几个关键点值得深入分析。首先,四个条件使用独立的 if 语句而非 if-else if-else 链式结构。这意味着理论上多个弹框可以同时显示(如果多个状态变量同时为 true)。但在实际使用中,每个弹框的打开按钮只会设置一个状态变量为 true,且关闭时会将所有四个变量重置为 false,所以不会出现多个弹框同时显示的情况。使用独立 if 而非 if-else if 的好处是代码更简洁——每个条件独立判断,不需要 else 分支。

其次,遮罩容器的 onClick 事件将所有四个状态变量统一设置为 false,实现了"点击遮罩关闭所有弹框"的交互模式。这是一种常见的弹框交互设计——用户可以通过点击弹框外的遮罩区域快速关闭弹框,无需精确点击关闭按钮。但需要注意的是,弹框内容容器需要阻止事件冒泡,否则点击弹框内部也会触发遮罩的关闭事件。在 ArkTS 中,弹框内容通常通过绝对定位(position)叠放在遮罩上方,且不设置 onClick 事件,因此点击弹框内部不会冒泡到遮罩层。

17.2 弹框1:新增 Spot

@Builder modalBodyAdd() {
  Column() {
    Row() {
      Text('+ 收录新 SPOT')
        .fontSize(16) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F') .letterSpacing(2)
    }
    .width('100%') .justifyContent(FlexAlign.Center)
    .padding({ top: 15, bottom: 15 })
    .linearGradient({
      angle: 90,
      colors: [[COLORS.neonOrange, 0], [COLORS.warnYellow, 0.5], [COLORS.neonPurple, 1]]
    })
    Scroll() {
      Column() {
        Text('SPOT 名称')
          .fontSize(11) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.neonOrange) .width('100%')
        TextInput({ placeholder: '给这个地方起个响亮的名字…' })
          .placeholderColor('#55555F') .fontSize(13)
          .fontColor('#FFFFFF') .width('100%')
          .backgroundColor(COLORS.darkField) .borderRadius(10)
          .margin({ top: 6 })
          .onChange((v: string) => { this.formSpotName = v })
        // ... 地点、难度星级、地面类型
      }
      .padding({ left: 18, right: 18, top: 16, bottom: 12 })
    }
    .layoutWeight(1) .scrollBar(BarState.Off)
    Row() {
      Text('再想想')
        .fontSize(13) .fontColor(COLORS.sub)
        .backgroundColor(COLORS.darkField) .borderRadius(20)
        .padding({ left: 26, right: 26, top: 11, bottom: 11 })
        .onClick(() => { this.addOpen = false })
      Text('提交收录 ▸')
        .fontSize(13) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
        .backgroundColor(COLORS.neonOrange)
        .borderRadius(20)
        .padding({ left: 26, right: 26, top: 11, bottom: 11 })
        .margin({ left: 12 })
        .onClick(() => { this.addOpen = false })
    }
    .width('100%') .justifyContent(FlexAlign.Center)
    .padding({ top: 12, bottom: 16 })
  }
  .width('88%') .height('72%') .borderRadius(20)
  .backgroundColor(COLORS.darkCard)
  .position({ x: '6%', y: '13%' })
}

新增 Spot 弹框的视觉风格是"黑色街头风·顶部荧光标题条"。弹框顶部是一个三色渐变标题条——从橙色到黄色到紫色的水平渐变(angle: 90),内含"+ 收录新 SPOT"标题(深色粗体字配合字间距 2)。这种高饱和度的渐变标题条在暗色弹框背景上形成了强烈的视觉焦点,传达了街头潮流的能量感。

弹框主体使用 Scroll 容器包裹表单内容,layoutWeight(1) 使滚动区域占据标题条和底部按钮之间的所有空间。表单包含四个输入项——Spot 名称(TextInput 文本输入)、地理位置(TextInput 文本输入)、难度星级(五颗星形按钮,可点击选择 1-5 星)、地面类型(四个胶囊按钮,可切换选择)。

每个 TextInput 组件通过 onChange((v: string) => { this.formSpotName = v }) 回调将用户输入实时同步到 @State 状态变量。这种"输入即状态更新"的模式是 ArkTS 声明式表单的核心——用户输入时状态自动更新,状态变化时 UI 自动反映新值(如星级选择器的实时反馈)。

难度星级选择器使用了 if-else 条件渲染来区分已选和未选的星星:

ForEach([1, 2, 3, 4, 5], (n: number) => {
  Text(n <= this.formDiff ? '★' : '☆')
    .fontSize(32)
    .fontColor(n <= this.formDiff ? COLORS.neonOrange : '#44444E')
    .margin({ left: 6, right: 6 })
    .onClick(() => { this.formDiff = n })
})

当用户点击某颗星时,this.formDiff 被设置为对应的数字 n,状态变化触发 ForEach 重新渲染——所有索引小于等于 n 的星星变为实心橙色,其余变为空心灰色。这种实时反馈的星级选择器是电商和评分场景中的常见组件。

地面类型胶囊选择器也使用了类似的条件渲染模式——选中项使用橙色背景和深色文字,未选项使用暗色背景和白色文字。点击切换选择时,this.formGround 被设置为对应的地面类型字符串。

17.3 弹框2:编辑装备工单

@Builder modalBodyEdit() {
  Column() {
    Row() {
      Column()
        .width(64) .height(18)
        .backgroundColor('rgba(255,213,74,0.8)')
        .borderRadius(3) .rotate({ angle: -6 })
      Column()
        .width(52) .height(18)
        .backgroundColor('rgba(56,217,255,0.7)')
        .borderRadius(3) .rotate({ angle: 5 })
        .margin({ left: 10 })
    }
    .width('100%') .justifyContent(FlexAlign.Center)
    .margin({ top: 10 })
    Text('🛠 装备工单')
      .fontSize(19) .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.neonPink) .rotate({ angle: -2 })
      .margin({ top: 6 })
    Text('STICKER WORKSHOP · 贴好再滑')
      .fontSize(9) .fontColor(COLORS.sub)
      .letterSpacing(1) .margin({ top: 2 })
    // ... 板面配置、磨损状态、备注
  }
  .width('86%') .borderRadius(6)
  .backgroundColor('#FFF6E8')
  .border({
    width: 2.5,
    color: COLORS.neonPink
  })
  .rotate({ angle: 2 })
  .padding({ left: 16, right: 16 })
  .position({ x: '7%', y: '16%' })
  .alignItems(HorizontalAlign.Start)
}

编辑装备弹框的视觉风格是"贴纸涂鸦风·旋转 2 度彩色卡"。这个弹框与其他三个弹框的暗色风格截然不同——它使用了浅色背景(#FFF6E8,接近米黄色),粉色边框(width: 2.5, color: COLORS.neonPink),以及整体旋转 2 度(rotate({ angle: 2 }))的涂鸦风格视觉效果。

弹框顶部的"胶带贴纸装饰"是视觉设计的点睛之笔——两个不同颜色、不同宽度和旋转角度的小矩形模拟了贴在纸上的彩色胶带条。黄色胶带条(64x18px,旋转-6度)和蓝色胶带条(52x18px,旋转5度)通过 justifyContent(FlexAlign.Center) 在一行中居中排列,形成错位的贴纸效果。

标题"🛠 装备工单"使用 19px 粗体粉色字体,配合 rotate({ angle: -2 }) 轻微逆时针旋转,进一步强化了手写涂鸦的随意感。副标题"STICKER WORKSHOP · 贴好再滑"使用 9px 灰色字间距 1 的字体。

弹框的表单内容包含板面配置选择器(五个宽度尺寸胶囊按钮:7.75"、8.0"、8.125"、8.25"、8.5")、磨损状态选择器(四项彩色卡片:新板/轻微磨损/中度战损/退役边缘)和涂鸦备注文本输入。板面配置选择器使用 formDeck 状态变量追踪当前选择,选中项使用粉色背景白色文字,未选项使用暗色背景白色文字。磨损状态选择器使用 formWear 状态变量,选中项的整个卡片背景变为选项颜色(如绿色、蓝色、橙色、粉色),文字变为深色,未选项使用统一的暗色背景和对应颜色的文字。

这种每个弹框拥有独特视觉风格的设计,展示了声明式 UI 在创意表达方面的灵活性——同一个 modalOverlay 架构框架下,通过不同的颜色方案、布局参数和装饰元素,可以呈现出完全不同的视觉风格,满足不同功能场景的设计需求。

17.4 弹框3:删除确认警示

@Builder modalBodyDel() {
  Column() {
    // 黄黑斜纹装饰条
    Row()
      .width('100%') .height(12)
      .linearGradient({
        angle: 45,
        colors: [
          ['#FFD400', 0], ['#151515', 0.08], ['#151515', 0.12], ['#FFD400', 0.2],
          ['#FFD400', 0.24], ['#151515', 0.32], ['#151515', 0.36], ['#FFD400', 0.44],
          ['#FFD400', 0.48], ['#151515', 0.56], ['#151515', 0.6], ['#FFD400', 0.68],
          ['#FFD400', 0.72], ['#151515', 0.8], ['#151515', 0.84], ['#FFD400', 0.92],
          ['#FFD400', 1]
        ]
      })
    Column() {
      Text('⚠️')
        .fontSize(44) .margin({ top: 20 })
      Text('确认退役这块板?')
        .fontSize(17) .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink) .margin({ top: 8 })
      Text('DUDE, THINK TWICE.')
        .fontSize(9) .fontColor(COLORS.warnYellow)
        .letterSpacing(2) .margin({ top: 3 })
      // ... 装备信息、提示文字、双按钮
    }
    .width('100%') .alignItems(HorizontalAlign.Center)
    .padding({ left: 18, right: 18 })
  }
  .width('82%') .borderRadius(16)
  .backgroundColor(COLORS.darkCard) .clip(true)
  .position({ x: '9%', y: '30%' })
}

删除确认弹框的视觉风格是"街头警示风·黄黑斜纹装饰条"。弹框顶部是一条 12px 高的黄黑斜纹装饰条,通过一个包含十七个颜色停止点的复杂线性渐变模拟。这些停止点在黄色(#FFD400)和黑色(#151515)之间交替,配合 angle: 45 的 45 度渐变角度,形成了黄黑斜纹警示条的视觉效果——这是施工现场、危险区域常见的警示标识。

实现这种斜纹效果的关键在于颜色停止点的精确分布。观察停止点的位置:0-0.08 是黄色,0.08-0.12 是黑色,0.2-0.24 是黄色,0.24-0.32 是黑色… 每个色块的宽度约为 8-12% 的渐变范围,配合 45 度角度,形成了一条条斜向的色带。这种使用渐变模拟条纹图案的技巧,展示了 linearGradient 属性在创意设计方面的潜力——即使没有图片资源或 SVG 支持,也可以通过精心设计的渐变参数实现复杂的视觉效果。

弹框内容区域以居中布局展示警示信息——一个 44px 的 ⚠️ 警示图标、"确认退役这块板?"的主标题、"DUDE, THINK TWICE.“的英文副标题(黄色字体配合字间距 2)。下方是装备信息表格(板面、状态、里程三行)和退役说明文字,最后是双按钮——“再想想”(灰色背景)和"确认退役”(黄色背景配黑色文字)。

17.5 弹框4:约板组局

@Builder modalBodyBiz() {
  Column() {
    Row() {
      Column() {
        Text('🤙 约板组局')
          .fontSize(17) .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('SKATE TOGETHER · 找搭子一起刷')
          .fontSize(9) .fontColor('rgba(255,255,255,0.7)')
          .letterSpacing(1) .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start) .layoutWeight(1)
      Text('✕')
        .fontSize(18) .fontColor('rgba(255,255,255,0.8)')
        .onClick(() => { this.bizOpen = false })
    }
    .width('100%') .alignItems(VerticalAlign.Center)
    .padding({ left: 18, right: 18, top: 16, bottom: 14 })
    .linearGradient({
      angle: 135,
      colors: [['#0A0A0F', 0], ['#5C2A8E', 0.55], [COLORS.neonPurple, 1]]
    })
    Scroll() {
      Column() {
        // 集合地点
        Text('📍 集合地点')
          .fontSize(11) .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.neonBlue) .width('100%')
        Row() {
          Text(this.bizSpot)
            .fontSize(13) .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink) .layoutWeight(1)
          Text('已选')
            .fontSize(9) .fontColor(COLORS.neonGreen)
        }
        // ... 时间、水平要求、报名滑手
      }
      .padding({ left: 18, right: 18, top: 14, bottom: 10 })
    }
    .layoutWeight(1) .scrollBar(BarState.Off)
    Row() {
      Text('不约了')
        .fontSize(13) .fontColor(COLORS.sub)
        .backgroundColor(COLORS.darkField) .borderRadius(20)
        .padding({ left: 24, right: 24, top: 11, bottom: 11 })
        .onClick(() => { this.bizOpen = false })
      Text('发起约板 🛹')
        .fontSize(13) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
        .backgroundColor(COLORS.neonGreen) .borderRadius(20)
        .padding({ left: 24, right: 24, top: 11, bottom: 11 })
        .margin({ left: 12 })
        .onClick(() => { this.bizOpen = false })
    }
    .width('100%') .justifyContent(FlexAlign.Center)
    .padding({ top: 12, bottom: 16 })
  }
  .width('88%') .height('74%') .borderRadius(20)
  .backgroundColor(COLORS.darkCard)
  .position({ x: '6%', y: '12%' })
}

约板组局弹框是四个弹框中功能最丰富的一个。弹框顶部是紫色渐变标题区,包含"🤙 约板组局"标题、"SKATE TOGETHER · 找搭子一起刷"副标题和右上角的 ✕ 关闭按钮。关闭按钮的 onClick 事件将 this.bizOpen 设置为 false,关闭弹框。

弹框主体使用 Scroll 容器包裹,包含四个内容模块——集合地点、集合时间、水平要求和已报名滑手。

集合地点模块展示当前选中的地点(通过 this.bizSpot 状态变量获取,该变量在点击 Spot 卡片或赛事卡片时被设置)和一个补充详细集合点的 TextInput 输入框。TextInputonChange 回调将用户输入同步到 this.bizSpot,覆盖之前从卡片传入的地点名。

集合时间模块使用四个胶囊按钮(今晚 19:00、明晚 19:30、周六 14:00、周日 10:00),其中"明晚 19:30"使用橙色高亮表示默认选中状态。在当前实现中,这些时间选项没有绑定状态变量,是静态的高亮状态。在实际应用中,应该使用一个 @State 变量(如 formTime)来追踪当前选中的时间,并通过条件渲染动态切换高亮状态。

水平要求模块使用 ForEach(LEVEL_OPTS, ...) 渲染三个分组单选项——新手友好、进阶刷街、大神乱杀。选中项使用选项颜色作为整行背景、深色文字和 ◉ 实心圆点标记;未选项使用暗色背景、白色文字和 ○ 空心圆点。点击切换选择时,this.bizLevel 被设置为对应的标签名。

已报名滑手模块通过 ForEach(BIZ_AVATARS, ...) 渲染八个滑手头像,每个头像下方有一个小圆点——前五个使用绿色(已确认到场),后三个使用灰色(未确认)。头像列表末尾还有一个"+12"的数字标签,表示还有十二位滑手未展示。这个头像列表被包裹在一个水平 Scroll 容器中,支持横向滚动查看所有报名滑手。

17.6 弹框交互流程

弹框内容 modalOverlay @State 状态 卡片/按钮 用户 弹框内容 modalOverlay @State 状态 卡片/按钮 用户 点击遮罩区域也可关闭弹框 点击 Spot 卡片 bizSpot = s.name, bizOpen = true bizOpen === true 条件激活 渲染 modalBodyBiz() 显示约板弹框 填写表单/选择选项 onChange 更新 form 变量 实时刷新选中态 点击"发起约板" bizOpen = false 条件失效 移除弹框渲染 弹框关闭 点击遮罩空白区 四个状态统一设为 false 移除所有弹框渲染

上面的时序图展示了从用户点击卡片打开弹框到关闭弹框的完整交互流程。整个流程体现了声明式 UI 的核心优势——开发者只需管理状态变量的值,UI 的显示和隐藏由框架自动处理。无需手动调用 show()hide() 方法,无需管理弹框的生命周期,一切由状态驱动。

十八、底部导航栏

18.1 bottomTabItem:导航项构建器

@Builder bottomTabItem(icon: string, label: string, tab: number) {
  Column() {
    Text(icon)
      .fontSize(20)
      .opacity(this.curTab === tab ? 1.0 : 0.42)
    Text(label)
      .fontSize(9)
      .fontColor(this.curTab === tab ? COLORS.neonOrange : COLORS.sub)
      .fontWeight(this.curTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
    if (this.curTab === tab) {
      Column()
        .width(16) .height(3)
        .backgroundColor(COLORS.neonOrange) .borderRadius(2)
        .margin({ top: 2 })
    }
  }
  .layoutWeight(1) .alignItems(HorizontalAlign.Center)
  .padding({ top: 6, bottom: 6 })
  .onClick(() => {
    this.mainTab = tab
    this.curTab = tab
  })
}

bottomTabItem 构建器是底部导航栏中单个导航项的渲染模板。它接收三个参数:icon(emoji 图标)、label(标签文字)和 tab(对应的 Tab 索引)。通过 this.curTab === tab 的条件判断,导航项的视觉状态分为选中态和未选中态两种。

选中态(this.curTab === tab)的导航项:图标完全不透明(opacity: 1.0),标签使用橙色粗体字体,底部还有一个 16x3 像素的橙色小条作为选中指示器。未选中态的导航项:图标半透明(opacity: 0.42),标签使用灰色常规字体,底部没有指示条。选中指示条通过 if (this.curTab === tab) 条件渲染控制——只有当前选中的导航项才会渲染这个小条,其他项不渲染。这种条件渲染的方式比通过 opacity 控制可见性更加干净——未选中的导航项根本没有指示条组件,而不是有一个透明的指示条占位。

每个导航项的 onClick 事件同时设置 this.mainTab = tabthis.curTab = tab 两个状态变量。mainTab 是底部导航的同步状态(用于可能的底部导航和顶部 Tab 条之间的联动),curTab 是实际控制内容区域渲染的状态。点击导航项后,curTab 的变化会触发 build 方法中的条件分支重新求值,渲染对应页面的内容。

18.2 bottomBar:底部导航栏组装

@Builder bottomBar() {
  Row() {
    this.bottomTabItem('🗺', 'Spot', 0)
    this.bottomTabItem('🧪', '技巧', 1)
    // 中央约板按钮
    Column() {
      Text('+')
        .fontSize(26) .fontWeight(FontWeight.Bold)
        .fontColor('#0A0A0F')
    }
    .width(48) .height(48) .borderRadius(24)
    .justifyContent(FlexAlign.Center)
    .linearGradient({
      angle: 135,
      colors: [[COLORS.neonOrange, 0], [COLORS.warnYellow, 0.5], [COLORS.neonPink, 1]]
    })
    .margin({ top: -20 })
    .shadow({ radius: 10, color: '#66FF5A1F', offsetY: 2 })
    .onClick(() => { this.bizOpen = true })
    this.bottomTabItem('🏆', '榜单', 4)
    this.bottomTabItem('🛹', '我的', 6)
  }
  .width('100%') .backgroundColor('#16161C')
  .borderRadius({ topLeft: 20, topRight: 20 })
  .padding({ left: 6, right: 6, bottom: 8 })
  .alignItems(VerticalAlign.Bottom)
}

bottomBar 构建器组装了底部导航栏。导航栏使用 Row 布局,包含四个导航项和一个中央悬浮按钮。四个导航项分别是 Spot(索引 0)、技巧(索引 1)、榜单(索引 4)、我的(索引 6),通过 layoutWeight(1) 等分宽度。导航项的索引不是连续的(跳过了 2、3、5),这是因为底部导航只映射了七个 Tab 中的四个核心功能,其他三个 Tab(装备车间、赛事日历、街拍 moment)只能通过顶部 Tab 条访问。

中央的"+"悬浮按钮是底部导航的视觉焦点——48x48 像素的圆形按钮(borderRadius(24)),使用三色渐变背景(橙色到黄色到粉色),margin({ top: -20 }) 使按钮向上突出于导航栏主体 20 像素,形成悬浮效果。shadow({ radius: 10, color: '#66FF5A1F', offsetY: 2 }) 为按钮添加了一个橙色半透明阴影,强化了悬浮的视觉立体感。按钮的 onClick 事件将 this.bizOpen 设置为 true,打开约板组局弹框。

导航栏容器使用 backgroundColor('#16161C') 暗色背景,borderRadius({ topLeft: 20, topRight: 20 }) 仅设置顶部圆角,配合 alignItems(VerticalAlign.Bottom) 底部对齐使得悬浮按钮的顶部突出于圆角边界。

十九、主构建函数

19.1 build 方法:Stack 布局组装

build() {
  Stack() {
    Column() {
      this.header()
      this.tabBar()
      Scroll() {
        Column() {
          if (this.curTab === 0) {
            this.pageSpot()
          } else if (this.curTab === 1) {
            this.pageTrick()
          } else if (this.curTab === 2) {
            this.pageGear()
          } else if (this.curTab === 3) {
            this.pageContest()
          } else if (this.curTab === 4) {
            this.pageRank()
          } else if (this.curTab === 5) {
            this.pageMoment()
          } else {
            this.pageMine()
          }
        }
        .width('100%')
    
        })
        Text('VOL.27 · 街拍特辑')
          .fontSize(10) .fontWeight(FontWeight.Bold)
    
      }
    }
    .width('100%') .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


在这里插入图片描述

综上所述,HarmonyOS 6.1.1 作为一个持续演进的操作系统版本,其配套的 HarmonyOS ArkTS API 24 声明式 UI 框架已经具备了构建复杂移动应用所需的全部核心能力。基于 HarmonyOS API 24 的类型系统提供了编译期的安全保障,@Observed@State 装饰器提供了运行时的响应式追踪,@Builder@Component 提供了组件级别的复用能力,ForEach 和条件渲染提供了列表和控制流的声明式表达,linearGradient 和各种视觉属性提供了丰富的渲染能力。这些能力的组合使得开发者可以在单文件内构建出涵盖多页面导航、多弹框交互、数据可视化、动画特效和表单输入的完整应用页面,同时保持代码的可读性、可维护性和可扩展性。随着鸿蒙生态的进一步成熟,我们期待看到更多基于 ArkTS 声明式框架构建的高品质原生应用涌现,为用户带来更流畅、更安全、更具创意的移动体验。

回望全文,从第一行 interface ColorPalette 的类型契约定义到最后一行 build() 方法中 Stack 布局的三层叠放组装,我们走过了一段完整的 HarmonyOS ArkTS API 24 声明式 UI 工程实践之旅。这段旅程涵盖了类型系统设计、响应式数据建模、设计令牌系统、纯函数业务逻辑、组件化构建、多弹框状态管理、数据可视化、动画系统、表单交互和布局架构等全部核心知识点。对于正在学习或即将进入 HarmonyOS 开发领域的开发者而言,本文的分析可以作为一个从理论到实践的完整参考——它不仅解释了"怎么做",更深入探讨了"为什么这样做"的技术决策考量。对于已经具备 HarmonyOS 开发经验的工程师而言,本文的架构分析和跨平台对比可以提供新的设计灵感和优化思路。无论读者的技术背景如何,希望本文都能为你在 HarmonyOS 6.1.1 和 HarmonyOS ArkTS API 24 生态中的开发实践贡献一份力量。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐