一、技术前言

在移动互联网高速发展的今天,年轻人的理财意识正在经历一场前所未有的觉醒。从"月光族"到"攒钱打卡",从"冲动消费"到"目标储蓄",从"闭门造车"到"社区互助"——这背后不仅是消费观念的转变,更是工具驱动的行为重塑。一款优秀的攒钱应用,不能只是冰冷的数字账本,它需要兼顾社区陪伴感、游戏化激励和银行级的专业信任感三重定位。本文将深入剖析一款基于 HarmonyOS ArkUI 框架构建的"掌上攒"应用,它将年轻人攒钱目标管理与记账社区融为一体,在视觉上采用银行风格头部搭配金币下落特效,在功能上覆盖愿望清单、账单分析、攒钱挑战和理财笔记四大核心场景。

1.1 行业背景:年轻人攒钱赛道的崛起

在这里插入图片描述

近年来,国内年轻人群体中"攒钱"已从一种被动行为演变为主动生活方式。支付宝余额宝、微信零钱通培养了零钱理财习惯,各种"365天存钱法""52周攒钱法"在社交媒体广泛传播,闲鱼二手交易回血成为副业标配。但现有工具要么过于银行化(功能强但缺乏社交激励),要么过于社交化(有社区氛围但缺乏专业记账)。"掌上攒"应用正是瞄准了这个空白:以银行级视觉建立专业信任,以社区打卡和挑战机制注入社交激励,以愿望清单和进度条提供目标可视化驱动。这种"专业+社交+目标"三位一体的产品定位,在技术实现上要求视觉风格既沉稳又活泼、功能模块既独立又联动、数据展示既精确又直观。

1.2 ArkUI 声明式 UI 范式深度解析

在这里插入图片描述
HarmonyOS 的 ArkUI 框架是华为面向全场景多设备的一套 UI 开发框架,其核心特征是声明式编程范式。与传统的命令式 UI(通过 findViewById 获取控件再逐个设置属性)截然不同,声明式 UI 让开发者只需描述"界面应该是什么样子",框架负责将状态变化高效地映射到视图更新。这种范式大幅减少了样板代码,使 UI 代码的可读性和可维护性显著提升。

在 ArkUI 中,组件化是构建界面的基本思路。每一个 @Component 修饰的 struct 就是一个可复用的 UI 单元,它拥有独立的状态和构建逻辑。组件之间通过参数传递和事件回调进行通信,形成清晰的层级关系。本应用中,整个页面被封装为单一的 @Entry @Component,内部通过一系列 @Builder 方法将头部、子导航、各内容页、弹框、底部栏等拆分为可组合的构建块。这种设计虽然在一个 struct 内完成,但通过 Builder 的拆分实现了逻辑上的组件化,便于阅读和维护。

1.3 装饰器体系:ArkUI 的状态管理骨架

在这里插入图片描述
ArkUI 提供了多层状态装饰器,每一种都有明确的适用场景和传播规则:

@State 是最基础的状态装饰器,用于组件内部可变状态。当其值改变时,框架自动触发该组件的重新渲染——这个过程是精确的差量更新,而非全量重绘。本应用定义了 18 个 @State 变量,覆盖导航索引、弹框开关、表单数据、列表数据和动画计数器等全部 UI 状态。每当用户点击 Tab 切换、打开弹框、输入文字或提交表单时,对应的 @State 变量被修改,ArkUI 框架立即检测到变化并重新渲染依赖该变量的 UI 区域。

@Observed 用于标注可观察的类。被 @Observed 修饰的类,其实例的属性变化可以被框架追踪。本应用的五个数据模型类(WishItem、BillItem、ChallengeItem、SavePost、NoteItem)均使用 @Observed 标注,确保当愿望的已存金额、账单的金额、挑战的参与状态等属性变化时,绑定了该对象的列表项会自动刷新。@Observed 通常与 @ObjectLink 配合使用实现跨组件观察,但本应用由于在单组件内完成,主要依赖 @State 数组的引用变化来触发列表更新。

@Builder 是 ArkUI 独有的视图构建装饰器。它不是状态管理装饰器,而是用于将一段 UI 构建逻辑封装为可复用的方法。@Builder 方法可以接收参数,可以在方法内部访问组件的 @State 变量,可以在条件分支和循环中使用。本应用定义了 14 个 @Builder 方法,涵盖了特效层、头部、子导航、五个内容页面、个人中心、四个弹框、遮罩层、底部栏和内容路由等所有 UI 区域。这种通过 Builder 拆分视图的方式,是 ArkUI 在单组件场景下实现"逻辑组件化"的核心手段。

@Entry 标记组件为页面入口,每个 ArkUI 页面有且仅有一个 @Entry 组件。@Component 声明一个自定义组件结构体。两者组合 @Entry @Component 定义了应用的根页面。

1.4 与 React/Flutter 的对比分析

在这里插入图片描述
ArkUI 的声明式范式与 React 的 JSX 和 Flutter 的 Widget 树在理念上有相似之处,但在实现细节上各有特色:

React 使用虚拟 DOM 进行差量对比,状态变化后通过 diff 算法计算最小变更集再批量提交到真实 DOM。ArkUI 则直接在 C++ 层维护组件树,状态变化通过观察者模式精确通知到依赖该状态的组件,跳过了虚拟 DOM 的 diff 过程,理论上更新效率更高。此外,React 的状态管理需要借助 useState/useReducer 等 Hook,而 ArkUI 的 @State 是编译期的装饰器,由框架自动生成观察和更新的样板代码。

Flutter 使用 Widget 树 + Element 树 + RenderObject 树三层架构,Widget 是不可变的配置描述,每次 setState 都会创建新的 Widget 对象再与 Element 树进行 diff。ArkUI 的组件结构体本身是可变的,@State 变量的修改直接触发组件方法重新执行,模型更简单直接。Flutter 的优势在于自绘引擎的跨平台一致性,ArkUI 的优势在于与 HarmonyOS 系统能力的深度集成。

在本应用中,一个值得对比的细节是动画实现方式。React 常用 react-spring 或 framer-motion 实现动画,Flutter 使用 AnimationController + Tween,而本应用采用的是"setInterval 驱动 tick 计数器 + 纯函数计算坐标"的方式——这是一种更接近"命令式动画"在声明式框架中的混合实践。虽然帧率不高(约 9 FPS),但配合取模算法的跳跃式坐标变化,视觉上形成了独特的"像素艺术"风格金币飘落效果,且实现成本极低,无需引入额外的动画库。

1.5 金融记账场景的技术挑战

在这里插入图片描述
金融理财类应用在技术实现上面临几个独特的挑战:

数据精度:金额计算不能有浮点误差。本应用在静态数据中使用整数和小数混合(如 8.5 元),在实际工程中应使用分(cent)为单位存储,前端显示时再除以 100。本应用作为演示版本,直接使用 number 类型存储金额,在 amountText 函数中使用 toFixed(1) 格式化,这在模拟数据场景下足够使用。

视觉信任感:金融应用必须通过视觉传递可靠、安全的感受。本应用采用 Material Design 蓝色系作为主色,头部使用深蓝渐变,这些色彩在招商银行、支付宝等成熟应用中已被验证能建立专业信任。同时,所有金额数字使用粗体大号字体,传递"数字即权威"的视觉暗示。

信息密度与可读性的平衡:账单页面需要展示收支对比图表、消费结构分布、流水列表等多种信息,如何在有限的移动端屏幕上组织这些内容而不显杂乱?本应用的策略是"卡片分区+纵向堆叠",每个信息块用白色卡片包裹,卡片间用 12 的间距分隔,背景用 #F4F7FB 的浅灰蓝形成层次。

动画与功能的共存:金币特效覆盖在整个界面上方,但绝不能干扰用户的点击和滑动操作。本应用通过 hitTestBehavior(HitTestMode.None) 让特效层"穿透"点击事件,这是 ArkUI 提供的命中测试控制能力,在 React 中需要手动设置 pointer-events: none CSS 属性实现类似效果。

1.6 关键技术决策

在这里插入图片描述
本应用有几个值得深入讨论的技术决策:

第一,色彩系统采用接口约束的常量对象,而非散落的魔法字符串。ColorPalette 接口定义了 16 个颜色字段,COLORS 常量实现该接口。这保证了全局视觉一致性,编辑器提供自动补全和类型检查,避免拼写错误导致的视觉偏差。这是一种工程化的色彩管理思维,远优于在各处硬编码十六进制字符串。

第二,所有数据模型用 @Observed 标注并配备构造函数,为未来接入真实后端 API 做好了准备。构造函数的参数与类的字段一一对应,使得从 JSON 数据创建模型实例只需一行 new WishItem(data.id, data.name, ...) 即可完成。

第三,弹框系统采用"遮罩层 + 内容卡"的 Stack 叠加模式,通过布尔状态变量控制显隐,简洁而高效。四个弹框共享同一个遮罩层 Builder,点击遮罩层会关闭所有弹框,交互逻辑统一。

第四,特效层设置 hitTestBehavior(HitTestMode.None),确保底层动画不拦截用户交互,这在游戏化视觉与功能可用性之间取得了平衡。同时,特效层在 Stack 中的层级位于内容区和弹框之间,弹框打开时自然遮盖特效。

第五,ForEach 键值函数的精心设计。例如愿望列表的键值为 'w' + w.id.toString() + w.saved.toString(),将 saved 纳入键值,确保当某个愿望的已存金额变化时,对应列表项会被正确更新而非复用旧渲染。这是对 ArkUI 列表渲染机制的深入理解。

二、整体架构流程图

在深入代码之前,先用多张架构流程图展示整个应用的结构全景。从底层数据模型到顶层视图组装,每一层都有清晰的职责边界。

2.1 系统分层架构图

数据基础层

视图组装层

bottomBar 底部导航

build 顶层Stack组装

mainContent 内容区路由

弹框系统

addModalBody 新增愿望卡

editModalBody 编辑目标卡

delModalBody 删除警示卡

challengeModalBody 挑战排行卡

modalOverlay 通用遮罩

组件构建层

fxLayer 特效层

header 银行头部

subNav 子导航栏

pageOverview 总览页

pageWish 愿望页

pageBill 账单页

pageChallenge 挑战页

pageNote 笔记页

pageMine 个人页

工具函数层

barH 柱状高度计算

percentOf 进度百分比

tagColor 标签配色

amountText 金额格式化

coinX/coinY/coinA 金币坐标与透明度

haloScale/haloA 光晕缩放与透明度

ColorPalette 色彩常量

@Observed 数据模型

静态模拟数据

从架构图可以清晰看到,应用遵循"数据 → 工具 → 组件 → 组装"的自底向上构建路径。数据基础层提供色彩规范、类型定义和模拟数据,是整个应用的根基;工具函数层是无状态的纯函数集合,负责数值计算和坐标运算,可被任意组件复用;组件构建层是 UI 的核心,包含特效、头部、五个内容页面和个人中心页;弹框系统作为独立的交互模块,由状态变量驱动显隐;最终所有构建块在视图组装层的 build 方法中通过 Stack 叠加为完整界面。

2.2 特效层动画流程图

aboutToAppear

setInterval 110ms

tick = tick + 1

tick @State 变化

fxLayer 重新渲染

coinX/coinY 计算新坐标

coinA 计算新透明度

haloScale/haloA 计算光晕

7枚金币位置更新

金币闪烁效果

存钱罐呼吸光晕

视觉呈现

这张流程图展示了特效层的动画驱动机制。aboutToAppear 启动 110 毫秒间隔的定时器,每次触发将 tick 加 1。由于 tick@State 变量,框架检测到变化后触发 fxLayer Builder 重新执行。在重新执行过程中,七枚金币分别调用 coinXcoinYcoinA 计算新坐标和透明度,存钱罐调用 haloScalehaloA 计算缩放和透明度,最终更新到视觉层,循环往复形成持续动画。

2.3 弹框系统状态转换图

点击新目标按钮

点击编辑按钮

点击删除/账单项

点击发起挑战按钮

提交/取消/点击遮罩

保存/放弃/点击遮罩

确认/取消/点击遮罩

发起/再想想/点击遮罩

点击创建愿望

校验通过

校验失败

关闭态

新增愿望

编辑目标

删除确认

发起挑战

提交校验

数据插入

弹框系统的状态转换图展示了四种弹框的生命周期。每个弹框从"关闭态"出发,由特定的用户操作触发打开。弹框打开后,用户可以通过提交表单、点击取消按钮或点击遮罩层三种方式关闭。提交时会经过校验,校验通过则执行数据操作并关闭,校验失败则保持打开状态。所有弹框共享同一个遮罩层,点击遮罩层会将四个布尔状态全部置为 false,确保任何时刻最多只有一个弹框可见。

2.4 数据流图

用户操作

@State 可变状态

静态数据源

unshift

splice替换

splice删除

unshift

unshift

驱动渲染

驱动渲染

WISH_LIST 愿望

BILL_LIST 账单

CHALLENGE_LIST 挑战

SAVE_POSTS 动态

NOTE_LIST 笔记

wishList

postList

新增愿望 doAdd

编辑目标 doEdit

删除记录 doDel

发起挑战 doChallenge

加入挑战 onClick

愿望列表

动态列表

数据流图展示了应用中数据的来源和流向。静态数据源(WISH_LIST、SAVE_POSTS)在组件初始化时赋值给 @State 变量(wishList、postList),成为可变状态。用户操作通过 unshift(头部插入)、splice(替换或删除)修改这些数组,数组引用变化触发 ArkUI 框架重新渲染对应的列表 UI。其他静态数据(BILL_LIST、CHALLENGE_LIST、NOTE_LIST)直接在 ForEach 中引用,不经过 @State 中转,因为它们在应用运行期间不需要被修改。

三、色彩体系设计

3.1 ColorPalette 接口定义

应用首先定义了一个 ColorPalette 接口,将所有颜色字段以强类型的方式约束起来:

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  gold: string;
}

这个接口定义了 16 个颜色字段,覆盖了主色系、强调色系、背景色系、文本色系、语义色系和装饰色系六大类别。使用接口约束的好处在于:当其他开发者引用 COLORS 帖量时,编辑器能提供自动补全和类型检查,避免拼写错误导致的视觉偏差。如果某个字段名拼写错误,TypeScript 编译器会立即报错,将问题拦截在编译期而非运行期。这是一种工程化的色彩管理思维,远优于在各处硬编码十六进制字符串。

接口中的每个字段都有明确的语义命名。primary 系列代表品牌主色及其明暗变体;accent 系列代表强调色及其浅色变体;bgcardBg 分别代表页面背景和卡片背景;textPrimarytextSecondarytextHint 构成三级文本色阶;border 是分隔线色;successwarningdanger 是三种语义状态色;white 用于深色背景上的文字和图标;gold 是特殊的装饰色,与攒钱主题契合。

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  primary: '#1565C0',
  primaryLight: '#E3F2FD',
  primaryDark: '#0D47A1',
  accent: '#FF8F00',
  accentLight: '#FFF8E1',
  bg: '#F4F7FB',
  cardBg: '#FFFFFF',
  textPrimary: '#10233B',
  textSecondary: '#5C7189',
  textHint: '#AEBDD0',
  border: '#E2EAF3',
  success: '#2E7D32',
  warning: '#F9A825',
  danger: '#D32F2F',
  white: '#FFFFFF',
  gold: '#FBC02D'
};

下面逐一分析每个色彩的 RGB 值、视觉感受和使用场景:

primary #1565C0(深蓝):RGB(21, 101, 192)。这是 Material Design 蓝色系中的 800 色阶,蓝色值 192 远高于红色 21 和绿色 101,形成纯粹的深蓝调。视觉上传递专业、可信赖、冷静的心理感受,是银行和金融机构的首选色。应用头部渐变的终点色、主按钮背景、选中态标签文字色均使用此色,建立"金融应用"的第一印象。在心理学上,深蓝能降低用户的心率感知,使其在查看资产数据时保持冷静,避免因数字波动产生冲动决策。

primaryLight #E3F2FD(浅蓝):RGB(227, 242, 253)。蓝色的 50 色阶,三通道值均很高,形成极浅的蓝白色。视觉上柔和、清爽,不会与白色背景产生强对比。用于选中态背景、信息提示底色(如愿望图标背景)、挑战列表的已加入按钮背景。与 primary 形成同色系明暗对比,在视觉上柔和过渡。

