引言:从一行代码看懂鸿蒙原生开发的精髓

在万物互联的时代浪潮之下,HarmonyOS(鸿蒙操作系统)作为华为倾力打造的全场景分布式操作系统,正在以势不可挡的姿态重塑移动开发的生态格局。与传统的 Android 或 iOS 开发不同,HarmonyOS 提供了一套全新的声明式 UI 开发范式——ArkUI,而其背后的核心编程语言便是 ArkTS。ArkTS 是在 TypeScript 基础上进行了深度扩展和严格约束的编程语言,它保留了 TypeScript 的类型系统优势,同时引入了诸如 @Component@State@Builder 等装饰器语法,使得开发者能够以更加简洁、直观、高效的方式构建用户界面。

在这里插入图片描述

本文将以一款功能完整的财务记账应用为案例,对该应用的全部源码进行逐段、逐行、逐字的深度解析。这款应用虽然以单文件形式呈现,但其内部架构清晰、模块划分明确、交互逻辑完整,涵盖了账单流水管理、数据可视化图表、预算监控预警、个人中心信息展示等财务管理应用的核心功能域。通过对这份源码的细致剖析,读者不仅能够掌握 ArkTS 的语法细节和 ArkUI 的组件化思想,更能深入理解状态驱动的 UI 编程范式在实际业务场景中的落地方式。

应用背景与产品定位

财务管理类应用一直是移动应用生态中不可或缺的重要组成部分。从早期的随手记、挖财记账,到后来的支付宝账本、微信记账,个人财务管理工具已经从简单的收支记录演进为集预算管理、数据分析、资产全景展示于一体的综合性金融助手。本文所解析的这款财务记账应用,正是对这一品类核心功能的精炼再现。

在这里插入图片描述

该应用面向有一定理财意识的个人用户,提供了以下核心能力:第一,交易流水的精细化管理,支持新增、编辑、删除收支记录,每笔交易都关联了分类、账户、商户、时间等多维度信息;第二,数据可视化的直观呈现,通过月度趋势柱状图、分类占比条形图、近七日趋势图等多种图表形式,让用户一目了然地掌握自己的消费规律;第三,预算体系的智能监控,为每个消费类别设置预算上限,并实时跟踪使用进度,当支出接近或超出预算时提供视觉预警;第四,个人资产的全景视图,聚合展示用户在银行、支付宝、微信、理财平台等多个账户的资产分布。

技术栈全景

从技术层面来看,本应用的技术栈具有以下鲜明特征:

编程语言层面,采用 ArkTS 作为唯一的开发语言。ArkTS 在 TypeScript 的基础上增加了对声明式 UI 的原生支持,通过装饰器系统实现了组件定义、状态管理、构建函数等核心概念。与纯 TypeScript 相比,ArkTS 强化了类型安全,限制了动态特性(如不支持 any 类型在严格模式下的滥用),并在编译期就能捕获大量潜在的运行时错误。

在这里插入图片描述

UI 框架层面,基于 ArkUI 声明式开发范式。ArkUI 提供了丰富的内置组件(ColumnRowTextImageTextInputScrollFlexStackDivider 等)和完善的布局能力(线性布局、层叠布局、弹性布局等),开发者通过链式调用的方式配置组件属性,以声明式的风格描述界面结构,框架自动负责界面的渲染和更新。

状态管理层面,采用 @State 装饰器驱动的响应式状态管理系统。当被 @State 修饰的变量发生变化时,框架会自动触发该组件的 build() 函数重新执行,从而实现界面与数据的自动同步。这种数据驱动的模式,让开发者无需手动操作 DOM 或调用 setState,极大地简化了状态管理的复杂度。

架构设计层面,采用组件化的模块拆分策略。整个应用由一个 @Entry 入口组件和多个 @Component 子组件构成,每个子组件负责一个独立功能模块(账单、图表、预算、个人中心),通过 @Builder 修饰的构建函数实现 UI 片段的复用。这种分层架构使得代码结构清晰、职责明确、易于维护和扩展。

