# HarmonyOS ArkTS 读书笔记应用源码逐段深度解析
引言:当阅读遇见鸿蒙
在移动互联网深度渗透日常生活的今天,阅读这一古老而永恒的精神活动正在经历数字化的重塑。纸质书墨香犹在,但越来越多的读者开始习惯于在手机、平板等终端设备上记录自己的阅读轨迹——读过了哪些书、读到哪一页、留下了怎样的感悟、年度目标完成了多少。一款优秀的读书笔记应用,不仅要做书籍的收纳盒,更要做思想的沉淀池、习惯的督促者以及数据的可视化看板。

本应用正是基于 HarmonyOS(鸿蒙) 分布式操作系统,采用 ArkTS 声明式开发范式与 ArkUI 声明式 UI 框架精心打造的一款读书笔记管理工具。ArkTS 是在 TypeScript 基础上扩展而来的编程语言,它保留了 TypeScript 的静态类型检查能力,同时为鸿蒙的声明式 UI 体系量身定制了一套装饰器语法体系。通过 @Entry、@Component、@State、@Builder、@Observed 等装饰器,开发者可以用极简的代码量表达出复杂的界面结构与状态流转逻辑,让数据驱动视图的编程范式真正落地。
技术栈全景
从技术架构的维度审视,本应用涉及以下核心技术要素:
- ArkTS 语言基础:TypeScript 风格的接口(
interface)、类(class)、枚举(enum)与类型注解,为应用提供了坚实的类型安全保障。每一处变量声明都带有明确的类型,编译期即可捕获大量潜在错误。 - ArkUI 声明式 UI:以
struct为组件容器,以链式调用的方式描述组件的属性与样式(如.width()、.fontSize()、.backgroundColor()),通过build()方法构建组件树,UI 的结构即代码的结构。 - 状态管理 V1 体系:通过
@State管理组件内部状态,通过@Observed标记可观察的数据模型类,实现"数据变化即 UI 更新"的响应式渲染。当@State变量发生变更,与之绑定的 UI 节点会自动重新渲染,无需手动调用刷新方法。 - @Builder 构建器模式:将可复用的 UI 片段封装为独立的
@Builder方法,实现 UI 的模块化拆分与组合复用,大幅降低build()方法的复杂度,提升代码可读性与可维护性。 - 数据可视化:应用内置了月度阅读页数柱状图、分类分布横向条形图、本周阅读柱状图等多种纯 ArkUI 原生组件绘制的图表,未引入任何第三方图表库,展现了声明式 UI 在数据可视化方面的灵活表达能力。
- 模态弹窗体系:通过
Stack布局叠加 + 条件渲染的方式,自实现了一套覆盖式模态弹窗系统,涵盖添加书籍、编辑书籍、删除确认、进度更新、摘录查看等多种交互场景。
设计美学:琥珀色的温暖
应用的视觉设计采用了一套以"琥珀色"为核心的暖色系配色方案。主色调 #FF8F00 是一种温润的琥珀橙,让人联想到旧书页泛黄的光泽与台灯下阅读的温暖氛围;辅助色 #4E342E 深棕色用于文字主体,沉稳而不失书卷气;背景色 #FFF8E1 是一种极浅的米黄色,模拟纸张的质感,长时间阅读也不会造成视觉疲劳。整套配色从背景到前景、从主色到点缀色,构成了一个和谐统一的暖色生态系统,完美契合"读书笔记"这一应用主题。
下面,让我们从源码的第一行开始,逐段、逐层地深入剖析这个应用的实现细节。
一、数据类型契约:interface 接口定义
1.1 颜色方案注释与 BookData 接口
// 读书笔记 — 琥珀 #FF8F00 | 棕色 #4E342E | 背景 #FFF8E1
interface BookData {
id: number
title: string
author: string
category: string
totalPages: number
currentPage: number
startDate: string
finishDate: string
rating: number
isCompleted: boolean
notes: string
coverEmoji: string
coverColor: string
keyQuotes: string[]
tags: string[]
}

应用源码的开篇第一行是一段颜色方案注释,以注释形式声明了整个应用的三色基调:琥珀色 #FF8F00 作为主强调色,深棕色 #4E342E 作为文字主色,浅米黄 #FFF8E1 作为全局背景色。这种将设计规范固化在代码注释首位的做法,体现了"设计即文档"的工程理念,方便团队中任何成员在打开源码的第一时间把握应用的视觉基调。
紧接着定义的是 BookData 接口,它是整个应用最核心的数据契约。在 ArkTS 中,interface 用于声明对象的形状(Shape),定义了一组属性的名称与类型组合。BookData 接口描绘了一本"书"在数字世界中的完整画像,共包含 15 个字段,可以归纳为五大语义维度:
- 身份标识维度:
id(数字主键)、title(书名)、author(作者)、category(分类,如文学、科技、历史等)。 - 阅读进度维度:
totalPages(总页数)、currentPage(当前阅读页数)、startDate(开始日期字符串)、finishDate(完成日期字符串)、isCompleted(是否读完的布尔标志)。 - 主观评价维度:
rating(评分,1~5 的整数)、notes(阅读笔记文本)。 - 视觉呈现维度:
coverEmoji(封面 Emoji 表情,如 📕📗📘)、coverColor(封面背景色,十六进制色值字符串)。 - 内容关联维度:
keyQuotes(关键金句数组)、tags(标签数组)。
这里值得注意几个设计决策。第一,日期采用 string 类型而非 Date 对象,这是一种务实的简化策略——在展示层,日期多以 “2024-01-05” 这样的字符串格式直接显示,省去了格式化转换的麻烦。第二,封面用 coverEmoji + coverColor 的组合代替真实图片资源,这意味着应用无需依赖任何图片素材,仅靠系统字体中的 Emoji 渲染即可呈现丰富的视觉差异,极大减小了应用包体积,同时保证了渲染性能。第三,keyQuotes 和 tags 均采用 string[] 数组类型,体现了"一书多句金句、一书多个标签"的一对多数据关系。
1.2 统计相关接口定义
interface GoalConfig {
label: string
current: number
target: number
color: string
icon: string
}
interface MonthPageItem {
month: string
pages: number
color: string
}
interface CategoryItem {
label: string
percent: number
count: number
color: string
}
interface StreakDay {
day: string
pages: number
color: string
}

除了核心的书籍数据,应用还为统计模块定义了四个辅助数据接口,每一个都服务于一种特定的可视化场景。
GoalConfig(目标配置)接口描述了一个阅读目标的完整信息:label 是目标名称(如"年度阅读目标"),current 与 target 分别是当前完成值与目标值,二者之比即进度百分比;color 是进度条颜色,icon 是目标前方的 Emoji 图标。这个接口是"阅读目标"卡片的直接数据来源。
MonthPageItem(月度页数项)接口服务于月度柱状图,每个对象代表一个月的阅读页数统计:month 是月份标签(如"1月"),pages 是当月阅读总页数,color 是该柱子的颜色。通过为每个月分配不同深浅的琥珀色,柱状图在传达数据的同时也具备了色彩层次的美感。
CategoryItem(分类项)接口用于分类分布横向条形图,label 是分类名称(如"文学"“科技”),percent 是该分类占比百分比,count 是该分类下的书籍数量,color 是条形颜色。这个接口同时承载了比例与绝对数量两种维度,使统计信息更加立体。
StreakDay(连续阅读日)接口描述一周中某一天的阅读情况:day 是星期简称(如"一"“二”),pages 是当天阅读页数,color 是柱子颜色。它服务于"本周阅读"柱状图,帮助用户审视自己的日常阅读节奏。
这四个接口的共同特征是:字段精简、职责单一、各自对应一个具体的可视化组件。这种"一接口一场景"的设计方式使得数据与视图之间形成了清晰的映射关系,当需求变化时,只需调整对应的接口与组件即可,不会产生交叉影响,体现了关注点分离(Separation of Concerns)的工程原则。
二、可观察数据模型:@Observed class BookModel
@Observed
class BookModel {
id: number
title: string
author: string
category: string
totalPages: number
currentPage: number
startDate: string
finishDate: string
rating: number
isCompleted: boolean
notes: string
coverEmoji: string
coverColor: string
keyQuotes: string[]
tags: string[]
constructor(id: number, title: string, author: string, category: string,
totalPages: number, currentPage: number, startDate: string,
finishDate: string, rating: number, isCompleted: boolean,
notes: string, coverEmoji: string, coverColor: string,
keyQuotes: string[], tags: string[]) {
this.id = id
this.title = title
this.author = author
this.category = category
this.totalPages = totalPages
this.currentPage = currentPage
this.startDate = startDate
this.finishDate = finishDate
this.rating = rating
this.isCompleted = isCompleted
this.notes = notes
this.coverEmoji = coverEmoji
this.coverColor = coverColor
this.keyQuotes = keyQuotes
this.tags = tags
}
}

在定义了 BookData 接口之后,应用紧接着声明了一个 BookModel 类。细心的读者会发现,BookModel 的字段与 BookData 接口完全一致,那么为什么要"重复"定义一份呢?关键在于类声明上方的 @Observed 装饰器。
@Observed 是 ArkUI 状态管理 V1 体系中的一个关键装饰器,它用于装饰类,使被装饰类的实例成为"可观察对象"。当一个 @Observed 类的属性发生变化时,ArkUI 框架能够感知到这一变化,并自动触发依赖该属性的 UI 组件进行重新渲染。相比之下,普通的 interface 只是一个编译期的类型契约,运行时不存在任何响应式能力。因此,BookData 接口承担的是纯类型定义的职责(可用于函数参数类型约束、泛型约束等静态场景),而 BookModel 类则承担的是运行时数据载体的职责,二者各司其职、互为补充。
BookModel 类的另一个重要设计是它显式声明了一个全参数的 constructor 构造函数。在 TypeScript 中,如果不在类中定义构造函数,会默认获得一个无参构造函数,但那样就无法在创建实例时一次性注入所有属性值。这里通过构造函数将 15 个参数逐一赋值给 this 的对应属性,使得创建书籍对象的代码非常简洁,例如 new BookModel(1, '百年孤独', '加西亚·马尔克斯', '文学', 360, 360, ...)。这种"构造即完备"的模式保证了每个 BookModel 实例在被创建的那一刻就处于数据完整的状态,避免了"先创建空对象再逐个赋值"可能导致的中间态不一致问题。
值得注意的是,构造函数的参数列表被合理地换行排版为多行,这是 ArkTS 代码风格中对长参数列表的常见处理方式,既保证了可读性,又符合单行长度规范。
三、页面枚举与组件状态声明
3.1 BookTab 枚举
enum BookTab {
LIBRARY = 0,
READING = 1,
STATS = 2,
PROFILE = 3
}