primaryDark #0D47A1(藏蓝):RGB(13, 71, 161)。蓝色的 900 色阶,比 primary 更深。红色值仅 13,几乎不可见,蓝色值 161 低于 primary 的 192,但整体明度更低。用于头部渐变的起始色,营造由深到浅的光照效果——仿佛光线从左上方照射,头部左上角最暗,右下方逐渐变亮。这种渐变模拟了银行 App 中常见的"金属质感"大卡片效果。

accent #FF8F00(琥珀橙):RGB(255, 143, 0)。红色拉满 255,绿色 143 居中,蓝色为 0,形成温暖的琥珀橙色。视觉上充满活力、引导注意力,在大量蓝色背景中形成视觉焦点。用于进度条、挑战卡渐变终点、挑战按钮背景、挑战天数选择器选中态。橙色与蓝色互补,在色彩理论中是最强对比色对之一,能有效引导用户视线到关键交互区域。

accentLight #FFF8E1(米黄):RGB(255, 248, 225)。三通道值均接近 255,形成极浅的暖黄色。视觉上温暖、包裹感强,用于愿望图标背景、挑战弹框头部底色、笔记列表中勋章标签背景。它比纯白多了一份温度,使包含它的区域看起来更亲切。

bg #F4F7FB(灰蓝白):RGB(244, 247, 251)。三通道值很高但蓝通道略高,形成略带冷色调的灰白色。视觉上比纯白更柔和,长时间阅读不刺眼。作为全局背景色,与卡片白色形成约 4% 的亮度差,使卡片自然"浮起"形成层次感。

cardBg #FFFFFF(纯白):RGB(255, 255, 255)。卡片背景色,是最干净纯粹的背景。所有信息内容都承载在白色卡片上,确保内容可读性最大化。白色与 bg 的亮度差虽然仅约 4%,但在移动端屏幕上足以形成清晰的视觉分层。

textPrimary #10233B(墨蓝黑):RGB(16, 35, 59)。不是纯黑(#000000)而是带蓝调的深色,与 primary 色系统一。纯黑在移动端 OLED 屏幕上过于刺眼,而墨蓝黑在保持高对比度的同时更加柔和。用于所有主要文字,如标题、金额数字、用户昵称等。

textSecondary #5C7189(灰蓝):RGB(92, 113, 137)。明度介于 textPrimary 和 textHint 之间,用于说明文字、副标题、辅助信息。如"目标 ¥2999 · 截止 12月31日"这类信息使用此色,与主标题形成层次但不喧宾夺主。

textHint #AEBDD0(浅灰蓝):RGB(174, 189, 208)。明度较高,用于时间戳、极弱化的辅助信息。如"5分钟前""32145阅读"这类不影响阅读主内容的元数据。在视觉层次中位于最底层。

border #E2EAF3(极浅灰蓝):RGB(226, 234, 243)。几乎不可见的分隔线色。用于底部导航栏顶部的 1px 分隔线,提供微妙的边界感而不形成视觉割裂。

success #2E7D32(深绿):RGB(46, 125, 50)。Material Design 绿色 800 色阶,用于收益金额、已达成状态、正向收益提示。绿色在金融语境中代表正向、增长、安全,如"基金收益分红 +¥86.4"使用此色。

warning #F9A825(金黄):RGB(249, 168, 37)。视觉上明亮温暖,预留用于提醒场景。本应用当前未在主要 UI 中使用此色,但保留在色彩体系中以备扩展需求。

danger #D32F2F(红色):RGB(211, 47, 47)。Material Design 红色 700 色阶,用于删除按钮文字、连续打卡天数标签、打卡动态的连续天数标签背景。红色在金融语境中代表警示、支出、风险。注意本应用的"删除"操作按钮文字使用 danger 红色,但删除弹框的确认按钮使用 primary 蓝色,这是有意的色彩区分——按钮文字用红色提示风险,确认按钮用蓝色保持整体调性统一。

white #FFFFFF(白色):RGB(255, 255, 255)。用于深色背景上的文字和图标,如头部渐变区域的所有文字、挑战卡顶部的白色标题等。

gold #FBC02D(金色):RGB(251, 192, 45)。装饰色,RGB 中红色 251 和绿色 192 都很高,蓝色仅 45,形成明亮的金黄色。用于金币主题、子导航栏选中态下方的圆点高亮。金色与攒钱主题高度契合——金币本身就是金黄色的,这个色彩的应用让整个界面的"攒钱"意象更加具象化。

整套色彩体系的核心策略是"蓝色铺底 + 橙金点睛":大面积的蓝色建立金融信任感,局部的橙色和金色引导用户关注攒钱目标和进度,绿色提示正向收益,红色警示风险操作。这种配色在招商银行、支付宝等成熟金融应用中屡见不鲜,但通过 Emoji 图标的加入和金币特效的叠加,又赋予了年轻化的活力。

四、数据模型层

应用定义了五个 @Observed 数据模型类,分别对应愿望清单、账单记录、攒钱挑战、打卡动态和理财笔记。@Observed 装饰器使类的实例成为可观察对象,当其属性变化时,绑定了该对象的 UI 组件会自动刷新。

4.1 愿望清单模型 WishItem

@Observed
export class WishItem {
  id: number = 0
  name: string = ''
  icon: string = ''
  target: number = 0
  saved: number = 0
  deadline: string = ''
  constructor(id: number, name: string, icon: string, target: number, saved: number, deadline: string) {
    this.id = id; this.name = name; this.icon = icon; this.target = target
    this.saved = saved; this.deadline = deadline
  }
}

WishItem 描述一个攒钱愿望,是应用最核心的数据模型。id 是唯一标识,用于 ForEach 的键值生成,确保列表项的正确复用和更新。name 是愿望名称,如"Switch 2 游戏机"“北海道旅行基金”,使用自然语言而非编码,更贴近用户的日常表达。

icon 使用 Emoji 表情而非图片资源,这大幅减小了包体积同时保持跨设备一致性——Emoji 在所有 HarmonyOS 设备上都能正确渲染,无需考虑不同分辨率和 DPR 的适配问题。每个愿望配有语义相关的 Emoji,如游戏机配🎮、旅行配✈️、宠物配🐱,视觉识别度高。

targetsaved 分别是目标金额和已存金额,两者之差即为"还差多少"。这两个字段是数值类型,在进度条计算中通过 percentOf(saved, target) 计算百分比。当 saved >= target 时,进度条变绿并显示"已达成"标记。

deadline 是截止日期,用自然语言描述如"12月31日"“明年5月”“长期”"已达成"而非时间戳。这种设计更符合年轻人的阅读习惯——"明年5月"比"2026-05-01"更直觉。但代价是无法做日期排序和提醒计算,实际工程中应同时存储一个 deadlineTimestamp 字段用于逻辑计算,deadline 字段仅用于显示。

构造函数中采用分号连接的紧凑赋值写法,this.id = id; this.name = name; ...,将六个赋值语句压缩在两行内,减少了代码行数但保持了可读性。@Observed 装饰器在编译期会为类的每个属性生成观察者代码,当属性被赋值时自动通知依赖该属性的 UI 组件更新。

4.2 账单模型 BillItem

@Observed
export class BillItem {
  id: number = 0
  title: string = ''
  category: string = ''
  amount: number = 0
  date: string = ''
  income: boolean = false
  constructor(id: number, title: string, category: string, amount: number, date: string, income: boolean) {
    this.id = id; this.title = title; this.category = category; this.amount = amount
    this.date = date; this.income = income
  }
}

BillItem 描述一条账单流水。category 字段存储消费分类,如"餐饮"“交通”“理财”“居住”“娱乐”“日用”“闲置”“工资”,与 CATEGORY_DIST 静态数据中的分类对应,用于消费结构可视化。这种分类设计参考了主流记账应用的常用分类法,覆盖了年轻人日常消费的主要场景。

amount 是金额字段,使用 number 类型。实际工程中应使用整数分(cent)为单位避免浮点误差,本应用作为演示使用元为单位,接受小数如 8.5 元。金额从 6 元(地铁通勤)到 12500 元(工资入账)跨度很大,能充分测试金额显示的适配性。

income 布尔值区分收入和支出,渲染时据此选择不同的图标(💰表示收入,💸表示支出)和金额颜色(绿色表示收入,深色表示支出)。这种"一个布尔值驱动两种渲染"的设计简洁高效,但在实际工程中如果收支类型更复杂(如转账、退款、代付),应改为枚举类型。

date 采用"08-22"的短格式,省略了年份(默认当前年),适合在列表项中紧凑展示。日期字段还参与账单列表的排序(按日期降序),但在本应用中静态数据已按日期排好,无需前端排序。

title 是账单标题,如"早餐·豆浆油条"“奶茶·杨枝甘露”“外卖·麻辣香锅”,比干巴巴的"餐饮消费"更有生活气息。这种文案风格是本应用"年轻化"定位的体现——让记账不再是冰冷的数据录入,而是生活记录的一部分。

4.3 挑战模型 ChallengeItem

@Observed
export class ChallengeItem {
  id: number = 0
  name: string = ''
  members: number = 0
  totalSaved: number = 0
  days: number = 0
  joined: boolean = false
  constructor(id: number, name: string, members: number, totalSaved: number, days: number, joined: boolean) {
    this.id = id; this.name = name; this.members = members; this.totalSaved = totalSaved
    this.days = days; this.joined = joined
  }
}

ChallengeItem 描述一个攒钱挑战。name 是挑战名称,如"365天存钱法"“奶茶戒断大作战”“工资到账先存30%”“带饭一周挑战”,这些名称来自社交媒体广泛传播的攒钱方法论,用户一看就知道挑战内容。

memberstotalSaved 是社区维度的数据,展示参与人数和累计攒钱总额。这两个数字通常很大(如 88421 人、28600000 元),因此在前端使用 amountText 函数格式化为"万"单位显示,如"8.8万人参与"“累计攒下2860.0万”。大数字的格式化是金融应用的基本功——直接显示原始数字会导致位数过多难以阅读。

days 表示挑战已进行天数,用于计算进度条宽度。barH(c.days, 365) 将天数相对于 365 天归一化为百分比,展示挑战的时间进度。这个字段在用户加入挑战后应随时间递增,但本应用作为演示版本使用静态值。

joined 标记当前用户是否已加入该挑战,控制按钮显示"加入"(橙色,可点击)还是"已加入"(灰色,不可点击)。点击"加入"按钮会向打卡动态列表插入一条"加入了挑战"的动态,实现社区互动反馈。joined 是布尔类型,在 ArkUI 中 @Observed 类的布尔属性变化也能触发 UI 更新,使得按钮状态切换即时响应。

4.4 打卡动态模型 SavePost

@Observed
export class SavePost {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  text: string = ''
  amount: number = 0
  streak: number = 0
  likes: number = 0
  time: string = ''
  constructor(id: number, nick: string, avatar: string, text: string, amount: number, streak: number, likes: number, time: string) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
    this.amount = amount; this.streak = streak; this.likes = likes; this.time = time
  }
}

SavePost 描述一条社区打卡动态。nick 是用户昵称,如"攒钱小能手"“奶茶绝缘体”“带饭大师”“闲鱼倒爷”——每个昵称都带有人设标签,让虚拟用户有记忆点。avatar 是头像 Emoji,与昵称语义关联,如"定投钉子户"配📈、"抠门艺术家"配🎨。

text 是动态正文,支持最多两行显示并带省略号(通过 maxLines(2)textOverflow({ overflow: TextOverflow.Ellipsis }) 实现),避免过长内容破坏卡片布局。文案充满年轻人的表达方式,如"同事都馋哭了"“猫粮基金今日+50,年糕我来了”“发现超市临期区宝藏,3块钱买了两盒草莓”,营造了活跃的社区氛围。

streak 是连续打卡天数,用红色标签背景 #FDECEA 强调,激发用户的坚持欲望——看到别人连续打卡 210 天,自己也会产生"我也要坚持"的心理。amount 是本次存入金额,likes 是点赞数。这三个字段组合在动态底部以"🪙 88元 · 🧡 46 · 5分钟前"的形式展示,信息密度高但层次清晰。

time 是发布时间,使用相对时间格式如"5分钟前"“2小时前”“刚刚”,比绝对时间更直觉。在用户发起新挑战或加入挑战时,新动态的 time 设为"刚刚",符合新发布内容的特征。id 设为 998 或 997 等临时值,避免与静态数据的 id 冲突。

4.5 理财笔记模型 NoteItem

@Observed
export class NoteItem {
  id: number = 0
  title: string = ''
  author: string = ''
  tag: string = ''
  reads: number = 0
  desc: string = ''
  constructor(id: number, title: string, author: string, tag: string, reads: number, desc: string) {
    this.id = id; this.title = title; this.author = author; this.tag = tag
    this.reads = reads; this.desc = desc
  }
}

NoteItem 描述一篇理财笔记。tag 字段存储内容分类,取值包括"科普"“测评”“心得”“习惯”“方法”,通过 tagColor 函数映射为不同颜色标签——科普和测评类用蓝色(理性客观),心得和习惯类用橙色(温暖分享),方法类用绿色(正向积极)。这种"语义-颜色"映射使笔记列表在视觉上自然分类,用户扫一眼就能定位内容类型。

reads 是阅读量,展示为"32145阅读",用极小字号(8px)弱化处理,避免阅读量数字干扰标题的阅读。desc 是一句话摘要,单行省略显示(通过 maxLines(1)textOverflow 实现),帮助用户在不点开详情的情况下快速判断内容是否值得阅读。

titleauthor 是基本的内容元数据。标题如"工资到账后的黄金10分钟"“记账三个月改变了什么”“五险一金到底怎么算"都是年轻人关心的理财话题,author 如"省钱侦探”“数据党小陈”“心理系阿糖”"HR老王"等昵称暗示了作者的专业背景,增加内容的可信度。

4.6 配置接口体系

除了五个数据模型,应用还定义了六个纯接口用于图表和导航配置:

interface CategoryDist { label: string; value: number; color: string; }
interface MonthChartItem { label: string; save: number; spend: number; }
interface QuickItem { icon: string; label: string; }
interface RankItem { rank: number; nick: string; avatar: string; saved: string; }
interface NavItem { icon: string; label: string; }