视觉设计层面,应用采用了一套精心设计的配色方案。主色调为深青绿色(Teal,#00695C),象征财务管理的稳健与信任感;支出金额使用红色(#C62828)标识,收入金额使用绿色(#2E7D32)标识,符合用户对"红出绿进"的财务直觉认知;整体背景采用浅青绿色(#E0F2F1),营造出清爽、专业的视觉氛围。每个消费类别还配有独立的颜色和图标,如餐饮为橙色汉堡图标、交通为蓝色汽车图标等,通过色彩编码和视觉符号增强了信息的可识别性。

下面,让我们正式进入源码的逐段解析之旅。


一、数据模型定义:接口设计与类型约束

1.1 消费类别元数据接口

interface ExpenseCategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

在这里插入图片描述

应用的第一段代码定义了 ExpenseCategoryMeta 接口,它描述了每一个消费类别的元数据结构。在这个接口中,label 字段存储类别的显示名称(如"餐饮"、“交通”),icon 字段存储对应的 Emoji 图标(如"🍔"、“🚗”),color 字段定义该类别的主色调(用于选中态背景、文字颜色等),bg 字段定义该类别的浅色背景(用于未选中态背景、列表项图标背景等)。

这种将视觉属性与业务数据绑定在一起的设计思路,体现了配置即数据的理念。通过将每个类别的视觉表现抽象为结构化的元数据,开发者可以在任何需要展示类别信息的地方统一引用,避免了在多处硬编码颜色值和图标字符串带来的维护成本。例如,当需要新增一个"旅行"类别时,只需在配置表中添加一条记录即可,所有引用该类别元数据的 UI 组件都会自动获取正确的视觉表现,无需逐个修改。

从 ArkTS 的类型系统角度来看,使用 interface 而非 class 来定义纯数据结构,是 ArkTS 编程中的最佳实践之一。接口在编译后不会产生运行时开销,它们仅用于编译期的类型检查,在运行时会被完全擦除。对于纯粹用于描述数据形状的场景,接口比类更加轻量和高效。

1.2 交易类型元数据接口

interface TransactionTypeMeta {
  label: string
  color: string
  isIncome: boolean
}

在这里插入图片描述

TransactionTypeMeta 接口定义了交易类型的元数据结构。在财务管理应用中,最基本的交易分类就是"收入"和"支出"两种类型。label 字段存储类型名称,color 字段存储该类型的标识颜色(支出为红色、收入为绿色),isIncome 是一个布尔值,用于在业务逻辑中快速判断该笔交易是收入还是支出。

值得注意的是,这里将 isIncome 作为元数据的一部分,而非在业务逻辑中通过字符串比较来判断(如 type === '收入'),这是一个值得借鉴的设计。字符串比较在代码中容易出现拼写错误,且语义不够明确。通过将判断逻辑前移到配置层面,代码的可读性和可维护性都得到了提升。在后续的业务代码中,开发者只需通过 TXN_TYPES[type].isIncome 即可获取收入/支出的布尔标识,既安全又简洁。

1.3 交易记录接口

interface Transaction {
  id: number
  title: string
  category: string
  type: string
  amount: number
  date: string
  time: string
  note: string
  account: string
  merchant: string
}

在这里插入图片描述

Transaction 接口是整个应用最核心的数据模型,它定义了一条完整的交易记录应包含的所有字段。让我们逐一分析每个字段的设计意图:

  • id: number:交易记录的唯一标识符,使用数值类型。在列表渲染和增删改查操作中,id 是区分不同交易记录的关键。
  • title: string:交易标题,如"星巴克拿铁"、“地铁通勤”,是用户最直观的描述性信息。
  • category: string:消费类别,对应 EXPENSE_CATS 配置表中的 key,如"餐饮"、“交通”。
  • type: string:交易类型,取值为"支出"或"收入",对应 TXN_TYPES 配置表中的 key。
  • amount: number:交易金额,使用数值类型而非字符串,确保了后续数值计算的精度和便利性。
  • date: string:交易日期,格式为 YYYY-MM-DD(如"2026-07-19")。虽然使用字符串存储日期不如 Date 对象灵活,但在展示场景中字符串格式更为直观,且省去了格式化转换的开销。
  • time: string:交易时间,格式为 HH:MM(如"08:30")。
  • note: string:备注信息,如"早餐咖啡",用于补充交易的上下文细节。
  • account: string:支付账户,如"微信支付"、“支付宝”、“招商银行”。
  • merchant: string:商户名称,如"星巴克旗舰店"、“美团外卖”。

这个接口的设计体现了财务管理应用对数据完整性的要求。每笔交易不仅有金额和类别,还记录了支付方式、商户信息和时间戳,这些维度的数据为后续的数据分析(如"在哪些商户消费最多"、“哪种支付方式使用最频繁”)提供了基础。在实际生产环境中,这些数据通常会存储在本地数据库(如 HarmonyOS 的关系型数据库 relationalStore)或云端服务器中,但本应用为了演示目的,使用了静态的 Mock 数据。

1.4 预算项接口

interface BudgetItem {
  category: string
  budget: number
  spent: number
}

在这里插入图片描述

BudgetItem 接口定义了预算管理的基本数据单元。category 字段标识消费类别,budget 字段存储该类别的预算总额,spent 字段记录当前已花费的金额。

这个接口的设计简洁而精炼,它没有直接存储"剩余金额"或"使用百分比"等衍生数据,而是只保留了最基础的原始数据。这种最小数据集的设计原则有两个好处:一是避免了数据冗余(衍生数据可以通过计算得出,无需存储),二是保证了数据的一致性(如果同时存储原始数据和衍生数据,更新时可能出现不一致的情况)。在后续的 UI 渲染逻辑中,剩余金额和使用百分比都是在 build() 函数中实时计算得出的,这确保了界面展示的永远是最新、最准确的数据。


二、配置常量与静态数据:应用的数据基石

2.1 消费类别配置表

const EXPENSE_CATS: Record<string, ExpenseCategoryMeta> = {
  '餐饮': { label: '餐饮', icon: '🍔', color: '#FF6F00', bg: '#FFF3E0' },
  '交通': { label: '交通', icon: '🚗', color: '#1565C0', bg: '#E3F2FD' },
  '购物': { label: '购物', icon: '🛍️', color: '#AD1457', bg: '#FCE4EC' },
  '娱乐': { label: '娱乐', icon: '🎮', color: '#7B1FA2', bg: '#F3E5F5' },
  '居住': { label: '居住', icon: '🏠', color: '#5D4037', bg: '#EFEBE9' },
  '医疗': { label: '医疗', icon: '💊', color: '#C62828', bg: '#FFEBEE' },
  '教育': { label: '教育', icon: '📚', color: '#00695C', bg: '#E0F2F1' },
  '工资': { label: '工资', icon: '💰', color: '#2E7D32', bg: '#E8F5E9' },
  '理财': { label: '理财', icon: '📈', color: '#1565C0', bg: '#E3F2FD' }
}

在这里插入图片描述

EXPENSE_CATS 是整个应用最重要的配置常量之一,它以 Record<string, ExpenseCategoryMeta> 的形式(即字符串键到元数据值的映射),集中管理了所有消费和收入类别的视觉表现。

从设计模式的角度来看,这是一种典型的配置驱动设计(Configuration-Driven Design)。所有的视觉属性——包括 Emoji 图标、主色、背景色——都被提取到这张统一的配置表中,而不是散落在各个组件的代码里。这种做法带来了显著的好处:第一,一致性,同一个类别在任何地方显示的颜色和图标都完全一致;第二,可维护性,修改某个类别的颜色只需改一处;第三,可扩展性,新增类别只需在配置表中添加一条记录。

在配色方案上,每个类别都经过了精心设计。color 字段使用的是 Material Design 色板中的深色调(如 #FF6F00 是 Amber 900),bg 字段使用的是同色系的浅色调(如 #FFF3E0 是 Amber 50)。这种深浅搭配的策略,使得选中态(使用 color 作为背景色,文字为白色)和未选中态(使用 bg 作为背景色,文字为 color)之间形成了清晰的视觉对比,用户能够一目了然地识别当前选中的分类。

2.2 交易类型配置表

const TXN_TYPES: Record<string, TransactionTypeMeta> = {
  '支出': { label: '支出', color: '#C62828', isIncome: false },
  '收入': { label: '收入', color: '#2E7D32', isIncome: true }
}

TXN_TYPES 配置表定义了两种交易类型的元数据。支出的颜色为红色(#C62828,Material Design Red 800),收入为绿色(#2E7D32,Material Design Green 800)。这种"红出绿进"的配色方案是财务管理应用的通用惯例,符合大多数用户的心理预期——红色代表资金的流出(支出),绿色代表资金的流入(收入)。

2.3 分类列表与图表数据

const CAT_LIST: string[] = ['餐饮', '交通', '购物', '娱乐', '居住', '医疗', '教育']
const INCOME_LIST: string[] = ['工资', '理财']
const MONTH_BAR: number[] = [3200, 4100, 2800, 5200, 3800, 4500, 3100, 4900, 3600, 4200, 3500, 4800]
const MONTH_LABELS: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']

这组常量定义了应用中使用的列表和图表数据。CAT_LISTINCOME_LIST 分别定义了支出类别和收入类别的列表,它们在新增记账的表单中使用——当用户选择"支出"类型时,分类选择器展示 CAT_LIST 中的类别;当选择"收入"类型时,展示 INCOME_LIST 中的类别。

MONTH_BAR 数组存储了 12 个月的收支数据(或支出数据),用于月度趋势柱状图的渲染。MONTH_LABELS 数组存储了月份标签。这两个数组的索引一一对应,在 ForEach 循环中通过索引访问,实现了数据与标签的同步展示。

值得注意的是,CAT_LIST 中只包含了支出类别(7 个),不包含收入类别(“工资"和"理财”)。这种将支出和收入分类分别管理的做法,确保了用户在新增记账时不会出现"选择了支出类型却选了工资分类"的逻辑错误,从数据层面就杜绝了不合理的组合。


三、Mock 数据与辅助函数:模拟真实业务场景

3.1 模拟交易数据

const mockTransactions: Transaction[] = [
  { id: 1, title: '星巴克拿铁', category: '餐饮', type: '支出', amount: 35, date: '2026-07-19', time: '08:30', note: '早餐咖啡', account: '微信支付', merchant: '星巴克旗舰店' },
  { id: 2, title: '地铁通勤', category: '交通', type: '支出', amount: 12, date: '2026-07-19', time: '09:15', note: '上班地铁往返', account: '交通卡', merchant: '地铁公司' },
  // ... (共30条交易记录,涵盖餐饮、交通、购物、娱乐、居住、医疗、教育、工资、理财等类别)
  { id: 30, title: '加油', category: '交通', type: '支出', amount: 350, date: '2026-07-11', time: '08:00', note: '95号汽油加满', account: '信用卡', merchant: '中石化加油站' }
]

mockTransactions 数组包含了 30 条精心构造的交易记录,这些数据模拟了一个真实用户在 2026 年 7 月 11 日至 7 月 19 日之间的日常消费行为。数据的设计非常贴近真实生活场景:有早晨的星巴克咖啡和巴比馒头,有上班的地铁通勤和深夜的滴滴打车,有午餐外卖和晚餐火锅,有月度工资到账和理财收益,还有房租、水电费、体检费等固定支出。

从数据分布来看,支出类别涵盖了所有 7 个支出分类,金额从 8 元(早餐包子)到 3500 元(房租)不等,既有日常小额消费,也有大额固定支出。收入记录包括月度工资(15000 元)、理财收益(280 元、320 元)和理财赎回(10500 元)。这些数据的多样性确保了应用的各个功能模块(账单列表、图表分析、预算监控)都能得到充分的展示和测试。

需要特别指出的是,这 30 条交易记录在账单列表中是通过逐条硬编码调用 this.txnItem() 的方式渲染的(而非使用 ForEach 循环遍历数组),这是一个在演示场景中常见的做法。在实际生产环境中,应该使用 ForEach(mockTransactions, (t: Transaction) => { this.txnItem(t) }) 来实现动态渲染,这样当数据源变化时(如新增、删除交易),列表会自动更新。

3.2 模拟预算数据

const mockBudgets: BudgetItem[] = [
  { category: '餐饮', budget: 2000, spent: 1280 },
  { category: '交通', budget: 800, spent: 539 },
  { category: '购物', budget: 1500, spent: 585 },
  { category: '娱乐', budget: 600, spent: 447 },
  { category: '居住', budget: 4000, spent: 3706 },
  { category: '医疗', budget: 500, spent: 625 },
  { category: '教育', budget: 500, spent: 327 }
]

mockBudgets 数组为每个支出类别定义了预算额度和已花费金额。这组数据的设计刻意包含了几种不同的预算状态:餐饮类别使用了 64%(1280/2000),处于正常范围;居住类别使用了 93%(3706/4000),接近预算上限;医疗类别花费 625 元超过了 500 元的预算,处于超支状态。这些不同状态的预算数据,使得预算管理页面能够展示正常、接近预警、超支等多种视觉反馈,充分验证了预算监控逻辑的正确性。

3.3 辅助函数

function getMonthlyIncome(): number { return 26100 }
function getMonthlyExpense(): number { return 7569 }
function getMonthlyBalance(): number { return 18531 }
function getTxnCount(): number { return 30 }

这四个辅助函数分别返回本月收入、本月支出、本月结余和交易笔数。在当前的实现中,这些函数返回的是硬编码的固定值,而非从 mockTransactions 数组中动态计算得出。

从架构设计的角度来看,将这些数据获取逻辑封装为函数而非直接使用常量,是一个有前瞻性的设计决策。在真实应用中,这些函数的实现只需要从数据库查询或从远程接口获取数据即可,而调用方代码无需任何修改。这种面向接口编程的思想——调用者依赖函数的抽象接口而非具体实现——使得代码在从 Mock 数据迁移到真实数据源时,改动范围被限制在最小的范围内。


四、Tab 枚举:模块导航的类型安全保障

enum ExpenseTab {
  BILL = 0,
  CHART = 1,
  BUDGET = 2,
  PROFILE = 3
}

ExpenseTab 枚举定义了应用的四个主功能模块:BILL(账单)、CHART(图表)、BUDGET(预算)和 PROFILE(我的)。每个枚举成员被赋予了一个从 0 开始的数值。

使用枚举而非魔法数字(magic number)来标识 Tab 页,是 ArkTS 类型安全编程的重要实践。如果在代码中直接使用 if (this.activeTab === 0) 这样的写法,不仅可读性差,而且容易出现拼写错误或数值混淆。通过枚举,代码变成了 if (this.activeTab === ExpenseTab.BILL),语义清晰且编译器能够进行类型检查,有效防止了因手误导致的运行时错误。

枚举成员显式赋值为 0、1、2、3,这种连续的数值在实际运行中与数组索引天然对应,便于在需要时进行索引计算或与底部导航栏的位置进行映射。虽然在当前的实现中并未直接利用这些数值进行索引操作,但这种显式赋值的做法使得代码的意图更加明确,也避免了因枚举值隐式分配可能带来的混淆。


五、入口组件与底部导航:应用的骨架架构

5.1 入口组件定义

@Entry
@Component
struct ExpenseApp {
  @State activeTab: ExpenseTab = ExpenseTab.BILL

  @Builder contentArea() {
    Column() {
      if (this.activeTab === ExpenseTab.BILL) {
        BillContent()
      } else if (this.activeTab === ExpenseTab.CHART) {
        ChartContent()
      } else if (this.activeTab === ExpenseTab.BUDGET) {
        BudgetContent()
      } else {
        ExpenseProfileContent()
      }
    }
    .layoutWeight(1)
  }

ExpenseApp 是整个应用的入口组件,通过 @Entry 装饰器标记为页面入口,通过 @Component 装饰器声明为一个自定义组件。这是每一个 HarmonyOS 应用页面的标准结构。

组件内部定义了一个 @State 状态变量 activeTab,初始值为 ExpenseTab.BILL,表示应用启动时默认展示账单页面。@State 装饰器是 ArkUI 响应式状态管理的核心——当 activeTab 的值发生变化时,所有引用了该状态的 UI 片段都会自动重新渲染。

contentArea() 是一个 @Builder 修饰的构建函数,它根据 activeTab 的当前值,条件性地渲染对应的子组件。这种条件渲染的模式实现了 Tab 页面的切换逻辑:当 activeTabBILL 时渲染 BillContent,为 CHART 时渲染 ChartContent,以此类推。外层的 Column 配合 .layoutWeight(1) 确保内容区域占据除底部导航栏之外的所有剩余空间。

从组件架构的角度来看,这种设计体现了组合优于继承的原则。ExpenseApp 本身不包含任何业务逻辑,它的职责仅限于管理 Tab 切换状态和组装子组件。每个子组件(BillContentChartContentBudgetContentExpenseProfileContent)都是独立的功能模块,拥有自己的状态和逻辑,彼此之间高度解耦。这种设计使得每个模块都可以独立开发、测试和维护,不会因为一个模块的修改而影响其他模块。

5.2 底部 Tab 项构建器

@Builder tabItem(icon: string, label: string, tab: ExpenseTab, accent: string) {
  Column() {
    Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
    Text(label).fontSize(10)
      .fontColor(this.activeTab === tab ? accent : '#999999')
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
    if (this.activeTab === tab) {
      Column().width(20).height(3).backgroundColor(accent).borderRadius(2).margin({ top: 2 })
    }
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)
  .padding({ top: 5, bottom: 5 })
  .onClick(() => { this.activeTab = tab })
}

tabItem 构建器是底部导航栏单个 Tab 项的渲染逻辑,它接受四个参数:icon(Emoji 图标)、label(文字标签)、tab(对应的枚举值)和 accent(主题色)。

这个构建器的精妙之处在于,它通过三元运算符实现了选中态和未选中态的视觉差异:选中时图标完全不透明(opacity: 1.0),文字使用主题色且加粗;未选中时图标半透明(opacity: 0.4),文字使用灰色(#999999)且常规字重。此外,选中状态下还会在文字下方渲染一个 20x3 的圆角小条作为选中指示器,通过 if (this.activeTab === tab) 条件渲染实现——只有当前选中的 Tab 才显示这个指示器。

onClick 事件处理器将 this.activeTab 设置为传入的 tab 值,触发状态更新后,整个底部导航栏会自动重新渲染,被点击的 Tab 变为选中态,其余 Tab 变为未选中态。同时,contentArea() 中的条件渲染也会响应这次状态变化,切换显示对应的功能模块页面。这就是 ArkUI 响应式编程的核心——状态变化驱动 UI 更新,开发者只需修改状态,无需手动操作 UI。

四个 Tab 各自拥有不同的主题色:账单为深青绿(#00695C)、图表为红色(#C62828)、预算为橙色(#FF6F00)、我的为蓝色(#1565C0)。这种多色彩的 Tab 设计在视觉上区分了不同功能模块,增强了应用的辨识度。

5.3 主构建函数

build() {
  Column() {
    this.contentArea()
    Divider().color('#E0E0E0').strokeWidth(0.5)
    Row() {
      this.tabItem('🧾', '账单', ExpenseTab.BILL, '#00695C')
      this.tabItem('📊', '图表', ExpenseTab.CHART, '#C62828')
      this.tabItem('🎯', '预算', ExpenseTab.BUDGET, '#FF6F00')
      this.tabItem('👤', '我的', ExpenseTab.PROFILE, '#1565C0')
    }
    .width('100%').backgroundColor('#FFFFFF').padding({ top: 4, bottom: 6 })
  }
  .width('100%').height('100%').backgroundColor('#E0F2F1')
}

build() 函数是组件的入口构建函数,定义了整个页面的视觉结构。外层 Column 将页面分为两部分:上方是占据剩余空间的 contentArea() 内容区域,下方是分隔线和底部导航栏。

Divider 组件在内容区域和导航栏之间渲染了一条 0.5 像素宽的浅灰色分割线,起到了视觉分隔的作用。底部 Row 容器内横向排列了四个 tabItem,每个通过 .layoutWeight(1) 等分导航栏宽度,确保四个 Tab 在屏幕上均匀分布。整个页面的背景色设置为浅青绿色(#E0F2F1),与主色调 #00695C 形成同色系的层次感。


六、账单模块:交易管理的核心战场

6.1 组件状态定义

@Component
struct BillContent {
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State selectedTxn: Transaction | null = null
  @State formTitle: string = ''
  @State formAmount: string = ''
  @State formCat: string = '餐饮'
  @State formType: string = '支出'
  @State formNote: string = ''

BillContent 是账单功能模块的核心组件,也是整个应用中状态最丰富、逻辑最复杂的组件。它定义了 8 个 @State 状态变量,可以将其分为三组:

弹窗控制状态组showAddModalshowEditModalshowDeleteConfirm 三个布尔值分别控制新增弹窗、编辑弹窗和删除确认弹窗的显示与隐藏。这种用布尔状态控制弹窗可见性的模式是 ArkUI 中实现模态对话框的常见做法——当状态为 true 时渲染弹窗组件,为 false 时不渲染。

选中数据状态组selectedTxn 存储当前被选中进行编辑或删除操作的交易记录。类型为 Transaction | null,初始值为 null,表示没有选中任何记录。当用户点击某条交易记录的编辑或删除按钮时,该记录会被赋值给 selectedTxn,随后的弹窗通过读取 selectedTxn 的属性来展示对应的数据。

表单数据状态组formTitleformAmountformCatformTypeformNote 这五个变量分别存储新增/编辑表单中各个输入字段的值。其中 formCat 默认为"餐饮",formType 默认为"支出",这与 CAT_LIST 的第一项和支出的默认类型一致,为用户提供了合理的初始值。

6.2 模态背景构建器

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

modalBg 是一个可复用的构建器,用于渲染弹窗的半透明背景遮罩。它接受一个 onClose 回调函数作为参数,当用户点击遮罩区域时触发关闭操作。

这个构建器体现了 ArkUI @Builder 参数化构建器的重要特性——函数式 UI 构建。通过将 onClose 回调作为参数传入,同一个构建器可以被不同的弹窗复用,每个弹窗只需传入自己的关闭逻辑即可。半透明黑色背景(rgba(0,0,0,0.5))覆盖整个屏幕,既遮挡了底层内容的视觉干扰,又保留了淡淡的透影效果,营造出标准的模态对话框视觉体验。

点击遮罩关闭弹窗是移动端应用的交互惯例,用户无需刻意寻找关闭按钮,只需点击弹窗外的任意区域即可取消操作。这种设计极大地提升了用户体验的流畅性。

6.3 新增记账弹窗

@Builder addTxnModal() {
  Column() {
    this.modalBg(() => { this.showAddModal = false })
    Column() {
      Row() {
        Text('➕ 新增记账').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Row().layoutWeight(1)
        Text('✕').fontSize(20).fontColor('#999999').onClick(() => { this.showAddModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 10 })
      Divider().color('#F0F0F0')
      Scroll() {
        Column() {
          // 支出/收入切换按钮
          Row() {
            Text('支出').fontSize(13).fontColor(this.formType === '支出' ? '#FFFFFF' : '#C62828')
              .backgroundColor(this.formType === '支出' ? '#C62828' : '#FFEBEE')
              .padding({ left: 20, right: 20, top: 8, bottom: 8 }).borderRadius(20)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .onClick(() => { this.formType = '支出'; this.formCat = '餐饮' })
            Text('收入').fontSize(13).fontColor(this.formType === '收入' ? '#FFFFFF' : '#2E7D32')
              .backgroundColor(this.formType === '收入' ? '#2E7D32' : '#E8F5E9')
              .padding({ left: 20, right: 20, top: 8, bottom: 8 }).borderRadius(20)
              .layoutWeight(1).textAlign(TextAlign.Center).margin({ left: 8 })
              .onClick(() => { this.formType = '收入'; this.formCat = '工资' })
          }
          .margin({ left: 20, right: 20, top: 16 })
          // 金额输入
          Text('金额').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
          TextInput({ placeholder: '¥ 0.00' })
            .placeholderColor('#CCCCCC').fontSize(28).fontWeight(FontWeight.Bold)
            .fontColor(this.formType === '支出' ? '#C62828' : '#2E7D32')
            .backgroundColor('#F5F5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formAmount = v })
          // 分类选择
          Text('分类').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(this.formType === '支出' ? CAT_LIST : INCOME_LIST, (c: string) => {
              Text((EXPENSE_CATS[c]?.icon ?? '') + ' ' + c)
                .fontSize(11)
                .fontColor(this.formCat === c ? '#FFFFFF' : EXPENSE_CATS[c]?.color ?? '#666666')
                .backgroundColor(this.formCat === c ? EXPENSE_CATS[c]?.color : EXPENSE_CATS[c]?.bg)
                .padding({ left: 10, right: 10, top: 6, bottom: 6 })
                .borderRadius(16).margin({ left: 4, right: 4, top: 4 })
                .onClick(() => { this.formCat = c })
            })
          }
          .margin({ left: 16, right: 16, top: 6 })
          // 备注
          Text('备注').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
          TextInput({ placeholder: '添加备注...' })
            .placeholderColor('#BBBBBB').fontSize(14)
            .backgroundColor('#F5F5F5').borderRadius(10)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formTitle = v })
        }
        .padding({ bottom: 16 })
      }
      .constraintSize({ maxHeight: '55%' })
      // 操作按钮
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(22)
          .padding({ left: 28, right: 28, top: 11, bottom: 11 })
          .onClick(() => { this.showAddModal = false })
        Text('保存').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#00695C').borderRadius(22)
          .padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
          .onClick(() => { this.showAddModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 14, bottom: 16 })
    }
    .width('90%').backgroundColor('#FFFFFF').borderRadius(18)
    .alignItems(HorizontalAlign.Center).position({ x: '5%', y: '12%' })
    .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

这是整个应用中最复杂的构建器之一,它渲染了新增记账的完整表单弹窗。让我们分层解析其设计逻辑:

结构层面,弹窗由两层 Column 嵌套构成。外层 Column 占据全屏,内部先渲染 modalBg 半透明遮罩,再渲染白色的弹窗主体。弹窗主体通过 position({ x: '5%', y: '12%' }) 定位在屏幕上方 12% 的位置,宽度为屏幕的 90%,配合 borderRadius(18) 的圆角和 shadow 阴影效果,营造出悬浮于背景之上的卡片视觉。zIndex(999) 确保弹窗始终显示在最顶层。

表单内容层面,弹窗内部使用 Scroll 组件包裹表单内容,并设置 constraintSize({ maxHeight: '55%' }) 限制最大高度。这意味着当表单内容超出屏幕可见区域时,用户可以在弹窗内滚动查看所有字段,而不会导致弹窗本身超出屏幕边界。

交互逻辑层面,支出/收入切换按钮的设计尤为巧妙。当用户点击"支出"时,不仅将 formType 设为"支出",还将 formCat 重置为"餐饮"(支出的默认分类);点击"收入"时,formCat 重置为"工资"(收入的默认分类)。这种联动逻辑确保了分类选择器始终展示与当前交易类型匹配的类别列表——通过 this.formType === '支出' ? CAT_LIST : INCOME_LIST 这一三元表达式实现。

分类选择器使用了 Flex({ wrap: FlexWrap.Wrap }) 弹性布局容器,配合 ForEach 循环渲染分类标签。每个标签通过三元运算符判断是否为当前选中分类,选中时使用类别的主色(color)作为背景并显示白色文字,未选中时使用浅色背景(bg)并显示类别主色文字。FlexWrap.Wrap 确保了当分类标签数量较多时自动换行,不会溢出容器。

金额输入框的颜色也跟随交易类型动态变化——支出时为红色,收入时为绿色,这一细节通过 this.formType === '支出' ? '#C62828' : '#2E7D32' 实现,让用户在输入金额时就能直观感受到当前的操作类型。

6.4 编辑记录弹窗

@Builder editTxnModal() {
  Column() {
    this.modalBg(() => { this.showEditModal = false })
    Column() {
      Row() {
        Text('✏️ 编辑记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Row().layoutWeight(1)
        Text('✕').fontSize(20).fontColor('#999999').onClick(() => { this.showEditModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 10 })
      Divider().color('#F0F0F0')
      Column() {
        Text('金额').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
        TextInput({ placeholder: '¥ ' + (this.selectedTxn?.amount ?? 0).toString() })
          .placeholderColor('#CCCCCC').fontSize(22).fontWeight(FontWeight.Bold)
          .fontColor(TXN_TYPES[this.selectedTxn?.type ?? '支出']?.color ?? '#C62828')
          .backgroundColor('#F5F5F5').borderRadius(12)
          .margin({ left: 20, right: 20, top: 4 })
          .onChange((v: string) => { this.formAmount = v })
        Text('备注').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
        TextInput({ placeholder: this.selectedTxn?.note ?? '' })
          .placeholderColor('#BBBBBB').fontSize(14)
          .backgroundColor('#F5F5F5').borderRadius(10)
          .margin({ left: 20, right: 20, top: 4 })
          .onChange((v: string) => { this.formNote = v })
      }
      .layoutWeight(1)
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(22)
          .padding({ left: 28, right: 28, top: 11, bottom: 11 })
          .onClick(() => { this.showEditModal = false })
        Text('保存修改').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6F00').borderRadius(22)
          .padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
          .onClick(() => { this.showEditModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 14, bottom: 16 })
    }
    .width('90%').backgroundColor('#FFFFFF').borderRadius(18)
    .alignItems(HorizontalAlign.Center).position({ x: '5%', y: '20%' })
    .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

编辑弹窗的结构与新增弹窗类似,但有几个关键差异值得深入分析:

数据预填充:编辑弹窗的输入框 placeholder 使用了 this.selectedTxn 的属性值作为占位文本——金额输入框显示当前交易金额,备注输入框显示当前备注。这里使用了可选链操作符(?.)和空值合并操作符(??)来安全地访问 selectedTxn 的属性,因为 selectedTxn 的类型是 Transaction | null,在 TypeScript/ArkTS 中必须进行空值检查。

颜色动态映射:金额输入框的文字颜色通过 TXN_TYPES[this.selectedTxn?.type ?? '支出']?.color ?? '#C62828' 获取,即从交易类型配置表中读取对应的颜色值。这种通过配置表驱动颜色的方式,确保了编辑弹窗中的颜色表现与新增弹窗、列表项中的颜色表现完全一致。

保存按钮颜色:编辑弹窗的保存按钮使用了橙色(#FF6F00)而非新增弹窗的青绿色(#00695C),通过颜色的差异提示用户当前处于"编辑"而非"新增"的操作上下文中。这种细微的色彩区分体现了对用户认知的细致考虑。

弹窗位置:编辑弹窗的垂直位置(y: '20%')比新增弹窗(y: '12%')更低,这种位置差异同样是帮助用户区分当前操作类型的视觉线索。

6.5 删除确认弹窗

@Builder deleteModal() {
  Column() {
    this.modalBg(() => { this.showDeleteConfirm = false })
    Column() {
      Text('⚠️').fontSize(48).margin({ top: 24 })
      Text('确认删除此记录?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
      Row() {
        Text(EXPENSE_CATS[this.selectedTxn?.category ?? '餐饮']?.icon ?? '💰').fontSize(24)
        Text((this.selectedTxn?.title ?? '')).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333').margin({ left: 8 })
        Text('¥' + (this.selectedTxn?.amount ?? 0).toString()).fontSize(14).fontWeight(FontWeight.Bold)
          .fontColor(TXN_TYPES[this.selectedTxn?.type ?? '支出']?.color ?? '#C62828').margin({ left: 8 })
      }
      .backgroundColor('#FFF5F5').borderRadius(12)
      .padding({ left: 16, right: 16, top: 12, bottom: 12 }).margin({ top: 16 })
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(22)
          .padding({ left: 28, right: 28, top: 11, bottom: 11 })
          .onClick(() => { this.showDeleteConfirm = false })
        Text('确认删除').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#F44336').borderRadius(22)
          .padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
          .onClick(() => { this.showDeleteConfirm = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 20, bottom: 20 })
    }
    .width('80%').backgroundColor('#FFFFFF').borderRadius(18)
    .alignItems(HorizontalAlign.Center).position({ x: '10%', y: '38%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

删除确认弹窗采用了居中对话框的设计风格,与新增和编辑弹窗在屏幕上方的布局形成对比。弹窗顶部展示一个 48 号字号的警告图标(⚠️),下方是确认提示文字,再下方是一个浅红色背景(#FFF5F5)的信息卡片,展示待删除交易记录的图标、标题和金额。

信息预览卡片是删除确认弹窗的设计亮点。在用户确认删除之前,弹窗清晰地展示了即将被删除的交易记录的关键信息,让用户进行二次确认。卡片中金额的颜色同样通过 TXN_TYPES 配置表动态获取,保持了全应用的颜色一致性。

按钮配色方面,"确认删除"按钮使用了醒目的红色(#F44336),与"取消"按钮的浅灰色形成强烈对比,这种危险操作的红色标识是 UI 设计中的通用规范,能够有效降低用户的误操作风险。

6.6 交易列表项构建器

@Builder txnItem(t: Transaction) {
  Row() {
    Column() {
      Text(EXPENSE_CATS[t.category]?.icon ?? '💰').fontSize(24)
    }
    .width(44).height(44)
    .backgroundColor(EXPENSE_CATS[t.category]?.bg ?? '#F5F5F5')
    .borderRadius(12)
    .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
    Column() {
      Text(t.title).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#212121')
      Text(t.date + ' · ' + t.merchant).fontSize(10).fontColor('#999999').margin({ top: 2 })
      Text(t.account + ' · ' + t.time).fontSize(9).fontColor('#CCCCCC').margin({ top: 1 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
    Column() {
      Text((t.type === '收入' ? '+' : '-') + '¥' + t.amount.toString())
        .fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(TXN_TYPES[t.type]?.color ?? '#C62828')
      Row() {
        Text('✏️').fontSize(12).fontColor('#00695C')
          .onClick(() => { this.selectedTxn = t; this.showEditModal = true })
        Text('🗑️').fontSize(12).fontColor('#F44336').margin({ left: 8 })
          .onClick(() => { this.selectedTxn = t; this.showDeleteConfirm = true })
      }
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF')
  .borderRadius(12).margin({ left: 12, right: 12, top: 5 })
  .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
}

txnItem 构建器负责渲染单条交易记录的列表项,它是账单页面中出现频率最高的 UI 单元。列表项采用 Row 水平布局,从左到右依次为:类别图标区、信息区、金额与操作区。

类别图标区:一个 44x44 的圆角方块,背景色为该类别的浅色背景(bg),内部居中显示该类别的 Emoji 图标。通过 EXPENSE_CATS[t.category]?.icon 获取图标,?.bg 获取背景色,这种从配置表读取的方式确保了图标和颜色的一致性。?? '💰'?? '#F5F5F5' 作为默认值,处理了类别不存在于配置表中的边界情况。

信息区:使用 layoutWeight(1) 占据中间所有剩余空间,垂直排列三个文本——标题(14 号字、中等字重、深色)、日期和商户(10 号字、灰色)、账户和时间(9 号字、浅灰色)。三个文本通过不同的字号和颜色形成了清晰的视觉层次,主标题最突出,辅助信息逐渐弱化,符合信息架构的优先级原则。

金额与操作区:金额显示在右侧,使用 TXN_TYPES[t.type]?.color 获取颜色(支出为红、收入为绿),金额前根据类型显示"+“或”-"号,让用户一眼就能区分收入和支出。金额下方是编辑(✏️)和删除(🗑️)两个操作按钮,分别绑定不同的 onClick 事件。点击编辑按钮时,将当前交易记录 t 赋值给 this.selectedTxn 并显示编辑弹窗;点击删除按钮时,同样赋值 selectedTxn 并显示删除确认弹窗。这种数据传递模式——在点击时将数据赋给状态变量——是 ArkUI 中实现列表项操作的标准做法。

列表项的整体视觉采用白色卡片风格,配合 12 像素圆角和轻微阴影(radius: 2),营造出 Material Design 卡片的悬浮感。

6.7 账单页面主构建函数

build() {
  Stack() {
    Column() {
      // 月度收支概览卡片
      Column() {
        Text('7月').fontSize(12).fontColor('#888888')
        Row() {
          Column() {
            Text('收入').fontSize(10).fontColor('#2E7D32')
            Text('¥' + getMonthlyIncome().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
          }.alignItems(HorizontalAlign.Start)
          Column().layoutWeight(1)
          Column() {
            Text('支出').fontSize(10).fontColor('#C62828')
            Text('¥' + getMonthlyExpense().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C62828')
          }.alignItems(HorizontalAlign.End)
        }
        .width('100%').margin({ top: 4 })
        Row() {
          Column() {
            Text('结余 ¥' + getMonthlyBalance().toString()).fontSize(12).fontColor('#00695C').fontWeight(FontWeight.Bold)
          }
          Column().layoutWeight(1)
          Text('+').fontSize(24).fontColor('#FFFFFF')
            .backgroundColor('#00695C').width(36).height(36).borderRadius(18)
            .textAlign(TextAlign.Center)
            .onClick(() => { this.showAddModal = true })
        }
        .width('100%').margin({ top: 8 })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12, top: 10 }).padding(16)
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

      Text('📋 交易明细').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
        .width('100%').padding({ left: 16, top: 14, bottom: 4 })
      Scroll() {
        Column() {
          this.txnItem(mockTransactions[0])
          this.txnItem(mockTransactions[1])
          // ... 共30条
          this.txnItem(mockTransactions[29])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
    if (this.showAddModal) { this.addTxnModal() }
    if (this.showEditModal) { this.editTxnModal() }
    if (this.showDeleteConfirm) { this.deleteModal() }
  }
  .width('100%').height('100%')
}

账单页面的 build() 函数使用 Stack 层叠布局作为根容器,这是实现弹窗叠加效果的关键。Stack 内部包含一个全屏的 Column(页面主体内容)和三个条件渲染的弹窗构建器。

月度收支概览卡片位于页面顶部,是一个白色圆角卡片,展示当月的收入、支出和结余数据。卡片采用左右对称布局——左侧为绿色收入,右侧为红色支出,中间通过 Column().layoutWeight(1) 撑开间距。卡片底部一行展示结余金额和新增按钮("+"号圆形按钮),点击新增按钮触发 showAddModal = true,弹出新增记账弹窗。

交易明细列表使用 Scroll 组件包裹,内部通过逐条调用 this.txnItem(mockTransactions[n]) 渲染 30 条交易记录。scrollBar(BarState.Off) 隐藏了滚动条,提供更干净的视觉体验。layoutWeight(1) 确保列表区域占据概览卡片下方的所有剩余空间。

弹窗的层叠渲染Stack 布局的核心价值。三个 if 条件语句分别检查 showAddModalshowEditModalshowDeleteConfirm 状态,当某个状态为 true 时,对应的弹窗构建器会被调用并渲染在 Stack 的最上层(由于后渲染的子组件在 Stack 中默认叠加在上方),从而实现了弹窗覆盖在页面内容之上的效果。这种通过状态控制弹窗显隐的模式简洁而有效,无需引入额外的模态管理机制。


七、图表模块:数据可视化的实现艺术

7.1 月度收支趋势柱状图

@Component
struct ChartContent {
  build() {
    Column() {
      Text('📊 数据分析').fontSize(18).fontWeight(FontWeight.Bold)
        .width('100%').padding({ left: 16, top: 14, bottom: 8 })
      Scroll() {
        Column() {
          Column() {
            Text('📈 月度收支趋势').fontSize(13).fontWeight(FontWeight.Bold)
              .width('100%').padding({ left: 16, top: 14, bottom: 10 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (m: number) => {
                Column() {
                  Text((MONTH_BAR[m] / 1000).toFixed(1) + 'k').fontSize(7).fontColor('#00695C')
                  Column()
                    .width(16)
                    .height((MONTH_BAR[m] / 5200 * 90).toFixed(0) + 'vp')
                    .backgroundColor(m === 3 ? '#C62828' : '#80CBC4')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                  Text(MONTH_LABELS[m]).fontSize(7).fontColor('#999999').margin({ top: 2 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 8, right: 8, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 6 })
          .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

ChartContent 组件负责整个数据分析和可视化模块的渲染。与 BillContent 不同,这个组件没有定义任何 @State 状态变量,因为它展示的是纯静态的图表数据,不涉及用户交互的状态变化。

月度趋势柱状图是图表模块的第一个可视化组件。它通过 ForEach 遍历 0 到 11 的索引数组,为每个月渲染一个柱子。每个柱子的结构包含三部分:顶部数值标签、中间柱体、底部月份标签。

柱体高度的计算逻辑是本段的精髓所在:(MONTH_BAR[m] / 5200 * 90).toFixed(0) + 'vp'。这里以 5200(12 个月中的最大值)作为基准值,将当月数据除以基准值得到比例,再乘以 90(最大高度,单位 vp)得到实际渲染高度。这种比例映射的方法确保了所有柱子的高度都在 0 到 90vp 之间,最高的柱子(4 月,5200)刚好达到 90vp,其他柱子按比例缩短。

m === 3(即 4 月)的柱子使用红色(#C62828)突出显示,其余使用浅青绿色(#80CBC4)。这种异常值高亮的设计引导用户关注数据中的峰值,增强了图表的信息传达效率。柱子顶部使用 borderRadius({ topLeft: 3, topRight: 3 }) 实现了圆角顶部的效果,使图表更加精致美观。

值得注意的是,柱子高度单位使用了 vp(virtual pixel,虚拟像素),这是 HarmonyOS 中的相对长度单位。vp 会根据屏幕密度自动缩放,确保在不同分辨率的设备上显示效果一致。将数值转换为字符串并拼接 'vp' 后缀,是 ArkUI 中设置尺寸为动态计算值的常见做法。

7.2 支出分类占比图

          Column() {
            Text('🥧 支出分类占比').fontSize(13).fontWeight(FontWeight.Bold)
              .width('100%').padding({ left: 16, top: 14, bottom: 8 })
            Column() {
              Row() { Text('🏠 居住').fontSize(11).fontColor('#5D4037').layoutWeight(1); Text('¥3706 · 49%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('49%').height(7).backgroundColor('#5D4037').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('🍔 餐饮').fontSize(11).fontColor('#FF6F00').layoutWeight(1); Text('¥1280 · 17%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('17%').height(7).backgroundColor('#FF6F00').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              // ... 其余类别
              Row() { Text('💊 医疗').fontSize(11).fontColor('#C62828').layoutWeight(1); Text('¥625 · 8%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('8%').height(7).backgroundColor('#C62828').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4 })
            }
            .padding({ left: 16, right: 16, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

支出分类占比图采用了水平条形图的形式展示各消费类别的支出占比。每个类别由两行组成:第一行是类别名称和金额百分比,第二行是一个进度条样式的彩色横条。

进度条的实现方式非常巧妙:使用 Column().width('49%').height(7) 作为彩色条,再用 Row().layoutWeight(1) 填充剩余空间。彩色条的宽度直接使用百分比字符串(如 '49%''17%'),这种写法利用了 ArkUI 对百分比宽度字符串的原生支持,简洁地实现了按比例渲染的效果。条形高度统一为 7vp,配合 4vp 的圆角,视觉效果精致而统一。

每个类别的颜色都从 EXPENSE_CATS 配置表对应的 color 值获取(虽然在这个硬编码的实现中直接写了颜色值,但与配置表中的值完全一致),确保了颜色在整个应用中的一致性。这种手动编码但保持一致的做法在演示项目中是可接受的,但在生产环境中应该通过 EXPENSE_CATS[category].color 动态获取。

7.3 收支概览卡片与近七日趋势图

          Row() {
            Column() {
              Text('💰').fontSize(20)
              Text('¥26100').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#2E7D32').margin({ top: 4 })
              Text('本月收入').fontSize(10).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            .backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 6, right: 3, top: 8 })
            Column() {
              Text('💸').fontSize(20)
              Text('¥7569').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C62828').margin({ top: 4 })
              Text('本月支出').fontSize(10).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            .backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 3, right: 6, top: 8 })
          }
          .width('100%')

          Column() {
            Text('📅 近7日趋势').fontSize(13).fontWeight(FontWeight.Bold)
              .width('100%').padding({ left: 16, top: 14, bottom: 10 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
                Column() {
                  Column()
                    .width(22)
                    .height(([120, 80, 45, 168, 35, 12, 28][d] / 168 * 70).toFixed(0) + 'vp')
                    .backgroundColor(d === 3 ? '#C62828' : '#80CBC4')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                  Text(['0713', '0714', '0715', '0716', '0717', '0718', '0719'][d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 12, right: 12, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

收支概览卡片使用 Row 横向排列两个等宽的统计卡片(通过 layoutWeight(1) 等分),分别展示本月收入和本月支出。两个卡片之间通过 margin({ left: 6, right: 3 })margin({ left: 3, right: 6 }) 的细微间距实现了视觉分隔。

近七日趋势图的结构与月度趋势柱状图几乎相同,但数据维度从 12 个月缩减为 7 天。柱体高度的计算逻辑完全一致:以最大值 168 为基准,将每日数据除以 168 再乘以 70(最大高度)。第 4 天(0716,168 元)被高亮为红色,引导用户关注这天的异常高消费。

这些图表组件共同展示了 ArkUI 在数据可视化方面的能力——虽然 ArkUI 内置组件没有直接的"图表"组件,但通过 ColumnRowForEach 的巧妙组合,开发者可以构建出柱状图、条形图等多种图表形式。对于更复杂的图表需求(如饼图、折线图),HarmonyOS 生态中也有第三方的图表库可供使用,但对于简单的数据展示,这种手动构建的方式更加轻量和可控。


八、预算模块:智能监控与预警系统

8.1 预算进度条构建器

@Component
struct BudgetContent {
  @Builder budgetBar(b: BudgetItem) {
    Column() {
      Row() {
        Text(EXPENSE_CATS[b.category]?.icon ?? '💰').fontSize(20)
        Text(b.category).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#212121').margin({ left: 8 }).layoutWeight(1)
        Text('¥' + b.spent + ' / ¥' + b.budget).fontSize(11).fontColor('#888888')
      }
      .width('100%')
      Row() {
        Column()
          .width((Math.min(b.spent / b.budget, 1.0) * 100).toFixed(0) + '%')
          .height(8)
          .backgroundColor(b.spent > b.budget ? '#F44336' : (b.spent / b.budget > 0.8 ? '#FF9800' : EXPENSE_CATS[b.category]?.color ?? '#00695C'))
          .borderRadius(4)
        Row().layoutWeight(1)
      }
      .width('100%').height(8).backgroundColor('#F0F0F0').borderRadius(4).margin({ top: 8 })
      Row() {
        Text('剩余 ¥' + (b.budget - b.spent > 0 ? (b.budget - b.spent) : 0).toString()).fontSize(10)
          .fontColor(b.spent > b.budget ? '#F44336' : '#4CAF50')
        Row().layoutWeight(1)
        Text(b.spent > b.budget ? '⚠️ 超支' : ((b.spent / b.budget * 100).toFixed(0) + '%')).fontSize(10)
          .fontColor(b.spent > b.budget ? '#F44336' : '#888888')
      }
      .width('100%').margin({ top: 6 })
    }
    .width('100%').padding(14).backgroundColor('#FFFFFF')
    .borderRadius(12).margin({ left: 12, right: 12, top: 6 })
    .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
  }

BudgetContent 组件负责预算管理模块的渲染,核心是 budgetBar 构建器,它为每个消费类别渲染一个预算进度卡片。

进度条宽度计算使用了 Math.min(b.spent / b.budget, 1.0) * 100 这一表达式。Math.min 的作用是将比例限制在 1.0 以内——当花费超过预算时(如医疗类别,625/500 = 1.25),进度条不会超出 100%,而是封顶在满格状态。这种设计避免了进度条溢出容器边界导致的视觉异常。

三级颜色预警系统是预算模块最精彩的设计:

  • 正常状态b.spent / b.budget <= 0.8):进度条使用类别自身的主色(从 EXPENSE_CATS 配置表获取),"剩余"文字为绿色(#4CAF50),显示使用百分比。
  • 接近预警0.8 < b.spent / b.budget <= 1.0):进度条变为橙色(#FF9800),提示用户预算即将用尽。
  • 超支状态b.spent > b.budget):进度条变为红色(#F44336),"剩余"文字变为红色并显示 0,右侧显示"⚠️ 超支"警告标识。

这种三级预警机制通过颜色和文字的双重变化,直观地向用户传达了预算使用的健康状态。80% 作为预警阈值是财务管理应用中的常见设定——在这个临界点提醒用户注意控制消费,还有 20% 的缓冲空间进行调整。

剩余金额计算中,b.budget - b.spent > 0 ? (b.budget - b.spent) : 0 确保了超支时显示 0 而非负数,避免了在界面上出现"-¥125"这样的负数金额,这种细节处理体现了对用户体验的细致考量。

8.2 预算页面主构建函数

  build() {
    Column() {
      Text('🎯 预算管理').fontSize(18).fontWeight(FontWeight.Bold)
        .width('100%').padding({ left: 16, top: 14, bottom: 8 })
      Column() {
        Row() {
          Column() {
            Text('¥7569').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#C62828')
            Text('已使用').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('¥9900').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#00695C')
            Text('总预算').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('¥2331').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
            Text('剩余').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ top: 14, bottom: 14 })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12 })
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })
      Scroll() {
        Column() {
          this.budgetBar(mockBudgets[0])
          this.budgetBar(mockBudgets[1])
          this.budgetBar(mockBudgets[2])
          this.budgetBar(mockBudgets[3])
          this.budgetBar(mockBudgets[4])
          this.budgetBar(mockBudgets[5])
          this.budgetBar(mockBudgets[6])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

预算页面的 build() 函数分为三个部分:页面标题、总预算概览卡片和预算明细列表。

总预算概览卡片横向排列三个等宽的统计区域——已使用、总预算、剩余,分别使用红色、青绿色和绿色标识。这三个数值之间的关系是:总预算 = 已使用 + 剩余(9900 = 7569 + 2331),数据的一致性验证了逻辑的正确性。

预算明细列表使用 Scroll 组件包裹 7 个 budgetBar 卡片,每个卡片对应一个支出类别的预算情况。与账单列表一样,这里也是逐条硬编码调用而非使用 ForEach 循环,在实际应用中应改为动态渲染以支持预算项的增删。


九、个人中心模块:用户信息与资产管理

9.1 个人中心构建函数

@Component
struct ExpenseProfileContent {
  build() {
    Column() {
      // 用户信息卡片
      Column() {
        Row() {
          Column() {
            Text('💳').fontSize(40)
          }
          .width(64).height(64).backgroundColor('#E0F2F1').borderRadius(32)
          .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
          Column() {
            Text('理财小能手').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
            Text('已记账 ' + getTxnCount() + ' 笔').fontSize(11).fontColor('#888888').margin({ top: 3 })
            Text('本月结余 ¥' + getMonthlyBalance().toString()).fontSize(10).fontColor('#00695C').margin({ top: 2 })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
        }
        .width('100%').padding(16)
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12, top: 10 })
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

      // 我的账户列表
      Column() {
        Text('🏦 我的账户').fontSize(13).fontWeight(FontWeight.Bold)
          .width('100%').padding({ left: 16, top: 14, bottom: 8 })
        Column() {
          Row() { Text('🏦').fontSize(18); Text('招商银行').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥45,230').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
          Divider().color('#F0F0F0')
          Row() { Text('💰').fontSize(18); Text('支付宝').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥8,560').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
          Divider().color('#F0F0F0')
          Row() { Text('💚').fontSize(18); Text('微信钱包').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥1,280').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
          Divider().color('#F0F0F0')
          Row() { Text('📈').fontSize(18); Text('蚂蚁财富').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥52,000').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
        }
        .padding({ left: 16, right: 16 })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12, top: 8 })
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })
      Text('v2.0 · 财务记账 · 2026').fontSize(10).fontColor('#CCCCCC')
        .margin({ top: 16, bottom: 16 })
    }
    .width('100%').height('100%')
  }
}

ExpenseProfileContent 组件负责个人中心模块的渲染。与 ChartContent 类似,这个组件也没有定义任何 @State 状态变量,因为个人中心展示的是只读的静态信息,不涉及交互状态的变化。

用户信息卡片位于页面顶部,左侧是一个 64x64 的圆形头像区域(使用浅青绿色背景和💳信用卡图标),右侧是用户信息——昵称"理财小能手"、已记账笔数和本月结余。已记账笔数通过 getTxnCount() 获取,本月结余通过 getMonthlyBalance() 获取,复用了之前定义的辅助函数。

账户列表是个人中心的核心内容,展示了用户在四个不同平台的资产分布:招商银行(¥45,230)、支付宝(¥8,560)、微信钱包(¥1,280)和蚂蚁财富(¥52,000)。每行账户信息由图标、名称、余额和右箭头()组成,右箭头暗示了可点击进入详情页的交互可能性(虽然当前实现中未绑定 onClick 事件)。

行与行之间使用 Divider 组件渲染分隔线,这种设计模式在 iOS 和 Android 的列表中非常常见,它通过细分割线将相关内容分组,既保持了视觉上的关联性,又区分了不同条目。

页面底部的版本信息"v2.0 · 财务记账 · 2026"使用 10 号浅灰色字体居中显示,这种低调的版本标识是应用底部常见的装饰元素。


十、模块对比总结

下表对本应用各功能模块的关键技术点进行横向对比总结:

对比维度账单模块 (Bill)图表模块 (Chart)预算模块 (Budget)个人中心 (Profile)
组件名称BillContentChartContentBudgetContentExpenseProfileContent
核心功能交易流水的展示、新增、编辑、删除收支趋势、分类占比、近7日趋势的可视化各类别预算的进度追踪与超支预警用户信息与多平台资产展示
状态管理8个 @State 变量(弹窗控制3个、选中数据1个、表单数据4个)无状态变量,纯静态展示无状态变量,纯静态展示无状态变量,纯静态展示
关键组件StackScrollTextInputFlexDividerScrollForEachRowColumnScrollColumnRowDividerColumnRowDivider
构建器数量5个(modalBgaddTxnModaleditTxnModaldeleteModaltxnItem0个(全部内联在 build 中)1个(budgetBar0个(全部内联在 build 中)
数据来源mockTransactions 数组 + 辅助函数MONTH_BARMONTH_LABELS 常量 + 硬编码值mockBudgets 数组 + EXPENSE_CATS 配置表辅助函数 + 硬编码值
设计模式模态对话框模式(Stack层叠 + 状态控制显隐)数据驱动渲染模式(ForEach + 比例计算)配置驱动 + 三级预警模式静态信息卡片模式
交互复杂度高(新增/编辑/删除弹窗 + 表单输入 + 类型联动)低(仅滚动浏览)中(进度条动态颜色 + 超支预警)低(仅信息展示)
视觉特色弹窗层叠、表单动态配色、卡片列表柱状图、条形图、比例映射、异常值高亮进度条、三级颜色预警(正常/接近/超支)圆形头像、账户列表、分隔线
色彩应用支出红/收入绿动态切换、分类配色柱状图双色(青绿+红高亮)、分类多色三级预警色(类别色/橙/红)青绿色品牌色统一标识
可复用性modalBg 参数化构建器、txnItem 列表项模板ForEach 遍历模式可复用budgetBar 进度条模板可复用列表行结构可抽象为构建器
扩展方向接入数据库、ForEach动态渲染、表单验证引入图表库、支持时间范围筛选预算编辑功能、阈值自定义账户详情页、资产管理趋势图

技术亮点综合评述

纵观整个应用的源码,我们可以提炼出以下几个核心技术亮点:

第一,配置驱动的设计哲学EXPENSE_CATSTXN_TYPES 两个配置表贯穿了应用的各个模块,从分类选择器的颜色到列表项的图标,从金额的颜色到预算进度条的配色,所有视觉表现都由配置数据统一驱动。这种设计使得应用的视觉风格高度一致,且修改成本极低。

第二,Stack 层叠布局实现模态弹窗。账单模块通过 Stack 配合 @State 布尔变量,优雅地实现了三种弹窗(新增、编辑、删除)的显示与隐藏。这种模式无需引入额外的模态管理框架,纯靠 ArkUI 的原生能力即可实现,简洁而高效。

第三,三级预警的颜色系统。预算模块通过 Math.min 封顶、比例阈值判断和颜色三元运算,构建了一套完整的预算健康度视觉反馈系统。正常(类别色)、接近预警(橙色)、超支(红色)三级状态一目了然,极大地提升了用户对预算使用情况的感知效率。

第四,比例映射的图表渲染思路。图表模块虽然使用了手动构建柱状图和条形图的原始方式,但其"数据值 / 最大值 * 最大高度"的比例映射算法,以及异常值红色高亮的设计,体现了数据可视化的核心思想。这种轻量级的图表实现方式,在数据量不大、图表类型简单的场景下,比引入第三方图表库更加高效。

第五,组件化的模块拆分策略。整个应用由一个入口组件和四个功能子组件构成,每个子组件拥有独立的状态空间和构建逻辑,通过 @Builder 构建器实现 UI 片段的复用。这种架构使得每个功能模块都可以独立开发、测试和迭代,符合高内聚低耦合的软件工程原则。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 117.ets - 财务记账 Expense Tracker
// Teal (#00695C) | Red (#C62828) | Green (#2E7D32) | Bg (#E0F2F1)

// ==================== Interfaces ====================
interface ExpenseCategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface TransactionTypeMeta {
  label: string
  color: string
  isIncome: boolean
}

interface Transaction {
  id: number
  title: string
  category: string
  type: string
  amount: number
  date: string
  time: string
  note: string
  account: string
  merchant: string
}

interface BudgetItem {
  category: string
  budget: number
  spent: number
}

// ==================== Config ====================
const EXPENSE_CATS: Record<string, ExpenseCategoryMeta> = {
  '餐饮': { label: '餐饮', icon: '🍔', color: '#FF6F00', bg: '#FFF3E0' },
  '交通': { label: '交通', icon: '🚗', color: '#1565C0', bg: '#E3F2FD' },
  '购物': { label: '购物', icon: '🛍️', color: '#AD1457', bg: '#FCE4EC' },
  '娱乐': { label: '娱乐', icon: '🎮', color: '#7B1FA2', bg: '#F3E5F5' },
  '居住': { label: '居住', icon: '🏠', color: '#5D4037', bg: '#EFEBE9' },
  '医疗': { label: '医疗', icon: '💊', color: '#C62828', bg: '#FFEBEE' },
  '教育': { label: '教育', icon: '📚', color: '#00695C', bg: '#E0F2F1' },
  '工资': { label: '工资', icon: '💰', color: '#2E7D32', bg: '#E8F5E9' },
  '理财': { label: '理财', icon: '📈', color: '#1565C0', bg: '#E3F2FD' }
}

const TXN_TYPES: Record<string, TransactionTypeMeta> = {
  '支出': { label: '支出', color: '#C62828', isIncome: false },
  '收入': { label: '收入', color: '#2E7D32', isIncome: true }
}

const CAT_LIST: string[] = ['餐饮', '交通', '购物', '娱乐', '居住', '医疗', '教育']
const INCOME_LIST: string[] = ['工资', '理财']
const MONTH_BAR: number[] = [3200, 4100, 2800, 5200, 3800, 4500, 3100, 4900, 3600, 4200, 3500, 4800]
const MONTH_LABELS: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']

// ==================== Data ====================
const mockTransactions: Transaction[] = [
  { id: 1, title: '星巴克拿铁', category: '餐饮', type: '支出', amount: 35, date: '2026-07-19', time: '08:30', note: '早餐咖啡', account: '微信支付', merchant: '星巴克旗舰店' },
  { id: 2, title: '地铁通勤', category: '交通', type: '支出', amount: 12, date: '2026-07-19', time: '09:15', note: '上班地铁往返', account: '交通卡', merchant: '地铁公司' },
  { id: 3, title: '午餐外卖', category: '餐饮', type: '支出', amount: 28, date: '2026-07-19', time: '12:00', note: '黄焖鸡米饭', account: '支付宝', merchant: '美团外卖' },
  { id: 4, title: '月度工资', category: '工资', type: '收入', amount: 15000, date: '2026-07-19', time: '10:00', note: '7月工资到账', account: '招商银行', merchant: '公司财务' },
  { id: 5, title: '超市购物', category: '购物', type: '支出', amount: 186, date: '2026-07-18', time: '19:30', note: '日用品+零食', account: '信用卡', merchant: '沃尔玛超市' },
  { id: 6, title: '电影票', category: '娱乐', type: '支出', amount: 80, date: '2026-07-18', time: '20:00', note: 'IMAX双人票', account: '微信支付', merchant: '万达影城' },
  { id: 7, title: '滴滴打车', category: '交通', type: '支出', amount: 32, date: '2026-07-18', time: '22:30', note: '回家打车', account: '支付宝', merchant: '滴滴出行' },
  { id: 8, title: '早餐包子', category: '餐饮', type: '支出', amount: 8, date: '2026-07-18', time: '07:30', note: '两个肉包一杯豆浆', account: '微信支付', merchant: '巴比馒头' },
  { id: 9, title: '理财收益', category: '理财', type: '收入', amount: 320, date: '2026-07-18', time: '09:00', note: '基金分红', account: '蚂蚁财富', merchant: '天弘基金' },
  { id: 10, title: '房租', category: '居住', type: '支出', amount: 3500, date: '2026-07-17', time: '10:00', note: '7月房租', account: '招商银行', merchant: '房东' },
  { id: 11, title: '水电费', category: '居住', type: '支出', amount: 156, date: '2026-07-17', time: '11:00', note: '电费+水费+燃气费', account: '支付宝', merchant: '供电局' },
  { id: 12, title: '健身房月卡', category: '娱乐', type: '支出', amount: 299, date: '2026-07-16', time: '15:00', note: '健身房月度会员', account: '信用卡', merchant: '威尔仕健身' },
  { id: 13, title: '午餐拉面', category: '餐饮', type: '支出', amount: 25, date: '2026-07-16', time: '12:30', note: '味千拉面', account: '微信支付', merchant: '味千拉面' },
  { id: 14, title: '买书', category: '教育', type: '支出', amount: 128, date: '2026-07-16', time: '20:00', note: '三本技术书', account: '支付宝', merchant: '当当网' },
  { id: 15, title: '感冒药', category: '医疗', type: '支出', amount: 45, date: '2026-07-15', time: '14:00', note: '感冒灵+退烧药', account: '微信支付', merchant: '大参林药房' },
  { id: 16, title: '晚餐火锅', category: '餐饮', type: '支出', amount: 168, date: '2026-07-15', time: '19:00', note: '四人聚餐AA', account: '支付宝', merchant: '海底捞火锅' },
  { id: 17, title: '公交卡充值', category: '交通', type: '支出', amount: 100, date: '2026-07-15', time: '08:00', note: '充值100元', account: '现金', merchant: '便利店' },
  { id: 18, title: '理财收益', category: '理财', type: '收入', amount: 280, date: '2026-07-15', time: '09:00', note: '债券基金收益', account: '蚂蚁财富', merchant: '华夏基金' },
  { id: 19, title: '购物衣服', category: '购物', type: '支出', amount: 399, date: '2026-07-14', time: '16:00', note: '夏季短袖两件', account: '信用卡', merchant: '优衣库' },
  { id: 20, title: '咖啡', category: '餐饮', type: '支出', amount: 30, date: '2026-07-14', time: '15:00', note: '下午茶瑞幸', account: '微信支付', merchant: '瑞幸咖啡' },
  { id: 21, title: '在线课程', category: '教育', type: '支出', amount: 199, date: '2026-07-14', time: '21:00', note: 'Python进阶课', account: '支付宝', merchant: '极客时间' },
  { id: 22, title: '外卖晚餐', category: '餐饮', type: '支出', amount: 35, date: '2026-07-13', time: '19:30', note: '麻辣香锅', account: '微信支付', merchant: '饿了么' },
  { id: 23, title: '打车', category: '交通', type: '支出', amount: 45, date: '2026-07-13', time: '23:00', note: '深夜打车回家', account: '支付宝', merchant: '滴滴出行' },
  { id: 24, title: '体检费', category: '医疗', type: '支出', amount: 580, date: '2026-07-13', time: '09:00', note: '年度体检套餐', account: '信用卡', merchant: '市第一医院' },
  { id: 25, title: '零食水果', category: '餐饮', type: '支出', amount: 52, date: '2026-07-12', time: '18:00', note: '水果+零食', account: '微信支付', merchant: '百果园' },
  { id: 26, title: '话费充值', category: '居住', type: '支出', amount: 50, date: '2026-07-12', time: '10:00', note: '手机话费', account: '支付宝', merchant: '中国移动' },
  { id: 27, title: '游戏充值', category: '娱乐', type: '支出', amount: 68, date: '2026-07-12', time: '22:00', note: '月卡+皮肤', account: '微信支付', merchant: '腾讯游戏' },
  { id: 28, title: '理财赎回', category: '理财', type: '收入', amount: 10500, date: '2026-07-11', time: '14:00', note: '定期到期赎回', account: '招商银行', merchant: '招商银行' },
  { id: 29, title: '午餐套餐', category: '餐饮', type: '支出', amount: 22, date: '2026-07-11', time: '12:00', note: '公司楼下快餐', account: '微信支付', merchant: '真功夫' },
  { id: 30, title: '加油', category: '交通', type: '支出', amount: 350, date: '2026-07-11', time: '08:00', note: '95号汽油加满', account: '信用卡', merchant: '中石化加油站' }
]

const mockBudgets: BudgetItem[] = [
  { category: '餐饮', budget: 2000, spent: 1280 },
  { category: '交通', budget: 800, spent: 539 },
  { category: '购物', budget: 1500, spent: 585 },
  { category: '娱乐', budget: 600, spent: 447 },
  { category: '居住', budget: 4000, spent: 3706 },
  { category: '医疗', budget: 500, spent: 625 },
  { category: '教育', budget: 500, spent: 327 }
]

function getMonthlyIncome(): number { return 26100 }
function getMonthlyExpense(): number { return 7569 }
function getMonthlyBalance(): number { return 18531 }
function getTxnCount(): number { return 30 }

// ==================== Tab Enum ====================
enum ExpenseTab {
  BILL = 0,
  CHART = 1,
  BUDGET = 2,
  PROFILE = 3
}

// ==================== Entry ====================
@Entry
@Component
struct ExpenseApp {
  @State activeTab: ExpenseTab = ExpenseTab.BILL

  @Builder contentArea() {
    Column() {
      if (this.activeTab === ExpenseTab.BILL) {
        BillContent()
      } else if (this.activeTab === ExpenseTab.CHART) {
        ChartContent()
      } else if (this.activeTab === ExpenseTab.BUDGET) {
        BudgetContent()
      } else {
        ExpenseProfileContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder tabItem(icon: string, label: string, tab: ExpenseTab, accent: string) {
    Column() {
      Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
      Text(label).fontSize(10)
        .fontColor(this.activeTab === tab ? accent : '#999999')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
      if (this.activeTab === tab) {
        Column().width(20).height(3).backgroundColor(accent).borderRadius(2).margin({ top: 2 })
      }
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center)
    .padding({ top: 5, bottom: 5 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Divider().color('#E0E0E0').strokeWidth(0.5)
      Row() {
        this.tabItem('🧾', '账单', ExpenseTab.BILL, '#00695C')
        this.tabItem('📊', '图表', ExpenseTab.CHART, '#C62828')
        this.tabItem('🎯', '预算', ExpenseTab.BUDGET, '#FF6F00')
        this.tabItem('👤', '我的', ExpenseTab.PROFILE, '#1565C0')
      }
      .width('100%').backgroundColor('#FFFFFF').padding({ top: 4, bottom: 6 })
    }
    .width('100%').height('100%').backgroundColor('#E0F2F1')
  }
}

// ==================== Bill Content ====================
@Component
struct BillContent {
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteConfirm: boolean = false
  @State selectedTxn: Transaction | null = null
  @State formTitle: string = ''
  @State formAmount: string = ''
  @State formCat: string = '餐饮'
  @State formType: string = '支出'
  @State formNote: string = ''

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

  @Builder addTxnModal() {
    Column() {
      this.modalBg(() => { this.showAddModal = false })
      Column() {
        Row() {
          Text('➕ 新增记账').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(20).fontColor('#999999').onClick(() => { this.showAddModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 10 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            Row() {
              Text('支出').fontSize(13).fontColor(this.formType === '支出' ? '#FFFFFF' : '#C62828')
                .backgroundColor(this.formType === '支出' ? '#C62828' : '#FFEBEE')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 }).borderRadius(20)
                .layoutWeight(1).textAlign(TextAlign.Center)
                .onClick(() => { this.formType = '支出'; this.formCat = '餐饮' })
              Text('收入').fontSize(13).fontColor(this.formType === '收入' ? '#FFFFFF' : '#2E7D32')
                .backgroundColor(this.formType === '收入' ? '#2E7D32' : '#E8F5E9')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 }).borderRadius(20)
                .layoutWeight(1).textAlign(TextAlign.Center).margin({ left: 8 })
                .onClick(() => { this.formType = '收入'; this.formCat = '工资' })
            }
            .margin({ left: 20, right: 20, top: 16 })
            Text('金额').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
            TextInput({ placeholder: '¥ 0.00' })
              .placeholderColor('#CCCCCC').fontSize(28).fontWeight(FontWeight.Bold)
              .fontColor(this.formType === '支出' ? '#C62828' : '#2E7D32')
              .backgroundColor('#F5F5F5').borderRadius(12)
              .margin({ left: 20, right: 20, top: 4 })
              .onChange((v: string) => { this.formAmount = v })
            Text('分类').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(this.formType === '支出' ? CAT_LIST : INCOME_LIST, (c: string) => {
                Text((EXPENSE_CATS[c]?.icon ?? '') + ' ' + c)
                  .fontSize(11)
                  .fontColor(this.formCat === c ? '#FFFFFF' : EXPENSE_CATS[c]?.color ?? '#666666')
                  .backgroundColor(this.formCat === c ? EXPENSE_CATS[c]?.color : EXPENSE_CATS[c]?.bg)
                  .padding({ left: 10, right: 10, top: 6, bottom: 6 })
                  .borderRadius(16).margin({ left: 4, right: 4, top: 4 })
                  .onClick(() => { this.formCat = c })
              })
            }
            .margin({ left: 16, right: 16, top: 6 })
            Text('备注').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
            TextInput({ placeholder: '添加备注...' })
              .placeholderColor('#BBBBBB').fontSize(14)
              .backgroundColor('#F5F5F5').borderRadius(10)
              .margin({ left: 20, right: 20, top: 4 })
              .onChange((v: string) => { this.formTitle = v })
          }
          .padding({ bottom: 16 })
        }
        .constraintSize({ maxHeight: '55%' })
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(22)
            .padding({ left: 28, right: 28, top: 11, bottom: 11 })
            .onClick(() => { this.showAddModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#00695C').borderRadius(22)
            .padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
            .onClick(() => { this.showAddModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 14, bottom: 16 })
      }
      .width('90%').backgroundColor('#FFFFFF').borderRadius(18)
      .alignItems(HorizontalAlign.Center).position({ x: '5%', y: '12%' })
      .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder editTxnModal() {
    Column() {
      this.modalBg(() => { this.showEditModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(20).fontColor('#999999').onClick(() => { this.showEditModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 10 })
        Divider().color('#F0F0F0')
        Column() {
          Text('金额').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
          TextInput({ placeholder: '¥ ' + (this.selectedTxn?.amount ?? 0).toString() })
            .placeholderColor('#CCCCCC').fontSize(22).fontWeight(FontWeight.Bold)
            .fontColor(TXN_TYPES[this.selectedTxn?.type ?? '支出']?.color ?? '#C62828')
            .backgroundColor('#F5F5F5').borderRadius(12)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formAmount = v })
          Text('备注').fontSize(12).fontColor('#888888').margin({ top: 16, left: 20 })
          TextInput({ placeholder: this.selectedTxn?.note ?? '' })
            .placeholderColor('#BBBBBB').fontSize(14)
            .backgroundColor('#F5F5F5').borderRadius(10)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formNote = v })
        }
        .layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(22)
            .padding({ left: 28, right: 28, top: 11, bottom: 11 })
            .onClick(() => { this.showEditModal = false })
          Text('保存修改').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#FF6F00').borderRadius(22)
            .padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 14, bottom: 16 })
      }
      .width('90%').backgroundColor('#FFFFFF').borderRadius(18)
      .alignItems(HorizontalAlign.Center).position({ x: '5%', y: '20%' })
      .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder deleteModal() {
    Column() {
      this.modalBg(() => { this.showDeleteConfirm = false })
      Column() {
        Text('⚠️').fontSize(48).margin({ top: 24 })
        Text('确认删除此记录?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Row() {
          Text(EXPENSE_CATS[this.selectedTxn?.category ?? '餐饮']?.icon ?? '💰').fontSize(24)
          Text((this.selectedTxn?.title ?? '')).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333').margin({ left: 8 })
          Text('¥' + (this.selectedTxn?.amount ?? 0).toString()).fontSize(14).fontWeight(FontWeight.Bold)
            .fontColor(TXN_TYPES[this.selectedTxn?.type ?? '支出']?.color ?? '#C62828').margin({ left: 8 })
        }
        .backgroundColor('#FFF5F5').borderRadius(12)
        .padding({ left: 16, right: 16, top: 12, bottom: 12 }).margin({ top: 16 })
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(22)
            .padding({ left: 28, right: 28, top: 11, bottom: 11 })
            .onClick(() => { this.showDeleteConfirm = false })
          Text('确认删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#F44336').borderRadius(22)
            .padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
            .onClick(() => { this.showDeleteConfirm = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 20, bottom: 20 })
      }
      .width('80%').backgroundColor('#FFFFFF').borderRadius(18)
      .alignItems(HorizontalAlign.Center).position({ x: '10%', y: '38%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder txnItem(t: Transaction) {
    Row() {
      Column() {
        Text(EXPENSE_CATS[t.category]?.icon ?? '💰').fontSize(24)
      }
      .width(44).height(44)
      .backgroundColor(EXPENSE_CATS[t.category]?.bg ?? '#F5F5F5')
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      Column() {
        Text(t.title).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#212121')
        Text(t.date + ' · ' + t.merchant).fontSize(10).fontColor('#999999').margin({ top: 2 })
        Text(t.account + ' · ' + t.time).fontSize(9).fontColor('#CCCCCC').margin({ top: 1 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      Column() {
        Text((t.type === '收入' ? '+' : '-') + '¥' + t.amount.toString())
          .fontSize(15).fontWeight(FontWeight.Bold)
          .fontColor(TXN_TYPES[t.type]?.color ?? '#C62828')
        Row() {
          Text('✏️').fontSize(12).fontColor('#00695C')
            .onClick(() => { this.selectedTxn = t; this.showEditModal = true })
          Text('🗑️').fontSize(12).fontColor('#F44336').margin({ left: 8 })
            .onClick(() => { this.selectedTxn = t; this.showDeleteConfirm = true })
        }
        .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF')
    .borderRadius(12).margin({ left: 12, right: 12, top: 5 })
    .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
  }

  build() {
    Stack() {
      Column() {
        Column() {
          Text('7月').fontSize(12).fontColor('#888888')
          Row() {
            Column() {
              Text('收入').fontSize(10).fontColor('#2E7D32')
              Text('¥' + getMonthlyIncome().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
            }.alignItems(HorizontalAlign.Start)
            Column().layoutWeight(1)
            Column() {
              Text('支出').fontSize(10).fontColor('#C62828')
              Text('¥' + getMonthlyExpense().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C62828')
            }.alignItems(HorizontalAlign.End)
          }
          .width('100%').margin({ top: 4 })
          Row() {
            Column() {
              Text('结余 ¥' + getMonthlyBalance().toString()).fontSize(12).fontColor('#00695C').fontWeight(FontWeight.Bold)
            }
            Column().layoutWeight(1)
            Text('+').fontSize(24).fontColor('#FFFFFF')
              .backgroundColor('#00695C').width(36).height(36).borderRadius(18)
              .textAlign(TextAlign.Center)
              .onClick(() => { this.showAddModal = true })
          }
          .width('100%').margin({ top: 8 })
        }
        .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
        .margin({ left: 12, right: 12, top: 10 }).padding(16)
        .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

        Text('📋 交易明细').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121')
          .width('100%').padding({ left: 16, top: 14, bottom: 4 })
        Scroll() {
          Column() {
            this.txnItem(mockTransactions[0])
            this.txnItem(mockTransactions[1])
            this.txnItem(mockTransactions[2])
            this.txnItem(mockTransactions[3])
            this.txnItem(mockTransactions[4])
            this.txnItem(mockTransactions[5])
            this.txnItem(mockTransactions[6])
            this.txnItem(mockTransactions[7])
            this.txnItem(mockTransactions[8])
            this.txnItem(mockTransactions[9])
            this.txnItem(mockTransactions[10])
            this.txnItem(mockTransactions[11])
            this.txnItem(mockTransactions[12])
            this.txnItem(mockTransactions[13])
            this.txnItem(mockTransactions[14])
            this.txnItem(mockTransactions[15])
            this.txnItem(mockTransactions[16])
            this.txnItem(mockTransactions[17])
            this.txnItem(mockTransactions[18])
            this.txnItem(mockTransactions[19])
            this.txnItem(mockTransactions[20])
            this.txnItem(mockTransactions[21])
            this.txnItem(mockTransactions[22])
            this.txnItem(mockTransactions[23])
            this.txnItem(mockTransactions[24])
            this.txnItem(mockTransactions[25])
            this.txnItem(mockTransactions[26])
            this.txnItem(mockTransactions[27])
            this.txnItem(mockTransactions[28])
            this.txnItem(mockTransactions[29])
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')
      if (this.showAddModal) { this.addTxnModal() }
      if (this.showEditModal) { this.editTxnModal() }
      if (this.showDeleteConfirm) { this.deleteModal() }
    }
    .width('100%').height('100%')
  }
}

// ==================== Chart Content ====================
@Component
struct ChartContent {
  build() {
    Column() {
      Text('📊 数据分析').fontSize(18).fontWeight(FontWeight.Bold)
        .width('100%').padding({ left: 16, top: 14, bottom: 8 })
      Scroll() {
        Column() {
          Column() {
            Text('📈 月度收支趋势').fontSize(13).fontWeight(FontWeight.Bold)
              .width('100%').padding({ left: 16, top: 14, bottom: 10 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (m: number) => {
                Column() {
                  Text((MONTH_BAR[m] / 1000).toFixed(1) + 'k').fontSize(7).fontColor('#00695C')
                  Column()
                    .width(16)
                    .height((MONTH_BAR[m] / 5200 * 90).toFixed(0) + 'vp')
                    .backgroundColor(m === 3 ? '#C62828' : '#80CBC4')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                  Text(MONTH_LABELS[m]).fontSize(7).fontColor('#999999').margin({ top: 2 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 8, right: 8, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 6 })
          .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

          Column() {
            Text('🥧 支出分类占比').fontSize(13).fontWeight(FontWeight.Bold)
              .width('100%').padding({ left: 16, top: 14, bottom: 8 })
            Column() {
              Row() { Text('🏠 居住').fontSize(11).fontColor('#5D4037').layoutWeight(1); Text('¥3706 · 49%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('49%').height(7).backgroundColor('#5D4037').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('🍔 餐饮').fontSize(11).fontColor('#FF6F00').layoutWeight(1); Text('¥1280 · 17%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('17%').height(7).backgroundColor('#FF6F00').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('🛍️ 购物').fontSize(11).fontColor('#AD1457').layoutWeight(1); Text('¥585 · 8%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('8%').height(7).backgroundColor('#AD1457').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('🚗 交通').fontSize(11).fontColor('#1565C0').layoutWeight(1); Text('¥539 · 7%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('7%').height(7).backgroundColor('#1565C0').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('🎮 娱乐').fontSize(11).fontColor('#7B1FA2').layoutWeight(1); Text('¥447 · 6%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('6%').height(7).backgroundColor('#7B1FA2').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('📚 教育').fontSize(11).fontColor('#00695C').layoutWeight(1); Text('¥327 · 4%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('4%').height(7).backgroundColor('#00695C').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4, bottom: 10 })
              Row() { Text('💊 医疗').fontSize(11).fontColor('#C62828').layoutWeight(1); Text('¥625 · 8%').fontSize(11).fontColor('#888888') }
              Row() { Column().width('8%').height(7).backgroundColor('#C62828').borderRadius(4); Row().layoutWeight(1) }
              .width('100%').margin({ top: 4 })
            }
            .padding({ left: 16, right: 16, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

          Row() {
            Column() {
              Text('💰').fontSize(20)
              Text('¥26100').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#2E7D32').margin({ top: 4 })
              Text('本月收入').fontSize(10).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            .backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 6, right: 3, top: 8 })
            Column() {
              Text('💸').fontSize(20)
              Text('¥7569').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C62828').margin({ top: 4 })
              Text('本月支出').fontSize(10).fontColor('#888888')
            }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 14, bottom: 14 })
            .backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 3, right: 6, top: 8 })
          }
          .width('100%')

          Column() {
            Text('📅 近7日趋势').fontSize(13).fontWeight(FontWeight.Bold)
              .width('100%').padding({ left: 16, top: 14, bottom: 10 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
                Column() {
                  Column()
                    .width(22)
                    .height(([120, 80, 45, 168, 35, 12, 28][d] / 168 * 70).toFixed(0) + 'vp')
                    .backgroundColor(d === 3 ? '#C62828' : '#80CBC4')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                  Text(['0713', '0714', '0715', '0716', '0717', '0718', '0719'][d]).fontSize(8).fontColor('#999999').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 12, right: 12, bottom: 14 })
          }
          .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
          .margin({ left: 12, right: 12, top: 8 })
          .shadow({ radius: 4, color: '#10000000', offsetY: 2 })
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ==================== Budget Content ====================
@Component
struct BudgetContent {
  @Builder budgetBar(b: BudgetItem) {
    Column() {
      Row() {
        Text(EXPENSE_CATS[b.category]?.icon ?? '💰').fontSize(20)
        Text(b.category).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#212121').margin({ left: 8 }).layoutWeight(1)
        Text('¥' + b.spent + ' / ¥' + b.budget).fontSize(11).fontColor('#888888')
      }
      .width('100%')
      Row() {
        Column()
          .width((Math.min(b.spent / b.budget, 1.0) * 100).toFixed(0) + '%')
          .height(8)
          .backgroundColor(b.spent > b.budget ? '#F44336' : (b.spent / b.budget > 0.8 ? '#FF9800' : EXPENSE_CATS[b.category]?.color ?? '#00695C'))
          .borderRadius(4)
        Row().layoutWeight(1)
      }
      .width('100%').height(8).backgroundColor('#F0F0F0').borderRadius(4).margin({ top: 8 })
      Row() {
        Text('剩余 ¥' + (b.budget - b.spent > 0 ? (b.budget - b.spent) : 0).toString()).fontSize(10)
          .fontColor(b.spent > b.budget ? '#F44336' : '#4CAF50')
        Row().layoutWeight(1)
        Text(b.spent > b.budget ? '⚠️ 超支' : ((b.spent / b.budget * 100).toFixed(0) + '%')).fontSize(10)
          .fontColor(b.spent > b.budget ? '#F44336' : '#888888')
      }
      .width('100%').margin({ top: 6 })
    }
    .width('100%').padding(14).backgroundColor('#FFFFFF')
    .borderRadius(12).margin({ left: 12, right: 12, top: 6 })
    .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
  }

  build() {
    Column() {
      Text('🎯 预算管理').fontSize(18).fontWeight(FontWeight.Bold)
        .width('100%').padding({ left: 16, top: 14, bottom: 8 })
      Column() {
        Row() {
          Column() {
            Text('¥7569').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#C62828')
            Text('已使用').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('¥9900').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#00695C')
            Text('总预算').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column() {
            Text('¥2331').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
            Text('剩余').fontSize(10).fontColor('#888888')
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ top: 14, bottom: 14 })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12 })
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })
      Scroll() {
        Column() {
          this.budgetBar(mockBudgets[0])
          this.budgetBar(mockBudgets[1])
          this.budgetBar(mockBudgets[2])
          this.budgetBar(mockBudgets[3])
          this.budgetBar(mockBudgets[4])
          this.budgetBar(mockBudgets[5])
          this.budgetBar(mockBudgets[6])
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ==================== Profile Content ====================
@Component
struct ExpenseProfileContent {
  build() {
    Column() {
      Column() {
        Row() {
          Column() {
            Text('💳').fontSize(40)
          }
          .width(64).height(64).backgroundColor('#E0F2F1').borderRadius(32)
          .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
          Column() {
            Text('理财小能手').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
            Text('已记账 ' + getTxnCount() + ' 笔').fontSize(11).fontColor('#888888').margin({ top: 3 })
            Text('本月结余 ¥' + getMonthlyBalance().toString()).fontSize(10).fontColor('#00695C').margin({ top: 2 })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
        }
        .width('100%').padding(16)
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12, top: 10 })
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })

      Column() {
        Text('🏦 我的账户').fontSize(13).fontWeight(FontWeight.Bold)
          .width('100%').padding({ left: 16, top: 14, bottom: 8 })
        Column() {
          Row() { Text('🏦').fontSize(18); Text('招商银行').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥45,230').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
          Divider().color('#F0F0F0')
          Row() { Text('💰').fontSize(18); Text('支付宝').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥8,560').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
          Divider().color('#F0F0F0')
          Row() { Text('💚').fontSize(18); Text('微信钱包').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥1,280').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
          Divider().color('#F0F0F0')
          Row() { Text('📈').fontSize(18); Text('蚂蚁财富').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('¥52,000').fontSize(12).fontColor('#00695C'); Text('›').fontColor('#CCCCCC').margin({ left: 8 }) }
          .width('100%').padding({ top: 12, bottom: 12, left: 4 })
        }
        .padding({ left: 16, right: 16 })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(16)
      .margin({ left: 12, right: 12, top: 8 })
      .shadow({ radius: 4, color: '#10000000', offsetY: 2 })
      Text('v2.0 · 财务记账 · 2026').fontSize(10).fontColor('#CCCCCC')
        .margin({ top: 16, bottom: 16 })
    }
    .width('100%').height('100%')
  }
}


结语

通过对这份财务记账应用源码的逐段深度解析,我们不仅看到了 ArkTS 语言在类型安全、装饰器系统、声明式 UI 等方面的技术特性,更看到了一个完整的移动应用从数据建模、配置管理、状态控制、交互设计到视觉呈现的全流程实现。这份源码虽然以单文件形式呈现,但其内部蕴含的组件化思想、配置驱动理念、响应式状态管理等设计智慧,对于每一位 HarmonyOS 开发者都具有深远的参考价值和学习意义。

在这里插入图片描述

HarmonyOS 的 ArkUI 开发范式代表了移动端 UI 开演进的最新方向——从命令式到声明式、从手动操作到状态驱动、从单文件到组件化。掌握这些理念和技术,不仅能帮助开发者构建出优秀的鸿蒙原生应用,更能拓宽对前端开发范式演进的理解和认知。希望本文的解析能够为读者的 HarmonyOS 开发之旅提供一份有价值的参考和指引。

更多推荐