BookTab 是一个数字枚举,定义了应用底部导航栏的四个标签页:LIBRARY(书单,值为 0)、READING(在读,值为 1)、STATS(统计,值为 2)、PROFILE(我的,值为 3)。
使用枚举而非魔法数字(magic number)来标识 Tab 页是极佳的工程实践。如果代码中到处写 if (this.activeTab === 0),阅读者必须反复查阅才能理解 0 代表哪个页面;而写成 if (this.activeTab === BookTab.LIBRARY) 则语义自明。同时,枚举还为未来的扩展提供了便利——如果需要新增一个 Tab 页,只需在枚举中添加一个成员并赋予新的整数值即可,所有引用枚举名的地方无需修改,编译器会在编译期检查是否遗漏了新分支的处理。
这里枚举成员被显式赋值为连续的整数 0、1、2、3,虽然 TypeScript 中数字枚举会自动从 0 开始递增,但显式赋值能够消除歧义,使代码意图更加清晰。
3.2 BookPage 组件与 @State 状态集合
@Entry
@Component
struct BookPage {
@State activeTab: BookTab = BookTab.LIBRARY
@State books: BookModel[] = []
@State showAddBook: boolean = false
@State showEditBook: boolean = false
@State showDeleteBook: boolean = false
@State showProgressModal: boolean = false
@State showQuoteModal: boolean = false
@State selectedIdx: number = -1
@State goals: GoalConfig[] = []
@State monthPages: MonthPageItem[] = []
@State maxPages: number = 3200
@State categories: CategoryItem[] = []
@State streakDays: StreakDay[] = []
@State readingNow: BookModel[] = []
@State formTitle: string = ''
@State formAuthor: string = ''
@State formCategory: string = ''
@State formTotal: number = 0
@State formCurrent: number = 0
@State formStart: string = ''
@State formRating: number = 0
@State formNotes: string = ''

这一段是整个应用的中枢神经——组件状态声明区。BookPage 被 @Entry 和 @Component 双重装饰:@Component 声明这是一个自定义组件,@Entry 则进一步声明它是页面的入口组件,即应用启动后渲染的第一个组件。
组件内部通过 @State 装饰器声明了一组状态变量。@State 是 ArkUI 中最基础的状态管理装饰器,被它修饰的变量一旦发生变更,框架会自动追踪所有引用该变量的 UI 节点,并触发它们的重新渲染。这里的状态变量可以按功能分组理解:
Tab 切换状态:activeTab 记录当前激活的标签页,初始值为 BookTab.LIBRARY(书单页)。它是整个应用导航的核心驱动力。
核心数据状态:books 是全部书籍的数组(BookModel[]),初始为空数组,将在 aboutToAppear 中填充;readingNow 是"正在阅读"书籍的子集数组,用于在读 Tab 页横向展示。goals、monthPages、categories、streakDays 分别对应目标、月度统计、分类统计、本周统计的数据源。maxPages 是柱状图的纵向最大刻度值,用于将实际页数等比缩放到柱子高度。
弹窗显隐状态:showAddBook、showEditBook、showDeleteBook、showProgressModal、showQuoteModal 五个布尔值分别控制五种模态弹窗的显示与隐藏,初始值均为 false。这种"一个布尔控一个弹窗"的设计虽然简单直接,但在弹窗数量较多时显得略为冗余——更优的方案可以是引入一个枚举类型的 activeModal 状态统一管理,但当前方案胜在直观易懂。
选中索引状态:selectedIdx 记录当前被操作(编辑、删除、更新进度、查看摘录)的书籍在 books 数组中的索引,初始值 -1 表示未选中任何书籍。使用 -1 作为"空值"标志是数值索引的常见约定。
表单状态:formTitle、formAuthor、formCategory、formTotal、formCurrent、formStart、formRating、formNotes 八个变量构成了一套表单数据模型,用于在添加/编辑书籍弹窗中双向绑定用户输入。它们被声明为 @State 意味着用户在输入框中的每一次击键都会触发状态更新,而状态的更新又会反向同步到输入框的显示——这就是 ArkUI 中 TextInput 组件配合 onChange 回调实现的数据双向流动。
将所有状态集中在入口组件中管理,是一种"单组件大状态"的架构选择。它的优势在于状态可见性高、组件间通信简单(因为都在同一个组件作用域内);代价是当应用规模增大时,入口组件会变得臃肿。但对于本应用这个量级而言,这种集中式管理是恰当且高效的。
四、数据初始化:aboutToAppear 生命周期
4.1 书籍数据初始化
aboutToAppear(): void {
this.books = [
new BookModel(1, '百年孤独', '加西亚·马尔克斯', '文学', 360, 360, '2024-01-05', '2024-01-28', 5, true, '魔幻现实主义的巅峰之作...', '📕', '#FF7043', [...], [...]),
// ... 共 25 本书的数据
new BookModel(25, '局部的意味', '汪曾祺', '文学', 250, 150, '2024-12-10', '', 4, false, '...', '📕', '#A1887F', [...], [...])
]

aboutToAppear 是 ArkUI 组件生命周期的关键回调之一。它在组件的 build() 方法首次执行之前被调用,且在整个生命周期中只执行一次。这个时机非常适合做数据初始化、网络请求、配置读取等准备工作。本应用在此方法中将所有模拟数据一次性灌入状态变量。
这里可以看到,this.books 被赋值为一个包含 25 个 BookModel 实例的数组。每一本书都是通过 new BookModel(...) 构造的完整对象,涵盖了书名、作者、分类、页数、进度、日期、评分、笔记、封面 Emoji、封面颜色、金句数组、标签数组等全部字段。25 本书横跨文学、科技、历史、哲学、经济、心理六大分类,既有已读完的(isCompleted: true),也有正在阅读的(isCompleted: false),数据覆盖面广且贴近真实阅读场景。
值得分析的是这些模拟数据的设计用心。每本书的 notes 字段都是一句精炼的读后感,如"魔幻现实主义的巅峰之作,布恩迪亚家族七代人的命运令人唏嘘";keyQuotes 数组中收录的是该书的经典语句,如百年孤独的"生命中真正重要的不是你遭遇了什么,而是你记住了什么"。这些数据不仅为 UI 展示提供了丰富的内容素材,更让整个应用在演示时具备了真实的阅读笔记应用气质,而非空洞的 Lorem Ipsum 占位文本。coverColor 字段为每本书分配了不同的色值,使得书单页面的卡片在视觉上呈现出丰富多彩的色调变化。
4.2 在读书目与统计数据初始化
this.readingNow = [
new BookModel(3, '三体', ...),
new BookModel(5, '思考,快与慢', ...),
new BookModel(12, '原则', ...),
new BookModel(16, '基因传', ...),
new BookModel(25, '局部的意味', ...)
]
this.goals = [
{ label: '年度阅读目标', current: 17, target: 24, color: '#FF8F00', icon: '📚' },
{ label: '已读页数', current: 5840, target: 8000, color: '#FFB300', icon: '📖' },
{ label: '读书笔记', current: 12, target: 20, color: '#FFCA28', icon: '✏️' }
]
this.monthPages = [
{ month: '1月', pages: 560, color: '#FF8F00' },
// ... 12 个月的数据
{ month: '12月', pages: 450, color: '#FFCA28' }
]
this.maxPages = 1500
this.categories = [
{ label: '文学', percent: 36, count: 9, color: '#FF8F00' },
// ... 6 个分类
{ label: '心理', percent: 8, count: 2, color: '#FFCA28' }
]
this.streakDays = [
{ day: '一', pages: 25, color: '#FF8F00' },
// ... 7 天的数据
{ day: '日', pages: 45, color: '#FFB300' }
]
}

在书籍主数据之后,aboutToAppear 依次初始化了五组辅助数据。
readingNow 数组从 books 中抽取了 5 本正在阅读的书(isCompleted 为 false 的),用于在读 Tab 页的横向卡片展示。需要注意的是,这里的 readingNow 元素是重新 new 出来的 BookModel 实例,而非对 books 中对应元素的引用。这意味着在读卡片展示的数据与书单卡片展示的数据是两份独立的副本,修改其中一份不会影响另一份。这种设计在演示场景下没有问题,但在真实应用中,更合理的做法是让 readingNow 引用 books 中的同一对象,以保证数据一致性。
goals 数组定义了三个阅读目标:年度阅读 17/24 本、已读页数 5840/8000 页、读书笔记 12/20 篇。每个目标都带有专属的颜色与 Emoji 图标,三个颜色 #FF8F00、#FFB300、#FFCA28 构成了从深到浅的琥珀色渐变,在目标卡片中并列展示时形成和谐的色彩韵律。
monthPages 数组包含了 1 月到 12 月的阅读页数数据,maxPages 被设为 1500,作为柱状图纵轴的最大刻度。7 月份以 1500 页达到峰值(恰好等于 maxPages),12 月份以 450 页处于低谷,数据呈现出阅读量的季节性波动特征。categories 数组统计了六大分类的占比与数量,文学类以 36% 占比居首。streakDays 数组记录了周一到周日的阅读页数,周六以 60 页居首。
整个 aboutToAppear 方法虽然篇幅较长,但逻辑清晰——它就是一个纯粹的数据装配车间,将预先准备好的模拟数据注入到各个状态变量中,为后续的 UI 渲染提供燃料。
五、辅助计算方法集
5.1 进度与格式化方法
getProgress(pages: number, total: number): number {
if (total === 0) { return 0 }
return Math.round(pages * 100 / total)
}
getStarStr(rating: number): string {
if (rating === 5) { return '★★★★★' }
if (rating === 4) { return '★★★★☆' }
if (rating === 3) { return '★★★☆☆' }
if (rating === 2) { return '★★☆☆☆' }
return '★☆☆☆☆'
}
getTagStr(tags: string[]): string {
return tags.join(' · ')
}
这三个方法是应用中最基础的工具函数,分别处理进度计算、评分格式化与标签拼接。
getProgress 方法接收已读页数与总页数,返回一个 0~100 的整数百分比。它的第一行做了一个防御性判断:if (total === 0) { return 0 },避免当总页数为 0 时发生除以零的运算错误(在 JavaScript 中除以零会得到 Infinity,而非报错,但这显然不是期望的显示结果)。随后使用 Math.round 对结果四舍五入,保证返回的是整数百分比,避免出现 73.33333% 这样的长小数。这个方法在书籍卡片进度条、目标卡片进度条、在读卡片百分比显示等多处被调用,是整个应用复用频率最高的方法之一。
getStarStr 方法将 1~5 的数字评分转换为五星字符串表示。它采用了一系列 if 判断,针对每个评分等级返回对应的实心星(★)与空心星(☆)组合。例如评分 4 返回 '★★★★☆'(四实一空)。这种用 Unicode 字符模拟星级评分的方式无需任何图片资源,纯文本即可渲染出直观的评分视觉效果,是轻量级应用的常见技巧。方法末尾的 return '★☆☆☆☆' 是默认分支,处理评分低于 2 的情况(包括 1 和 0)。
getTagStr 方法将标签数组用中圆点连接符拼接为一个字符串。例如 ['经典', '魔幻现实', '拉美文学'] 会被拼接为 '经典 · 魔幻现实 · 拉美文学'。使用 Array.join() 方法是实现此功能最简洁的方式,中圆点 · 作为分隔符既美观又符合中文排版习惯。
5.2 聚合统计方法
getCompletedCount(): number {
let count = 0
for (let b of this.books) { if (b.isCompleted) { count++ } }
return count
}
getTotalPages(): number {
let total = 0
for (let b of this.books) { total += b.currentPage }
return total
}
getCategoryCount(cat: string): number {
let count = 0
for (let b of this.books) { if (b.category === cat) { count++ } }
return count
}
这三个方法都是对 books 数组进行遍历聚合的计算函数。
getCompletedCount 统计已读完的书籍数量,通过 for...of 循环遍历 books,每当遇到 isCompleted 为 true 的书籍就将计数器加一。getTotalPages 累加所有书籍的 currentPage(当前阅读页数),得到总阅读页数。getCategoryCount 接收一个分类名称参数,统计该分类下的书籍数量。
这三个方法的共同特点是在每次被调用时都重新遍历整个 books 数组进行实时计算,而非缓存计算结果。这种"即时计算"策略的好处是数据始终与 books 数组保持同步——当书籍被删除或进度被更新后,下一次调用这些方法就能得到最新的统计值,无需手动维护缓存的更新逻辑。代价是每次调用都要遍历数组,但在本应用 25 本书的数据规模下,性能开销可以忽略不计。如果数据量上升到数千本,则应考虑引入缓存或增量更新机制。
值得注意的是,这些方法被声明为组件的成员方法(而非独立的顶层函数),因此可以通过 this 访问组件的 books 状态。在 ArkUI 的 @Builder 方法中,调用这些方法的方式是 this.getCompletedCount(),由于它们读取了 @State books,当 books 变化时,引用这些方法的 UI 节点会自动重新渲染。
六、弹窗交互逻辑:操作方法集
6.1 提交与关闭方法
doAddBook(): void {
this.showAddBook = false
}
doEditBook(): void {
this.showEditBook = false
}
doDeleteBook(): void {
if (this.selectedIdx >= 0 && this.selectedIdx < this.books.length) {
this.books.splice(this.selectedIdx, 1)
}
this.showDeleteBook = false
this.selectedIdx = -1
}
doUpdateProgress(): void {
this.showProgressModal = false
}
这一组以 do 为前缀的方法是各弹窗的"提交"动作处理器。
doAddBook 和 doEditBook 的实现目前只做了关闭弹窗的操作(将对应的 show* 状态置为 false),并未真正将表单数据写入 books 数组。这说明应用当前处于演示原型阶段,表单的提交逻辑被简化为仅关闭弹窗。在真实应用中,doAddBook 应当将 formTitle、formAuthor 等表单字段组装为一个新的 BookModel 并 push 到 this.books 中;doEditBook 应当根据 selectedIdx 将表单字段写回 this.books[selectedIdx] 的对应属性。
doDeleteBook 是唯一真正执行了数据变更的方法。它首先做边界校验:this.selectedIdx >= 0 && this.selectedIdx < this.books.length,确保选中的索引合法有效,然后调用 this.books.splice(this.selectedIdx, 1) 从数组中移除该位置的元素。splice 是 JavaScript 数组的原生方法,第一个参数是起始索引,第二个参数是要删除的元素个数。删除完成后,关闭删除弹窗并重置 selectedIdx 为 -1。由于 books 是 @State 变量,splice 操作会触发 ArkUI 的数组变更检测,自动重新渲染依赖 books 的 UI 节点——书单列表会实时减少一项,统计数字也会同步更新。这就是声明式 UI 与响应式状态的威力:开发者只需关注"数据怎么变",UI 的更新由框架自动完成。
doUpdateProgress 与 doAddBook/doEditBook 类似,目前仅关闭弹窗,真实应用中应将 formCurrent 写回 books[selectedIdx].currentPage。
6.2 弹窗打开方法
openEdit(idx: number): void {
this.selectedIdx = idx
this.formTitle = this.books[idx].title
this.formAuthor = this.books[idx].author
this.formCategory = this.books[idx].category
this.formTotal = this.books[idx].totalPages
this.formCurrent = this.books[idx].currentPage
this.formRating = this.books[idx].rating
this.formNotes = this.books[idx].notes
this.showEditBook = true
}
openDelete(idx: number): void {
this.selectedIdx = idx
this.showDeleteBook = true
}
openProgress(idx: number): void {
this.selectedIdx = idx
this.formCurrent = this.books[idx].currentPage
this.formTotal = this.books[idx].totalPages
this.showProgressModal = true
}
openQuote(idx: number): void {
this.selectedIdx = idx
this.showQuoteModal = true
}
这一组以 open 为前缀的方法负责"打开弹窗前的数据准备"。它们都接收一个 idx 参数,即被操作书籍在 books 数组中的索引。
openEdit 是最复杂的一个,它在打开编辑弹窗之前,将目标书籍的现有数据逐一拷贝到表单状态变量中:formTitle、formAuthor、formCategory、formTotal、formCurrent、formRating、formNotes。这种"先回填再展示"的模式使得编辑弹窗在打开时,输入框中已经显示了书籍的当前信息,用户可以在此基础上进行修改,而非面对空白表单。这是编辑功能的标准交互范式。最后将 showEditBook 置为 true 触发弹窗显示。
openDelete 只需记录 selectedIdx 并显示删除确认弹窗,无需回填表单数据,因为删除操作不需要用户输入。openProgress 回填了 formCurrent 和 formTotal 两个字段,因为进度更新弹窗只需要修改当前页数。openQuote 仅记录索引并显示摘录弹窗,摘录内容直接从 books[selectedIdx].keyQuotes 读取。
这四个方法体现了"打开弹窗即完成数据预载"的设计模式,将数据准备与弹窗显示封装在一个方法中,调用方只需 this.openEdit(idx) 一行代码即可完成全部准备工作,接口简洁。
七、底部导航构建器
7.1 bottomNav 导航容器
@Builder bottomNav() {
Row() {
this.bottomTabItem('📚', '书单', BookTab.LIBRARY)
this.bottomTabItem('📖', '在读', BookTab.READING)
this.bottomTabItem('📊', '统计', BookTab.STATS)
this.bottomTabItem('👤', '我的', BookTab.PROFILE)
}
.width('100%').height(56)
.backgroundColor('#FFFFFF')
.padding({ bottom: 4 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
@Builder 是 ArkUI 中用于定义可复用 UI 片段的装饰器。被 @Builder 修饰的方法返回一段 UI 结构,可以在 build() 或其他 @Builder 中通过 this.xxx() 的方式调用。bottomNav 就是底部导航栏的构建器。
它以一个 Row(水平行容器)为根,内部依次调用了四次 this.bottomTabItem(...),分别构建四个导航项。Row 的宽度为 100%、高度为 56vp(虚拟像素),背景色为纯白 #FFFFFF,底部内边距 4vp。最关键的是 .shadow() 属性——它为导航栏添加了一个向上的阴影:radius: 8 控制模糊半径,color: '#1A000000' 是带有 0x1A(约 10%)透明度的黑色,offsetY: -2 表示阴影向上偏移 2vp。这个向上投射的阴影使得白色导航栏与上方的浅黄色内容区之间产生了视觉分离感,营造出导航栏"浮"在内容之上的层次效果。
7.2 bottomTabItem 单个导航项
@Builder bottomTabItem(icon: string, label: string, tab: BookTab) {
Column() {
Text(icon).fontSize(22)
.opacity(this.activeTab === tab ? 1.0 : 0.4)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? '#FF8F00' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column().width(16).height(3)
.backgroundColor('#FF8F00').borderRadius(2).margin({ top: 3 })
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 4 })
.onClick(() => { this.activeTab = tab })
}
bottomTabItem 是单个导航项的构建器,接收三个参数:icon(Emoji 图标)、label(文字标签)、tab(对应的 BookTab 枚举值)。它的精妙之处在于通过 this.activeTab === tab 的条件判断,实现了选中态与未选中态的差异化渲染。
每个导航项是一个垂直排列的 Column,内部从上到下依次是:图标 Text、标签 Text,以及(仅在选中时显示的)底部指示条。图标的透明度在选中时为 1.0(完全不透明),未选中时降为 0.4(半透明),形成"高亮 vs 淡化"的视觉对比。标签文字在选中时为琥珀色 #FF8F00 且加粗(FontWeight.Bold),未选中时为灰色 #999999 且常规字重。当导航项被选中时,还会在标签下方渲染一个 16×3vp 的琥珀色小圆角条作为选中指示器,进一步强化选中态的视觉反馈。
容器使用 .layoutWeight(1) 使四个导航项在 Row 中均分宽度,.alignItems(HorizontalAlign.Center) 使内部内容水平居中。.onClick(() => { this.activeTab = tab }) 是导航的核心交互逻辑——点击导航项时,将 activeTab 状态切换为该项对应的枚举值,由于 activeTab 是 @State 变量,框架会自动重新渲染 build() 中依赖 activeTab 的条件分支,从而切换显示的 Tab 页内容。这就是整个应用导航流转的核心机制:点击 → 状态变更 → UI 自动刷新。
八、模态弹窗体系
8.1 modalBg 通用遮罩层
@Builder modalBg(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.45)').onClick(onClose)
}
modalBg 是所有模态弹窗共用的半透明遮罩层构建器。它接收一个 onClose 回调函数作为参数,渲染一个铺满全屏的 Column,背景色为 rgba(0,0,0,0.45)——即透明度为 45% 的纯黑色。这个遮罩层覆盖在页面内容之上,使背景内容变暗,从而将用户的视觉焦点引导到弹窗主体上。同时,遮罩层绑定了 onClick(onClose),即点击遮罩区域的任意位置都会触发关闭回调,关闭对应的弹窗。这是模态弹窗的标准交互范式——点击遮罩关闭。
@Builder 方法可以接收参数是 ArkUI 的一个重要特性。这里 onClose 是一个函数类型的参数,通过传入不同的回调函数,同一个 modalBg 构建器可以服务于不同的弹窗关闭逻辑(如 () => { this.showAddBook = false } 或 () => { this.showEditBook = false }),实现了高度的复用性。
8.2 addBookModal 添加书籍弹窗
@Builder addBookModal() {
Column() {
this.modalBg(() => { this.showAddBook = false })
Column() {
Scroll() {
Column() {
Text('添加书籍').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 16 })
Text('书名').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '请输入书名' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formTitle = v })
// ... 作者、分类、总页数、当前页、开始日期、笔记等输入字段
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddBook = false })
Column().layoutWeight(1)
Text('添加').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doAddBook() })
}.width('100%')
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(999)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
addBookModal 是添加书籍弹窗的构建器,结构较为复杂,体现了 ArkUI 构建表单弹窗的完整范式。
最外层是一个铺满全屏的 Column,通过 .position({ x: 0, y: 0 }).zIndex(999) 定位在屏幕左上角并设置较高的 z 轴层级,确保它覆盖在页面内容之上。.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) 使弹窗主体在屏幕中水平和垂直居中。
外层 Column 内部首先调用 this.modalBg(...) 渲染遮罩层,然后渲染弹窗主体。弹窗主体是一个宽度 90%、背景白色、圆角 18vp 的 Column,通过 .constraintSize({ maxHeight: '85%' }) 限制最大高度不超过屏幕的 85%,并添加了较大模糊半径(24)的阴影模拟悬浮效果。主体内部包裹了一个 Scroll 组件——这是关键设计:当表单内容较多超出弹窗最大高度时,用户可以在弹窗内部滚动查看和填写所有字段,而不会因为内容溢出而无法操作。
表单内容由标题"添加书籍"和一系列"标签 + 输入框"对组成。每个字段遵循统一的布局模式:先用一个小号灰色 Text 显示字段标签(如"书名"“作者”),下方紧跟一个 TextInput 输入框。输入框统一采用浅灰背景 #F5F5F5、8vp 圆角、40vp 高度、左侧 12vp 内边距的样式,视觉风格一致。TextInput 的 onChange 回调将用户输入的值实时同步到对应的 @State 表单变量中(如 this.formTitle = v),实现数据绑定。对于数字类型字段(总页数、当前页),通过 .type(InputType.Number) 限制输入为数字键盘。
弹窗底部是一个 Row,左侧是"取消"文字按钮(点击关闭弹窗),中间用 Column().layoutWeight(1) 占位撑开,右侧是琥珀色的"添加"按钮(点击调用 this.doAddBook())。这种"取消在左、确认在右"的按钮布局是对话框的经典交互规范。
8.3 editBookModal 编辑书籍弹窗
@Builder editBookModal() {
Column() {
this.modalBg(() => { this.showEditBook = false })
Column() {
Scroll() {
Column() {
Text('编辑书籍')...
TextInput({ text: this.formTitle })
.onChange((v: string) => { this.formTitle = v })
// ... 作者、分类、当前页、评分等字段
Row() {
Text('取消')...onClick(() => { this.showEditBook = false })
Text('更新')...onClick(() => { this.doEditBook() })
}
}
}
}
.width('90%')...zIndex(1000)
}
}
editBookModal 的结构与 addBookModal 高度相似,区别在于两点。第一,TextInput 使用的是 text 参数而非 placeholder 参数:TextInput({ text: this.formTitle })。text 参数用于设置输入框的初始显示值,这意味着编辑弹窗打开时,输入框中已经回填了书籍的现有数据(这些数据在 openEdit 方法中已被预载到表单状态变量中),用户看到的是"已填充"的表单而非空白表单。第二,按钮文字从"添加"变为"更新",点击调用 this.doEditBook()。zIndex 设为 1000,略高于添加弹窗的 999,确保编辑弹窗在层级上优先于添加弹窗。
8.4 deleteBookModal 删除确认弹窗
@Builder deleteBookModal() {
Column() {
this.modalBg(() => { this.showDeleteBook = false })
Column() {
Text('确认删除').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 8 })
Text('删除后将移除该书籍及所有阅读记录,确认删除?').fontSize(14)
.fontColor('#666666').textAlign(TextAlign.Center)
.padding({ left: 16, right: 16 }).margin({ bottom: 20 })
Row() {
Text('取消')...backgroundColor('#F5F5F5')
.onClick(() => { this.showDeleteBook = false })
Column().width(16)
Text('删除')...backgroundColor('#E53935')
.onClick(() => { this.doDeleteBook() })
}
}
.width('80%')...zIndex(1001)
}
}
deleteBookModal 是一个简洁的确认对话框,用于在删除书籍前向用户进行二次确认。弹窗主体宽度为 80%(比表单弹窗的 90% 更窄),内部没有 Scroll 包裹,因为内容简短不需要滚动。
主体内容由标题"确认删除"、警示文案、以及两个按钮组成。警示文案"删除后将移除该书籍及所有阅读记录,确认删除?"使用 textAlign(TextAlign.Center) 居中显示,向用户明确说明删除操作的后果。按钮区域的布局与表单弹窗不同:取消按钮和删除按钮之间用 Column().width(16) 作为固定间距(而非 layoutWeight 弹性占位),两个按钮都采用了胶囊形(borderRadius(20))的填充式样式。删除按钮使用红色 #E53935 作为背景色,这是危险操作的标准警示色,与取消按钮的浅灰背景形成鲜明对比,引导用户谨慎操作。zIndex 为 1001。
8.5 progressModal 进度更新弹窗
@Builder progressModal() {
Column() {
this.modalBg(() => { this.showProgressModal = false })
Column() {
Text('更新阅读进度')...
Text('当前页数')...
TextInput({ text: this.formCurrent.toString() })
.type(InputType.Number)...
Text('共 ' + this.formTotal.toString() + ' 页')...
Row() {
Text('取消')...onClick(() => { this.showProgressModal = false })
Text('更新')...onClick(() => { this.doUpdateProgress() })
}
}
.width('80%')...zIndex(1002)
}
}
progressModal 是一个专用于更新阅读进度的轻量弹窗。它只包含一个数字输入框,用于修改当前阅读页数。输入框通过 text: this.formCurrent.toString() 回填当前页数,type(InputType.Number) 限制为数字输入。输入框下方有一行辅助文案"共 X 页",通过字符串拼接 this.formTotal.toString() 显示总页数,为用户提供参照。zIndex 为 1002。
8.6 quoteModal 摘录弹窗
@Builder quoteModal() {
Column() {
this.modalBg(() => { this.showQuoteModal = false })
Column() {
Text('💬 精彩摘录')...
Text(this.books.length > 0 && this.selectedIdx >= 0 ? this.books[this.selectedIdx].title : '')...
Text('"' + (...keyQuotes[0]...) + '"')
.fontStyle(FontStyle.Italic).textAlign(TextAlign.Center)...
Text('关闭')...onClick(() => { this.showQuoteModal = false })
}
.width('85%')...zIndex(1003)
}
}
quoteModal 用于展示书籍的精彩金句摘录。弹窗标题"💬 精彩摘录"带有 Emoji 点缀。书名通过条件表达式 this.books.length > 0 && this.selectedIdx >= 0 ? this.books[this.selectedIdx].title : '' 安全读取,前半部分确保 books 数组非空,后半部分确保 selectedIdx 合法,双重防御避免了数组越界访问。金句文本用双引号包裹,并通过 .fontStyle(FontStyle.Italic) 设置为斜体,模拟引文的排版风格。弹窗底部只有一个"关闭"按钮,因为这是纯展示型弹窗,无需提交操作。zIndex 为 1003。
整个弹窗体系通过递增的 zIndex(999→1000→1001→1002→1003)确保了各弹窗的层叠顺序,虽然实际上同时只会有一个弹窗显示,但这种层级管理为未来的多弹窗叠加场景预留了空间。
九、卡片构建器:goalCard 与 bookCard
9.1 goalCard 目标卡片
@Builder goalCard(item: GoalConfig) {
Column() {
Row() {
Text(item.icon).fontSize(20).margin({ right: 8 })
Column() {
Text(item.label).fontSize(12).fontColor('#666666')
Text(item.current.toString() + ' / ' + item.target.toString())
.fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4E342E')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
}.width('100%').margin({ bottom: 8 })
Row() {
Column()
.width(this.getProgress(item.current, item.target).toString() + '%')
.height(8).backgroundColor(item.color)
.borderRadius(4)
Column().layoutWeight(1)
}
.width('100%').height(8).backgroundColor('#F5F5F5').borderRadius(4)
}
.width('100%').backgroundColor('#FFFFF5').borderRadius(12)
.padding(14).margin({ bottom: 10 })
}
goalCard 构建器渲染单个阅读目标卡片,接收一个 GoalConfig 类型的参数。
卡片整体是一个 Column,背景色为极浅的暖黄 #FFFFF5(比页面背景 #FFF8E1 更浅,形成微妙的层次区分),12vp 圆角,14vp 内边距。内容分上下两部分:上方是一个 Row,左侧是目标 Emoji 图标,右侧是一个 Column 包含目标名称(小号灰色文字)和"当前值 / 目标值"的数值文本(加粗深棕色)。Column().layoutWeight(1) 使文字部分占据图标右侧的所有剩余空间。
下方是进度条,这是卡片的核心视觉元素。进度条的实现方式非常巧妙:外层是一个 Row,宽度 100%、高度 8vp、背景色浅灰 #F5F5F5、圆角 4vp,作为进度条的"轨道"。轨道内部有两个 Column:第一个 Column 的宽度通过 this.getProgress(item.current, item.target).toString() + '%' 动态计算——将进度百分比转换为字符串并拼接 % 符号作为宽度值(如 "71%"),高度 8vp、背景色为目标的专属颜色、圆角 4vp,作为进度条的"已填充部分"。第二个 Column().layoutWeight(1) 占据剩余空间,作为"未填充部分"。
这种"两个 Column 拼接模拟进度条"的方式是 ArkUI 中实现进度条的常见技巧。由于 ArkUI 的 Progress 组件样式定制能力有限,开发者常通过这种自定义布局来实现完全可控的进度条视觉效果。当 current 或 target 发生变化时,getProgress 重新计算百分比,第一个 Column 的宽度自动更新,进度条视觉实时变化。
9.2 bookCard 书籍卡片
@Builder bookCard(book: BookModel, idx: number) {
Row() {
Column() {
Text(book.coverEmoji).fontSize(36)
}
.width(68).height(88).backgroundColor(book.coverColor).borderRadius(10)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.shadow({ radius: 4, color: '#20000000', offsetY: 2 })
Column() {
Row() {
Column() {
Text(book.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#4E342E').maxLines(1)
Text(book.author).fontSize(11).fontColor('#999999').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
if (book.isCompleted) {
Text('已读完').fontSize(9).fontColor('#4CAF50')
.backgroundColor('#E8F5E9').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
} else {
Text('在读').fontSize(9).fontColor('#FF8F00')
.backgroundColor('#FFF8E1').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
}.width('100%')
Row() {
Text(book.category).fontSize(10).fontColor('#FF8F00')
.backgroundColor('#FFF3E0').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(this.getTagStr(book.tags)).fontSize(9).fontColor('#AAAAAA').margin({ left: 6 }).maxLines(1)
}.margin({ top: 4 })
Row() {
Text(this.getStarStr(book.rating)).fontSize(9).fontColor('#FF8F00')
Text(book.currentPage.toString() + '/' + book.totalPages.toString() + '页')
.fontSize(10).fontColor('#999999').margin({ left: 8 })
}.margin({ top: 4 })
Row() {
Column()
.width(this.getProgress(book.currentPage, book.totalPages).toString() + '%')
.height(4).backgroundColor('#FF8F00').borderRadius(2)
Column().layoutWeight(1)
}
.width('100%').height(4).backgroundColor('#F0F0F0').borderRadius(2).margin({ top: 6 })
Row() {
Text('编辑').fontSize(10).fontColor('#FF8F00')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.border({ width: 1, color: '#FF8F00' }).borderRadius(10)
.onClick(() => { this.openEdit(idx) })
Text(book.isCompleted ? '摘录' : '进度').fontSize(10).fontColor('#FF8F00')
.border({ width: 1, color: '#FF8F00' }).borderRadius(10).margin({ left: 6 })
.onClick(() => { book.isCompleted ? this.openQuote(idx) : this.openProgress(idx) })
Column().layoutWeight(1)
Text('删除').fontSize(10).fontColor('#E53935')
.border({ width: 1, color: '#E53935' }).borderRadius(10)
.onClick(() => { this.openDelete(idx) })
}.width('100%').margin({ top: 8 })
}
.layoutWeight(1).margin({ left: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(14).margin({ bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
}
bookCard 是整个应用中最复杂、信息密度最高的构建器,它渲染单本书的完整卡片视图,接收 book(书籍数据)和 idx(数组索引)两个参数。
卡片整体是一个水平 Row,分为左右两部分。左侧是封面区:一个 68×88vp 的 Column,背景色为书籍的 coverColor,10vp 圆角,内部居中显示一个 36 号字号的 Emoji 封面图标。封面区添加了轻微的阴影(offsetY: 2),使其产生立体浮起的效果,模拟实体书封面的质感。
右侧是信息区,通过 .layoutWeight(1) 占据封面右侧的全部剩余空间,左侧 12vp 间距。信息区内部从上到下包含五层内容:
第一层是标题行:左侧 Column 包含书名(15号加粗深棕色,maxLines(1) 限制单行)和作者(11号灰色),右侧是状态标签——通过 if...else 条件渲染,已读完的书显示绿色"已读完"标签(#4CAF50 文字 + #E8F5E9 背景),正在读的书显示琥珀色"在读"标签。状态标签的字号极小(9号),采用胶囊形小标签样式。
第二层是分类与标签行:分类名以琥珀色小标签形式显示,旁边是用 getTagStr 拼接的标签字符串(灰色小字,单行显示)。
第三层是评分与页数行:左侧是 getStarStr 转换的五星字符串,右侧是"当前页/总页"的页数信息。
第四层是进度条:与 goalCard 中的进度条实现方式完全一致,但高度更细(4vp),颜色固定为琥珀色,视觉上更加精致。
第五层是操作按钮行:三个文字按钮——“编辑”(调用 openEdit)、“摘录"或"进度”(根据 isCompleted 状态决定:已读完显示"摘录"调用 openQuote,正在读显示"进度"调用 openProgress)、“删除”(调用 openDelete,红色边框)。按钮采用描边样式(border 而非 backgroundColor),10vp 圆角的胶囊形。编辑和摘录/进度按钮在左侧,删除按钮在右侧,中间用 layoutWeight(1) 撑开,形成"主要操作在左、危险操作在右"的布局,符合用户操作习惯与安全设计原则。
整个卡片以白色背景、14vp 圆角、14vp 内边距呈现,带有轻微阴影,悬浮在浅黄色页面背景之上,视觉层次分明。bookCard 是书单页的核心展示单元,它的信息架构直接决定了用户浏览书单时的信息获取效率。
9.3 readingCard 在读卡片
@Builder readingCard(book: BookModel, idx: number) {
Column() {
Column() {
Text(book.coverEmoji).fontSize(32)
}
.width(60).height(80).backgroundColor(book.coverColor).borderRadius(8)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Text(book.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4E342E')
.maxLines(1).margin({ top: 8 }).width(100).textAlign(TextAlign.Center)
Text(book.author).fontSize(10).fontColor('#999999').maxLines(1)
Row() {
Column()
.width(this.getProgress(book.currentPage, book.totalPages).toString() + '%')
.height(4).backgroundColor('#FF8F00').borderRadius(2)
Column().layoutWeight(1)
}
.width(100).height(4).backgroundColor('#F0F0F0').borderRadius(2).margin({ top: 6 })
Text(this.getProgress(book.currentPage, book.totalPages).toString() + '%')
.fontSize(10).fontColor('#FF8F00').margin({ top: 4 })
Text('更新进度').fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#FF8F00').borderRadius(10)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.margin({ top: 8 })
.onClick(() => { this.openProgress(idx) })
}
.alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(14)
.padding({ top: 16, bottom: 14, left: 16, right: 16 })
.margin({ right: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
}
readingCard 是在读 Tab 页横向滚动列表中的单本书卡片,与 bookCard 相比,它的信息更精简、布局更紧凑,适合在水平排列中展示。
卡片是一个垂直 Column,内容居中对齐(.alignItems(HorizontalAlign.Center))。从上到下依次是:封面(60×80vp,比 bookCard 的 68×88vp 略小)、书名(13号加粗,固定宽度 100vp 居中,单行)、作者(10号灰色)、进度条(宽度固定 100vp)、进度百分比数字(琥珀色)、"更新进度"按钮(琥珀色填充按钮,点击调用 openProgress)。
与 bookCard 的关键差异在于:readingCard 隐藏了分类、标签、评分、笔记等信息,只保留了识别一本书最核心的要素——封面、书名、作者、进度,以及一个直接的"更新进度"行动入口。这种信息精简是合理的,因为"在读"页面的核心场景是快速查看正在阅读的书的进度并便捷地更新它,过多的信息反而会干扰核心操作。卡片右侧 10vp 的 margin 在水平排列时形成卡片间距。这种"精简版卡片"的设计体现了"场景决定信息密度"的 UI 设计原则——同一数据在不同场景下应有不同的呈现粒度。
十、Tab 页构建器:libraryTab 与 readingTab
10.1 libraryTab 书单页
@Builder libraryTab() {
Scroll() {
Column() {
Row() {
Text('阅读目标').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#4E342E')
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 6 })
Column() {
this.goalCard(this.goals[0])
this.goalCard(this.goals[1])
this.goalCard(this.goals[2])
}
.padding({ left: 12, right: 12 })
Row() {
Text('所有书籍').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#4E342E')
Column().layoutWeight(1)
Text('+ 添加').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(16)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.showAddBook = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
Column() {
this.bookCard(this.books[0], 0)
this.bookCard(this.books[1], 1)
// ... 共 25 个 bookCard 调用
this.bookCard(this.books[24], 24)
}
.padding({ left: 12, right: 12 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
libraryTab 是书单页的构建器,也是应用的默认首页。它以 Scroll 为根容器,使整个页面内容可以纵向滚动,并通过 .scrollBar(BarState.Off) 隐藏了滚动条,使界面更加干净。.layoutWeight(1) 使 Scroll 占据顶部标题栏与底部导航栏之间的全部可用空间。
页面内容分为四个区块。第一区块是"阅读目标"标题,一个简单的 Row 包含标题文字。第二区块是目标卡片列表,通过三次调用 this.goalCard(this.goals[0/1/2]) 渲染三个目标卡片。第三区块是"所有书籍"标题行,左侧标题,右侧琥珀色的"+ 添加"按钮(点击将 showAddBook 置为 true 打开添加弹窗)。第四区块是书籍卡片列表,通过 25 次 this.bookCard(this.books[n], n) 调用渲染全部 25 本书。
这里有一个值得讨论的实现细节:书籍卡片是通过手动展开 25 次调用而非使用 ForEach 循环渲染的。在 ArkUI 中,ForEach 是渲染列表数据的标准方式,它能够根据数据源的变化自动增删 UI 节点。本应用采用手动展开的方式,可能是因为数据规模固定(演示场景),且 ForEach 需要为每个元素提供唯一的键值(keyGenerator)。但这种方式在数据动态变化时存在局限——例如删除某本书后,索引会错位。不过由于 doDeleteBook 使用了 splice 删除数组元素,ArkUI 的数组变更检测仍会触发重新渲染,只是渲染逻辑不够"声明式"。在真实应用中,推荐使用 ForEach 替代手动展开。
页面末尾的 Column().height(20) 是一个 20vp 高的占位元素,为底部导航栏留出视觉间距,避免最后一张卡片紧贴导航栏。
10.2 readingTab 在读页
@Builder readingTab() {
Scroll() {
Column() {
Text('正在阅读')...
Row() {
this.readingCard(this.readingNow[0], 0)
this.readingCard(this.readingNow[1], 1)
this.readingCard(this.readingNow[2], 2)
this.readingCard(this.readingNow[3], 3)
this.readingCard(this.readingNow[4], 4)
}
.padding({ left: 12, right: 4 })
Text('阅读摘录')...
Column() {
this.quoteHighlight(this.books[0])
this.quoteHighlight(this.books[3])
// ... 8 个摘录卡片
}
Text('阅读笔记')...
Column() {
this.noteCard(this.books[2])
this.noteCard(this.books[4])
// ... 6 个笔记卡片
}
Column().height(20)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
readingTab 是在读页的构建器,内容比书单页更加丰富多元,由三个区块组成。
第一区块"正在阅读":标题下方是一个 Row,内部水平排列 5 个 readingCard。由于 5 张卡片的总宽度可能超出屏幕宽度,这个 Row 应当配合横向滚动使用,但当前代码中 Row 未包裹在 Scroll 中,意味着如果屏幕宽度不足,卡片可能被压缩或溢出。在实际运行中,ArkUI 的 Row 默认不会自动滚动,这是一个潜在的小问题。不过由于每张卡片宽度较窄(约 132vp),在主流手机屏幕上 5 张卡片恰好可以横向排列。
第二区块"阅读摘录":通过 8 次 this.quoteHighlight(this.books[n]) 调用渲染 8 条金句摘录卡片,选用了 books 数组中索引为 0、3、8、13、14、17、19、22 的书籍,覆盖了百年孤独、活着、围城、万历十五年、月亮与六便士、1984、影响力、小王子等经典作品的金句。
第三区块"阅读笔记":通过 6 次 this.noteCard(this.books[n]) 调用渲染 6 条阅读笔记卡片,选用索引 2、4、7、11、15、17 的书籍,展示三体、思考快与慢、明朝那些事儿、原则、基因传、1984 的读后感。
在读页的设计思路是"以内容为驱动"——它不再像书单页那样罗列所有书籍的元信息,而是聚焦于阅读过程中产生的"内容资产":正在读什么、摘录了什么、笔记写了什么。这种从"管理"到"沉浸"的场景转换,使得在读页具备了更强的阅读氛围感。
十一、摘录与笔记卡片构建器
11.1 quoteHighlight 摘录卡片
@Builder quoteHighlight(book: BookModel) {
Row() {
Column() {
Text(book.coverEmoji).fontSize(24)
}
.width(48).height(48).backgroundColor(book.coverColor).borderRadius(8)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Text('"' + book.keyQuotes[0] + '"').fontSize(13)
.fontColor('#555555').fontStyle(FontStyle.Italic).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('—— ' + book.title + ' · ' + book.author).fontSize(10)
.fontColor('#AAAAAA').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}
.width('100%').backgroundColor('#FFFDF5').borderRadius(12)
.border({ width: 1, color: '#FFE0B2' })
.padding(14).margin({ bottom: 8 })
}
quoteHighlight 渲染单条金句摘录卡片,接收一个 BookModel 参数。
卡片是一个 Row,左侧是 48×48vp 的小封面方块(比 bookCard 和 readingCard 的封面都小,因为摘录卡片的核心内容是文字而非书籍识别),右侧是金句文本区。
金句文本通过 "'" + book.keyQuotes[0] + "'" 拼接双引号包裹显示,字号 13、深灰色 #555555、斜体(FontStyle.Italic),最多 2 行(maxLines(2)),超出部分以省略号截断(textOverflow({ overflow: TextOverflow.Ellipsis }))。斜体是引文的经典排版样式,使金句在视觉上区别于普通正文。下方是出处信息"—— 书名 · 作者",10 号浅灰色,模仿学术引用的署名格式。
卡片整体背景色为极浅暖黄 #FFFDF5,带有 #FFE0B2(浅琥珀色)的 1vp 描边,营造出一种"便签纸"的质感。这种描边样式与其它卡片的阴影样式不同,使摘录卡片在视觉上自成一类,便于用户在浏览时快速识别。
11.2 noteCard 笔记卡片
@Builder noteCard(book: BookModel) {
Row() {
Column() {
Text(book.coverEmoji).fontSize(24)
}
.width(48).height(48)...
Column() {
Row() {
Text(book.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4E342E')
Column().layoutWeight(1)
Text(this.getStarStr(book.rating)).fontSize(8).fontColor('#FF8F00')
}.width('100%')
Text(book.notes).fontSize(11).fontColor('#888888').maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }).margin({ top: 4 })
Row() {
Text(book.isCompleted ? '已读完' : '在读')...
Text(book.category).fontSize(9).fontColor('#999999').margin({ left: 6 })
}.margin({ top: 6 })
}
.layoutWeight(1)...
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding(14).margin({ bottom: 8 })
.shadow({ radius: 3, color: '#10000000', offsetY: 1 })
}
noteCard 渲染单条阅读笔记卡片,结构与 quoteHighlight 类似但信息更丰富。右侧内容区包含三层:标题行(书名 + 评分星级,星级字号极小为 8 号)、笔记正文(11 号灰色,最多 2 行省略截断)、底部标签行(阅读状态标签 + 分类名)。
笔记卡片采用白色背景 + 阴影的样式(与摘录卡片的描边样式区分),使两类内容卡片在视觉上各有特色。笔记正文使用 maxLines(2) 限制为两行,因为笔记通常比金句更长,两行能展示更多内容同时避免单张卡片过高破坏列表的节奏感。
十二、数据可视化:monthBarChart 月度柱状图
@Builder monthBarChart() {
Column() {
Text('月度阅读页数').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 16 }).alignSelf(ItemAlign.Start)
Row() {
// 第 1~5 月的柱子
Column() {
Text(this.monthPages[0].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[0].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[0].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[0].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
// ... monthPages[1] ~ monthPages[4]
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
Row() {
// 第 6~10 月的柱子
// ... 同上结构
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
Row() {
// 第 11~12 月的柱子 + 2 个空占位
// ... monthPages[10], monthPages[11], Column().width(22), Column().width(22)
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, top: 10, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
}
monthBarChart 是月度阅读页数柱状图的构建器,也是应用中最具技术含量的可视化组件之一。它完全使用 ArkUI 原生组件(Column、Text、Row)手工绘制,未借助任何图表库。
柱状图包含 12 根柱子,对应 1~12 月。由于屏幕宽度有限,12 根柱子被拆分到三个 Row 中:第一个 Row 放 1~5 月(5 根),第二个 Row 放 6~10 月(5 根),第三个 Row 放 11~12 月(2 根)加 2 个空占位(Column().width(22),与前两行的 5 列对齐)。每个 Row 使用 .justifyContent(FlexAlign.SpaceAround) 使柱子均匀分布,.alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐——这是柱状图的基本要求。
每根柱子的结构是一个 Column,从上到下包含三部分:数值标签(页数,8 号灰色)、柱体(Column)、月份标签(8 号灰色)。柱体的核心计算在于高度:.height(this.monthPages[0].pages * 160 / this.maxPages)。这个公式将实际页数等比映射到 0~160vp 的像素高度范围——以 maxPages(1500)为满刻度,160vp 为最大柱高。例如 7 月份 1500 页,高度 = 1500 × 160 / 1500 = 160vp(满高);12 月份 450 页,高度 = 450 × 160 / 1500 = 48vp。柱体宽度固定 22vp,顶部圆角(borderRadius({ topLeft: 3, topRight: 3 })),背景色取自数据的 color 字段。
这种"纯组件绘制图表"的方式展现了 ArkUI 的灵活性。优点是零依赖、可完全定制样式、性能好(无 Canvas 绘制开销);缺点是代码量大(12 根柱子需手动展开 12 段几乎相同的代码),且不支持动画过渡。在真实项目中,对于这种重复结构,可以考虑用 ForEach 配合 @Builder 方法来简化。当前的手动展开方式虽然冗长,但每根柱子的数据绑定关系一目了然,便于调试。
十三、分类分布条:catDistBar
@Builder catDistBar(item: CategoryItem) {
Column() {
Row() {
Text(item.label).fontSize(12).fontColor('#555555').width(36)
Row() {
Column()
.width(item.percent.toString() + '%')
.height(14).backgroundColor(item.color)
.borderRadius({ topLeft: 7, bottomLeft: 7 })
if (item.percent < 100) {
Column().layoutWeight(1)
}
}
.layoutWeight(1).height(14).backgroundColor('#F5F5F5').borderRadius(7)
Text(item.percent.toString() + '%').fontSize(10).fontColor('#999999').width(34).textAlign(TextAlign.End)
Text(item.count.toString() + '本').fontSize(10).fontColor('#AAAAAA').width(24).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
}
.margin({ bottom: 8 })
}
catDistBar 渲染单个分类的分布条,是一种横向条形图。它接收一个 CategoryItem 参数,展示分类名称、占比条、百分比数值和书籍数量。
每一行是一个 Row,从左到右包含四部分:分类标签(固定宽度 36vp)、占比条(layoutWeight(1) 弹性宽度)、百分比文字(固定宽度 34vp,右对齐)、数量文字(固定宽度 24vp,右对齐)。固定宽度的文字列使所有行的数值在垂直方向上对齐,形成整齐的表格化视觉效果。
占比条的实现与进度条类似:外层 Row 是浅灰轨道(14vp 高,7vp 圆角),内部第一个 Column 宽度为 item.percent.toString() + '%'(如"36%"),背景色为分类颜色,左侧圆角。当 percent < 100 时,追加一个 Column().layoutWeight(1) 填充剩余空间。这个条件判断是必要的——如果百分比为 100%,就不需要填充剩余空间,否则会出现一个多余的空 Column。
十四、statsTab 统计页
@Builder statsTab() {
Scroll() {
Column() {
Row() {
Column() {
Text(this.books.length.toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
Text('总藏书').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
Column() {
Text(this.getCompletedCount().toString())...
Text('已读')...
}.layoutWeight(1)...margin({ left: 8 })
Column() {
Text(this.getTotalPages().toString())...
Text('已读页')...
}.layoutWeight(1)...margin({ left: 8 })
}
.width('100%').padding({ left: 12, right: 12, top: 12 })
this.monthBarChart()
Column() {
Text('分类统计')...
this.catDistBar(this.categories[0])
// ... 6 个分类分布条
}
Column() {
Text('本周阅读')...
Row() {
// 7 根柱子,每根高度 = streakDays[i].pages * 80 / 60
Column() {
Text(this.streakDays[0].pages.toString() + '页')...
Column()
.width(30)
.height(this.streakDays[0].pages * 80 / 60)
.backgroundColor(this.streakDays[0].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[0].day).fontSize(10)...
}.alignItems(HorizontalAlign.Center)
// ... 7 天
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
}
Column().height(20)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
statsTab 是统计页的构建器,是应用中数据可视化最集中的页面。页面从上到下包含四个数据模块。
第一个模块是顶部数据概览卡片:一个 Row 包含三个等宽的统计卡片(layoutWeight(1) 均分),分别显示"总藏书"数量(books.length)、"已读"数量(getCompletedCount())、"已读页"总数(getTotalPages())。每个卡片是一个白色背景、12vp 圆角的 Column,上方是大号加粗的数值(28 号字体,琥珀色系),下方是小号灰色标签。三个数值使用了琥珀色系的三种深浅(#FF8F00、#FFB300、#FF8F00),在视觉上形成色彩层次。卡片之间通过 margin({ left: 8 }) 留出间距。
第二个模块是月度柱状图:直接调用 this.monthBarChart(),即上文详细分析的 12 月柱状图。
第三个模块是分类统计:一个白色卡片容器内包含标题"分类统计"和 6 个 catDistBar 调用,展示六大分类的占比分布。
第四个模块是本周阅读柱状图:结构与月度柱状图类似,但只有 7 根柱子(周一到周日),且高度计算公式不同:this.streakDays[0].pages * 80 / 60。这里以 60 页为满刻度(因为本周最高阅读量为周六的 60 页),映射到 80vp 的最大柱高。柱子宽度 30vp(比月度柱子的 22vp 更宽,因为只有 7 根柱子有更充裕的水平空间),顶部圆角 4vp。每根柱子上方显示"X页"数值,下方显示星期简称。
统计页的设计思路是"一屏纵览全局"——通过数值卡片、柱状图、条形图三种可视化形式的组合,让用户在不滚动的情况下就能快速获取阅读量的整体概况,向下滚动则能查看更细致的分类与周度数据。这种"概览在前、细节在后"的信息架构是数据看板类页面的通用设计模式。
十五、profileTab 我的页与 profOptionItem
15.1 profileTab 我的页
@Builder profileTab() {
Scroll() {
Column() {
Column() {
Column().width(72).height(72).backgroundColor('#FFE0B2').borderRadius(36)
Row() {
Column().width(72).height(72).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Text('📚').fontSize(32)
}
Column()
Text('读书人').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#4E342E').margin({ top: 12 })
Text('阅读是一座随身携带的避难所').fontSize(13).fontColor('#999999').margin({ top: 4 })
}
.width('100%').alignItems(HorizontalAlign.Center).padding({ top: 30, bottom: 20 })
Row() {
Column() {
Text(this.books.length.toString()).fontSize(24)...
Text('藏书')...
}.layoutWeight(1)...
Column() {
Text(this.getCompletedCount().toString())...
Text('已读')...
}.layoutWeight(1)...
Column() {
Text(this.getTotalPages().toString())...
Text('页数')...
}.layoutWeight(1)...
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 16, bottom: 16 }).margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column() {
this.profOptionItem('📚', '我的书架', this.books.length.toString() + '本书')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('✏️', '读书笔记', this.getCompletedCount().toString() + '篇')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('💬', '摘录收藏', '25条金句')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('🎯', '阅读目标', '年度24本 | 已完成17本')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('⚙️', '偏好设置', '主题与提醒')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
profileTab 是"我的"个人中心页的构建器。页面从上到下分为三个区块。
第一区块是用户头部信息:一个居中的 Column,包含头像和用户信息。头像的实现比较特殊——先渲染了一个 72×72vp、背景色 #FFE0B2(浅琥珀色)、borderRadius(36)(圆形)的 Column 作为头像底色,然后在上方叠加了一个同样尺寸的 Column(通过 Row 包裹)居中显示 📚 Emoji 作为头像图标。这种"底层色块 + 上层 Emoji"的叠加方式模拟了用户头像的效果。头像下方依次是用户名"读书人"(20 号加粗)和个性签名"阅读是一座随身携带的避难所"(13 号灰色,这句话引自毛姆)。整个头部区域居中对齐,上下各有 30vp 和 20vp 的内边距,留白充足,营造个人主页的仪式感。
第二区块是数据概览行:与统计页顶部的三宫格类似,展示藏书数、已读数、页数三个核心数据,但字号略小(24 号 vs 28 号),背景为白色卡片。
第三区块是功能选项列表:一个白色卡片容器内包含 5 个 profOptionItem 调用,选项之间用 1vp 高、#FFF3E0(浅琥珀色)背景色的 Row 作为分隔线。五个选项分别是"我的书架"“读书笔记”“摘录收藏”“阅读目标”“偏好设置”,每个选项的右侧副标题动态显示对应的数据(如"25本书""17篇"等)或静态文案。
15.2 profOptionItem 选项项
@Builder profOptionItem(icon: string, title: string, sub: string) {
Row() {
Text(icon).fontSize(18).margin({ right: 12 })
Column() {
Text(title).fontSize(15).fontColor('#333333')
Text(sub).fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('>').fontSize(14).fontColor('#CCCCCC')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
profOptionItem 是个人中心选项列表的单项构建器,接收 icon、title、sub 三个参数。每项是一个 Row:左侧 Emoji 图标(18 号)、中间标题+副标题的 Column(layoutWeight(1) 占满中间空间)、右侧 > 箭头符号(14 号浅灰色 #CCCCCC)。这个 > 箭头是列表项"可点击进入下一级"的视觉提示,是移动端设置列表的经典交互符号。整项有 16vp 水平内边距和 14vp 垂直内边距,点击区域充足。虽然当前代码中 profOptionItem 未绑定 onClick 事件,但在真实应用中,每项应跳转到对应的功能子页面。
十六、build 主构建方法:应用骨架
build() {
Stack() {
Column() {
Row() {
Text('读书笔记').fontSize(22).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column().layoutWeight(1)
Text('📖').fontSize(24)
}
.width('100%').height(52).backgroundColor('#FF8F00')
.padding({ left: 16, right: 16 })
if (this.activeTab === BookTab.LIBRARY) {
this.libraryTab()
} else if (this.activeTab === BookTab.READING) {
this.readingTab()
} else if (this.activeTab === BookTab.STATS) {
this.statsTab()
} else {
this.profileTab()
}
this.bottomNav()
}
.width('100%').height('100%')
.backgroundColor('#FFF8E1')
if (this.showAddBook) { this.addBookModal() }
if (this.showEditBook) { this.editBookModal() }
if (this.showDeleteBook) { this.deleteBookModal() }
if (this.showProgressModal) { this.progressProgressModal() }
if (this.showQuoteModal) { this.quoteModal() }
}
.width('100%').height('100%')
}
build() 方法是每个 ArkUI 组件的核心——它定义了组件的 UI 结构。BookPage 的 build() 方法以 Stack(层叠布局)为根容器,这是实现模态弹窗覆盖效果的关键。
Stack 内部包含两大层级。第一层是主界面 Column,它构成了应用的主体骨架,从上到下包含三部分:顶部标题栏、Tab 内容区、底部导航栏。
顶部标题栏是一个 Row,高度 52vp,背景色为应用主色 #FF8F00(琥珀色),左侧是"读书笔记"标题(22 号加粗白色),中间用 Column().layoutWeight(1) 撑开,右侧是 📖 Emoji 图标。这个琥珀色的标题栏是整个应用视觉的"锚点",与底部导航栏的选中色呼应,确立了应用的色彩身份。
Tab 内容区通过 if...else if...else 条件分支根据 activeTab 的值渲染对应的 Tab 页构建器。当 activeTab 为 LIBRARY 时渲染 libraryTab(),为 READING 时渲染 readingTab(),为 STATS 时渲染 statsTab(),否则(即 PROFILE)渲染 profileTab()。由于 activeTab 是 @State 变量,当用户点击底部导航栏切换 Tab 时,这段条件分支会自动重新求值,销毁旧的 Tab 内容并创建新的 Tab 内容,实现页面切换。这种"条件渲染切换页面"的方式是 ArkUI 中实现简单 Tab 导航的轻量方案(复杂场景可使用 Tabs 组件)。
底部导航栏调用 this.bottomNav()。
主界面 Column 的背景色设为 #FFF8E1(浅米黄),即应用的页面背景色。
第二层是模态弹窗层:五个 if 条件语句分别根据对应的 show* 布尔状态决定是否渲染各弹窗。由于这些弹窗位于 Stack 中主界面 Column 的后面(在 Stack 中后声明的元素层级更高),当任一弹窗被渲染时,它会覆盖在主界面之上。这种"Stack 叠加 + 条件渲染"的弹窗实现方式是 ArkUI 中自建模态系统的标准模式,相比系统提供的 Dialog 组件,它具有更高的样式定制自由度。
整个 build() 方法的结构清晰地体现了应用的架构层次:Stack(弹窗层叠)→ Column(主界面纵向布局)→ 标题栏 + Tab内容 + 导航栏。这种"骨架 + 条件分支 + 构建器调用"的组合方式,使得 build() 方法本身保持了极高的可读性——尽管应用功能复杂,但 build() 方法只有三十余行,所有细节都被封装在各自的 @Builder 方法中。
十七、架构总结与模块横向对比
纵观全文,本应用虽然是一个单文件、单组件的读书笔记应用,但其内部结构却展现出了清晰的层次划分和模块化设计思想。从数据层的接口与模型定义,到状态层的 @State 变量集合,再到逻辑层的辅助方法与交互方法,最后到视图层的 @Builder 构建器群与 build() 主方法,每一层各司其职、环环相扣。
应用的核心架构可以概括为:以 @State 为状态中枢,以 @Builder 为视图单元,以 aboutToAppear 为数据入口,以 build() 为渲染出口。数据从 aboutToAppear 灌入状态变量,状态变量驱动 @Builder 渲染视图,用户的交互操作(点击、输入)修改状态变量,状态变量的变更又自动触发视图刷新——这条"数据→状态→视图→交互→状态"的闭环,正是 ArkUI 声明式 UI 的核心运行机制。
在状态管理方面,应用采用了"集中式单组件状态"策略,所有状态都在 BookPage 入口组件中声明。这种策略的优势在于状态的全局可见性和组件间通信的零成本(因为所有逻辑都在同一作用域内),代价是入口组件承担了较多的状态管理职责。对于本应用的规模而言,这种策略是恰当的。若应用规模进一步扩大,可以考虑引入 @Provide/@Consume 进行跨组件状态传递,或将状态管理抽取到独立的 @Observed 状态管理类中。
在设计模式方面,应用广泛运用了构建器模式(@Builder 方法封装可复用 UI 片段)、模板方法模式(modalBg 提供弹窗骨架,各具体弹窗填充内容)、策略模式(if...else 条件分支根据状态选择不同的渲染策略)等设计模式,使得代码结构清晰、复用性高。
下面通过一个表格,对本应用四大 Tab 页模块的关键技术点进行横向对比总结:
| 对比维度 | 书单页(LIBRARY) | 在读页(READING) | 统计页(STATS) | 我的页(PROFILE) |
|---|---|---|---|---|
| 核心功能 | 展示全部书籍列表与阅读目标,支持添加/编辑/删除/更新进度 | 展示正在阅读的书目、精彩摘录与阅读笔记 | 多维度数据可视化:藏书概览、月度趋势、分类分布、周度节奏 | 个人信息展示、数据汇总、功能入口列表 |
| 数据来源 | books 数组(25本)+ goals 数组(3项目标) |
readingNow 数组(5本)+ books 中精选的摘录与笔记 |
books(聚合计算)+ monthPages + categories + streakDays |
books(聚合计算)+ 静态文案 |
| 状态管理要点 | 依赖 books、goals、showAddBook 等多个状态;删除操作直接 mutate books |
依赖 readingNow 数组;进度更新通过 showProgressModal 触发 |
纯展示型页面,无直接交互状态;依赖四个统计数据数组 | 纯展示型页面;数据通过 getCompletedCount/getTotalPages 实时计算 |
| 关键组件 | goalCard、bookCard、addBookModal(共用) |
readingCard、quoteHighlight、noteCard |
monthBarChart、catDistBar、三宫格数据卡片 |
profOptionItem、数据概览行、用户头部 |
| 可视化形式 | 进度条(目标卡片 + 书籍卡片) | 进度条(在读卡片)+ 横向卡片列表 | 柱状图(月度 + 周度)+ 横向条形图(分类)+ 数值卡片 | 数值卡片 + 列表项 |
| 交互入口 | 添加按钮、编辑/摘录/进度/删除按钮 | 更新进度按钮 | 无(纯展示) | 功能选项列表(未绑定跳转) |
| 布局特征 | Scroll 纵向滚动 + Column 流式排列 |
Scroll 纵向滚动 + Row 横向卡片 + Column 纵向列表 |
Scroll 纵向滚动 + 多区块 Column 堆叠 |
Scroll 纵向滚动 + 居中头部 + 卡片列表 |
| 设计模式 | 构建器模式(bookCard/goalCard 复用) |
构建器模式 + 内容驱动渲染 | 构建器模式 + 即时计算策略 | 构建器模式 + 列表项分隔线模式 |
| 色彩运用 | 白色卡片 + 琥珀色强调 | 白色卡片 + 浅黄摘录卡 | 琥珀色系柱状图(6色渐变) | 浅琥珀头像 + 白色卡片 |
| 信息密度 | 高(每卡含 10+ 字段) | 中高(三区块各有侧重) | 高(四类图表密集排列) | 中(头部留白 + 功能列表) |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// 读书笔记 — 琥珀 #FF8F00 | 棕色 #4E342E | 背景 #FFF8E1
interface BookData {
id: number
title: string
author: string
category: string
totalPages: number
currentPage: number
startDate: string
finishDate: string
rating: number
isCompleted: boolean
notes: string
coverEmoji: string
coverColor: string
keyQuotes: string[]
tags: string[]
}
interface GoalConfig {
label: string
current: number
target: number
color: string
icon: string
}
interface MonthPageItem {
month: string
pages: number
color: string
}
interface CategoryItem {
label: string
percent: number
count: number
color: string
}
interface StreakDay {
day: string
pages: number
color: string
}
@Observed
class BookModel {
id: number
title: string
author: string
category: string
totalPages: number
currentPage: number
startDate: string
finishDate: string
rating: number
isCompleted: boolean
notes: string
coverEmoji: string
coverColor: string
keyQuotes: string[]
tags: string[]
constructor(id: number, title: string, author: string, category: string,
totalPages: number, currentPage: number, startDate: string,
finishDate: string, rating: number, isCompleted: boolean,
notes: string, coverEmoji: string, coverColor: string,
keyQuotes: string[], tags: string[]) {
this.id = id
this.title = title
this.author = author
this.category = category
this.totalPages = totalPages
this.currentPage = currentPage
this.startDate = startDate
this.finishDate = finishDate
this.rating = rating
this.isCompleted = isCompleted
this.notes = notes
this.coverEmoji = coverEmoji
this.coverColor = coverColor
this.keyQuotes = keyQuotes
this.tags = tags
}
}
enum BookTab {
LIBRARY = 0,
READING = 1,
STATS = 2,
PROFILE = 3
}
@Entry
@Component
struct BookPage {
@State activeTab: BookTab = BookTab.LIBRARY
@State books: BookModel[] = []
@State showAddBook: boolean = false
@State showEditBook: boolean = false
@State showDeleteBook: boolean = false
@State showProgressModal: boolean = false
@State showQuoteModal: boolean = false
@State selectedIdx: number = -1
@State goals: GoalConfig[] = []
@State monthPages: MonthPageItem[] = []
@State maxPages: number = 3200
@State categories: CategoryItem[] = []
@State streakDays: StreakDay[] = []
@State readingNow: BookModel[] = []
@State formTitle: string = ''
@State formAuthor: string = ''
@State formCategory: string = ''
@State formTotal: number = 0
@State formCurrent: number = 0
@State formStart: string = ''
@State formRating: number = 0
@State formNotes: string = ''
aboutToAppear(): void {
this.books = [
new BookModel(1, '百年孤独', '加西亚·马尔克斯', '文学', 360, 360, '2024-01-05', '2024-01-28', 5, true, '魔幻现实主义的巅峰之作,布恩迪亚家族七代人的命运令人唏嘘。', '📕', '#FF7043', ['生命中真正重要的不是你遭遇了什么,而是你记住了什么。'], ['经典', '魔幻现实', '拉美文学']),
new BookModel(2, '人类简史', '尤瓦尔·赫拉利', '历史', 440, 440, '2024-02-01', '2024-03-05', 5, true, '从认知革命到科学革命,重新审视人类的过去与未来。', '📗', '#66BB6A', ['历史从无正义。'], ['历史', '畅销', '思维']),
new BookModel(3, '三体', '刘慈欣', '科技', 550, 320, '2024-03-10', '', 5, false, '正在阅读黑暗森林理论,宇宙社会学的思考让人着迷。', '📘', '#42A5F5', ['给岁月以文明,而不是给文明以岁月。'], ['科幻', '中国', '经典']),
new BookModel(4, '活着', '余华', '文学', 200, 200, '2024-01-15', '2024-01-20', 5, true, '福贵的一生是中国近现代史的缩影,朴素而震撼。', '📙', '#FFA726', ['人是为了活着本身而活着。'], ['经典', '中国文学', '悲剧']),
new BookModel(5, '思考,快与慢', '丹尼尔·卡尼曼', '心理', 530, 250, '2024-04-01', '', 4, false, '系统1与系统2的划分帮助我们理解决策偏见。', '📓', '#AB47BC', ['我们很容易对自己知道的事情过分自信。'], ['心理学', '行为经济', '思维']),
new BookModel(6, '国富论', '亚当·斯密', '经济', 450, 450, '2024-05-10', '2024-06-20', 4, true, '经济学的奠基之作,关于分工与市场的洞见至今不过时。', '📔', '#795548', ['我们的晚餐并非来自屠夫、酿酒师的恩惠。'], ['经济学', '经典', '哲学']),
new BookModel(7, '苏菲的世界', '乔斯坦·贾德', '哲学', 520, 520, '2024-02-15', '2024-04-10', 5, true, '以小说的形式讲述西方哲学史,适合哲学入门。', '📒', '#26C6DA', ['你太习惯这个世界了,才会对任何事情都不感到惊奇。'], ['哲学', '入门', '小说']),
new BookModel(8, '明朝那些事儿', '当年明月', '历史', 1800, 1200, '2024-07-01', '', 5, false, '读到了万历年间,张居正改革的细节令人叹服。', '📕', '#EF5350', ['所谓成功,就是用自己的方式度过人生。'], ['历史', '中国', '通俗']),
new BookModel(9, '围城', '钱钟书', '文学', 380, 380, '2024-03-20', '2024-04-15', 5, true, '婚姻是一座围城,城里的人想出去,城外的人想进来。', '📙', '#FFCA28', ['婚姻是一座围城。'], ['中国文学', '讽刺', '经典']),
new BookModel(10, '未来简史', '尤瓦尔·赫拉利', '科技', 410, 410, '2024-04-20', '2024-05-15', 4, true, '从智人到神人,数据主义对未来的颠覆性思考。', '📗', '#9CCC65', ['历史始于人创造神,终结于人成为神。'], ['科技', '未来', '畅销']),
new BookModel(11, '挪威的森林', '村上春树', '文学', 350, 350, '2024-05-20', '2024-06-10', 4, true, '青春与死亡的哀歌,村上式孤独贯穿始终。', '📕', '#8D6E63', ['死不是生的对立面,而是作为生的一部分永存。'], ['日本文学', '青春', '爱情']),
new BookModel(12, '原则', '瑞·达利欧', '经济', 580, 320, '2024-06-15', '', 4, false, '正在阅读关于生活原则的部分,对决策很有启发。', '📔', '#BDBDBD', ['痛苦+反思=进步。'], ['商业', '管理', '思维']),
new BookModel(13, '局外人', '加缪', '哲学', 180, 180, '2024-06-20', '2024-06-28', 5, true, '存在主义经典,默尔索的冷漠是对荒诞世界的回应。', '📒', '#78909C', ['今天,妈妈死了。也许是在昨天,我不知道。'], ['哲学', '存在主义', '法国文学']),
new BookModel(14, '万历十五年', '黄仁宇', '历史', 300, 300, '2024-07-05', '2024-07-18', 5, true, '以1587年为切入点剖析明朝衰落的深层原因。', '📕', '#FF8A65', ['中国两千年来,以道德代替法制。'], ['历史', '中国', '经典']),
new BookModel(15, '月亮与六便士', '毛姆', '文学', 280, 280, '2024-07-20', '2024-08-05', 5, true, '以高更为原型,关于理想与现实终极抉择的故事。', '📙', '#5C6BC0', ['满地都是六便士,他却抬头看见了月亮。'], ['英国文学', '经典', '艺术']),
new BookModel(16, '基因传', '悉达多·穆克吉', '科技', 620, 180, '2024-08-10', '', 4, false, '基因科学的宏大史诗,刚读到基因编辑部分。', '📘', '#29B6F6', ['基因组是一部用ACGT写成的史书。'], ['科普', '生物', '医学']),
new BookModel(17, '杀死一只知更鸟', '哈珀·李', '文学', 320, 320, '2024-08-15', '2024-08-30', 5, true, '关于勇气与正义的成长小说,阿提克斯是最好的父亲。', '📕', '#FF7043', ['你永远不会真正了解一个人,除非你穿上他的鞋子。'], ['美国文学', '成长', '正义']),
new BookModel(18, '失控', '凯文·凯利', '科技', 700, 400, '2024-09-01', '', 4, false, '关于分布式系统和自组织的思考,蜂群思维很有趣。', '📗', '#4FC3F7', ['未来已经到来,只是分布不均。'], ['科技', '未来学', '思维']),
new BookModel(19, '1984', '乔治·奥威尔', '文学', 310, 310, '2024-09-10', '2024-09-22', 5, true, '反乌托邦经典,老大哥正在看着你的恐惧感挥之不去。', '📕', '#424242', ['谁控制了过去,谁就控制了未来。'], ['反乌托邦', '政治', '经典']),
new BookModel(20, '影响力', '罗伯特·西奥迪尼', '心理', 380, 380, '2024-10-01', '2024-10-20', 5, true, '六大影响力武器,理解了为什么人们会说是。', '📓', '#EC407A', ['人们倾向于回报他人的善意。'], ['心理学', '营销', '思维']),
new BookModel(21, '枪炮、病菌与钢铁', '贾雷德·戴蒙德', '历史', 490, 490, '2024-10-25', '2024-11-15', 5, true, '地理决定论的宏大叙事,解释了各大洲的差异。', '📔', '#8D6E63', ['历史并非由单一因素决定。'], ['历史', '人类学', '畅销']),
new BookModel(22, '小王子', '圣埃克苏佩里', '文学', 120, 120, '2024-11-01', '2024-11-03', 5, true, '写给大人的童话,每次重读都有新的感悟。', '📙', '#FFCA28', ['真正重要的东西是眼睛看不见的。'], ['童话', '哲学', '经典']),
new BookModel(23, '创新者的窘境', '克莱顿·克里斯坦森', '经济', 290, 290, '2024-11-20', '2024-12-05', 4, true, '颠覆性创新的经典理论,对大企业宿命的分析令人深思。', '📔', '#78909C', ['好企业被淘汰恰恰是因为把每件事都做对了。'], ['商业', '创新', '管理']),
new BookModel(24, '思考的乐趣', '马丁·加德纳', '科技', 360, 360, '2024-12-01', '2024-12-20', 4, true, '数学游戏和逻辑谜题的大合集,头脑体操的乐趣。', '📘', '#26A69A', ['数学不是数字的游戏,而是模式的科学。'], ['数学', '科普', '思维']),
new BookModel(25, '局部的意味', '汪曾祺', '文学', 250, 150, '2024-12-10', '', 4, false, '汪曾祺的生活散文,烟火气中见真情。', '📕', '#A1887F', ['人间有味是清欢。'], ['散文', '中国文学', '生活'])
]
this.readingNow = [
new BookModel(3, '三体', '刘慈欣', '科技', 550, 320, '2024-03-10', '', 5, false, '正在阅读黑暗森林理论。', '📘', '#42A5F5', ['给岁月以文明。'], ['科幻', '中国'], ),
new BookModel(5, '思考,快与慢', '丹尼尔·卡尼曼', '心理', 530, 250, '2024-04-01', '', 4, false, '系统1与系统2。', '📓', '#AB47BC', ['我们很容易自信。'], ['心理学'], ),
new BookModel(12, '原则', '瑞·达利欧', '经济', 580, 320, '2024-06-15', '', 4, false, '生活原则。', '📔', '#BDBDBD', ['痛苦+反思=进步。'], ['商业'], ),
new BookModel(16, '基因传', '悉达多·穆克吉', '科技', 620, 180, '2024-08-10', '', 4, false, '基因科学。', '📘', '#29B6F6', ['基因组是一部史书。'], ['科普'], ),
new BookModel(25, '局部的意味', '汪曾祺', '文学', 250, 150, '2024-12-10', '', 4, false, '生活散文。', '📕', '#A1887F', ['人间有味是清欢。'], ['散文'], )
]
this.goals = [
{ label: '年度阅读目标', current: 17, target: 24, color: '#FF8F00', icon: '📚' },
{ label: '已读页数', current: 5840, target: 8000, color: '#FFB300', icon: '📖' },
{ label: '读书笔记', current: 12, target: 20, color: '#FFCA28', icon: '✏️' }
]
this.monthPages = [
{ month: '1月', pages: 560, color: '#FF8F00' },
{ month: '2月', pages: 960, color: '#FFB300' },
{ month: '3月', pages: 930, color: '#FFC107' },
{ month: '4月', pages: 1150, color: '#FFD54F' },
{ month: '5月', pages: 860, color: '#FFE082' },
{ month: '6月', pages: 1060, color: '#FFCA28' },
{ month: '7月', pages: 1500, color: '#FFB300' },
{ month: '8月', pages: 1100, color: '#FFC107' },
{ month: '9月', pages: 1330, color: '#FF8F00' },
{ month: '10月', pages: 870, color: '#FFD54F' },
{ month: '11月', pages: 710, color: '#FFE082' },
{ month: '12月', pages: 450, color: '#FFCA28' }
]
this.maxPages = 1500
this.categories = [
{ label: '文学', percent: 36, count: 9, color: '#FF8F00' },
{ label: '科技', percent: 20, count: 5, color: '#FFB300' },
{ label: '历史', percent: 16, count: 4, color: '#FFC107' },
{ label: '哲学', percent: 8, count: 2, color: '#FFD54F' },
{ label: '经济', percent: 12, count: 3, color: '#FFE082' },
{ label: '心理', percent: 8, count: 2, color: '#FFCA28' }
]
this.streakDays = [
{ day: '一', pages: 25, color: '#FF8F00' },
{ day: '二', pages: 40, color: '#FFB300' },
{ day: '三', pages: 35, color: '#FFC107' },
{ day: '四', pages: 50, color: '#FFD54F' },
{ day: '五', pages: 30, color: '#FFE082' },
{ day: '六', pages: 60, color: '#FFCA28' },
{ day: '日', pages: 45, color: '#FFB300' }
]
}
getProgress(pages: number, total: number): number {
if (total === 0) { return 0 }
return Math.round(pages * 100 / total)
}
getStarStr(rating: number): string {
if (rating === 5) { return '★★★★★' }
if (rating === 4) { return '★★★★☆' }
if (rating === 3) { return '★★★☆☆' }
if (rating === 2) { return '★★☆☆☆' }
return '★☆☆☆☆'
}
getTagStr(tags: string[]): string {
return tags.join(' · ')
}
getCompletedCount(): number {
let count = 0
for (let b of this.books) { if (b.isCompleted) { count++ } }
return count
}
getTotalPages(): number {
let total = 0
for (let b of this.books) { total += b.currentPage }
return total
}
getCategoryCount(cat: string): number {
let count = 0
for (let b of this.books) { if (b.category === cat) { count++ } }
return count
}
doAddBook(): void {
this.showAddBook = false
}
doEditBook(): void {
this.showEditBook = false
}
doDeleteBook(): void {
if (this.selectedIdx >= 0 && this.selectedIdx < this.books.length) {
this.books.splice(this.selectedIdx, 1)
}
this.showDeleteBook = false
this.selectedIdx = -1
}
doUpdateProgress(): void {
this.showProgressModal = false
}
openEdit(idx: number): void {
this.selectedIdx = idx
this.formTitle = this.books[idx].title
this.formAuthor = this.books[idx].author
this.formCategory = this.books[idx].category
this.formTotal = this.books[idx].totalPages
this.formCurrent = this.books[idx].currentPage
this.formRating = this.books[idx].rating
this.formNotes = this.books[idx].notes
this.showEditBook = true
}
openDelete(idx: number): void {
this.selectedIdx = idx
this.showDeleteBook = true
}
openProgress(idx: number): void {
this.selectedIdx = idx
this.formCurrent = this.books[idx].currentPage
this.formTotal = this.books[idx].totalPages
this.showProgressModal = true
}
openQuote(idx: number): void {
this.selectedIdx = idx
this.showQuoteModal = true
}
@Builder bottomNav() {
Row() {
this.bottomTabItem('📚', '书单', BookTab.LIBRARY)
this.bottomTabItem('📖', '在读', BookTab.READING)
this.bottomTabItem('📊', '统计', BookTab.STATS)
this.bottomTabItem('👤', '我的', BookTab.PROFILE)
}
.width('100%').height(56)
.backgroundColor('#FFFFFF')
.padding({ bottom: 4 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
@Builder bottomTabItem(icon: string, label: string, tab: BookTab) {
Column() {
Text(icon).fontSize(22)
.opacity(this.activeTab === tab ? 1.0 : 0.4)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? '#FF8F00' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column().width(16).height(3)
.backgroundColor('#FF8F00').borderRadius(2).margin({ top: 3 })
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 4 })
.onClick(() => { this.activeTab = tab })
}
@Builder modalBg(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.45)').onClick(onClose)
}
@Builder addBookModal() {
Column() {
this.modalBg(() => { this.showAddBook = false })
Column() {
Scroll() {
Column() {
Text('添加书籍').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 16 })
Text('书名').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '请输入书名' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formTitle = v })
Text('作者').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '作者姓名' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formAuthor = v })
Text('分类').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '文学/科技/历史/哲学/经济/心理' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formCategory = v })
Text('总页数').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '总页数' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formTotal = parseInt(v) })
Text('当前页').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '当前阅读页数' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formCurrent = parseInt(v) })
Text('开始日期').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '如 2024-05-01' })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formStart = v })
Text('笔记').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ placeholder: '阅读笔记...' })
.width('100%').height(60).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12, top: 8 }).margin({ bottom: 16 })
.onChange((v: string) => { this.formNotes = v })
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddBook = false })
Column().layoutWeight(1)
Text('添加').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doAddBook() })
}.width('100%')
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(999)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder editBookModal() {
Column() {
this.modalBg(() => { this.showEditBook = false })
Column() {
Scroll() {
Column() {
Text('编辑书籍').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 16 })
Text('书名').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formTitle })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formTitle = v })
Text('作者').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formAuthor })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formAuthor = v })
Text('分类').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formCategory })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formCategory = v })
Text('当前页').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formCurrent.toString() })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 12 })
.onChange((v: string) => { this.formCurrent = parseInt(v) })
Text('评分').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formRating.toString() })
.width('100%').height(40).fontSize(14)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 16 })
.onChange((v: string) => { this.formRating = parseInt(v) })
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showEditBook = false })
Column().layoutWeight(1)
Text('更新').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doEditBook() })
}.width('100%')
}
.width('100%').padding(20).alignItems(HorizontalAlign.Start)
}
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(18)
.constraintSize({ maxHeight: '85%' })
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1000)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder deleteBookModal() {
Column() {
this.modalBg(() => { this.showDeleteBook = false })
Column() {
Text('确认删除').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 8 })
Text('删除后将移除该书籍及所有阅读记录,确认删除?').fontSize(14)
.fontColor('#666666').textAlign(TextAlign.Center)
.padding({ left: 16, right: 16 }).margin({ bottom: 20 })
Row() {
Text('取消').fontSize(15).fontColor('#666666')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.showDeleteBook = false })
Column().width(16)
Text('删除').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#E53935').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.doDeleteBook() })
}
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
.padding(24).alignItems(HorizontalAlign.Center)
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1001)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder progressModal() {
Column() {
this.modalBg(() => { this.showProgressModal = false })
Column() {
Text('更新阅读进度').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 16 })
Text('当前页数').fontSize(13).fontColor('#666666').margin({ bottom: 4 }).alignSelf(ItemAlign.Start)
TextInput({ text: this.formCurrent.toString() })
.width('100%').height(44).fontSize(16)
.backgroundColor('#F5F5F5').borderRadius(8).type(InputType.Number)
.padding({ left: 12 }).margin({ bottom: 8 })
Text('共 ' + this.formTotal.toString() + ' 页').fontSize(12).fontColor('#999999').margin({ bottom: 16 })
Row() {
Text('取消').fontSize(15).fontColor('#999999')
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showProgressModal = false })
Column().layoutWeight(1)
Text('更新').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.doUpdateProgress() })
}.width('100%')
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
.padding(20).alignItems(HorizontalAlign.Center)
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1002)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder quoteModal() {
Column() {
this.modalBg(() => { this.showQuoteModal = false })
Column() {
Text('💬 精彩摘录').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 12 })
Text(this.books.length > 0 && this.selectedIdx >= 0 ? this.books[this.selectedIdx].title : '').fontSize(14)
.fontColor('#FF8F00').margin({ bottom: 16 })
Text('"' + (this.books.length > 0 && this.selectedIdx >= 0 ? this.books[this.selectedIdx].keyQuotes[0] : '') + '"').fontSize(15)
.fontColor('#555555').fontStyle(FontStyle.Italic).textAlign(TextAlign.Center)
.padding({ left: 20, right: 20 }).margin({ bottom: 20 })
Text('关闭').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(20)
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.onClick(() => { this.showQuoteModal = false })
}
.width('85%').backgroundColor('#FFFFFF').borderRadius(18)
.padding(20).alignItems(HorizontalAlign.Center)
.shadow({ radius: 24, color: '#40000000', offsetY: 8 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 }).zIndex(1003)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
@Builder goalCard(item: GoalConfig) {
Column() {
Row() {
Text(item.icon).fontSize(20).margin({ right: 8 })
Column() {
Text(item.label).fontSize(12).fontColor('#666666')
Text(item.current.toString() + ' / ' + item.target.toString()).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4E342E')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
}.width('100%').margin({ bottom: 8 })
Row() {
Column()
.width(this.getProgress(item.current, item.target).toString() + '%')
.height(8).backgroundColor(item.color)
.borderRadius(4)
Column().layoutWeight(1)
}
.width('100%').height(8).backgroundColor('#F5F5F5').borderRadius(4)
}
.width('100%').backgroundColor('#FFFFF5').borderRadius(12)
.padding(14).margin({ bottom: 10 })
}
@Builder bookCard(book: BookModel, idx: number) {
Row() {
Column() {
Text(book.coverEmoji).fontSize(36)
}
.width(68).height(88).backgroundColor(book.coverColor).borderRadius(10)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.shadow({ radius: 4, color: '#20000000', offsetY: 2 })
Column() {
Row() {
Column() {
Text(book.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#4E342E').maxLines(1)
Text(book.author).fontSize(11).fontColor('#999999').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
if (book.isCompleted) {
Text('已读完').fontSize(9).fontColor('#4CAF50')
.backgroundColor('#E8F5E9').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
} else {
Text('在读').fontSize(9).fontColor('#FF8F00')
.backgroundColor('#FFF8E1').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
}.width('100%')
Row() {
Text(book.category).fontSize(10).fontColor('#FF8F00')
.backgroundColor('#FFF3E0').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(this.getTagStr(book.tags)).fontSize(9).fontColor('#AAAAAA').margin({ left: 6 }).maxLines(1)
}.margin({ top: 4 })
Row() {
Text(this.getStarStr(book.rating)).fontSize(9).fontColor('#FF8F00')
Text(book.currentPage.toString() + '/' + book.totalPages.toString() + '页').fontSize(10).fontColor('#999999').margin({ left: 8 })
}.margin({ top: 4 })
Row() {
Column()
.width(this.getProgress(book.currentPage, book.totalPages).toString() + '%')
.height(4).backgroundColor('#FF8F00')
.borderRadius(2)
Column().layoutWeight(1)
}
.width('100%').height(4).backgroundColor('#F0F0F0').borderRadius(2).margin({ top: 6 })
Row() {
Text('编辑').fontSize(10).fontColor('#FF8F00')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.border({ width: 1, color: '#FF8F00' }).borderRadius(10)
.onClick(() => { this.openEdit(idx) })
Text(book.isCompleted ? '摘录' : '进度').fontSize(10).fontColor('#FF8F00')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.border({ width: 1, color: '#FF8F00' }).borderRadius(10)
.margin({ left: 6 })
.onClick(() => { book.isCompleted ? this.openQuote(idx) : this.openProgress(idx) })
Column().layoutWeight(1)
Text('删除').fontSize(10).fontColor('#E53935')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.border({ width: 1, color: '#E53935' }).borderRadius(10)
.onClick(() => { this.openDelete(idx) })
}.width('100%').margin({ top: 8 })
}
.layoutWeight(1).margin({ left: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(14).margin({ bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
}
@Builder readingCard(book: BookModel, idx: number) {
Column() {
Column() {
Text(book.coverEmoji).fontSize(32)
}
.width(60).height(80).backgroundColor(book.coverColor).borderRadius(8)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Text(book.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#4E342E')
.maxLines(1).margin({ top: 8 }).width(100).textAlign(TextAlign.Center)
Text(book.author).fontSize(10).fontColor('#999999').maxLines(1)
Row() {
Column()
.width(this.getProgress(book.currentPage, book.totalPages).toString() + '%')
.height(4).backgroundColor('#FF8F00').borderRadius(2)
Column().layoutWeight(1)
}
.width(100).height(4).backgroundColor('#F0F0F0').borderRadius(2).margin({ top: 6 })
Text(this.getProgress(book.currentPage, book.totalPages).toString() + '%').fontSize(10).fontColor('#FF8F00').margin({ top: 4 })
Text('更新进度').fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#FF8F00').borderRadius(10)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.margin({ top: 8 })
.onClick(() => { this.openProgress(idx) })
}
.alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(14)
.padding({ top: 16, bottom: 14, left: 16, right: 16 })
.margin({ right: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
}
@Builder libraryTab() {
Scroll() {
Column() {
Row() {
Text('阅读目标').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#4E342E')
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 6 })
Column() {
this.goalCard(this.goals[0])
this.goalCard(this.goals[1])
this.goalCard(this.goals[2])
}
.padding({ left: 12, right: 12 })
Row() {
Text('所有书籍').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#4E342E')
Column().layoutWeight(1)
Text('+ 添加').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#FF8F00').borderRadius(16)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.onClick(() => { this.showAddBook = true })
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
Column() {
this.bookCard(this.books[0], 0)
this.bookCard(this.books[1], 1)
this.bookCard(this.books[2], 2)
this.bookCard(this.books[3], 3)
this.bookCard(this.books[4], 4)
this.bookCard(this.books[5], 5)
this.bookCard(this.books[6], 6)
this.bookCard(this.books[7], 7)
this.bookCard(this.books[8], 8)
this.bookCard(this.books[9], 9)
this.bookCard(this.books[10], 10)
this.bookCard(this.books[11], 11)
this.bookCard(this.books[12], 12)
this.bookCard(this.books[13], 13)
this.bookCard(this.books[14], 14)
this.bookCard(this.books[15], 15)
this.bookCard(this.books[16], 16)
this.bookCard(this.books[17], 17)
this.bookCard(this.books[18], 18)
this.bookCard(this.books[19], 19)
this.bookCard(this.books[20], 20)
this.bookCard(this.books[21], 21)
this.bookCard(this.books[22], 22)
this.bookCard(this.books[23], 23)
this.bookCard(this.books[24], 24)
}
.padding({ left: 12, right: 12 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder readingTab() {
Scroll() {
Column() {
Text('正在阅读').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#4E342E')
.padding({ left: 16, top: 16, bottom: 4 })
.alignSelf(ItemAlign.Start)
Row() {
this.readingCard(this.readingNow[0], 0)
this.readingCard(this.readingNow[1], 1)
this.readingCard(this.readingNow[2], 2)
this.readingCard(this.readingNow[3], 3)
this.readingCard(this.readingNow[4], 4)
}
.padding({ left: 12, right: 4 })
Text('阅读摘录').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#4E342E')
.padding({ left: 16, top: 20, bottom: 8 })
.alignSelf(ItemAlign.Start)
Column() {
this.quoteHighlight(this.books[0])
this.quoteHighlight(this.books[3])
this.quoteHighlight(this.books[8])
this.quoteHighlight(this.books[13])
this.quoteHighlight(this.books[14])
this.quoteHighlight(this.books[17])
this.quoteHighlight(this.books[19])
this.quoteHighlight(this.books[22])
}
.padding({ left: 12, right: 12 })
Text('阅读笔记').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#4E342E')
.padding({ left: 16, top: 16, bottom: 8 })
.alignSelf(ItemAlign.Start)
Column() {
this.noteCard(this.books[2])
this.noteCard(this.books[4])
this.noteCard(this.books[7])
this.noteCard(this.books[11])
this.noteCard(this.books[15])
this.noteCard(this.books[17])
}
.padding({ left: 12, right: 12 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder quoteHighlight(book: BookModel) {
Row() {
Column() {
Text(book.coverEmoji).fontSize(24)
}
.width(48).height(48).backgroundColor(book.coverColor).borderRadius(8)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Text('"' + book.keyQuotes[0] + '"').fontSize(13)
.fontColor('#555555').fontStyle(FontStyle.Italic).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('—— ' + book.title + ' · ' + book.author).fontSize(10)
.fontColor('#AAAAAA').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}
.width('100%').backgroundColor('#FFFDF5').borderRadius(12)
.border({ width: 1, color: '#FFE0B2' })
.padding(14).margin({ bottom: 8 })
}
@Builder noteCard(book: BookModel) {
Row() {
Column() {
Text(book.coverEmoji).fontSize(24)
}
.width(48).height(48).backgroundColor(book.coverColor).borderRadius(8)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(book.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#4E342E')
Column().layoutWeight(1)
Text(this.getStarStr(book.rating)).fontSize(8).fontColor('#FF8F00')
}.width('100%')
Text(book.notes).fontSize(11).fontColor('#888888').maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }).margin({ top: 4 })
Row() {
Text(book.isCompleted ? '已读完' : '在读').fontSize(9).fontColor(book.isCompleted ? '#4CAF50' : '#FF8F00')
.backgroundColor(book.isCompleted ? '#E8F5E9' : '#FFF8E1').borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(book.category).fontSize(9).fontColor('#999999').margin({ left: 6 })
}.margin({ top: 6 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding(14).margin({ bottom: 8 })
.shadow({ radius: 3, color: '#10000000', offsetY: 1 })
}
@Builder monthBarChart() {
Column() {
Text('月度阅读页数').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 16 }).alignSelf(ItemAlign.Start)
Row() {
Column() {
Text(this.monthPages[0].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[0].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[0].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[0].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[1].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[1].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[1].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[1].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[2].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[2].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[2].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[2].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[3].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[3].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[3].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[3].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[4].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[4].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[4].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[4].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
Row() {
Column() {
Text(this.monthPages[5].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[5].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[5].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[5].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[6].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[6].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[6].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[6].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[7].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[7].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[7].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[7].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[8].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[8].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[8].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[8].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[9].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[9].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[9].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[9].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
Row() {
Column() {
Text(this.monthPages[10].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[10].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[10].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[10].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.monthPages[11].pages.toString()).fontSize(8).fontColor('#999999').margin({ bottom: 4 })
Column()
.width(22)
.height(this.monthPages[11].pages * 160 / this.maxPages)
.backgroundColor(this.monthPages[11].color)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(this.monthPages[11].month).fontSize(8).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column().width(22)
Column().width(22)
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, top: 10, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
}
@Builder catDistBar(item: CategoryItem) {
Column() {
Row() {
Text(item.label).fontSize(12).fontColor('#555555').width(36)
Row() {
Column()
.width(item.percent.toString() + '%')
.height(14).backgroundColor(item.color)
.borderRadius({ topLeft: 7, bottomLeft: 7 })
if (item.percent < 100) {
Column().layoutWeight(1)
}
}
.layoutWeight(1).height(14).backgroundColor('#F5F5F5').borderRadius(7)
Text(item.percent.toString() + '%').fontSize(10).fontColor('#999999').width(34).textAlign(TextAlign.End)
Text(item.count.toString() + '本').fontSize(10).fontColor('#AAAAAA').width(24).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
}
.margin({ bottom: 8 })
}
@Builder statsTab() {
Scroll() {
Column() {
Row() {
Column() {
Text(this.books.length.toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
Text('总藏书').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12)
Column() {
Text(this.getCompletedCount().toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFB300')
Text('已读').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 8 })
Column() {
Text(this.getTotalPages().toString()).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
Text('已读页').fontSize(11).fontColor('#999999').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding(14)
.backgroundColor('#FFFFFF').borderRadius(12).margin({ left: 8 })
}
.width('100%').padding({ left: 12, right: 12, top: 12 })
this.monthBarChart()
Column() {
Text('分类统计').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
this.catDistBar(this.categories[0])
this.catDistBar(this.categories[1])
this.catDistBar(this.categories[2])
this.catDistBar(this.categories[3])
this.catDistBar(this.categories[4])
this.catDistBar(this.categories[5])
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, bottom: 10 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column() {
Text('本周阅读').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#4E342E').margin({ bottom: 14 }).alignSelf(ItemAlign.Start)
Row() {
Column() {
Text(this.streakDays[0].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
Column()
.width(30)
.height(this.streakDays[0].pages * 80 / 60)
.backgroundColor(this.streakDays[0].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[0].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.streakDays[1].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFB300')
Column()
.width(30)
.height(this.streakDays[1].pages * 80 / 60)
.backgroundColor(this.streakDays[1].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[1].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.streakDays[2].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFC107')
Column()
.width(30)
.height(this.streakDays[2].pages * 80 / 60)
.backgroundColor(this.streakDays[2].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[2].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.streakDays[3].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFD54F')
Column()
.width(30)
.height(this.streakDays[3].pages * 80 / 60)
.backgroundColor(this.streakDays[3].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[3].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.streakDays[4].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFE082')
Column()
.width(30)
.height(this.streakDays[4].pages * 80 / 60)
.backgroundColor(this.streakDays[4].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[4].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.streakDays[5].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFCA28')
Column()
.width(30)
.height(this.streakDays[5].pages * 80 / 60)
.backgroundColor(this.streakDays[5].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[5].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
Column() {
Text(this.streakDays[6].pages.toString() + '页').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFB300')
Column()
.width(30)
.height(this.streakDays[6].pages * 80 / 60)
.backgroundColor(this.streakDays[6].color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(this.streakDays[6].day).fontSize(10).fontColor('#666666').margin({ top: 4 })
}.alignItems(HorizontalAlign.Center)
}
.width('100%').justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Bottom)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.padding(16).margin({ left: 12, right: 12, bottom: 16 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder profileTab() {
Scroll() {
Column() {
Column() {
Column().width(72).height(72).backgroundColor('#FFE0B2').borderRadius(36)
Row() {
Column().width(72).height(72).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Text('📚').fontSize(32)
}
Column()
Text('读书人').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#4E342E').margin({ top: 12 })
Text('阅读是一座随身携带的避难所').fontSize(13).fontColor('#999999').margin({ top: 4 })
}
.width('100%').alignItems(HorizontalAlign.Center).padding({ top: 30, bottom: 20 })
Row() {
Column() {
Text(this.books.length.toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
Text('藏书').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.getCompletedCount().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFB300')
Text('已读').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.getTotalPages().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFC107')
Text('页数').fontSize(11).fontColor('#999999')
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 16, bottom: 16 }).margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column() {
this.profOptionItem('📚', '我的书架', this.books.length.toString() + '本书')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('✏️', '读书笔记', this.getCompletedCount().toString() + '篇')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('💬', '摘录收藏', '25条金句')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('🎯', '阅读目标', '年度24本 | 已完成17本')
Row().width('100%').height(1).backgroundColor('#FFF3E0')
this.profOptionItem('⚙️', '偏好设置', '主题与提醒')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, bottom: 12 })
.shadow({ radius: 4, color: '#15000000', offsetY: 1 })
Column().height(20)
}
.width('100%')
}
.layoutWeight(1).scrollBar(BarState.Off)
}
@Builder profOptionItem(icon: string, title: string, sub: string) {
Row() {
Text(icon).fontSize(18).margin({ right: 12 })
Column() {
Text(title).fontSize(15).fontColor('#333333')
Text(sub).fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('>').fontSize(14).fontColor('#CCCCCC')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
build() {
Stack() {
Column() {
Row() {
Text('读书笔记').fontSize(22).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column().layoutWeight(1)
Text('📖').fontSize(24)
}
.width('100%').height(52).backgroundColor('#FF8F00')
.padding({ left: 16, right: 16 })
if (this.activeTab === BookTab.LIBRARY) {
this.libraryTab()
} else if (this.activeTab === BookTab.READING) {
this.readingTab()
} else if (this.activeTab === BookTab.STATS) {
this.statsTab()
} else {
this.profileTab()
}
this.bottomNav()
}
.width('100%').height('100%')
.backgroundColor('#FFF8E1')
if (this.showAddBook) { this.addBookModal() }
if (this.showEditBook) { this.editBookModal() }
if (this.showDeleteBook) { this.deleteBookModal() }
if (this.showProgressModal) { this.progressModal() }
if (this.showQuoteModal) { this.quoteModal() }
}
.width('100%').height('100%')
}
}
结语
通过对这份源码的逐段深度解析,我们可以看到,一款功能完备、视觉精致的读书笔记应用,在 ArkTS/ArkUI 的声明式范式下,可以用单文件、单组件的方式高效实现。这得益于 ArkUI 三大核心能力的协同:@State 提供的响应式状态管理让数据与视图自动同步,@Builder 提供的构建器机制让 UI 得以模块化拆分与复用,链式调用的声明式语法让组件的样式与结构得以在一行代码中流畅表达。

当然,本应用作为演示原型,在某些方面仍有优化空间:表单提交逻辑尚未真正写回数据、列表渲染未使用 ForEach、横向卡片列表未包裹 Scroll 等。但这些不影响它作为一份优秀的 ArkTS 教学案例的价值——它几乎涵盖了一个真实应用所需的全部基础要素:数据建模、状态管理、列表渲染、表单交互、模态弹窗、数据可视化、多 Tab 导航。对于初学 HarmonyOS ArkTS 的开发者而言,逐行研读这份源码,能够快速建立起对声明式 UI 编程范式的整体认知,为后续开发更复杂的鸿蒙原生应用打下坚实基础。
更多推荐

所有评论(0)