CategoryDist 用于消费结构分布,每项包含标签(如"居住")、百分比(如 32)和对应颜色(如 #1565C0)。value 是百分比数值,六个分类的值之和恰好为 100,确保进度条总宽度填满。

MonthChartItem 用于月度攒钱 vs 花钱对比图,savespend 分别代表每月攒下和花掉的金额。数据覆盖 3 月到 8 月共 6 个月,每月数值在 2900 到 5100 之间波动,5100 作为最大值传入 barH 函数用于柱状高度归一化。

QuickItem 是头部快捷入口配置,包含图标 Emoji 和标签文字。RankItem 是排行榜条目,saved 字段直接存储格式化后的字符串如"4.2万",而非原始数值——这是为了避免在前端重复格式化。NavItem 是底部导航条目。

这些接口不带 @Observed,因为它们是静态配置数据,不需要驱动 UI 更新。使用 interface 而非 class 是因为它们只需要类型约束,不需要构造函数和方法。接口在编译后会被完全擦除,不产生运行时开销。

五、静态数据与模拟数据

应用在顶层定义了大量静态常量数组,作为模拟数据源。这些数据覆盖了所有页面所需的内容,使得应用在不接入后端的情况下即可完整运行和展示。这种"模拟数据先行"的开发模式,允许前端独立于后端完成全部 UI 开发和交互测试,在后端 API 就绪后只需替换数据源即可。

5.1 愿望列表 WISH_LIST

const WISH_LIST: WishItem[] = [
  new WishItem(1, 'Switch 2 游戏机', '🎮', 2999, 1860, '12月31日'),
  new WishItem(2, '北海道旅行基金', '✈️', 12000, 4520, '明年5月'),
  new WishItem(3, '给妈妈换新手机', '📱', 3999, 3500, '11月20日'),
  new WishItem(4, '降噪耳机', '🎧', 1899, 900, '明年1月'),
  new WishItem(5, '应急备用金', '🛡️', 20000, 12800, '长期'),
  new WishItem(6, '人体工学椅', '🪑', 2499, 1980, '10月15日'),
  new WishItem(7, '演唱会门票', '🎤', 1580, 1580, '已达成'),
  new WishItem(8, '宠物年检套餐', '🐱', 1200, 300, '12月')
];

八条愿望数据精心设计,涵盖了年轻人常见的攒钱场景:数码产品(Switch 2、降噪耳机)、旅行(北海道)、孝心(给妈妈换手机)、应急(备用金)、生活品质(人体工学椅)、娱乐(演唱会)、宠物(年检套餐)。这种多样性确保了不同用户都能在列表中找到共鸣。

从数据设计角度看,每条愿望的金额和进度各不相同:第 1 条 1860/2999 = 62%,第 3 条 3500/3999 = 88%,第 7 条 1580/1580 = 100%(已达成),第 8 条 300/1200 = 25%。这种进度的多样性确保了进度条视觉的丰富性——有刚起步的短条、有接近完成的长条、有满格的绿色条。

第 7 条"演唱会门票"的 saved 等于 target,代表已达成,在渲染时进度条会变为绿色并显示"✅ 已达成"。第 5 条"应急备用金"的 deadline 设为"长期",表示没有明确截止日期,这种灵活的日期表达是自然语言日期的优势。

5.2 账单列表 BILL_LIST

const BILL_LIST: BillItem[] = [
  new BillItem(1, '工资入账', '工资', 12500, '08-22', true),
  new BillItem(2, '早餐·豆浆油条', '餐饮', 8.5, '08-22', false),
  new BillItem(3, '地铁通勤', '交通', 6, '08-22', false),
  new BillItem(4, '基金定投自动扣款', '理财', 1000, '08-21', false),
  new BillItem(5, '外卖·麻辣香锅', '餐饮', 32.8, '08-21', false),
  new BillItem(6, '奶茶·杨枝甘露', '餐饮', 18, '08-21', false),
  new BillItem(7, '房租转账', '居住', 2600, '08-20', false),
  new BillItem(8, '视频会员续费', '娱乐', 25, '08-20', false),
  new BillItem(9, '基金收益分红', '理财', 86.4, '08-20', true),
  new BillItem(10, '超市采购·一周食材', '日用', 156.7, '08-19', false),
  new BillItem(11, '电影票两张', '娱乐', 78, '08-19', false),
  new BillItem(12, '闲鱼卖出旧键盘', '闲置', 220, '08-18', true)
];

账单数据包含 12 条记录,涵盖工资、餐饮、交通、理财、居住、娱乐、日用、闲置交易等分类。其中 3 条为收入(工资 12500、基金收益 86.4、闲鱼卖出 220),9 条为支出。收入和支出的比例约为 1:3,符合年轻人"大部分消费、少量收入"的日常结构。

金额从 6 元(地铁通勤)到 12500 元(工资)跨度很大,能充分测试金额显示的适配性——个位数金额和万级金额在同一列表中展示,需要字号和格式化策略的一致性。小金额如 8.5 元保留一位小数,大金额如 12500 元直接显示整数。

每条记录都配有生动的标题,如"奶茶·杨枝甘露"“外卖·麻辣香锅”“超市采购·一周食材”,比干巴巴的"餐饮消费"更有生活气息。标题中的"·"分隔符将品类和具体物品分开,既有分类信息又有个性化描述。日期从 08-22 到 08-18 按时间降序排列,最近的记录在最上方。

5.3 挑战列表 CHALLENGE_LIST

const CHALLENGE_LIST: ChallengeItem[] = [
  new ChallengeItem(1, '365天存钱法', 88421, 28600000, 210, true),
  new ChallengeItem(2, '奶茶戒断大作战', 12035, 1580000, 45, false),
  new ChallengeItem(3, '工资到账先存30%', 45210, 9870000, 90, true),
  new ChallengeItem(4, '地铁代替打车', 8932, 762000, 30, false),
  new ChallengeItem(5, '带饭一周挑战', 5432, 398000, 14, false),
  new ChallengeItem(6, '双十一不动本金', 32876, 5410000, 60, true)
];

挑战数据设计了 6 个不同主题的攒钱挑战,涵盖长期习惯养成(365天存钱法)、消费戒断(奶茶戒断)、储蓄纪律(先存30%)、出行优化(地铁代替打车)、生活技能(带饭挑战)、购物节防御(双十一不动本金)。这些挑战主题来自社交媒体广泛传播的攒钱方法论,用户一看就知道挑战内容。

参与人数从 5432 到 88421 不等,累计攒金额从 398000 到 28600000 不等,这些大数字通过 amountText 函数格式化为"万"单位显示。天数从 14 到 210 不等,通过 barH(c.days, 365) 计算相对于 365 天的进度百分比。3 个标记为已加入(joined: true),3 个未加入,确保两种按钮状态的视觉都有展示。

5.4 社区动态列表 SAVE_POSTS

const SAVE_POSTS: SavePost[] = [
  new SavePost(1, '攒钱小能手', '💪', '今日存入88元,距离Switch2还差1139元!', 88, 128, 46, '5分钟前'),
  new SavePost(2, '奶茶绝缘体', '🧋', '戒奶茶第45天,省下的钱够买机票了', 22, 45, 89, '20分钟前'),
  new SavePost(3, '带饭大师', '🍱', '这周带饭5天,省了187元,同事都馋哭了', 187, 32, 67, '1小时前'),
  new SavePost(4, '房租砍价王', '🏠', '和房东谈续租降了200/月,一年省2400', 2400, 3, 102, '2小时前'),
  new SavePost(5, '闲鱼倒爷', '♻️', '卖掉三件闲置,回血568元', 568, 15, 78, '3小时前'),
  new SavePost(6, '定投钉子户', '📈', '定投第210天,收益率4.2%,继续躺平', 1000, 210, 156, '4小时前'),
  new SavePost(7, '抠门艺术家', '🎨', '发现超市临期区宝藏,3块钱买了两盒草莓', 3, 88, 134, '5小时前'),
  new SavePost(8, '攒钱买猫', '🐱', '猫粮基金今日+50,年糕我来了', 50, 66, 55, '6小时前')
];

动态数据设计了 8 条社区打卡内容,每条都有独特的昵称和 Avatar Emoji,文案充满年轻人的表达方式。“同事都馋哭了”“猫粮基金今日+50,年糕我来了”"发现超市临期区宝藏"等文案,既有攒钱干货又有生活趣味,营造了活跃的社区氛围。

连续打卡天数从 3 到 210 不等,streak 字段值越大,红色标签越醒目,对用户的激励效果越强。"定投钉子户"的 streak 为 210 天,是列表中最高的,传递"坚持就是胜利"的心理暗示。点赞数从 46 到 156 不等,高点赞的内容通常更有参考价值。

时间从"5分钟前"到"6小时前"递增,形成时间线效果。在总览页面只展示前 3 条(postList.slice(0, 3)),在用户发起新挑战或加入挑战时,新动态会通过 unshift 插入到列表头部,time 设为"刚刚"。

5.5 理财笔记列表 NOTE_LIST

const NOTE_LIST: NoteItem[] = [
  new NoteItem(1, '工资到账后的黄金10分钟', '省钱侦探', '习惯', 32145, '发薪日的自动分配流程,先存后花'),
  new NoteItem(2, '零钱理财 vs 货币基金全对比', '数据党小陈', '测评', 28673, '七日年化、申购赎回时效实测'),
  new NoteItem(3, '记账三个月改变了什么', '攒钱小能手', '心得', 45201, '消费结构可视化后的行为改变'),
  new NoteItem(4, '五险一金到底怎么算', ' HR老王', '科普', 19876, '到手工资的计算公式拆解'),
  new NoteItem(5, '攒钱目标怎么定才科学', '心理系阿糖', '方法', 15432, 'SMART原则在存钱上的应用'),
  new NoteItem(6, '年轻人的第一份保险清单', '保险课代表', '科普', 23987, '意外+医疗的最低配置思路'),
  new NoteItem(7, '双11不剁手指南', '省钱侦探', '习惯', 17654, '购物车冷静期与比价工具'),
  new NoteItem(8, '副业攒钱可行吗', '打工人阿凯', '心得', 12908, '时间成本与收益的真实账本')
];

理财笔记数据包含 8 条内容,涵盖工资管理、理财对比、记账心得、社保科普、目标设定、保险配置、消费控制、副业评估等年轻人关心的理财话题。每篇笔记的 tag 取值为"习惯"“测评”“心得”“科普”“方法”,通过 tagColor 函数映射为蓝、橙、绿三种颜色标签。

阅读量从 12908 到 45201 不等,高阅读量的内容通常更受社区欢迎。“记账三个月改变了什么"以 45201 阅读量居首,说明"行为改变"类内容最受关注。作者昵称如"省钱侦探”“数据党小陈”“心理系阿糖”“HR老王”“保险课代表”"打工人阿凯"暗示了作者的专业背景——侦探善于发现省钱机会、数据党擅长量化分析、心理系懂行为动机、HR懂社保、保险课代表懂保险、打工人懂副业——增加内容的可信度和专业感。

5.6 图表与导航配置数据

const CATEGORY_DIST: CategoryDist[] = [
  { label: '居住', value: 32, color: '#1565C0' },
  { label: '餐饮', value: 26, color: '#FF8F00' },
  { label: '理财', value: 18, color: '#2E7D32' },
  { label: '娱乐', value: 12, color: '#FBC02D' },
  { label: '交通', value: 7, color: '#6D4C41' },
  { label: '其他', value: 5, color: '#AEBDD0' }
];

消费结构分布数据中,六个分类的百分比之和恰好为 100(32+26+18+12+7+5=100),每个分类配有独立颜色。颜色与 COLORS 常量中的色系保持一致但略有扩展:居住用 primary 蓝、餐饮用 accent 橙、理财用 success 绿、娱乐用 gold 金、交通用棕色 #6D4C41(COLORS 中没有棕色,这是扩展色)、其他用 textHint 灰。棕色的引入增加了色彩层次,使六个分类各具辨识度。

const MONTH_CHART: MonthChartItem[] = [
  { label: '3月', save: 3200, spend: 4800 },
  { label: '4月', save: 3600, spend: 4400 },
  { label: '5月', save: 2900, spend: 5100 },
  { label: '6月', save: 4200, spend: 3900 },
  { label: '7月', save: 4500, spend: 3600 },
  { label: '8月', save: 3800, spend: 4100 }
];

月度图表数据覆盖 3 月到 8 月共 6 个月,每月的 savespend 数值在 2900 到 5100 之间波动。5100 是六个月中的最大值,作为 barH 函数的 max 参数用于柱状高度归一化。数据设计上,5 月花钱最多(5100)、7 月攒钱最多(4500),呈现了"攒钱和花钱此消彼长"的波动趋势,8 月攒钱率 48% 是当前月的最新数据。

const QUICK_ITEMS: QuickItem[] = [
  { icon: '💰', label: '存一笔' },
  { icon: '📝', label: '记一笔' },
  { icon: '🎯', label: '新目标' },
  { icon: '⚔️', label: '挑战' }
];

const RANK_LIST: RankItem[] = [
  { rank: 1, nick: '定投钉子户', avatar: '📈', saved: '4.2万' },
  { rank: 2, nick: '房租砍价王', avatar: '🏠', saved: '3.8万' },
  { rank: 3, nick: '攒钱小能手', avatar: '💪', saved: '3.1万' }
];

const NAV_LIST: NavItem[] = [
  { icon: '🏠', label: '首页' },
  { icon: '🧾', label: '账单' },
  { icon: '⚔️', label: '挑战' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['总览', '愿望', '账单', '挑战', '笔记'];

快捷入口的四个按钮分别对应存钱(💰存一笔)、记账(📝记一笔)、新建目标(🎯新目标)和发起挑战(⚔️挑战),其中"新目标"和"挑战"绑定了弹框打开逻辑,"记一笔"切换到账单 Tab,"存一笔"未绑定操作(预留扩展)。

排行榜数据中 saved 字段直接存储格式化后的字符串"4.2万",而非原始数值 42000——这是为了避免在前端重复格式化。排行榜数据在总览页展示前三名,同时在发起挑战弹框中复用为"虚位以待"的占位头像。

底部导航的四个主 Tab 对应应用的四大功能域:首页(综合入口)、账单(收支记录)、挑战(社区互动)、我的(个人中心)。子导航的五个标签(总览、愿望、账单、挑战、笔记)仅在首页主 Tab 下显示,实现"首页五内容 Tab"的布局。

六、工具函数体系

应用在顶层定义了八个纯函数,它们不依赖任何组件状态,接收参数返回计算结果,是高度可复用的工具层。纯函数的优势在于可测试性——给定相同的输入必定得到相同的输出,无需模拟组件上下文。

6.1 柱状高度计算 barH

function barH(v: number, max: number): number {
  return Math.round(100 * v / max);
}

barH 将一个数值 v 相对于最大值 max 归一化为 0-100 的整数百分比,用于柱状图和进度条的高度/宽度计算。Math.round 确保返回整数,避免浮点数导致的渲染亚像素模糊。

这个函数在账单页的月度图表中用于计算双柱高度:barH(m.save, 5100)barH(m.spend, 5100),5100 是六个月中的最大值。在消费结构条形图中用于计算每条进度条宽度:barH(c.value, 32),32 是六个分类中的最大值。在挑战页用于计算挑战时间进度条宽度:barH(c.days, 365),365 是挑战的最大天数。

使用统一的归一化函数保证了所有柱状图和进度条的视觉比例一致——无论数据范围是 0-32 还是 0-5100,最终渲染的宽度百分比都在 0-100 之间,使得不同图表的视觉规模可比较。

6.2 进度百分比 percentOf

function percentOf(saved: number, target: number): number {
  if (target <= 0) {
    return 0;
  }
  return Math.round(100 * saved / target);
}

percentOf 计算已存金额占目标金额的百分比。与 barH 不同,它增加了 target <= 0 的边界保护,避免除零错误——当目标金额为 0 或负数时直接返回 0。这是一个防御性编程的实践,确保函数在异常输入下不会产生 NaNInfinity

saved 超过 target 时,返回值可能大于 100(如 percentOf(1580, 1580) 返回 100,但 percentOf(2000, 1000) 返回 200)。因此调用方在渲染进度条时需要做上限处理。在愿望页中,通过条件判断 percentOf(w.saved, w.target) >= 100 来切换"已达成"状态和绿色进度条,而非截断百分比——100% 和 200% 在视觉上都是满格,但状态判断需要区分"是否达成"而非"超出多少"。

6.3 标签配色 tagColor

function tagColor(t: string): string {
  if (t === '科普' || t === '测评') {
    return COLORS.primary;
  }
  if (t === '心得' || t === '习惯') {
    return COLORS.accent;
  }
  return COLORS.success;
}

tagColor 根据笔记标签返回对应的背景色。"科普"和"测评"类内容使用蓝色 COLORS.primary,传递理性、客观、可信赖的心理暗示——这类内容通常是数据分析或知识普及,蓝色符合其"冷静分析"的调性。“心得"和"习惯"类内容使用橙色 COLORS.accent,传递温暖、分享、个人化的心理暗示——这类内容是个人经验分享,橙色的温度感更贴合。其余标签(如"方法”)默认使用绿色 COLORS.success,传递正向、积极、可执行的心理暗示。

这种"语义-颜色"映射使笔记列表在视觉上自然分类。用户扫一眼标签颜色就能判断内容类型:蓝标签是知识科普、橙标签是经验分享、绿标签是方法论。这种色彩编码在金融内容社区中尤为有效——用户在海量信息中快速定位自己需要的内容类型。

6.4 金额格式化 amountText

function amountText(a: number): number {
  if (a >= 10000) {
    return (a / 10000).toFixed(1) + '万';
  }
  return a.toString();
}

amountText 将大数字格式化为"万"单位字符串。当数值达到一万时,除以一万并保留一位小数,拼接"万"后缀。如 88421 变为"8.8万",28600000 变为"2860.0万",45210 变为"4.5万"。当数值不足一万时,直接转为字符串返回。

这个函数在挑战卡片的参与人数和累计攒金额展示中使用。大数字直接显示"28600000"位数过多难以阅读,格式化为"2860.0万"后信息密度更高。注意返回类型声明为 number 但实际返回 string(当 a >= 10000 时返回 string,否则也返回 a.toString()string),这在 ArkUI 的类型系统中由于运行时宽松而不会报错,但从严谨性角度应修正为 string 返回类型。这是一个技术债务,在实际工程中应修正。

6.5 金币坐标计算 coinX / coinY

function coinX(tick: number, i: number): number {
  return 30 + ((tick * 13 + i * 151) % 620);
}
function coinY(tick: number, i: number): number {
  return 20 + ((tick * 17 + i * 83) % 680);
}

coinXcoinY 是金币特效的核心坐标计算函数。它们接收当前 tick 值和金币索引 i,通过取模运算生成坐标值。coinX 的返回值范围是 30 到 650(30 + 620),coinY 的返回值范围是 20 到 700(20 + 680)。

使用不同的乘数确保每枚金币的运动轨迹不同:tick * 13 中 13 是 X 坐标的 tick 系数,i * 151 中 151 是金币索引系数。13 和 151 都是质数,选择质数作为乘数可以减少周期性重合,使金币的运动轨迹更加不规律。同理,coinY 使用 17 和 83 作为乘数,也是质数。

取模基数 620 和 680 约等于屏幕宽高,使金币在屏幕范围内运动。由于取模运算的特性,当 tick 持续递增时,坐标值会在 0 到基数之间循环变化,但由于乘数和偏移的综合作用,循环周期很长,视觉上表现为不规律的飘散运动。

6.6 金币透明度计算 coinA

function coinA(tick: number, i: number): number {
  if ((tick + i * 3) % 4 === 0) {
    return 0.9;
  }
  return 0.25;
}

coinA 控制金币的透明度。当 (tick + i * 3) % 4 === 0 时返回 0.9(高亮),否则返回 0.25(暗淡)。这意味着每 4 个 tick 中有 1 个 tick 金币处于高亮状态,3 个 tick 处于暗淡状态,形成闪烁效果。

i * 3 的偏移确保每枚金币的高亮时刻不同——金币 0 在 tick % 4 === 0 时高亮,金币 1 在 (tick + 3) % 4 === 0 即 tick % 4 === 1 时高亮,金币 2 在 tick % 4 === 2 时高亮。这样七枚金币不会同时高亮或同时暗淡,而是错落闪烁,视觉效果更自然。

6.7 光晕缩放与透明度 haloScale / haloA

function haloScale(tick: number): number {
  return 1 + ((tick % 8) * 0.06);
}
function haloA(tick: number): number {
  if (tick % 8 > 4) {
    return 0.35;
  }
  return 0.12;
}

haloScale 在 1.0 到 1.42 之间循环缩放。tick % 8 的值在 0 到 7 之间循环,乘以 0.06 后得到 0 到 0.42 的增量,加上 1.0 得到 1.0 到 1.42 的缩放比例。8 个 tick 构成一个完整的缩放周期:从 1.0 逐渐增大到 1.42,然后跳回 1.0 重新开始。这种"锯齿波"式的缩放配合 110ms 的 tick 间隔,形成"呼吸光晕"效果——存钱罐仿佛在缓慢地一呼一吸。

haloA 在周期的前半段(tick % 8 的值为 0 到 4)返回低透明度 0.12,后半段(值为 5 到 7)返回高透明度 0.35。这意味着存钱罐先暗后亮,配合 haloScale 的先小后大,形成"从暗处逐渐变亮变大"的脉冲效果,仿佛存钱罐在发光吸引金币。

这两个函数配合 coinA 的闪烁,营造出金币环绕存钱罐的灵动氛围。这种基于取模的伪随机算法虽然不是真正的随机数(它有确定的周期和模式),但在视觉效果上足够自然,且确定性使得动画可复现——在相同 tick 值下,金币位置和透明度完全相同,便于调试。

七、组件主体结构

7.1 状态变量定义

@Entry
@Component
struct PagePocketSave {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State challengeOpen: boolean = false
  @State editIdx: number = 0
  @State editName: string = ''
  @State editTarget: string = ''
  @State editDeadline: string = ''
  @State addName: string = ''
  @State addTarget: string = ''
  @State addIcon: string = '🎯'
  @State challengeName: string = ''
  @State challengeDays: string = ''
  @State wishList: WishItem[] = WISH_LIST
  @State postList: SavePost[] = SAVE_POSTS
  @State fxTimer: number = -1

组件定义了 18 个 @State 变量,可分为四组:

导航状态组mainTab 控制底部四个主 Tab 的当前选中项(0=首页、1=账单、2=挑战、3=我的),subTab 控制首页下五个内容 Tab 的选中项(0=总览、1=愿望、2=账单、3=挑战、4=笔记)。这两个变量的改变会触发 mainContent Builder 中的条件分支重新执行,实现页面切换。当 mainTab 为 0 时,先渲染头部和子导航,再根据 subTab 选择五个内容页之一;其他 mainTab 值直接渲染对应页面。

弹框状态组addOpeneditOpendelOpenchallengeOpen 是四个布尔值,分别控制四种弹框的显示与隐藏。任何一个为 true 时,build 方法中对应的条件渲染分支就会创建弹框层。设计为四个独立布尔值而非一个枚举,是因为同时最多只有一个弹框打开,但每个弹框有自己的打开和关闭逻辑,独立布尔值更灵活。

表单数据组editIdxeditNameeditTargeteditDeadline 服务于编辑目标弹框,addNameaddTargetaddIcon 服务于新增愿望弹框,challengeNamechallengeDays 服务于发起挑战弹框。这些变量在弹框打开时被初始化(openAdd/openEdit/openChallenge 方法中),在用户输入时通过 TextInputonChange 回调更新,在提交时被 doAdd/doEdit/doChallenge 方法读取。

数据与特效组wishListpostList 分别持有愿望列表和动态列表的可变副本(初始值来自静态数据 WISH_LISTSAVE_POSTS),当用户执行新增、编辑、删除操作时,这两个数组会被修改并触发列表重新渲染。tick 是动画计数器,每 110ms 自增 1。fxTimer 存储 setInterval 的 ID,用于在组件销毁时清理定时器。

7.2 生命周期

aboutToAppear(): void {
  this.fxTimer = setInterval(() => {
    this.tick = this.tick + 1;
  }, 110);
}

aboutToDisappear(): void {
  if (this.fxTimer > 0) {
    clearInterval(this.fxTimer);
    this.fxTimer = -1;
  }
}

aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、渲染前调用。这里启动一个 110 毫秒间隔的定时器,每次触发将 tick 加 1。由于 tick@State 变量,每次自增都会触发依赖它的特效层 Builder 重新渲染,从而驱动金币和光晕动画。

110 毫秒的间隔约为 9 FPS(1000/110 ≈ 9.09),虽然帧率不高,但配合取模算法的跳跃式坐标变化,视觉上形成了"金币闪烁飘落"的独特效果,而非平滑动画。这是一种有意为之的"像素艺术"风格选择——平滑的物理动画反而不符合"金币"这种离散物体的视觉特性,而跳跃式的位置变化更像金币在空中翻滚下落。

aboutToDisappear 在组件销毁前调用,清理定时器以防止内存泄漏。clearInterval(this.fxTimer) 取消定时器,然后将 fxTimer 置为 -1 作为"已清理"标记。这是 ArkUI 生命周期管理的最佳实践——每一个在 aboutToAppear 中创建的资源,都应在 aboutToDisappear 中释放。如果忘记清理定时器,组件销毁后定时器仍会继续执行,引用已销毁组件的 this.tick,导致内存泄漏和潜在的运行时错误。

ArkUI 的生命周期回调还包括 aboutToReuse(组件复用时调用)和 onPageShow/onPageHide(页面显示/隐藏时调用),本应用由于是单页面且不需要复用,仅使用了最基本的生命周期对。

7.3 打开弹框函数

openAdd(): void {
  this.addName = '';
  this.addTarget = '';
  this.addIcon = '🎯';
  this.addOpen = true;
}

openAdd 在打开新增愿望弹框前重置表单字段为初始值,确保每次打开都是干净的输入状态。addNameaddTarget 清空为空字符串,addIcon 默认设为目标 Emoji ‘🎯’。最后将 addOpen 置为 true 触发弹框渲染。这种"先重置再打开"的模式确保了用户每次打开弹框不会看到上次的残留输入。

openEdit(index: number): void {
  if (index >= 0 && index < this.wishList.length) {
    this.editIdx = index;
    this.editName = this.wishList[index].name;
    this.editTarget = this.wishList[index].target.toString();
    this.editDeadline = this.wishList[index].deadline;
  }
  this.editOpen = true;
}

openEdit 接收愿望列表的索引,先做边界检查(index >= 0 && index < this.wishList.length,防止越界访问导致运行时错误),然后将对应愿望的数据填充到编辑表单字段中,实现"回显"功能。editName 设为愿望名称,editTarget 将数字 target 转为字符串(因为 TextInputtext 参数需要字符串类型),editDeadline 设为截止日期。最后打开编辑弹框。

openDel(): void {
  this.delOpen = true;
}

openChallenge(): void {
  this.challengeName = '';
  this.challengeDays = '';
  this.challengeOpen = true;
}

openDel 仅设置 delOpentrue,因为删除弹框不需要表单数据。openChallenge 重置挑战名称和天数字段后打开弹框,逻辑与 openAdd 类似。

7.4 执行操作函数

doAdd(): void {
  if (this.addName.length > 0 && this.addTarget.length > 0) {
    this.wishList.unshift(new WishItem(999, this.addName, this.addIcon, parseInt(this.addTarget), 0, '未设置'));
  }
  this.addOpen = false;
}

doAdd 先校验愿望名称和目标金额非空(length > 0),然后使用 unshift 将新愿望插入到列表头部。unshift 而非 push 的选择是有意的——新愿望在列表头部,使其在 UI 上第一时间可见,给用户即时的视觉反馈。新愿望的 id 设为 999(临时值),saved 初始为 0,deadline 设为"未设置"。parseInt 将文本输入的字符串转为整数。无论校验是否通过,最后都会关闭弹框(this.addOpen = false)——校验失败时弹框关闭但数据未插入,实际工程中应给用户提示。

doEdit(): void {
  if (this.editIdx >= 0 && this.editIdx < this.wishList.length) {
    const old = this.wishList[this.editIdx];
    this.wishList.splice(this.editIdx, 1, new WishItem(old.id, this.editName, old.icon, parseInt(this.editTarget), old.saved, this.editDeadline));
  }
  this.editOpen = false;
}

doEdit 使用 splice 的替换模式(第二个参数为 1 表示删除 1 个元素并插入新元素),用编辑后的新 WishItem 替换原数组中对应位置的元素。注意它保留了原愿望的 idiconsaved(已存金额),只更新了名称、目标金额和截止日期。这种"部分更新"策略避免了编辑目标时意外清零已存进度——用户修改目标金额时,已存金额保持不变。

doDel(): void {
  if (this.wishList.length > 0) {
    this.wishList.splice(0, 1);
  }
  this.delOpen = false;
}

doDel 删除列表的第一个元素(索引 0)。这是一个简化实现——实际应用中应根据当前选中项的索引删除,但作为演示版本,它展示了删除操作的完整流程:检查列表非空 → 执行 splice 删除 → 关闭弹框。

doChallenge(): void {
  if (this.challengeName.length > 0) {
    this.postList.unshift(new SavePost(998, '我', '🙋', '发起了挑战「' + this.challengeName + '」,求监督!', 0, 0, 0, '刚刚'));
  }
  this.challengeOpen = false;
}

doChallenge 在挑战名称非空时,向动态列表头部插入一条"我"发起挑战的打卡动态。文案中嵌入挑战名称"发起了挑战「奶茶戒断30天」,求监督!“,模拟社区互动效果。这条动态的 id 为 998(临时值),streaklikes 均为 0,time 为"刚刚”,符合新发布内容的特征。

八、特效层详解

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
      Text('🪙')
        .fontSize(i % 3 === 0 ? 15 : 11)
        .opacity(coinA(this.tick, i))
        .translate({ x: coinX(this.tick, i), y: coinY(this.tick, i) })
    }, (i: number) => 'coin' + i.toString())
    Text('🐷')
      .fontSize(22)
      .opacity(haloA(this.tick))
      .scale({ x: haloScale(this.tick), y: haloScale(this.tick) })
      .translate({ x: 540, y: 560 })
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

特效层是整个应用最具视觉记忆点的部分。它使用 Stack 容器叠加两组元素:七枚金币 Emoji 和一只存钱罐 Emoji。

8.1 金币飘落动画原理

金币部分通过 ForEach 渲染索引 0-6 共七枚金币。每枚的字号在 15 和 11 之间交替(i % 3 === 0 时为 15,否则为 11),形成大小差异——索引 0、3、6 的金币较大(15px),索引 1、2、4、5 的金币较小(11px),模拟近大远小的透视效果。

opacity 调用 coinA(this.tick, i),使每枚金币在不同时刻呈现高亮(0.9)或暗淡(0.25)状态,产生闪烁效果。由于 coinA 函数中 (tick + i * 3) % 4 的偏移设计,七枚金币的高亮时刻错落分布,不会同时闪烁,视觉效果更自然。

translate 调用 coinXcoinY 计算坐标,使金币在 620x680 的范围内不断跳动。每次 tick 自增 1,所有金币的坐标都会跳变到新位置,配合 110ms 的 tick 间隔,形成"金币飘散下落"的动态视觉。由于取模运算的特性,坐标变化是跳跃式的而非连续的,这形成了独特的"闪烁飘落"效果,不同于物理引擎驱动的平滑下落动画。

ForEach 的键值函数返回 'coin' + i,确保金币在 tick 变化时是原地更新而非重建。如果键值随 tick 变化(如返回 'coin' + i + tick),ArkUI 会认为列表项发生变化而销毁旧组件创建新组件,导致性能下降。稳定的键值让框架仅更新现有组件的属性(opacity 和 translate),提升渲染效率。

8.2 存钱罐光晕动画原理

存钱罐部分固定在坐标 (540, 560) 位置——通过 translate({ x: 540, y: 560 }) 实现。opacityhaloA 控制,scalehaloScale 控制,两者都以 8 个 tick 为周期循环变化。

haloScale 从 1.0 逐渐增大到 1.42(8 tick 周期),模拟存钱罐"变大"的过程,仿佛在充气膨胀。haloA 在前半周期(tick % 8 <= 4)返回 0.12(暗淡),后半周期返回 0.35(明亮),形成"从暗到亮"的过渡。两者配合形成"呼吸光晕"效果——存钱罐在明暗之间脉动,尺寸在 1.0 到 1.42 之间缩放,仿佛在发光吸引金币。

存钱罐的字号固定为 22px,比金币(11-15px)更大,使其在视觉上是"中心焦点",金币是"环绕粒子"。这种主次关系符合攒钱主题——存钱罐是目标,金币是积累过程。

8.3 穿透点击的设计

最关键的设计hitTestBehavior(HitTestMode.None),这行代码使整个特效层不参与命中测试,即所有触摸事件会穿透特效层到达下方的功能层。这确保了金币动画虽然覆盖在界面上方,但不会干扰用户点击按钮、滑动列表等操作。

HitTestMode 是 ArkUI 提供的命中测试模式枚举,有四个值:Default(默认,参与命中测试)、Block(阻断后续节点的命中测试)、Transparent(透明区域也参与命中测试)、None(完全不参与命中测试)。本应用选择 None 是因为特效层纯装饰性,不应接收任何点击事件。

这是游戏化视觉与功能可用性之间的精妙平衡——如果特效层拦截点击,用户每次点击按钮时可能点到金币上导致操作失败,严重损害用户体验。通过 HitTestMode.None,特效层成为"视觉装饰但不影响交互"的透明层。

8.4 Stack 层级设计

特效层在 build 方法中被放置在内容区和底部栏之上(Stack 的第二层),但位于弹框层之下。这意味着:

  • 弹框打开时,弹框层在特效层之上,自然遮盖特效
  • 特效层在功能层之上,金币动画覆盖在内容上方
  • 由于 HitTestMode.None,点击穿透到功能层
  • 视觉上金币飘落在内容之上,但不影响操作

这种三层 Stack 结构(功能层 → 特效层 → 弹框层)是本应用架构的核心设计之一。

九、头部区域详解

@Builder
header() {
  Column({ space: 12 }) {
    Row({ space: 10 }) {
      Column({ space: 2 }) {
        Text('🙋')
          .fontSize(30)
          .padding(8)
          .backgroundColor('rgba(255,255,255,0.25)')
          .borderRadius(20)
        Text('尊享 Lv.6')
          .fontSize(8)
          .fontColor('#BBDEFB')
      }
      .alignItems(HorizontalAlign.Center)

      Column({ space: 3 }) {
        Text('我的总资产 (元)')
          .fontSize(10)
          .fontColor('#BBDEFB')
        Text('86,420.58')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('本月已攒 +¥4,500 · 收益率 3.2%')
          .fontSize(10)
          .fontColor('#BBDEFB')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Column({ space: 6 }) {
        Text('🔍')
          .fontSize(16)
        Text('明细')
          .fontSize(9)
          .fontColor('#BBDEFB')
      }
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.subTab = 2;
      })
    }
    .width('100%')

    Row({ space: 8 }) {
      ForEach(QUICK_ITEMS, (q: QuickItem) => {
        Column({ space: 4 }) {
          Text(q.icon)
            .fontSize(18)
          Text(q.label)
            .fontSize(9)
            .fontColor('#E3F2FD')
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .backgroundColor('rgba(255,255,255,0.16)')
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          if (q.label === '新目标') {
            this.openAdd();
          } else if (q.label === '挑战') {
            this.openChallenge();
          } else if (q.label === '记一笔') {
            this.subTab = 2;
          }
        })
      }, (q: QuickItem) => q.label)
    }
    .width('100%')
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 14, bottom: 18 })
  .linearGradient({
    angle: 135,
    colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]]
  })
}

头部区域采用银行 App 经典的"深蓝渐变大卡片"风格,整体结构分为上下两部分。

9.1 上半部分:三列资产展示

上半部分是一个三列布局的 Row

左列是用户头像和等级。🙋 Emoji 配 8 的内边距和半透明白色圆形背景(rgba(255,255,255,0.25)),圆角 20 形成圆形头像效果。下方是"尊享 Lv.6"等级标签,8px 的极小字号配浅蓝色 #BBDEFB 文字,弱化等级信息但不缺失。等级系统的设计增加了游戏化元素,激励用户持续使用。

中列使用 layoutWeight(1) 占据剩余空间,展示三行信息:第一行是"我的总资产 (元)"标签,10px 浅蓝色文字;第二行是"86,420.58"的大号白色粗体金额,24px 字号配合 FontWeight.Bold,是整个头部最醒目的信息——这个数字代表用户的全部资产,是金融应用最核心的数据;第三行是"本月已攒 +¥4,500 · 收益率 3.2%"的辅助信息,10px 浅蓝色文字,提供增量数据。

三列的色彩层次分明:头像使用半透明白色,主文字使用纯白,辅助文字使用浅蓝 #BBDEFB,在深蓝渐变背景上形成"白 → 浅蓝 → 深蓝"的视觉层次。这种层次设计让用户视线自然聚焦到白色大号金额上,符合金融应用"数字即核心"的设计理念。

右列是搜索图标🔍和"明细"文字,点击后跳转到账单 Tab(this.subTab = 2)。这提供了一条从头部直达账单详情的快捷路径,用户查看资产后想了解明细时无需翻到底部导航。

9.2 下半部分:快捷入口栏

下半部分是四个快捷入口按钮,横向等宽排列(每个 layoutWeight(1)),使用半透明白色背景(rgba(255,255,255,0.16))和 12 的圆角。四个按钮分别是"💰存一笔"“📝记一笔”“🎯新目标”“⚔️挑战”。

点击逻辑通过标签文字判断(q.label):"新目标"打开新增愿望弹框(this.openAdd()),"挑战"打开发起挑战弹框(this.openChallenge()),"记一笔"切换到账单 Tab(this.subTab = 2),"存一笔"未绑定操作(预留扩展)。这种基于标签文字的条件分支虽然不如枚举类型严谨,但在静态数据场景下足够清晰。

快捷入口栏的设计让用户在头部就能触达四个核心操作,无需翻找底部导航——这是银行 App 常见的"高频操作前置"设计模式。

9.3 渐变背景设计

整个头部使用 linearGradientprimaryDark(#0D47A1)到 primary(#1565C0)的 135 度渐变填充。angle: 135 表示渐变方向从左上到右下,colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]] 表示从 0% 位置的深蓝到 100% 位置的浅蓝。

这种渐变模拟了从左上方光源照射的效果——左上角最暗(primaryDark),右下方逐渐变亮(primary),形成金属质感的深蓝大卡片。这里没有使用动画,是纯静态渐变,符合银行风格头部"沉稳可靠"的设计取向。

头部的 padding 设置为 { left: 16, right: 16, top: 14, bottom: 18 },底部的 bottom: 18 留出了较大间距,与下方子导航栏形成呼吸感。左右 16 的内边距是移动端常见的安全边距,确保内容不被屏幕边缘裁切。

十、子导航栏详解

@Builder
subNav() {
  Row() {
    ForEach(SUB_NAV_LIST, (t: string, idx: number) => {
      Column({ space: 4 }) {
        Text(t)
          .fontSize(this.subTab === idx ? 14 : 12)
          .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
        if (this.subTab === idx) {
          Text('●')
            .fontSize(5)
            .fontColor(COLORS.gold)
        } else {
          Text(' ')
            .fontSize(5)
            .fontColor('transparent')
        }
      }
      .layoutWeight(1)
      .padding({ top: 8, bottom: 6 })
      .backgroundColor(this.subTab === idx ? COLORS.primary : 'transparent')
      .borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.subTab = idx;
      })
    }, (t: string) => t)
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 8 })
  .backgroundColor(COLORS.bg)
}

子导航栏实现"余额圆环高亮式"的 Tab 切换效果。五个标签(总览、愿望、账单、挑战、笔记)横向等宽排列,每个 layoutWeight(1) 自适应宽度。

10.1 选中态的五维视觉强化

选中项的视觉处理包含五个维度的变化:

  1. 字号放大:选中时 14px,未选中 12px,形成 2px 的差异。字号差异在移动端是显著的视觉变化,足以让用户一眼识别当前 Tab。

  2. 字重加粗:选中时 FontWeight.Bold,未选中 FontWeight.Normal。加粗的文字在视觉上更"重",与字号放大配合强化选中态的存在感。

  3. 文字颜色反转:选中时白色(在蓝色背景上),未选中灰色 textSecondary。颜色的反转是最强烈的视觉变化——从灰色变为白色配蓝色背景,形成明显的对比。

  4. 背景色填充:选中时填充 primary 蓝色并圆角 16,形成"胶囊"效果。圆角 16 配合 layoutWeight(1) 的等宽布局,使选中 Tab 呈现为蓝色圆角矩形,与未选中 Tab 的透明背景形成鲜明对比。

  5. 金色圆点:选中时在文字下方显示一个 5px 的金色 ,未选中时用透明色('transparent')占位保持高度一致。

10.2 金色圆点的点睛之笔

金色圆点的设计是子导航栏的点睛之笔——它呼应了金币主题,在蓝色胶囊下方点缀一抹金色,既是选中状态的视觉标记,也是攒钱主题的隐喻。每当用户切换 Tab,金色圆点会从一个 Tab 跳到另一个 Tab,视觉上像一枚金币在移动。

使用透明色占位(Text(' ').fontSize(5).fontColor('transparent'))而非条件渲染高度,确保了 Tab 切换时不会出现布局跳动。如果选中态多一个圆点而未选中态少一个元素,Tab 的整体高度会变化,导致下方内容区的位置跳动,影响用户体验。透明色占位保持了所有 Tab 的高度一致,切换时只有颜色和背景变化,没有尺寸变化。

10.3 点击交互

点击任何 Tab 时,onClick 回调将 this.subTab 设为对应的 idx,触发 ArkUI 重新渲染子导航栏和内容区。由于 subTab@State 变量,框架自动检测到变化,重新执行依赖 subTab 的条件分支,切换显示对应的内容页面。

整个子导航栏的背景使用 COLORS.bg(#F4F7FB),与头部渐变和内容区卡片形成"深蓝头部 → 浅灰蓝导航 → 白色卡片"的三级色彩层次。padding({ left: 12, right: 12, top: 8 }) 提供了适度的内边距,使 Tab 之间和与上下区域都有呼吸感。

十一、总览页面详解

@Builder
pageOverview() {
  Column({ space: 12 }) {
    Row({ space: 10 }) {
      Column({ space: 4 }) {
        Text('💰 零钱罐')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        Text('¥12,860')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
        Text('七日年化 2.31%')
          .fontSize(9)
          .fontColor(COLORS.success)
      }
      .layoutWeight(1)
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .alignItems(HorizontalAlign.Start)

      Column({ space: 4 }) {
        Text('🎯 目标进度')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        Text('62%')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
        Text('心愿7/8个进行中')
          .fontSize(9)
          .fontColor(COLORS.textHint)
      }
      .layoutWeight(1)
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .alignItems(HorizontalAlign.Start)

      Column({ space: 4 }) {
        Text('🔥 连续打卡')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        Text('128天')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
        Text('超过98%的攒友')
          .fontSize(9)
          .fontColor(COLORS.textHint)
      }
      .layoutWeight(1)
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')

    Column({ space: 10 }) {
      Text('🏆 攒钱月度榜')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      ForEach(RANK_LIST, (r: RankItem) => {
        Row({ space: 10 }) {
          Text(r.rank === 1 ? '🥇' : (r.rank === 2 ? '🥈' : '🥉'))
            .fontSize(18)
          Text(r.avatar)
            .fontSize(20)
            .padding(4)
            .backgroundColor(COLORS.primaryLight)
            .borderRadius(12)
          Text(r.nick)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(r.saved)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .width('100%')
      }, (r: RankItem) => 'rk' + r.rank.toString())
    }
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)

    Column({ space: 10 }) {
      Text('📅 存钱日历 · 8月')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      ForEach([0, 1, 2, 3], (row: number) => {
        Row({ space: 6 }) {
          ForEach([1, 2, 3, 4, 5, 6, 7], (d: number) => {
            Text((row * 7 + d).toString())
              .fontSize(10)
              .fontColor((row * 7 + d) % 3 === 0 ? COLORS.white : COLORS.textSecondary)
              .padding(4)
              .backgroundColor((row * 7 + d) % 3 === 0 ? COLORS.accent : COLORS.bg)
              .borderRadius(8)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
          }, (d: number) => 'cal' + (row * 7 + d).toString())
        }
        .width('100%')
      }, (row: number) => 'row' + row.toString())
      Text('● 已存钱日 · 本月存钱22天')
        .fontSize(9)
        .fontColor(COLORS.textHint)
    }
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)

    ForEach(this.postList.slice(0, 3), (p: SavePost) => {
      Row({ space: 10 }) {
        Text(p.avatar)
          .fontSize(22)
          .padding(5)
          .backgroundColor(COLORS.accentLight)
          .borderRadius(14)
        Column({ space: 3 }) {
          Row({ space: 6 }) {
            Text(p.nick)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('连续' + p.streak.toString() + '天')
              .fontSize(8)
              .fontColor(COLORS.danger)
              .padding({ left: 5, right: 5, top: 1, bottom: 1 })
              .backgroundColor('#FDECEA')
              .borderRadius(6)
          }
          Text(p.text)
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text('🪙 ' + p.amount.toString() + '元 · 🧡 ' + p.likes.toString() + ' · ' + p.time)
            .fontSize(9)
            .fontColor(COLORS.textHint)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }, (p: SavePost) => 'po' + p.id.toString() + p.time)
  }
  .width('100%')
}

总览页是首页的默认内容页,信息密度最高。它由四个区块从上到下排列:

11.1 资产三卡片区

三张白色卡片等宽并排,分别展示零钱罐余额(💰零钱罐 ¥12,860,蓝色,七日年化 2.31% 绿色)、目标进度(🎯目标进度 62%,橙色,心愿7/8个进行中)、连续打卡天数(🔥连续打卡 128天,红色,超过98%的攒友)。

每张卡片采用"标题 → 大数字 → 辅助说明"的三层结构,颜色编码与内容语义一致:零钱罐用蓝色(对应 primary 主色,传递银行信任感),目标进度用橙色(对应 accent 强调色,引导注意力),连续打卡用红色(对应 danger 危险色,传递紧迫感——“128天不能断”)。layoutWeight(1) 确保三卡等宽,14 的圆角和 12 的内边距营造舒适的卡片比例。

11.2 攒钱月度榜

白色卡片内展示前三名排行榜。每行包含奖牌 Emoji(🥇🥈🥉,根据 r.rank 值选择)、头像 Emoji(配 primaryLight 浅蓝背景圆角 12 包裹)、昵称(layoutWeight(1) 自适应宽度)和攒钱金额(primary 蓝色粗体)。视觉重点从左到右递减——奖牌最醒目,金额次之,昵称填充中间空间。

ForEach 的键值函数返回 'rk' + r.rank.toString(),以排名作为键值,确保排行榜项的正确复用。

11.3 存钱日历

展示 8 月的 4 行 7 列共 28 天的日历网格。外层 ForEach 遍历行索引 0-3,内层 ForEach 遍历列索引 1-7,日期计算为 row * 7 + d(1 到 28)。每隔 3 天(% 3 === 0)的日期用橙色高亮背景表示"已存钱日",文字为白色;其余日期用灰背景,文字为灰色。

这个日历虽然简化(没有对齐真实星期,28 天也不完整),但直观传达了"持续打卡"的概念——用户一眼就能看到本月哪些天存了钱,橙色的密集程度反映了存钱频率。底部标注"● 已存钱日 · 本月存钱22天"提供汇总信息。

11.4 打卡动态流

使用 this.postList.slice(0, 3) 取前 3 条动态展示。slice(0, 3) 是数组的切片方法,返回前三个元素的新数组,不修改原数组。

每条动态是横向布局:左侧是头像 Emoji(22px,配 accentLight 米黄背景圆角 14 包裹),右侧是三行信息。第一行是昵称加连续天数标签——连续天数标签使用红色文字配 #FDECEA 浅红背景圆角 6,8px 字号,紧凑而醒目。第二行是动态文案,11px 字号配 textSecondary 灰色,maxLines(2) 限制最多两行,textOverflow({ overflow: TextOverflow.Ellipsis }) 超出部分显示省略号。第三行是底部信息行,格式为"🪙 88元 · 🧡 46 · 5分钟前",9px 字号配 textHint 浅灰,信息密度高但层次清晰。

ForEach 的键值函数返回 'po' + p.id.toString() + p.time,将 id 和 time 组合作为键值,确保动态列表的正确复用和更新。

十二、愿望页面详解

@Builder
pageWish() {
  Column({ space: 10 }) {
    Row({ space: 8 }) {
      Text('🎯')
        .fontSize(18)
      Text('7个心愿进行中 · 1个已达成')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .layoutWeight(1)
      Text('+ 新愿望')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor(COLORS.primary)
        .borderRadius(14)
        .onClick(() => {
          this.openAdd();
        })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 10, bottom: 10 })
    .backgroundColor(COLORS.primaryLight)
    .borderRadius(16)

    ForEach(this.wishList, (w: WishItem, idx: number) => {
      Column({ space: 10 }) {
        Row({ space: 10 }) {
          Text(w.icon)
            .fontSize(26)
            .padding(8)
            .backgroundColor(COLORS.accentLight)
            .borderRadius(14)
          Column({ space: 3 }) {
            Text(w.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('目标 ¥' + w.target.toString() + ' · 截止 ' + w.deadline)
              .fontSize(9)
              .fontColor(COLORS.textHint)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column({ space: 4 }) {
            Text('编辑')
              .fontSize(10)
              .fontColor(COLORS.primary)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              .backgroundColor(COLORS.primaryLight)
              .borderRadius(10)
              .onClick(() => {
                this.openEdit(idx);
              })
            Text('删除')
              .fontSize(10)
              .fontColor(COLORS.danger)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              .backgroundColor('#FDECEA')
              .borderRadius(10)
              .onClick(() => {
                this.openDel();
              })
          }
        }
        .width('100%')

        Stack({ alignContent: Alignment.Start }) {
          Column()
            .width('100%')
            .height(10)
            .backgroundColor(COLORS.bg)
            .borderRadius(5)
          Column()
            .width(percentOf(w.saved, w.target) + '%')
            .height(10)
            .backgroundColor(percentOf(w.saved, w.target) >= 100 ? COLORS.success : COLORS.accent)
            .borderRadius(5)
        }
        .width('100%')

        Row({ space: 8 }) {
          Text('已存 ¥' + w.saved.toString())
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('还差 ¥' + (w.target - w.saved > 0 ? (w.target - w.saved).toString() : '0'))
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
          Text(percentOf(w.saved, w.target) >= 100 ? '✅ 已达成' : percentOf(w.saved, w.target).toString() + '%')
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .layoutWeight(1)
            .textAlign(TextAlign.End)
        }
        .width('100%')
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
    }, (w: WishItem) => 'w' + w.id.toString() + w.saved.toString())
  }
  .width('100%')
}

愿望页面是应用的核心功能页,展示用户的攒钱目标列表。

12.1 顶部状态栏

顶部是浅蓝色(primaryLight)的状态栏,显示🎯图标、“7个心愿进行中 · 1个已达成"的状态文字和右侧蓝色”+ 新愿望"按钮。状态文字使用 layoutWeight(1) 填充中间空间,"新愿望"按钮使用 primary 蓝色背景圆角 14,点击调用 this.openAdd() 打开新增愿望弹框。这个状态栏提供了页面内容的汇总信息和创建新愿望的入口。

12.2 愿望卡片结构

每个愿望卡片的视觉层次精心设计,分为三层:

头部行:左侧是 26px 的大号 Emoji 图标,配 accentLight 米黄色背景圆角 14 包裹,形成醒目的愿望图标。中间是愿望名称(13px 粗体墨蓝黑)和"目标 ¥2999 · 截止 12月31日"的辅助信息(9px 浅灰蓝),使用 layoutWeight(1) 填充空间。右侧是垂直排列的"编辑"和"删除"两个小按钮——"编辑"使用 primary 蓝色文字配 primaryLight 浅蓝背景,"删除"使用 danger 红色文字配 #FDECEA 浅红背景,颜色编码明确区分操作性质。

进度条:使用 Stack 叠加两层 Column。底层是灰色背景条(width('100%')height(10)backgroundColor(COLORS.bg)borderRadius(5)),上层是彩色进度条(width(percentOf(w.saved, w.target) + '%')height(10)borderRadius(5))。进度条宽度由 percentOf 计算的百分比决定。关键的颜色判断逻辑是 percentOf(w.saved, w.target) >= 100 ? COLORS.success : COLORS.accent——达到或超过 100% 时进度条变绿,否则为橙色。这种颜色变化提供了即时的视觉反馈——绿色代表"达成了",橙色代表"还在路上"。

底部数据行:展示三组信息——“已存 ¥1860”(蓝色粗体)、“还差 ¥1139”(灰色)、百分比状态(灰色右对齐)。当达到 100% 时显示"✅ 已达成"文字。w.target - w.saved > 0 ? (w.target - w.saved).toString() : '0' 的三元判断确保当已存金额超过目标时,"还差"显示为 0 而非负数。

12.3 ForEach 键值设计

ForEach 的键值函数 'w' + w.id.toString() + w.saved.toString()saved 纳入键值。这是一个关键的设计——如果键值仅用 w.id,当某个愿望的 saved 变化时(如用户存了一笔钱),ArkUI 的列表 diff 算法会认为该项的键值未变而复用旧渲染,导致进度条和数据不更新。将 saved 纳入键值后,saved 变化会导致键值变化,框架识别为"新项"而重新渲染。

十三、账单页面详解

@Builder
pageBill() {
  Column({ space: 12 }) {
    Column({ space: 10 }) {
      Text('📊 攒钱 vs 花钱(近6月)')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Row({ space: 6 }) {
        ForEach(MONTH_CHART, (m: MonthChartItem) => {
          Column({ space: 4 }) {
            Row({ space: 2 }) {
              Column()
                .width(8)
                .height(barH(m.save, 5100))
                .backgroundColor(COLORS.primary)
                .borderRadius(3)
              Column()
                .width(8)
                .height(barH(m.spend, 5100))
                .backgroundColor(COLORS.accentLight)
                .borderRadius(3)
            }
            Text(m.label)
              .fontSize(8)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .height(100)
          .justifyContent(FlexAlign.End)
          .alignItems(HorizontalAlign.Center)
        }, (m: MonthChartItem) => m.label)
      }
      .width('100%')
      Row({ space: 12 }) {
        Text('■ 攒下')
          .fontSize(9)
          .fontColor(COLORS.primary)
        Text('■ 花掉')
          .fontSize(9)
          .fontColor(COLORS.textHint)
        Text('8月攒钱率 48% ↑')
          .fontSize(9)
          .fontColor(COLORS.success)
          .layoutWeight(1)
          .textAlign(TextAlign.End)
      }
      .width('100%')
    }
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)

    Column({ space: 8 }) {
      Text('🧾 消费结构')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      ForEach(CATEGORY_DIST, (c: CategoryDist) => {
        Row({ space: 8 }) {
          Text(c.label)
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
            .width(36)
          Stack({ alignContent: Alignment.Start }) {
            Column()
              .width('100%')
              .height(8)
              .backgroundColor(COLORS.bg)
              .borderRadius(4)
            Column()
              .width(barH(c.value, 32) + '%')
              .height(8)
              .backgroundColor(c.color)
              .borderRadius(4)
          }
          .layoutWeight(1)
          .height(8)
          Text(c.value.toString() + '%')
            .fontSize(10)
            .fontColor(COLORS.textPrimary)
            .width(28)
        }
        .width('100%')
      }, (c: CategoryDist) => c.label)
    }
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)

    ForEach(BILL_LIST, (b: BillItem) => {
      Row({ space: 10 }) {
        Text(b.income ? '💰' : '💸')
          .fontSize(18)
          .padding(6)
          .backgroundColor(b.income ? '#E8F5E9' : COLORS.bg)
          .borderRadius(12)
        Column({ space: 2 }) {
          Text(b.title)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(b.category + ' · ' + b.date)
            .fontSize(9)
            .fontColor(COLORS.textHint)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text((b.income ? '+' : '-') + '¥' + b.amount.toString())
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(b.income ? COLORS.success : COLORS.textPrimary)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      .onClick(() => {
        this.openDel();
      })
    }, (b: BillItem) => 'b' + b.id.toString())
  }
  .width('100%')
}

账单页面分为三个区块:月度双柱对比图、消费结构条形图、账单流水列表。

13.1 月度双柱对比图

使用 MONTH_CHART 数据渲染 6 个月的双柱状图。每个月份并排展示蓝色"攒下"柱和浅黄"花掉"柱,柱子宽度均为 8px,间距 2px。柱子高度通过 barH(m.save, 5100)barH(m.spend, 5100) 计算,5100 是六个月中的最大值,确保所有柱子高度不超过容器。

容器高度固定为 100 并使用 justifyContent(FlexAlign.End) 使柱子从底部对齐生长——这是柱状图的标准布局方式,柱子从底部向上延伸,最高值到达顶部,短柱子在底部。alignItems(HorizontalAlign.Center) 使柱子和月份标签水平居中。

下方有图例和"8月攒钱率 48% ↑"的成功色提示。攒钱率的计算逻辑是 save / (save + spend) * 100,8 月数据为 3800 / (3800 + 4100) * 100 ≈ 48%,↑ 箭头表示较上月有所提升。

13.2 消费结构条形图

使用 CATEGORY_DIST 数据渲染六条水平进度条。每条的颜色来自数据自身的 color 字段(居住用蓝色、餐饮用橙色、理财用绿色、娱乐用金色、交通用棕色、其他用灰色),与 COLORS 常量中的色系一致但略有扩展。

左侧标签固定 36px 宽度,中间进度条 layoutWeight(1) 自适应,右侧百分比固定 28px 宽度。进度条使用 Stack 叠加两层 Column:底层灰色背景条,上层彩色进度条宽度由 barH(c.value, 32) + '%' 计算(32 是六个分类中的最大值)。这种结构化的横向条形图在移动端比饼图更易阅读——饼图在小屏幕上扇区太小难以辨识,而横向条形图可以充分利用屏幕宽度。

13.3 账单流水列表

遍历 BILL_LIST 渲染 12 条记录。每条记录左侧的图标根据 income 布尔值选择 💰(收入,配 #E8F5E9 浅绿背景)或 💸(支出,配 COLORS.bg 灰背景)。中间是标题(12px 粗体墨蓝黑)和"分类 · 日期"(9px 浅灰蓝),使用 layoutWeight(1) 填充空间。右侧是金额,格式为"+¥12500"或"-¥8.5",收入为绿色加号,支出为深色减号。

点击任意账单项会打开删除确认弹框(this.openDel()),模拟删除操作。这个交互设计让用户长按或点击账单时可以删除记录,但在实际工程中应区分"查看详情"和"删除"两种操作——点击打开详情,长按或滑动触发删除。

十四、挑战页面详解

@Builder
pageChallenge() {
  Column({ space: 10 }) {
    Column({ space: 6 }) {
      Text('⚔️ 组队攒钱,一起坚持')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text('参与挑战的攒友平均多存 38%')
        .fontSize(10)
        .fontColor('#BBDEFB')
      Text('发起挑战')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.primaryDark)
        .padding({ left: 16, right: 16, top: 6, bottom: 6 })
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .onClick(() => {
          this.openChallenge();
        })
    }
    .width('100%')
    .padding(16)
    .linearGradient({
      angle: 120,
      colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]]
    })
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)

    ForEach(CHALLENGE_LIST, (c: ChallengeItem) => {
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text(c.days.toString() + '天')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('进行中')
            .fontSize(8)
            .fontColor('#BBDEFB')
        }
        .width(64)
        .height(64)
        .backgroundColor(COLORS.primary)
        .borderRadius(16)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Text(c.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('👥 ' + amountText(c.members) + '人参与 · 累计攒下 ¥' + amountText(c.totalSaved))
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
          Stack({ alignContent: Alignment.Start }) {
            Column()
              .width('100%')
              .height(6)
              .backgroundColor(COLORS.bg)
              .borderRadius(3)
            Column()
              .width(barH(c.days, 365) + '%')
              .height(6)
              .backgroundColor(COLORS.accent)
              .borderRadius(3)
          }
          .width('100%')
          .height(6)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text(c.joined ? '已加入' : '加入')
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor(c.joined ? COLORS.textHint : COLORS.white)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .backgroundColor(c.joined ? COLORS.bg : COLORS.accent)
          .borderRadius(12)
          .onClick(() => {
            if (!c.joined) {
              this.postList.unshift(new SavePost(997, '我', '🙋', '加入了挑战「' + c.name + '」,day 1 打卡!', 0, 1, 0, '刚刚'));
            }
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
    }, (c: ChallengeItem) => 'ch' + c.id.toString())
  }
  .width('100%')
}

14.1 顶部渐变引导卡

挑战页面的顶部是一张从蓝色到橙色的 120 度渐变引导卡(linearGradient({ angle: 120, colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]] }))。蓝色到橙色的渐变象征着"理性到热情"的过渡,非常符合挑战主题——攒钱本身是理性的,但挑战机制注入了热情和社交激励。

卡片内三行内容:白色粗体标题"⚔️ 组队攒钱,一起坚持"、浅蓝色辅助文字"参与挑战的攒友平均多存 38%"(用数据激励用户参与)、白底蓝字"发起挑战"按钮(点击打开发起挑战弹框)。"平均多存 38%"是一个精心设计的激励文案——用具体数字而非空泛口号,更有说服力。

14.2 挑战列表卡片

挑战列表的每条卡片是三段式布局:

左侧是 64x64 的蓝色方块,展示"天数 + 进行中"。如"210天"用 15px 白色粗体,"进行中"用 8px 浅蓝色。方块使用 justifyContent(FlexAlign.Center)alignItems(HorizontalAlign.Center) 使内容居中。这个方块的设计参考了日历打卡应用中常见的"天数计数器"样式,让用户一眼看到挑战持续了多久。

中间展示挑战名称(13px 粗体墨蓝黑)、参与人数和累计金额(均通过 amountText 格式化为"万"单位),以及一条橙色进度条。进度条宽度由 barH(c.days, 365) 计算即挑战天数占 365 天的比例。使用 layoutWeight(1) 填充中间空间。amountText(c.members) 将 88421 格式化为"8.8万",amountText(c.totalSaved) 将 28600000 格式化为"2860.0万"。

右侧是加入按钮。已加入的显示灰色"已加入"(COLORS.bg 背景 + COLORS.textHint 文字),未加入的显示橙色"加入"(COLORS.accent 背景 + COLORS.white 文字)。按钮使用 borderRadius(12) 和适度的 padding。

14.3 加入挑战的交互

点击未加入的按钮会向 postList 头部插入一条"加入了挑战「365天存钱法」,day 1 打卡!“的打卡动态,实现社区互动反馈。新动态的 id 为 997(临时值),avatar 为🙋,streak 为 1(刚开始打卡),likes 为 0,time 为"刚刚”。这种设计让用户加入挑战后立即在社区动态中看到自己的打卡记录,获得即时的社交反馈。

注意这里的 onClick 回调有 if (!c.joined) 的判断——只有未加入的挑战才能点击加入。但加入后 c.joined 不会变为 true(因为 CHALLENGE_LIST 是静态数据,不是 @State 变量),实际工程中应将挑战列表设为 @State 并在加入时修改 joined 属性。

十五、笔记页面详解

@Builder
pageNote() {
  Column({ space: 10 }) {
    ForEach(NOTE_LIST, (n: NoteItem) => {
      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Text(n.tag)
            .fontSize(9)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .backgroundColor(tagColor(n.tag))
            .borderRadius(8)
          Text(n.reads.toString() + '阅读')
            .fontSize(8)
            .fontColor(COLORS.textHint)
        }
        Column({ space: 3 }) {
          Text(n.title)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(n.desc)
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text('✍ ' + n.author)
            .fontSize(9)
            .fontColor(COLORS.textHint)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('📚')
          .fontSize(16)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }, (n: NoteItem) => 'n' + n.id.toString())
  }
  .width('100%')
}

笔记页面是最简洁的内容页,仅有一个列表。每条笔记卡片是横向三段式布局:

左侧是彩色标签和阅读量。标签文字(如"习惯"“测评”“心得”)使用白色文字配 tagColor(n.tag) 返回的颜色背景,8 的左右内边距和 2 的上下内边距,圆角 8 形成紧凑的标签。颜色由 tagColor 函数决定——科普和测评用蓝色,心得和习惯用橙色,方法用绿色。标签下方是阅读量"32145阅读",8px 极小字号配 textHint 浅灰,弱化处理。

中间是三行文字内容。第一行是标题(12px 粗体墨蓝黑),第二行是单行摘要(10px 灰色,maxLines(1) 限制一行,textOverflow({ overflow: TextOverflow.Ellipsis }) 超出显示省略号),第三行是作者"✍ 省钱侦探"(9px 浅灰)。使用 layoutWeight(1) 填充中间空间。

右侧是📚阅读图标,16px 字号,暗示点击可阅读全文。

整体视觉克制,以文字内容为主,标签颜色是唯一的装饰元素。这种"内容优先"的设计在阅读类应用中是常见做法——减少视觉干扰,让用户专注于内容本身。

十六、个人页面详解

@Builder
pageMine() {
  Column({ space: 12 }) {
    Row({ space: 12 }) {
      Text('🙋')
        .fontSize(34)
        .padding(10)
        .backgroundColor(COLORS.primaryLight)
        .borderRadius(22)
      Column({ space: 4 }) {
        Text('攒钱小达人')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('攒龄 128 天 · 累计攒下 ¥86,420')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
        Row({ space: 4 }) {
          Text('🏅 攒钱新星')
            .fontSize(8)
            .fontColor(COLORS.gold)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.accentLight)
            .borderRadius(8)
          Text('🔥 打卡狂魔')
            .fontSize(8)
            .fontColor(COLORS.danger)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor('#FDECEA')
            .borderRadius(8)
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(18)

    Column({ space: 0 }) {
      ForEach(['绑定银行卡', '自动攒钱设置', '账单导出', '帮助与反馈'], (t: string) => {
        Row({ space: 10 }) {
          Text('💳')
            .fontSize(16)
          Text(t)
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('>')
            .fontSize(12)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        .onClick(() => {
          this.mainTab = 0;
        })
      }, (t: string) => t)
    }
    .padding({ left: 14, right: 14 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)

    Row({ space: 8 }) {
      Column({ space: 2 }) {
        Text('38')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
        Text('心愿')
          .fontSize(9)
          .fontColor(COLORS.textHint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      Column({ space: 2 }) {
        Text('6')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
        Text('挑战')
          .fontSize(9)
          .fontColor(COLORS.textHint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      Column({ space: 2 }) {
        Text('128')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
        Text('连续打卡')
          .fontSize(9)
          .fontColor(COLORS.textHint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      Column({ space: 2 }) {
        Text('12')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.success)
        Text('笔记')
          .fontSize(9)
          .fontColor(COLORS.textHint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
  }
  .width('100%')
}

个人页面包含三个区块:用户信息卡、设置菜单列表、统计数据卡。

16.1 用户信息卡

顶部是用户信息卡,圆角 18(比其他卡片的 14-16 更大),传达"个人主页"的专属感。左侧是🙋头像(34px 字号配 primaryLight 浅蓝背景圆角 22 包裹),右侧是三行信息:昵称"攒钱小达人"(15px 粗体)、攒龄和累计攒下金额(10px 灰色)、两个勋章标签——"🏅 攒钱新星"用金色文字配米黄背景,"🔥 打卡狂魔"用红色文字配浅红背景。勋章标签的设计增加了游戏化元素,展示用户的成就。

16.2 设置菜单列表

中间是设置菜单列表,包含"绑定银行卡"“自动攒钱设置”“账单导出”“帮助与反馈"四个选项。每行左侧是💳图标,中间是菜单文字(layoutWeight(1) 填充),右侧是”>"箭头(textHint 浅灰),形成经典的 iOS 风格设置列表。点击任意菜单项会跳回首页(this.mainTab = 0),这是演示版本的简化处理,实际工程中应跳转到对应的设置子页面。

16.3 统计数据卡

底部是四列统计数据卡,分别展示心愿数(38,蓝色)、挑战数(6,橙色)、连续打卡天数(128,红色)、笔记数(12,绿色)。四列等宽排列(layoutWeight(1)),数字使用 15px 粗体,标签使用 9px 浅灰。颜色编码与各功能页的主题色一致——心愿用蓝色(愿望页头部状态栏背景色)、挑战用橙色(挑战页渐变终点色)、打卡用红色(打卡天数标签色)、笔记用绿色(方法标签色)。

十七、弹框系统详解

应用实现了四种风格各异的弹框,每种弹框都有独特的视觉设计和交互逻辑。所有弹框通过 build 方法中的条件渲染(if (this.addOpen)if (this.editOpen) 等)控制显隐,打开时创建弹框层,关闭时销毁。

17.1 新增愿望弹框(渐变头图卡)

@Builder
addModalBody() {
  Column({ space: 0 }) {
    Column({ space: 4 }) {
      Text('🎯 创建新愿望')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text('有目标的人攒钱更快')
        .fontSize(10)
        .fontColor('#BBDEFB')
    }
    .width('100%')
    .padding(16)
    .linearGradient({
      angle: 120,
      colors: [[COLORS.primaryDark, 0.0], [COLORS.accent, 1.0]]
    })
    .borderRadius({ topLeft: 20, topRight: 20 })
    .alignItems(HorizontalAlign.Center)

    Column({ space: 12 }) {
      Column({ space: 6 }) {
        Text('愿望名称')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        TextInput({ placeholder: '比如:去一趟新疆旅行' })
          .onChange((v: string) => {
            this.addName = v;
          })
          .height(38)
          .fontSize(11)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('目标金额(元)')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        TextInput({ placeholder: '0' })
          .onChange((v: string) => {
            this.addTarget = v;
          })
          .height(38)
          .fontSize(11)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('选个图标')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        Row({ space: 8 }) {
          ForEach(['🎯', '🎮', '✈️', '📱', '🐱', '💍'], (ic: string) => {
            Text(ic)
              .fontSize(18)
              .padding(6)
              .backgroundColor(this.addIcon === ic ? COLORS.primary : COLORS.bg)
              .borderRadius(12)
              .onClick(() => {
                this.addIcon = ic;
              })
          }, (ic: string) => ic)
        }
        .width('100%')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)

      Row({ space: 10 }) {
        Text('取消')
          .fontSize(12)
          .fontColor(COLORS.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.bg)
          .borderRadius(20)
          .onClick(() => {
            this.addOpen = false;
          })
        Text('创建愿望')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.primary)
          .borderRadius(20)
          .onClick(() => {
            this.doAdd();
          })
      }
      .width('100%')
    }
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }
  .width('100%')
  .constraintSize({ maxHeight: '80%' })
}

新增愿望弹框采用"渐变头图卡"设计。顶部是从深蓝到橙色的 120 度渐变区域(linearGradient({ angle: 120, colors: [[COLORS.primaryDark, 0.0], [COLORS.accent, 1.0]] })),展示标题"🎯 创建新愿望"和激励文案"有目标的人攒钱更快"。渐变方向 120 度从左下到右上,深蓝在左下角,橙色在右上角,形成温暖向上的视觉感受。borderRadius({ topLeft: 20, topRight: 20 }) 只设置顶部圆角,与下方白色表单区域形成卡片整体感。

下方白色表单区域包含三个输入项和底部按钮行:

愿望名称输入TextInputplaceholder 为"比如:去一趟新疆旅行",onChange 回调将输入值赋给 this.addName。高度 38,字号 11,背景使用 COLORS.bg 浅灰蓝,圆角 10。

目标金额输入placeholder 为"0",onChange 赋值给 this.addTarget。样式与愿望名称输入一致。

图标选择器:六个 Emoji 候选(🎯🎮✈️📱🐱💍),水平排列。选中项背景为 COLORS.primary 蓝色,未选中为 COLORS.bg 灰色。点击更新 this.addIcon,视觉反馈即时。fontSize(18)padding(6) 使每个图标有足够的触摸区域。

底部按钮行:取消(灰底灰字)和创建愿望(蓝底白字粗体),各占 layoutWeight(1) 等宽。取消按钮点击关闭弹框,创建按钮点击调用 this.doAdd() 提交表单。圆角 20 使按钮呈胶囊形。

constraintSize({ maxHeight: '80%' }) 限制弹框最大高度为屏幕的 80%,防止内容过多时溢出屏幕。

17.2 编辑目标弹框(分段进度卡)

@Builder
editModalBody() {
  Column({ space: 14 }) {
    Row({ space: 8 }) {
      Text('✏️')
        .fontSize(16)
      Text('编辑攒钱目标')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(14)
        .fontColor(COLORS.textHint)
        .onClick(() => {
          this.editOpen = false;
        })
    }
    .width('100%')

    Row({ space: 0 }) {
      ForEach(['基本信息', '金额', '期限'], (t: string, idx: number) => {
        Column({ space: 3 }) {
          Text((idx + 1).toString())
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding(5)
            .backgroundColor(COLORS.primary)
            .borderRadius(10)
          Text(t)
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
      }, (t: string) => t)
    }
    .width('100%')

    Column({ space: 6 }) {
      Text('愿望名称')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
      TextInput({ text: this.editName, placeholder: '愿望名称' })
        .onChange((v: string) => {
          this.editName = v;
        })
        .height(38)
        .fontSize(11)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)

    Column({ space: 6 }) {
      Text('目标金额(元)')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
      TextInput({ text: this.editTarget, placeholder: '目标金额' })
        .onChange((v: string) => {
          this.editTarget = v;
        })
        .height(38)
        .fontSize(11)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)

    Column({ space: 6 }) {
      Text('截止日期')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
      Row({ space: 6 }) {
        ForEach(['今年内', '明年', '长期'], (d: string) => {
          Text(d)
            .fontSize(10)
            .fontColor(this.editDeadline === d ? COLORS.white : COLORS.textSecondary)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(this.editDeadline === d ? COLORS.accent : COLORS.bg)
            .borderRadius(12)
            .onClick(() => {
              this.editDeadline = d;
            })
        }, (d: string) => d)
      }
      .width('100%')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)

    Row({ space: 10 }) {
      Text('放弃编辑')
        .fontSize(12)
        .fontColor(COLORS.textSecondary)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.bg)
        .borderRadius(20)
        .onClick(() => {
          this.editOpen = false;
        })
      Text('保存目标')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.primary)
        .borderRadius(20)
        .onClick(() => {
          this.doEdit();
        })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }
}

编辑弹框的特色是顶部的三段式进度指示器,展示"基本信息 → 金额 → 期限"三个步骤编号和名称,每步用蓝色圆形数字标记。这种分段进度设计向用户暗示编辑操作是一个有步骤的过程,虽然实际上三个字段同时可编辑,但视觉上的分段让操作显得更有条理。

表单包含名称输入框(TextInput({ text: this.editName }),使用 text 参数而非 placeholder 来回显当前值)、目标金额输入框(同样带回显值)、截止日期选择器(三个快捷选项:今年内、明年、长期,选中态橙色背景 COLORS.accent,未选中灰色背景)。

与新增弹框不同,编辑弹框的截止日期使用快捷选项而非自由输入,这简化了用户操作——大多数攒钱目标的截止日期都可以归入"今年内""明年"或"长期"三类。快捷选项的选中态用橙色背景,与新增弹框的图标选择器选中态用蓝色背景形成区分。

弹框右上角有✕关闭按钮(onClick 设置 editOpen = false),这是新增弹框没有的——编辑弹框允许直接关闭而不保存,而新增弹框只有取消和创建两个按钮。底部是"放弃编辑"和"保存目标"按钮,分别使用灰色和蓝色配色。

整个弹框使用统一的白色背景和 20 圆角,视觉比新增弹框更简洁——新增弹框有渐变头部,编辑弹框没有渐变头部,而是用分段进度指示器替代。

17.3 删除记录弹框(蓝色警示卡)

@Builder
delModalBody() {
  Column({ space: 14 }) {
    Text('🗑️')
      .fontSize(32)
      .padding(13)
      .backgroundColor(COLORS.primaryLight)
      .borderRadius(24)
    Text('确认删除这条记录?')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
    Text('删除后该记录将从账本中移除且无法恢复,攒钱进度会同步回退')
      .fontSize(11)
      .fontColor(COLORS.textSecondary)
      .textAlign(TextAlign.Center)
      .lineHeight(17)
    Row({ space: 10 }) {
      Text('取消')
        .fontSize(12)
        .fontColor(COLORS.primary)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.primaryLight)
        .borderRadius(20)
        .onClick(() => {
          this.delOpen = false;
        })
      Text('删除')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.primary)
        .borderRadius(20)
        .onClick(() => {
          this.doDel();
        })
    }
    .width('100%')
    .padding({ top: 4 })
  }
  .width('86%')
  .padding(20)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(20)
  .alignItems(HorizontalAlign.Center)
}

删除弹框采用居中式的警示卡片设计,宽度仅为 86%(比其他弹框的 100% 窄),视觉聚焦。整个弹框内容居中对齐(alignItems(HorizontalAlign.Center)),形成"中央对话框"的经典布局。

顶部是大号垃圾桶图标🗑️(32px 字号配 13 的内边距和 primaryLight 浅蓝圆形背景,圆角 24),视觉上明确传达"删除"操作的性质。中间是加粗的确认问题"确认删除这条记录?“(15px 墨蓝黑粗体),下方是灰色说明文字(11px,居中对齐,lineHeight(17) 提供舒适的行间距),特别提醒"删除后该记录将从账本中移除且无法恢复,攒钱进度会同步回退”——这句话既说明了操作的不可逆性,又提醒了删除对攒钱进度的影响,是金融应用删除操作的必要提示。

底部是取消(浅蓝底蓝字)和删除(蓝底白字粗体)两个按钮。注意这里"删除"按钮使用的是 primary 蓝色而非 danger 红色,这与传统的红色删除按钮不同。这个设计选择保持了弹框整体的蓝色基调统一——在银行风格应用中,蓝色是"权威操作"的颜色,使用蓝色而非红色删除按钮,暗示这个操作是经过系统确认的安全操作,而非危险操作。

17.4 发起挑战弹框(排行榜卡)

@Builder
challengeModalBody() {
  Column({ space: 0 }) {
    Row({ space: 10 }) {
      Text('⚔️')
        .fontSize(22)
        .padding(8)
        .backgroundColor(COLORS.accentLight)
        .borderRadius(14)
      Column({ space: 2 }) {
        Text('发起攒钱挑战')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('让全社区的攒友监督你')
          .fontSize(10)
          .fontColor(COLORS.textHint)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.accentLight)
    .borderRadius({ topLeft: 20, topRight: 20 })

    Column({ space: 12 }) {
      Column({ space: 6 }) {
        Text('挑战名称')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        TextInput({ placeholder: '比如:奶茶戒断30天' })
          .onChange((v: string) => {
            this.challengeName = v;
          })
          .height(38)
          .fontSize(11)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('挑战天数')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
        Row({ space: 6 }) {
          ForEach(['7天', '21天', '100天', '365天'], (d: string) => {
            Text(d)
              .fontSize(10)
              .fontColor(this.challengeDays === d ? COLORS.white : COLORS.textSecondary)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(this.challengeDays === d ? COLORS.accent : COLORS.bg)
              .borderRadius(10)
              .onClick(() => {
                this.challengeDays = d;
              })
          }, (d: string) => d)
        }
        .width('100%')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)

      Row({ space: 8 }) {
        ForEach(RANK_LIST, (r: RankItem) => {
          Column({ space: 2 }) {
            Text(r.avatar)
              .fontSize(18)
            Text('虚位以待')
              .fontSize(8)
              .fontColor(COLORS.textHint)
          }
          .layoutWeight(1)
          .padding(8)
          .backgroundColor(COLORS.bg)
          .borderRadius(12)
          .alignItems(HorizontalAlign.Center)
        }, (r: RankItem) => 'mc' + r.rank.toString())
      }
      .width('100%')

      Row({ space: 10 }) {
        Text('再想想')
          .fontSize(12)
          .fontColor(COLORS.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.bg)
          .borderRadius(20)
          .onClick(() => {
            this.challengeOpen = false;
          })
        Text('发起挑战')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.accent)
          .borderRadius(20)
          .onClick(() => {
            this.doChallenge();
          })
      }
      .width('100%')
    }
    .padding(14)
    .backgroundColor(COLORS.cardBg)
  }
  .width('100%')
  .constraintSize({ maxHeight: '80%' })
}

发起挑战弹框采用"排行榜卡"设计,头部使用米黄色背景(COLORS.accentLight)配⚔️剑盾图标,与新增弹框的渐变头部形成风格区分。

表单包含挑战名称输入框和天数快捷选择(7天、21天、100天、365天,选中态橙色背景 COLORS.accent)。天数选项的设计参考了行为心理学中的"21天习惯养成"理论——7天是短期冲刺,21天是习惯养成周期,100天是深度巩固,365天是年度挑战。用户选择不同天数暗示了不同的攒钱决心。

最具创意的是中间的"虚位以待"排行榜占位区——复用了 RANK_LIST 的三个头像位置,但将昵称替换为"虚位以待"灰色文字。这个设计暗示发起挑战后自己的头像将出现在排行榜上,将功能表单与社区竞争元素融合,是一个巧妙的激励机制设计。

底部是"再想想"(灰底灰字)和"发起挑战"(橙底白字粗体)按钮。注意发起挑战按钮使用橙色 COLORS.accent 而非蓝色 COLORS.primary,与新增愿望弹框的蓝色"创建愿望"按钮形成色彩区分——橙色更热烈,符合挑战主题的社交激励调性。

17.5 通用遮罩层

@Builder
modalOverlay() {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(16,35,59,0.55)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.addOpen = false;
      this.editOpen = false;
      this.delOpen = false;
      this.challengeOpen = false;
    })
}

所有弹框共享同一个遮罩层。遮罩层是一个全屏的 Column,背景色为半透明的深蓝灰色(rgba(16,35,59,0.55),RGB(16,35,59) 对应 textPrimary 的墨蓝黑色,透明度 0.55 使背景内容半隐半现)。

justifyContent(FlexAlign.Center) 使内容居中对齐——虽然遮罩层本身没有子组件(内容组件在外层 Column 中并列排列),但这个属性确保了如果后续有内容追加时自动居中。

onClick 回调将四个弹框状态全部置为 false,实现"点击遮罩关闭所有弹框"的交互。这是一种"一网打尽"式的关闭策略——无论当前打开了哪个弹框,点击遮罩都会关闭。这种设计的优点是逻辑简单,缺点是无法支持"点击遮罩不关闭"的弹框(如强制确认场景),但本应用所有弹框都是非强制的,适合这种设计。

十八、底部导航与主内容组装

18.1 底部导航栏

@Builder
bottomBar() {
  Column({ space: 0 }) {
    Column()
      .width('100%')
      .height(1)
      .backgroundColor(COLORS.border)
    Row() {
      ForEach(NAV_LIST, (n: NavItem, idx: number) => {
        Column({ space: 3 }) {
          Text(n.icon)
            .fontSize(20)
          Text(n.label)
            .fontSize(9)
            .fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 6, bottom: 6 })
        .onClick(() => {
          this.mainTab = idx;
        })
      }, (n: NavItem) => n.label)
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg)
}

底部导航栏顶部有 1px 的极浅分隔线(height(1)COLORS.border 极浅灰蓝色),与内容区形成微妙的边界感。四个主 Tab 等宽排列(每个 layoutWeight(1)),分别是"🏠首页"“🧾账单”“⚔️挑战”“👤我的”。

选中项的图标下方文字变为 primary 蓝色,未选中为 textHint 浅灰色。注意图标本身不随选中状态变化(保持 Emoji 原色),仅通过文字颜色区分——这种设计较为克制,避免了选中态视觉变化过多。Emoji 图标的彩色特性使其不适合做颜色变化(变色会破坏 Emoji 的辨识度),因此仅通过文字颜色传递选中态。

ForEach 的键值函数返回 n.label,以标签文字作为键值,确保导航项的正确复用。点击任何 Tab 时,onClick 回调将 this.mainTab 设为对应的 idx,触发 ArkUI 重新渲染 mainContent 中的条件分支,切换显示对应的主页面。

底部导航栏使用白色背景(COLORS.cardBg),与内容区的灰色背景形成对比,使导航栏在视觉上"固定"在底部,不会被内容区的滚动内容遮挡。

18.2 内容区路由

@Builder
mainContent() {
  Scroll() {
    Column() {
      if (this.mainTab === 0) {
        this.header()
        this.subNav()
        Column({ space: 10 }) {
          if (this.subTab === 0) {
            this.pageOverview()
          } else if (this.subTab === 1) {
            this.pageWish()
          } else if (this.subTab === 2) {
            this.pageBill()
          } else if (this.subTab === 3) {
            this.pageChallenge()
          } else {
            this.pageNote()
          }
        }
        .width('100%')
        .padding(14)
      } else if (this.mainTab === 1) {
        Column({ space: 0 }) {
          this.subNav()
        }
        .width('100%')
        Column({ space: 10 }) {
          this.pageBill()
        }
        .width('100%')
        .padding(14)
      } else if (this.mainTab === 2) {
        Column({ space: 10 }) {
          this.pageChallenge()
        }
        .width('100%')
        .padding(14)
      } else {
        Column({ space: 10 }) {
          this.pageMine()
        }
        .width('100%')
        .padding(14)
      }
    }
    .width('100%')
  }
  .layoutWeight(1)
  .width('100%')
}

内容区使用 Scroll 包裹,使所有页面内容可滚动。通过 mainTabsubTab 的双重条件分支实现页面路由。

首页(mainTab=0) 是最复杂的路由:先渲染头部 header() 和子导航 subNav(),再根据 subTab 选择五个内容页之一(0=总览、1=愿望、2=账单、3=挑战、4=笔记)。内容页外层用 Column({ space: 10 }) 包裹并提供 14 的 padding。

账单页(mainTab=1) 复用子导航 subNav() 并固定显示账单内容 pageBill()。注意这里虽然子导航渲染了,但点击子导航的 Tab 不会切换账单页的内容——因为账单页的路由不依赖 subTab。这是应用的一个设计简化,实际工程中账单页可以不显示子导航或让子导航只显示"账单"高亮。

挑战页(mainTab=2) 直接展示挑战大厅 pageChallenge(),不需要头部和子导航。

我的页(mainTab=3) 展示个人中心 pageMine(),同样不需要头部和子导航。

layoutWeight(1) 使内容区占据底部栏之上的所有剩余空间。Scroll 组件允许内容超出屏幕高度时滚动浏览,这是长内容列表页面的标准布局方式。

18.3 顶层 build 组装

build() {
  Stack() {
    Column() {
      this.mainContent()
      this.bottomBar()
    }
    .width('100%')
    .height('100%')

    this.fxLayer()

    if (this.addOpen) {
      Column() {
        this.modalOverlay()
        Column() {
          this.addModalBody()
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .height('100%')
    }

    if (this.editOpen) {
      Column() {
        this.modalOverlay()
        Column() {
          this.editModalBody()
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .height('100%')
    }

    if (this.delOpen) {
      Column() {
        this.modalOverlay()
        Column() {
          this.delModalBody()
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .height('100%')
    }

    if (this.challengeOpen) {
      Column() {
        this.modalOverlay()
        Column() {
          this.challengeModalBody()
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .height('100%')
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build 方法是 ArkUI 组件的入口构建器,使用 Stack 将所有层级叠加。

最底层Column 包裹的内容区和底部栏(垂直排列)。mainContent() 占据上方可滚动区域,bottomBar() 固定在底部。这个 Column 使用 width('100%')height('100%') 填满整个屏幕。

第二层是特效层 fxLayer(),覆盖在内容区上方但通过 HitTestMode.None 不拦截点击。金币飘落在内容之上,但不影响用户操作。

第三层及以上是四个弹框层,通过 if 条件渲染控制显隐。每个弹框层都是一个全屏的 Column,内含两个子元素:modalOverlay()(遮罩层)和对应的内容卡(如 addModalBody())。遮罩层先渲染(在底层),内容卡后渲染(在上层),justifyContent(FlexAlign.Center) 使内容卡在遮罩层中居中显示。

四个弹框层的渲染顺序遵循 if 条件的排列顺序:新增愿望、编辑目标、删除记录、发起挑战。但由于同时最多只有一个弹框打开(四个布尔状态互相独立但实际使用中不会同时为 true),所以渲染顺序不影响视觉。如果出现两个弹框同时打开的情况,后渲染的弹框会覆盖在前者之上。

整个 Stack 使用 COLORS.bg(#F4F7FB)作为背景色,这是应用的全局背景色。Stack 的子元素按声明顺序从底到顶叠加——先声明的在底层,后声明的在顶层。这种层级设计确保了:功能界面在最底层,特效在中间层(不拦截点击),弹框在最顶层(完全遮盖)。

十九、功能模块对比表

模块 核心职责 关键状态变量 数据来源 视觉特色 交互方式 Builder 方法
特效层 金币飘落+存钱罐光晕 tick 纯函数计算 Emoji+取模动画 不可交互(穿透) fxLayer
银行头部 总资产展示+快捷入口 subTab 静态常量 深蓝渐变大卡 点击跳转/打开弹框 header
子导航栏 首页内容Tab切换 subTab SUB_NAV_LIST 胶囊高亮+金色圆点 点击切换 subNav
总览页 资产概览+榜单+日历+动态 postList 多源混合 三卡+榜单+日历网格 只读浏览 pageOverview
愿望页 攒钱目标进度管理 wishList WISH_LIST+动态增删 进度条+操作按钮 编辑/删除/新增 pageWish
账单页 收支图表+流水列表 BILL_LIST+图表数据 双柱图+条形图+列表 点击删除 pageBill
挑战页 社区挑战参与 postList CHALLENGE_LIST 渐变引导卡+天数方块 加入挑战 pageChallenge
笔记页 理财内容浏览 NOTE_LIST 彩色标签+文字列表 只读浏览 pageNote
个人页 用户信息+设置入口 mainTab 静态常量 头像卡+菜单+统计 点击回首页 pageMine
新增弹框 创建攒钱愿望 addName/addTarget/addIcon 用户输入 渐变头图+图标选择 表单提交 addModalBody
编辑弹框 修改愿望目标 editName/editTarget/editDeadline 回显+用户输入 分段进度指示 表单提交 editModalBody
删除弹框 确认删除操作 delOpen 蓝色警示居中卡 二选一确认 delModalBody
挑战弹框 发起社区挑战 challengeName/challengeDays 用户输入 排行榜虚位占位 表单提交 challengeModalBody
通用遮罩 弹框背景遮罩 四个布尔值 半透明深蓝灰 点击关闭全部 modalOverlay
底部导航 主Tab切换 mainTab NAV_LIST 文字色变 点击切换 bottomBar
内容路由 页面组装与路由 mainTab/subTab 条件分支渲染 mainContent

弹框设计对比表

弹框 头部风格 主按钮颜色 宽度 最大高度 特殊设计
新增愿望 深蓝到橙渐变 蓝色 primary 100% 80% 六图标选择器
编辑目标 无渐变+分段进度 蓝色 primary 100% 80% 三步进度指示器
删除记录 无头部+居中图标 蓝色 primary 86% 无限制 警示文案+进度回退提醒
发起挑战 米黄背景+剑盾图标 橙色 accent 100% 80% 虚位以待排行榜占位

色彩语义对比表

色彩 RGB 值 语义含义 使用场景
primary #1565C0 (21,101,192) 专业、信任 头部渐变终点、主按钮、选中态
accent #FF8F00 (255,143,0) 活力、注意 进度条、挑战渐变、挑战按钮
success #2E7D32 (46,125,50) 正向、增长 收益金额、已达成状态
danger #D32F2F (211,47,47) 警示、支出 删除按钮、连续打卡标签
gold #FBC02D (251,192,45) 攒钱、装饰 子导航圆点、金币主题

二十、总结与展望

本文深入剖析了一款基于 HarmonyOS ArkUI 框架的年轻人攒钱社区应用,从色彩体系、数据模型、工具函数到组件构建、弹框系统、特效层和最终组装,完整呈现了声明式 UI 开发的实践路径。以下从多个维度进行总结。

20.1 技术架构总结

该应用充分展现了 ArkUI 声明式范式的优势。通过 @State 管理状态、@Builder 拆分视图、@Observed 观察数据,整个应用在单一组件结构中实现了复杂的多页面路由和弹框管理。18 个 @State 变量覆盖了导航索引、弹框开关、表单数据、列表数据和动画计数器等全部 UI 状态,每个状态变化都由框架自动检测并触发精确的差量更新。

特效层的设计尤其值得借鉴——利用 setInterval 驱动 tick 计数器,配合八个纯函数计算坐标和透明度,以极低的代码量实现了持续的金币飘落动画和存钱罐呼吸光晕。同时通过 hitTestBehavior(HitTestMode.None) 确保动画不干扰功能交互,这是"游戏化视觉"与"功能可用性"平衡的优秀范例。这种"数据驱动动画"的模式是声明式框架下动画实现的典型实践——动画的每一帧都是状态的函数,状态变化触发框架重新渲染,视觉变化是状态映射的自然结果。

20.2 视觉设计总结

应用的色彩策略"蓝色铺底+橙金点睛"精准传达了"金融专业感+年轻活力感"的双重定位。16 种色彩通过 ColorPalette 接口强类型约束,每个色彩都有明确的语义含义和使用场景。银行风格头部的大号资产数字和深蓝渐变建立信任,金币特效和 Emoji 头像注入趣味。

四种弹框各具风格(渐变头图、分段进度、蓝色警示、排行榜卡)避免了弹框交互的单调感。子导航栏的"胶囊高亮+金色圆点"设计在选中态视觉上呼应了金币主题,是细节设计的亮点。整个应用的视觉层次从上到下形成"深蓝头部 → 浅灰蓝导航 → 白色卡片"的三级色彩递进,层次分明且不显杂乱。

20.3 工程实践总结

数据模型的 @Observed 标注和构造函数设计为接入真实后端做好了准备——五个数据模型类各有明确的字段定义和构造函数,从 JSON 数据创建实例只需一行代码。静态数据与动态状态的分离使数据替换成本极低——将 WISH_LISTSAVE_POSTS 替换为 API 返回的数据,其余代码无需改动。

工具函数的纯函数设计保证了可测试性和可复用性。八个函数不依赖任何组件状态,接收参数返回计算结果,可以独立于组件进行单元测试。ForEach 键值函数的设计(如将 saved 纳入愿望列表的键值)体现了对 ArkUI 列表渲染机制的深入理解——键值的变化决定列表项是否重新渲染,将数据中变化的字段纳入键值是确保列表正确更新的关键。

20.4 交互设计总结

应用的交互设计遵循"高频操作前置、低频操作收纳"的原则。头部快捷入口栏将"存一笔"“记一笔”“新目标”"挑战"四个高频操作放在最显眼的位置,用户无需翻找底部导航即可触达。弹框系统采用"遮罩层+内容卡"的 Stack 叠加模式,点击遮罩关闭所有弹框,交互逻辑统一且符合用户直觉。

愿望列表的编辑和删除操作直接嵌入在卡片右侧,用户无需进入二级页面即可操作——这种"就地编辑"模式减少了页面跳转,提高了操作效率。挑战列表的加入按钮也直接在卡片上,点击后立即在社区动态中生成打卡记录,形成"操作→反馈"的闭环。

20.5 攒钱社区产品设计总结

从产品角度看,"掌上攒"应用精准定位了年轻人攒钱场景的核心需求:目标管理(愿望清单+进度条)、记账分析(账单流水+消费结构图表)、社交激励(打卡动态+攒钱挑战+排行榜)、知识获取(理财笔记)。这四个功能域通过底部导航和子导航组织为清晰的信息架构,用户可以在不同功能间快速切换。

游戏化元素的融入是该应用的亮点——金币特效、等级标签(尊享 Lv.6)、连续打卡天数、勋章标签(攒钱新星、打卡狂魔)、挑战排行榜等设计,将攒钱从枯燥的数字管理变为有成就感的游戏化体验。这种"游戏化+金融"的融合设计正是年轻人攒钱应用区别于传统银行 App 的核心竞争力。

20.6 技术展望与未来方向

展望未来,该应用可以在以下方向深化:

其一,将 setInterval 驱动的 tick 特效升级为 animateTo 显式动画 API。ArkUI 提供了 animateToanimation 两个动画修饰器,可以实现更平滑的过渡效果和更精细的缓动控制。当前约 9 FPS 的跳跃式动画虽然形成了独特的像素艺术风格,但在高端设备上可以提供 60 FPS 的平滑动画选项,让用户自行选择动画质量。

其二,引入 @ObjectLink 实现跨组件的数据观察,将单组件拆分为真正的组件树。当前所有 UI 都在一个 @Entry @Component 内,通过 @Builder 拆分虽然是可行方案,但无法实现组件级的复用和独立更新。将愿望卡片、账单条目、挑战卡片等拆分为独立的 @Component,配合 @ObjectLink 观察数据变化,可以实现更精细的差量更新——只有变化的卡片重新渲染,而非整个列表重新构建。

其三,接入分布式数据管理,实现多设备间的攒钱数据同步。HarmonyOS 的分布式能力是该框架的独特优势,用户在手机上记录的账单可以自动同步到平板和智慧屏,实现"随时随地记账"的无缝体验。这需要将静态数据替换为分布式数据管理接口返回的 @Observed 数据。

其四,利用 ArkUI 的 @Watch 装饰器监听状态变化,实现攒钱达成目标时的自动庆祝动效。当 percentOf(w.saved, w.target) 从 99 变为 100 时,触发一个庆祝动画——金币爆发、烟花特效、成就弹框等,给用户即时的正向反馈,增强攒钱的成就感。

其五,将 Emoji 图标替换为自定义矢量图标或 Lottie 动画图标。Emoji 的优势是跨设备一致性和零包体积,但视觉精细度有限。在高端版本中,可以使用 SVG 矢量图标或 Lottie 动画替代 Emoji,实现更精细的视觉效果和品牌识别度。同时可以引入图标主题切换功能,让用户自定义应用图标风格。

在鸿蒙生态蓬勃发展的当下,ArkUI 声明式框架为开发者提供了高效、优雅的 UI 构建能力。本文剖析的应用虽以模拟数据驱动,但其架构分层、色彩体系、特效设计和弹框系统的实践,为同类金融理财类应用的开发提供了可复用的参考范式。随着 HarmonyOS 的持续演进和全场景多设备战略的推进,声明式 UI 的潜力将进一步释放——从手机到平板,从手表到智慧屏,从车机到 AR 眼镜,一套代码多端运行的能力将为开发者带来前所未有的效率提升。期待更多开发者在全场景多设备的舞台上,用代码书写更优雅的用户体验,用技术赋能更智慧的生活方式。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