基于HarmonyOS API 24的ArkTS阅读App实战:HarmonyOS 6.1.1与HarmonyOS ArkTS API 24构建QQ阅读书城多场景页面深度解析
技术引言
HarmonyOS 6.1.1 作为华为分布式操作系统的最新演进版本,搭载了 ArkTS API 24 这一强大的应用开发框架。ArkTS 在 TypeScript 基础上扩展了声明式 UI 范式,通过 @Entry、@Component、@State、@Builder、@Observed 等装饰器体系,为开发者提供了高效的状态管理与组件化能力。本文以"QQ阅读·书城"阅读App为演示场景,深入剖析基于HarmonyOS API 24构建的网文书城、听书、漫画、出版书四大业务场景的完整实现。该页面采用蓝绿书卷风视觉设计,融合底部四Tab导航、顶部六功能Tab切换、四种弹框交互、柱状图数据可视化及墨点粒子特效动画,全面展示了ArkTS在复杂阅读类应用中的工程实践能力。
整体架构概览
在深入代码细节之前,我们先通过架构图理解整个阅读App的模块组织关系。该应用采用"主页面 + 多内容子组件"的分层架构,主页面负责导航与全局状态管理,各子组件负责具体Tab内容渲染。
从架构图中可以看出,整个应用围绕 QQReadApp 主组件展开,向下分为状态管理、生命周期、构建器层、内容子组件层和数据模型层五个维度。这种分层设计保证了关注点分离,使得每个模块各司其职,便于维护和扩展。
第一段:配色系统与色彩调色板设计
interface ColorPalette {
primary: string
primaryDark: string
teal: string
tealLight: string
bg: string
cardBg: string
ink: string
gray: string
hint: string
border: string
white: string
red: string
gold: string
paper: string
}
const COLORS: ColorPalette = {
primary: '#2C5F8A',
primaryDark: '#1D4368',
teal: '#3AA6A0',
tealLight: '#E2F3F1',
bg: '#F4F7F5',
cardBg: '#FFFFFF',
ink: '#22333B',
gray: '#5C6B73',
hint: '#9AA8AF',
border: '#E3EAE7',
white: '#FFFFFF',
red: '#D94F4F',
gold: '#C9A063',
paper: '#F9F5EC'
}

这段代码定义了整个阅读App的色彩体系基础。在HarmonyOS ArkTS中,interface用于声明对象的结构类型,ColorPalette接口规范了配色方案必须包含的14个色彩字段,从主色调到辅助色、从背景色到边框色,形成了一套完整的色彩语义系统。
COLORS常量以墨蓝(#2C5F8A)为主色调,搭配竹青色(#3AA6A0)作为辅助强调色,构建出"蓝绿书卷风"的视觉基调。墨蓝色沉稳大气,契合书城的阅读氛围;竹青色清新雅致,用于交互态高亮和功能性提示。米纸色(#F9F5EC)模拟纸张质感,呼应传统书卷意象。
这种集中式配色管理带来了显著的工程优势。首先,所有UI组件引用统一的色彩常量,避免了硬编码颜色值导致的视觉不一致问题。其次,若需要切换主题色或适配暗色模式,只需修改这一处配置即可全局生效。此外,通过interface约束类型,ArkTS编译器能在开发阶段就发现拼写错误或类型不匹配,提升了代码的健壮性。在实际阅读类App中,色彩的一致性对用户体验至关重要,读者长时间注视屏幕时,柔和的蓝绿配色能有效降低视觉疲劳。
第二段:元信息接口体系设计
interface BookCategoryMeta {
label: string
icon: string
color: string
bg: string
}
interface BookStatusMeta {
label: string
color: string
bg: string
icon: string
}
interface KingKongEntry {
label: string
icon: string
color: string
bg: string
}
interface BannerInfo {
title: string
sub: string
tag: string
bg: string
icon: string
}
interface ParticleDot {
x: number
y: number
size: number
color: string
speed: number
phase: number
}
interface WeekStat {
day: string
minutes: number
}
interface ShelfBook {
name: string
icon: string
color: string
progress: string
}

这里集中定义了7个元信息接口,构成了整个应用的数据类型契约体系。在ArkTS的强类型环境中,interface扮演着至关重要的角色——它不仅约束了数据结构,更充当了组件间通信的"协议书"。
BookCategoryMeta和BookStatusMeta分别描述书籍分类和状态的元数据,每个接口都包含label、icon、color、bg四个核心字段,这种"标签+图标+前景色+背景色"的四元组模式是阅读类App中最常见的UI元信息单元。KingKongEntry复用了相同的字段结构,用于金刚区导航入口,体现了接口设计的复用性。
BannerInfo扩展了横幅广告所需的信息,增加了title、sub、tag三个文案字段,支持活动推广、福利发放等运营场景。ParticleDot是粒子特效的数据载体,包含坐标(x, y)、尺寸(size)、速度(speed)和相位(phase),为动画系统提供了精确的运动参数。WeekStat以极简的双字段结构记录每日阅读时长,服务于柱状图可视化。ShelfBook则封装了书架列表项的展示数据。
这种"一个业务概念对应一个interface"的设计哲学,使得代码具有极高的可读性和可维护性。当新开发者阅读代码时,通过接口定义就能快速理解数据结构,无需深入实现细节。同时,强类型约束在编译阶段就能拦截类型错误,大幅降低了运行时崩溃的风险。
第三段:书籍数据模型与@Observed装饰器
@Observed
class BookItem {
id: number = 0
name: string = ''
author: string = ''
category: string = ''
icon: string = ''
coverColor: string = ''
wordCount: number = 0
collection: number = 0
status: string = '连载中'
tags: string[] = []
desc: string = ''
rating: number = 0
chapterCount: number = 0
updateTime: string = ''
isFree: boolean = false
constructor(id: number, name: string, author: string, category: string, icon: string,
coverColor: string, wordCount: number, collection: number, status: string,
tags: string[], desc: string, rating: number, chapterCount: number,
updateTime: string, isFree: boolean) {
this.id = id
this.name = name
this.author = author
this.category = category
this.icon = icon
this.coverColor = coverColor
this.wordCount = wordCount
this.collection = collection
this.status = status
this.tags = tags
this.desc = desc
this.rating = rating
this.chapterCount = chapterCount
this.updateTime = updateTime
this.isFree = isFree
}
}
BookItem是整个阅读App的核心数据模型,承载了书籍的完整信息。该类使用了@Observed装饰器进行修饰,这是ArkTS状态管理体系中的关键装饰器之一。@Observed使得该类的实例成为可观察对象,当其属性发生变化时,依赖该数据的UI组件会自动触发重渲染,实现了数据驱动视图的核心能力。
类中定义了15个属性,涵盖了书籍的唯一标识(id)、基本信息(name、author、category)、视觉资源(icon、coverColor)、统计数据(wordCount、collection、chapterCount)、状态信息(status、updateTime、isFree)、内容描述(desc、tags)和评分(rating)。每个属性都设置了默认值,这是ArkTS的最佳实践——确保对象在构造前就具备合法的初始状态,避免undefined导致的渲染异常。
构造函数采用全参数设计,虽然参数列表较长,但保证了对象创建时数据的完整性。在实际项目中,如果属性过多,也可以考虑使用对象字面量传入的方式简化构造调用。tags属性使用string[]数组类型存储标签列表,支持一本书拥有多个分类标签(如"东方玄幻"和"热血"同时存在),这种多标签设计更贴近真实阅读App的业务逻辑。
第四段:基于Record的配置映射表
const BOOK_CATEGORY_CONFIG: Record<string, BookCategoryMeta> = {
'玄幻': { label: '玄幻', icon: '⚡', color: '#2C5F8A', bg: '#E5EEF5' },
'都市': { label: '都市', icon: '🏙', color: '#3AA6A0', bg: '#E2F3F1' },
'悬疑': { label: '悬疑', icon: '🔍', color: '#6B5CA5', bg: '#ECE8F7' },
'言情': { label: '言情', icon: '💗', color: '#D94F7A', bg: '#FBE5EC' },
'科幻': { label: '科幻', icon: '🚀', color: '#2A7FB8', bg: '#E3F1FA' },
'历史': { label: '历史', icon: '📜', color: '#C9A063', bg: '#F6EEDD' },
'漫画': { label: '漫画', icon: '🎨', color: '#E07B39', bg: '#FCEBDD' },
'出版': { label: '出版', icon: '📚', color: '#4C7A5C', bg: '#E6F2EA' }
}
const BOOK_STATUS_CONFIG: Record<string, BookStatusMeta> = {
'连载中': { label: '连载中', color: '#3AA6A0', bg: '#E2F3F1', icon: '✍' },
'已完结': { label: '已完结', color: '#4C7A5C', bg: '#E6F2EA', icon: '✅' },
'限时免费': { label: '限时免费', color: '#D94F4F', bg: '#FAE5E5', icon: '🎁' }
}

这两段代码展示了ArkTS中Record类型的高级用法。Record<string, BookCategoryMeta>定义了一个以字符串为键、以BookCategoryMeta为值的映射表类型,它将书籍分类名称与对应的视觉元数据关联起来,形成了一套查找表。
BOOK_CATEGORY_CONFIG配置了8个书籍分类,从玄幻、都市到漫画、出版,每个分类都配有专属的emoji图标、前景色和背景色。这种设计使得UI层在渲染分类标签时,只需通过分类名称即可快速获取完整的展示方案,无需在组件中编写大量的条件判断逻辑。例如,当渲染一本"玄幻"类书籍时,直接通过BOOK_CATEGORY_CONFIG['玄幻']就能获取到墨蓝色主题和闪电图标。
BOOK_STATUS_CONFIG则映射了三种书籍状态——连载中、已完结和限时免费。连载状态使用竹青色和书写图标,传达"持续更新"的语义;完结状态用深绿色和勾选图标,暗示"完整可读";限时免费用红色和礼物图标,营造紧迫感和优惠感。这种"状态-视觉"的映射设计,在阅读App中非常常见,它将业务语义与视觉语言建立了稳定的对应关系。
使用Record而非普通对象的好处在于类型安全。ArkTS编译器会检查所有值的类型是否符合BookCategoryMeta或BookStatusMeta的结构定义,避免遗漏字段或类型错误。同时,Record作为只读常量,保证了配置数据在运行期间不会被意外篡改,提升了应用的稳定性。
第五段:金刚区与横幅数据源
const KINGKONG_ENTRIES: KingKongEntry[] = [
{ label: '免费', icon: '🆓', color: '#2C5F8A', bg: '#E5EEF5' },
{ label: '男生', icon: '⚔', color: '#3AA6A0', bg: '#E2F3F1' },
{ label: '女生', icon: '🌸', color: '#D94F7A', bg: '#FBE5EC' },
{ label: '出版', icon: '📚', color: '#C9A063', bg: '#F6EEDD' },
{ label: '听书', icon: '🎧', color: '#6B5CA5', bg: '#ECE8F7' },
{ label: '漫画', icon: '🎨', color: '#E07B39', bg: '#FCEBDD' },
{ label: '排行', icon: '🏆', color: '#B8860B', bg: '#F7EDD4' },
{ label: '书单', icon: '📋', color: '#4C7A5C', bg: '#E6F2EA' }
]
const BANNER_LIST: BannerInfo[] = [
{ title: '仙侠征文大赛', sub: '百万奖金 等你执笔', tag: '活动', bg: '#2C5F8A', icon: '🗡' },
{ title: '夜读计划', sub: '每晚八点 免费好书', tag: '福利', bg: '#3AA6A0', icon: '🌙' },
{ title: '夜读计划', sub: '每晚八点 免费好书', tag: '福利', bg: '#3AA6A0', icon: '🌙' }
]

金刚区是阅读App中最核心的导航入口区域,通常位于首页Banner下方,以网格形式展示高频功能入口。KINGKONG_ENTRIES数组定义了8个金刚区入口,覆盖了免费、男生、女生、出版、听书、漫画、排行和书单等全场景功能。每个入口都配有独立的色彩方案,在视觉上形成丰富的色彩矩阵,既美观又能帮助用户快速区分不同功能。
8个入口采用"四列两行"的网格布局,配合emoji图标和文字标签,信息密度高且识别性强。免费入口用墨蓝色强调"省钱"价值;男生和女生入口分别用竹青色和玫红色区分性别频道;听书和漫画入口用紫色和橙色突出内容形态差异。这种色彩编码策略在认知心理学中被称为"色彩关联记忆",能有效提升用户的导航效率。
BANNER_LIST定义了3条横幅广告数据,每条包含标题、副标题、标签和背景色。横幅采用横向滑动展示,墨蓝色的"仙侠征文大赛"引导用户参与创作,竹青色的"夜读计划"推广夜间免费阅读,紫色的"新书首发"吸引用户追新。横幅的背景色直接使用配色系统中的主色调,保证了视觉一致性。在实际项目中,横幅数据通常从服务器动态获取,这里使用静态数组模拟,简化了演示复杂度。
第六段:阅读统计与书架数据
const WEEK_STATS: WeekStat[] = [
{ day: '周一', minutes: 45 },
{ day: '周二', minutes: 80 },
{ day: '周三', minutes: 60 },
{ day: '周四', minutes: 120 },
{ day: '周五', minutes: 95 },
{ day: '周六', minutes: 160 },
{ day: '周日', minutes: 140 }
]
const SHELF_BOOKS: ShelfBook[] = [
{ name: '斗破苍穹', icon: '🔥', color: '#E07B39', progress: '已读 78%' },
{ name: '庆余年', icon: '📜', color: '#C9A063', progress: '已读 42%' },
{ name: '夜的命名术', icon: '🌙', color: '#2C5F8A', progress: '已读 65%' },
{ name: '诡秘之主', icon: '🎭', color: '#6B5CA5', progress: '已读 90%' },
{ name: '大奉打更人', icon: '🔔', color: '#3AA6A0', progress: '已读 23%' },
{ name: '道诡异仙', icon: '☯', color: '#4C7A5C', progress: '已读 55%' }
]

WEEK_STATS数组记录了一周七天的阅读时长数据,以分钟为单位。这组数据将用于出版页Tab中的柱状图可视化展示。数据呈现出明显的周末阅读高峰特征——周六160分钟、周日140分钟远高于工作日的阅读时长,这符合大多数用户的阅读习惯。柱状图通过statBarHeight函数将分钟数映射为柱子高度,直观地展示阅读时长的周变化趋势。
SHELF_BOOKS数组定义了6本书架中的书籍,每本包含名称、图标、色彩和阅读进度。书架是用户管理已收藏书籍的核心页面,阅读进度信息(如"已读 78%")帮助用户快速定位到上次阅读位置。6本书涵盖了玄幻、历史、悬疑等多种类型,模拟了真实用户的多样化阅读偏好。
书架数据的color字段用于生成书封背景色,在UI渲染时通过拼接透明度后缀(如color + '22')实现半透明效果。progress字段以字符串形式存储进度百分比,虽然这在数据处理上不如数值类型灵活,但在直接渲染显示时更加便捷。在实际工程中,进度数据通常以数值存储,在显示时再格式化为字符串,以支持进度条等可视化组件的渲染。
第七段:静态书籍数据集构建
const mockBooks: BookItem[] = [
new BookItem(1, '山海经异闻录', '墨白先生', '玄幻', '⚡', '#2C5F8A', 356, 1280, '连载中',
['东方玄幻', '热血'], '少年偶得上古山海残卷,驭异兽、掌神通,踏遍九州山海之界。', 4.6, 1268, '2小时前', false),
new BookItem(2, '都市超级医生', '柳叶刀客', '都市', '🏙', '#3AA6A0', 212, 860, '连载中',
['都市', '医术'], '实习医生觉醒古医传承,一根银针震惊整个医学界。', 4.3, 856, '5小时前', true),
new BookItem(3, '雾都疑案', '夜航西飞', '悬疑', '🔍', '#6B5CA5', 180, 640, '已完结',
['推理', '民国'], '雾锁申城,连环失踪案背后藏着二十年的惊天秘密。', 4.8, 620, '已完结', false),
new BookItem(7, '喵星人饲养手册', '鱼干大人', '漫画', '🎨', '#E07B39', 96, 1500, '连载中',
['萌宠', '日常'], '三只猫咪的爆笑日常,铲屎官的快乐源泉。', 4.9, 386, '1小时前', true),
new BookItem(8, '活着为了讲述', '加西亚', '出版', '📚', '#4C7A5C', 32, 300, '已完结',
['传记', '外国文学'], '诺贝尔文学奖得主的自传,魔幻现实主义的源头活水。', 4.8, 45, '出版精品', false),
new BookItem(15, '宇宙尽头的书店', '陈春成', '出版', '🌠', '#6B5CA5', 22, 260, '已完结',
['短篇', '想象'], '一家开在宇宙尽头的书店,贩卖所有未曾写出的书。', 4.8, 30, '出版精品', false)
]

mockBooks数组是整个应用的数据核心,通过new BookItem(...)构造函数实例化了15本完整的书籍数据。每本书的构造参数涵盖了id、名称、作者、分类、图标、封面色、字数、收藏量、状态、标签、简介、评分、章节数、更新时间和免费标识等15个维度的信息。这里展示了6本代表性书籍的完整数据。
数据设计上注重多样性:分类覆盖了玄幻、都市、悬疑、漫画、出版等主流类型;状态包含连载中、已完结两种形态;免费标识有true和false两种取值。评分从4.3到4.9不等,收藏量从300到2100万不等,字数从22万字到520万字不等,这种数据梯度使得UI渲染时能充分测试各种数值的显示效果。
每本书的简介文案都经过精心撰写,既有悬念感又有吸引力。例如"少年偶得上古山海残卷,驭异兽、掌神通"营造了玄幻世界的恢弘感;"雾锁申城,连环失踪案背后藏着二十年的惊天秘密"则点出了悬疑小说的紧张氛围。在实际项目中,这些数据将通过网络请求从服务器获取,这里使用静态数组模拟,便于在没有后端服务的情况下进行UI开发和调试。
值得注意的是,mockBooks虽然声明为const,但数组内容是可变的。在ArkTS中,const仅保证变量引用不可变,数组内部元素仍可增删。如果需要完全不可变的数据,可以考虑使用readonly修饰符或深冻结技术。不过对于演示数据而言,当前设计已足够满足需求。
第八段:纯函数工具集
function formatWan(n: number): string {
if (n >= 10000) {
return (n / 10000).toFixed(1) + '亿'
}
return n.toString() + '万'
}
function statBarHeight(m: number, maxM: number): string {
const h = Math.round(m / maxM * 88) + 8
return h.toString() + 'vp'
}
function ratingStars(r: number): string {
if (r >= 4.8) {
return '★★★★★'
} else if (r >= 4.5) {
return '★★★★☆'
} else if (r >= 4.0) {
return '★★★★'
}
return '★★★'
}
function topFiveBooks(): BookItem[] {
return mockBooks
}

这组纯函数工具集承担了数据格式化和计算转换的职责。formatWan函数将数值格式化为"万"或"亿"单位的字符串,当数值大于等于10000时自动转换为"亿"单位并保留一位小数,否则以"万"为单位显示。这种智能单位转换在阅读App中非常实用——收藏量可能从几十万到几千万不等,统一的格式化输出保证了UI显示的一致性。
statBarHeight函数将阅读时长映射为柱状图的柱子高度。它接受当前分钟数和最大分钟数两个参数,通过比例计算得到0-88vp范围内的高度值,再加上8vp的基础偏移量,确保即使阅读时长为0的柱子也有最小可见高度。返回值以"vp"为单位(virtual pixel,虚拟像素),这是HarmonyOS中的响应式布局单位,能根据屏幕密度自动缩放。
ratingStars函数将数值评分转换为星级字符串。4.8分以上显示五星,4.5-4.8分显示四星半(用☆表示半星),4.0-4.5分显示四星,低于4.0分显示三星。这种分级评价方式在阅读App中很常见,直观地传达了书籍的质量等级。
topFiveBooks函数目前直接返回完整的mockBooks数组,设计上预留了筛选逻辑的扩展空间。在实际项目中,这个函数可以实现排序、分页、过滤等复杂逻辑,例如按收藏量降序排列并取前5本。将这些数据处理逻辑封装为纯函数,使得业务逻辑与UI渲染完全解耦,便于单元测试和后续维护。
纯函数的核心特征是"相同输入必产生相同输出"且无副作用。这四个函数都满足纯函数定义——它们不依赖外部可变状态,不修改输入参数,只根据输入计算返回值。在ArkTS的响应式系统中,纯函数的确定性保证了UI渲染的可预测性,是构建可靠应用的重要基础。
第九段:墨点粒子特效系统
function initParticles(): ParticleDot[] {
const list: ParticleDot[] = []
const colors: string[] = ['#2C5F8A', '#3AA6A0', '#C9A063', '#6B5CA5', '#D94F7A']
for (let i = 0; i < 18; i++) {
const dot: ParticleDot = {
x: Math.random() * 340,
y: Math.random() * 700,
size: 8 + Math.random() * 14,
color: colors[i % colors.length],
speed: 0.6 + Math.random() * 1.4,
phase: i
}
list.push(dot)
}
return list
}
function updateParticles(list: ParticleDot[]): ParticleDot[] {
const next: ParticleDot[] = []
for (let i = 0; i < list.length; i++) {
const p = list[i]
const ny = p.y - p.speed
const dot: ParticleDot = {
x: p.x,
y: ny < -20 ? 720 : ny,
size: p.size,
color: p.color,
speed: p.speed,
phase: p.phase
}
next.push(dot)
}
return next
}
墨点粒子特效系统是该阅读App的一大视觉亮点,灵感来源于传统书法中的墨点飞溅效果。initParticles函数在应用启动时初始化18个粒子,每个粒子随机分布在340x700的屏幕区域内,尺寸在8-22vp之间随机取值。5种颜色循环分配——墨蓝、竹青、金色、紫色和玫红,与整体配色系统保持一致。每个粒子的上升速度在0.6-2.0之间随机生成,phase字段记录粒子的序号作为唯一标识。
updateParticles函数负责每帧更新粒子位置。它遍历当前粒子列表,将每个粒子的y坐标减去其速度值,模拟粒子向上漂浮的效果。当粒子的y坐标小于-20(即飘出屏幕顶部)时,将其重置到y=720(屏幕底部),形成粒子循环上升的视觉效果。这种"回收重置"策略避免了频繁创建和销毁对象带来的性能开销。
整个粒子系统通过setInterval以60ms间隔驱动更新(约16-17帧/秒),在性能和流畅度之间取得了平衡。每次更新生成全新的粒子数组而非原地修改,这是ArkTS响应式系统的最佳实践——@State状态变量的变更通过整体替换触发UI重渲染,比逐个修改数组元素更高效。
墨点粒子以emoji字符✒渲染,配合0.35的透明度,呈现出若隐若现的墨色飘散效果。这种设计不仅增加了视觉层次感,还呼应了"书卷风"的主题意象——仿佛墨迹在纸面上缓缓晕开。在性能方面,18个粒子的轻量级动画对CPU和内存的消耗极低,即使在中低端设备上也能保持流畅运行。
第十段:Tab枚举与状态管理
enum BookTab {
RECOMMEND = 0,
MALE = 1,
FEMALE = 2,
PUBLISH = 3,
COMIC = 4,
AUDIO = 5
}
enum BottomTab {
MALL = 0,
SHELF = 1,
LISTEN = 2,
MINE = 3
}

这两个枚举定义了应用的两级导航体系。BookTab枚举了书城顶部的6个功能Tab——推荐、男生、女生、出版、漫画和听书,每个Tab对应一个内容子组件。BottomTab枚举了底部的4个主导航Tab——书城、书架、听书和我的,每个Tab对应一个完整的页面视图。
使用枚举而非魔法数字(magic number)是软件工程中的最佳实践。如果直接使用0、1、2等数字表示Tab索引,代码可读性会大幅降低——if (this.activeTab === 0)远不如if (this.activeTab === BookTab.RECOMMEND)清晰。枚举为每个Tab赋予了语义化的名称,使得代码自文档化,降低了维护成本。
枚举值从0开始递增,这与JavaScript数组索引的惯例一致,便于在ForEach渲染时进行索引计算。在ArkTS中,枚举是编译时常量,不会增加运行时开销。同时,枚举具有类型安全特性——如果传入不存在的Tab值,编译器会报错,有效防止了无效状态的出现。
两级Tab的设计遵循了"信息架构"原则:底部Tab定义应用的顶层信息结构(四大核心场景),顶部Tab在书城场景内进一步细分内容分类。这种层级化导航既保证了功能的完整性,又避免了将所有入口平铺导致的认知负担过重问题。
第十一段:主页面状态声明
@Entry
@Component
struct QQReadApp {
@State activeTab: BookTab = BookTab.RECOMMEND
@State bottomTab: BottomTab = BottomTab.MALL
@State showShelfModal: boolean = false
@State showSettingModal: boolean = false
@State showDeleteModal: boolean = false
@State showDetailModal: boolean = false
@State selectedBook: BookItem | null = null
@State particles: ParticleDot[] = []
@State fontSize: number = 16
@State readBg: string = '米黄'
@State pageMode: string = '仿真翻页'
@State shelfCount: number = 12
@State todayMinutes: number = 42
private timerId: number = -1
QQReadApp是整个应用的入口组件,通过@Entry装饰器标记为页面入口,@Component声明为自定义组件。struct内部声明了12个@State状态变量和1个private变量,构成了应用的全局状态管理中心。
@State是ArkTS最核心的状态装饰器,被修饰的变量一旦发生变化,会自动触发关联UI组件的重渲染。activeTab和bottomTab管理两级Tab的选中状态;四个show*Modal布尔变量控制四种弹框的显示隐藏;selectedBook使用联合类型BookItem | null,既能存储选中的书籍对象,又能用null表示未选中状态。
particles数组存储粒子特效数据,fontSize、readBg、pageMode三个变量管理阅读设置——字号(14-20)、阅读背景(米黄/护眼绿/夜间)和翻页模式(仿真翻页/左右平移/上下滚动)。shelfCount和todayMinutes记录书架数量和今日阅读时长。
timerId使用private修饰而非@State,因为它是粒子动画的定时器ID,不涉及UI渲染,无需触发重渲染。这种区分@State和private的做法是ArkTS性能优化的要点——只有需要驱动UI更新的变量才使用@State,纯逻辑变量使用private,避免不必要的重渲染开销。
上图展示了应用的核心状态流转关系。底部Tab在四个主页面间切换,顶部Tab在六个内容子组件间切换,弹框系统独立运作,通过布尔状态变量控制显示与隐藏。
第十二段:组件生命周期管理
aboutToAppear() {
this.particles = initParticles()
this.timerId = setInterval(() => {
this.particles = updateParticles(this.particles)
}, 60)
}
aboutToDisappear() {
if (this.timerId >= 0) {
clearInterval(this.timerId)
}
}
aboutToAppear和aboutToDisappear是ArkTS组件生命周期的两个核心回调函数。aboutToAppear在组件创建后、build方法执行前调用,适合进行数据初始化和资源准备;aboutToDisappear在组件销毁前调用,负责资源清理和释放。
在aboutToAppear中,首先调用initParticles()初始化粒子数组,赋值给this.particles状态变量。随后通过setInterval创建一个60ms间隔的定时器,定时调用updateParticles更新粒子位置。定时器ID保存在this.timerId中,供后续清理使用。这里的箭头函数() => { ... }捕获了this的引用,确保回调中能正确访问组件实例。
在aboutToDisappear中,检查timerId是否有效(大于等于0),如果有效则调用clearInterval清除定时器。这一步至关重要——如果不在组件销毁时清除定时器,定时器会继续在后台运行,造成内存泄漏和无效的CPU计算。在复杂的组件树中,未清理的定时器是导致性能下降的常见原因之一。
HarmonyOS ArkTS的组件生命周期遵循严格的管理机制。aboutToAppear只调用一次,保证了初始化逻辑不会重复执行;aboutToDisappear也只调用一次,确保资源被正确释放。这种"创建-使用-销毁"的完整生命周期管理,是构建高质量应用的基础。在实际项目中,这两个回调还可以用于网络请求的发起与取消、事件订阅与取消订阅、传感器监听与停止等场景。
第十三段:遮罩与粒子层构建器
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(24,40,52,0.55)')
.onClick(onClose)
}
@Builder
particleLayer() {
ForEach(this.particles, (p: ParticleDot) => {
Text('✒')
.fontSize(p.size)
.fontColor(p.color)
.opacity(0.35)
.position({ x: p.x, y: p.y })
}, (p: ParticleDot) => p.phase.toString() + '_' + Math.round(p.y).toString())
}
@Builder是ArkTS中用于声明UI构建函数的装饰器,它允许将UI片段封装为可复用的方法。与@Component不同,@Builder方法不创建独立组件实例,而是在调用处内联展开,开销更小,适合简单的UI片段复用。
modalOverlay是一个参数化的构建器,接受一个onClose回调函数。它渲染一个全屏半透明遮罩层,背景色为rgba(24,40,52,0.55)——深墨蓝色配合0.55的透明度,既遮挡底层内容又不完全遮挡视觉。点击遮罩层时触发onClose回调,关闭对应的弹框。这种"点击外部关闭"的交互模式是模态弹框的标准行为。
particleLayer构建器渲染粒子特效层。通过ForEach遍历this.particles数组,为每个粒子渲染一个✒字符。粒子的位置通过.position({ x, y })绝对定位,尺寸、颜色和透明度都从粒子数据中读取。ForEach的第三个参数是键值生成器,使用phase + '_' + y的组合作为唯一键——当粒子y坐标变化时键值也随之变化,确保ForEach能正确识别数据变更并触发更新。
使用phase.toString() + '_' + Math.round(p.y).toString()作为键值是一个精巧的设计。phase是粒子的序号(0-17),保证每个粒子的基础唯一性;Math.round(p.y)取y坐标的整数值,当粒子移动到新的整数像素行时键值发生变化,触发该粒子的重渲染。这种键值策略在保证渲染正确性的同时,尽量减少了不必要的重绘开销。
第十四段:弹框一——加入书架
@Builder
addShelfModal() {
Stack() {
this.modalOverlay(() => {
this.showShelfModal = false
})
Column() {
Column() {
Text('📥 加入书架')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('把好书装进口袋,随时随地续读')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 4 })
}
.width('100%')
.padding(20)
.linearGradient({
angle: 135,
colors: [['#1D4368', 0], ['#2C5F8A', 1]]
})
.borderRadius({ topLeft: 16, topRight: 16 })
addShelfModal是第一个弹框构建器,实现了"加入书架"功能弹框。整个弹框采用Stack堆叠布局,底层是半透明遮罩,上层是白色卡片内容区。这种"遮罩+卡片"的层叠结构是模态弹框的经典实现方式。
弹框头部使用linearGradient线性渐变背景,从#1D4368(深墨蓝)到#2C5F8A(墨蓝),渐变角度为135度,营造出从左上到右下的光影效果。头部包含标题"📥 加入书架"和副标题"把好书装进口袋,随时随地续读",白色文字在深色渐变背景上具有高对比度。.borderRadius({ topLeft: 16, topRight: 16 })只设置上方两个圆角,与卡片下方的内容区无缝衔接。
内容区展示书籍的封面图标、书名、作者信息和评分字数等元数据。底部提供"暂不"和"加入书架"两个按钮,分别使用灰色和墨蓝色背景,视觉上区分主次操作。点击"加入书架"按钮时,shelfCount自增1并关闭弹框,实现了书架数量的实时更新。
Row({ space: 12 }) {
Text('暂不')
.fontSize(14)
.fontColor(COLORS.gray)
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.showShelfModal = false
})
Text('加入书架')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.primary)
.borderRadius(20)
.onClick(() => {
this.shelfCount = this.shelfCount + 1
this.showShelfModal = false
})
}
按钮区域使用Row({ space: 12 })水平排列两个按钮,间距12vp。两个按钮都使用.layoutWeight(1)实现等宽分配,.textAlign(TextAlign.Center)居中文字,.borderRadius(20)设置胶囊形圆角。主按钮"加入书架"使用FontWeight.Bold加粗字重和墨蓝色背景,视觉权重明显高于次按钮"暂不",引导用户优先选择主操作。点击"加入书架"时同时执行两个状态变更——shelfCount加1和showShelfModal设为false,前者更新书架计数显示,后者关闭弹框,两个状态变更会在同一渲染周期内合并处理。
第十五段:弹框二——阅读设置
@Builder
readSettingModal() {
Stack() {
this.modalOverlay(() => {
this.showSettingModal = false
})
Column() {
Row() {
Text('⚙ 阅读设置')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.hint)
.onClick(() => {
this.showSettingModal = false
})
}
readSettingModal是阅读设置弹框,采用竹青色表单卡片风格。弹框头部使用Row水平布局,左侧是标题"⚙ 阅读设置",中间用.layoutWeight(1)的空Column撑开空间,右侧是关闭按钮"✕"。这种"左标题-右关闭"的布局是设置类弹框的标准模式。
Column({ space: 10 }) {
Text('字号')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 10 }) {
Text('小')
.fontSize(12)
.fontColor(this.fontSize === 14 ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 14, right: 14 })
.backgroundColor(this.fontSize === 14 ? COLORS.teal : COLORS.tealLight)
.borderRadius(16)
.onClick(() => {
this.fontSize = 14
})
Text('标准')
.fontSize(13)
.fontColor(this.fontSize === 16 ? '#FFFFFF' : COLORS.gray)
.backgroundColor(this.fontSize === 16 ? COLORS.teal : COLORS.tealLight)
.borderRadius(16)
.onClick(() => {
this.fontSize = 16
})
设置弹框包含三个设置项:字号、阅读背景和翻页模式。字号设置提供"小"(14)、“标准”(16)、“大”(18)、“特大”(20)四个选项,每个选项的字体颜色和背景色通过三元运算符动态切换——选中时显示白色文字和竹青色背景,未选中时显示灰色文字和浅竹青色背景。这种"选中态高亮"的视觉反馈使用户能一眼看出当前选中的选项。
Column({ space: 10 }) {
Text('阅读背景')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 10 }) {
Text('米黄')
.fontSize(13)
.fontColor(this.readBg === '米黄' ? COLORS.teal : COLORS.gray)
.padding({ top: 8, bottom: 8, left: 16, right: 16 })
.backgroundColor(COLORS.paper)
.borderRadius(10)
.border({ width: this.readBg === '米黄' ? 2 : 1, color: this.readBg === '米黄' ? COLORS.teal : COLORS.border })
.onClick(() => {
this.readBg = '米黄'
})
Text('护眼绿')
.backgroundColor('#E2F3F1')
.border({ width: this.readBg === '护眼绿' ? 2 : 1, color: this.readBg === '护眼绿' ? COLORS.teal : COLORS.border })
Text('夜间')
.fontColor(this.readBg === '夜间' ? '#FFFFFF' : '#99AABB')
.backgroundColor('#2B3A44')
阅读背景设置提供"米黄"、"护眼绿"和"夜间"三种选项,每种选项的背景色直接使用对应的阅读背景色——米黄使用纸张色#F9F5EC,护眼绿使用浅竹青#E2F3F1,夜间使用深灰蓝#2B3A44。选中态通过边框宽度(2vp vs 1vp)和边框颜色(竹青色 vs 边框色)来区分,比纯背景色变化更加微妙但同样有效。夜间模式的文字颜色也相应调整为浅色,确保在深色背景上的可读性。
整个设置弹框的交互逻辑完全由@State状态变量驱动。用户点击任一选项时,对应的状态变量(fontSize、readBg、pageMode)被更新,ArkTS框架自动检测到状态变化,触发弹框UI的重渲染,更新所有选项的选中态视觉。这种"状态驱动UI"的响应式编程模式,使得交互逻辑简洁清晰,无需手动操作DOM或调用重绘方法。
第十六段:弹框三——删除确认
@Builder
deleteBookModal() {
Stack() {
this.modalOverlay(() => {
this.showDeleteModal = false
})
Column({ space: 14 }) {
Text('🗑')
.fontSize(40)
Text('移出书架')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('移出后阅读进度将不再同步,确定要移出《山海经异闻录》吗?')
.fontSize(13)
.fontColor(COLORS.gray)
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Text('再想想')
.fontSize(14)
.fontColor(COLORS.gray)
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.showDeleteModal = false
})
Text('确认移出')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.red)
.borderRadius(20)
.onClick(() => {
if (this.shelfCount > 0) {
this.shelfCount = this.shelfCount - 1
}
this.showDeleteModal = false
})
}
.width('100%')
.margin({ top: 6 })
}
.width('72%')
.padding(22)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Center)
}
deleteBookModal是删除确认弹框,采用小尺寸警示卡片设计。弹框宽度仅为72%,比其他弹框(84%-88%)更窄,营造出"小而聚焦"的警示感。内容区垂直排列垃圾桶图标、标题"移出书架"、说明文字和双按钮,所有元素居中对齐。
说明文字"移出后阅读进度将不再同步"明确告知用户操作后果,这是良好UX设计的原则——在执行不可逆操作前,充分提示影响范围。按钮区域同样使用"再想想"和"确认移出"的双按钮模式,但确认按钮使用红色(COLORS.red)背景而非墨蓝色,红色在色彩心理学中代表警示和危险,有效传达了"删除"操作的严重性。
点击"确认移出"时,先检查shelfCount > 0防止数量变为负数,然后将计数减1并关闭弹框。这种防御性编程(defensive programming)确保了即使书架为空,也不会出现负数的异常状态。整个删除确认流程简洁高效——点击删除图标弹出确认框,用户确认后立即执行删除并更新UI,无需额外的加载等待。
第十七段:弹框四——书籍详情
@Builder
bookDetailModal() {
Stack() {
this.modalOverlay(() => {
this.showDetailModal = false
})
Column() {
Scroll() {
Column() {
Row({ space: 14 }) {
Text('⚡')
.fontSize(38)
.width(88)
.height(118)
.textAlign(TextAlign.Center)
.backgroundColor('#E5EEF5')
.borderRadius(10)
Column({ space: 8 }) {
Text('山海经异闻录')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('墨白先生 著')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 6 }) {
Text('玄幻')
.fontSize(11)
.fontColor(COLORS.primary)
.padding({ top: 3, bottom: 3, left: 8, right: 8 })
.backgroundColor('#E5EEF5')
.borderRadius(4)
Text('连载中')
.fontSize(11)
.fontColor(COLORS.teal)
.backgroundColor('#E2F3F1')
.borderRadius(4)
}
bookDetailModal是书籍详情大卡片弹框,是四个弹框中信息量最大的一个。弹框使用Scroll可滚动容器包裹内容,配合.constraintSize({ maxHeight: '80%' })限制最大高度,当内容超出时自动出现滚动条(此处隐藏滚动条)。这种"限高滚动"设计确保弹框内容多时不会超出屏幕,同时保持视觉完整。
弹框顶部是书籍基本信息区,左侧是88x118vp的封面图标区,右侧是书名、作者和标签。标签使用小型圆角矩形展示,"玄幻"标签用墨蓝色文字配浅蓝背景,"连载中"标签用竹青色文字配浅竹青背景,通过颜色编码快速区分分类和状态。
Row({ space: 8 }) {
Column() {
Text('★ 4.6')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('评分')
.fontSize(11)
.fontColor(COLORS.hint)
}
Column() {
Text('356万')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('字数')
.fontSize(11)
.fontColor(COLORS.hint)
}
Column() {
Text('1280万')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('收藏')
.fontSize(11)
.fontColor(COLORS.hint)
}
}
数据统计区以三列网格展示评分、字数和收藏量。每列上方是大号加粗数值,下方是小号灰色标签。评分用金色(COLORS.gold)突出显示,字数和收藏量用墨色显示,通过色彩差异引导用户关注评分这一核心指标。这种"数值+标签"的卡片式数据展示在阅读App中非常普遍,清晰且信息密度高。
弹框中还包含书籍简介(米纸色背景卡片)、目录列表(5章章节标题,VIP章节标注金色标签)和底部双按钮(“免费试读"和"加入书架”)。点击"加入书架"按钮时,先关闭详情弹框(showDetailModal = false)再打开书架弹框(showShelfModal = true),实现了弹框间的链式跳转。这种设计使用户在查看详情后能无缝进入加入书架流程,提升了操作连贯性。
第十八段:顶部Tab与底部Tab构建器
@Builder
topTabItem(label: string, tab: BookTab) {
Column({ space: 6 }) {
Text(label)
.fontSize(this.activeTab === tab ? 16 : 14)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.activeTab === tab ? COLORS.primary : COLORS.gray)
Column()
.width(this.activeTab === tab ? 20 : 0)
.height(3)
.borderRadius(2)
.backgroundColor(COLORS.primary)
}
.padding({ left: 12, right: 12 })
.onClick(() => {
this.activeTab = tab
})
}
@Builder
bottomTabItem(icon: string, label: string, tab: BottomTab) {
Column({ space: 4 }) {
Text(icon)
.fontSize(22)
Text(label)
.fontSize(11)
.fontColor(this.bottomTab === tab ? COLORS.primary : COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.bottomTab = tab
})
}
@Builder
bottomTabBar() {
Row() {
this.bottomTabItem('🏬', '书城', BottomTab.MALL)
this.bottomTabItem('📖', '书架', BottomTab.SHELF)
this.bottomTabItem('🎧', '听书', BottomTab.LISTEN)
this.bottomTabItem('👤', '我的', BottomTab.MINE)
}
.width('100%')
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
}
这三个构建器实现了应用的两级导航栏。topTabItem是顶部Tab项的构建器,接受标签文本和Tab枚举值两个参数。选中态通过四个维度的视觉差异来体现:字号(16 vs 14)、字重(Bold vs Normal)、颜色(墨蓝 vs 灰色)和底部指示条(20vp宽 vs 0宽)。底部指示条是一个3vp高的小矩形,选中时宽度为20vp并显示墨蓝色,未选中时宽度为0实现"隐藏"效果。这种"指示条"设计是移动端Tab导航的标配,比纯颜色变化更具辨识度。
bottomTabItem是底部Tab项的构建器,上方是22vp的emoji图标,下方是11vp的文字标签。选中态仅通过文字颜色变化(墨蓝 vs 浅灰)来体现,图标保持不变。底部Tab的选中态反馈比顶部Tab更简洁,因为底部Tab通常面积更大、点击频率更高,过度的视觉变化反而会分散注意力。
bottomTabBar将四个底部Tab项组装成完整的导航栏。使用Row水平排列,每个Tab项通过.layoutWeight(1)等分宽度。导航栏整体使用白色背景和1vp边框,与上方内容区形成视觉分隔。在实际项目中,底部导航栏通常还需要适配安全区域(如iPhone的Home Indicator),在HarmonyOS中可以通过.expandSafeArea()等API实现。
两个Tab构建器都通过.onClick修改对应的状态变量(activeTab或bottomTab),ArkTS框架自动响应状态变化,触发关联内容区域的重渲染,实现Tab切换效果。这种"点击-改状态-自动重渲染"的响应式模式是ArkTS开发的核心范式。
第十九段:书城头部与书城页构建器
@Builder
mallHeader() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Column() {
Row({ space: 6 }) {
Text('🔍')
.fontSize(14)
Text('搜索书名、作者、类型')
.fontSize(13)
.fontColor(COLORS.hint)
}
.padding({ top: 9, bottom: 9, left: 14, right: 14 })
.layoutWeight(1)
.backgroundColor(COLORS.bg)
.borderRadius(20)
}
Text('🔔')
.fontSize(22)
.onClick(() => {
this.showSettingModal = true
})
}
.width('100%')
Row() {
this.topTabItem('推荐', BookTab.RECOMMEND)
this.topTabItem('男生', BookTab.MALE)
this.topTabItem('女生', BookTab.FEMALE)
this.topTabItem('出版', BookTab.PUBLISH)
this.topTabItem('漫画', BookTab.COMIC)
this.topTabItem('听书', BookTab.AUDIO)
}
.width('100%')
}
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
.backgroundColor(COLORS.white)
}
mallHeader构建器渲染书城页的头部区域,包含搜索栏和顶部Tab栏两部分。搜索栏使用Row水平布局,左侧是搜索图标和占位文字组成的搜索框(浅灰背景、胶囊形圆角),右侧是通知铃铛图标。点击铃铛图标会打开阅读设置弹框,这里复用了设置弹框而非创建新的通知页面,简化了演示逻辑。
搜索框的设计采用了"模拟搜索框"模式——使用Text而非TextInput,因为演示页面不需要真实的搜索功能。搜索框使用.layoutWeight(1)占据剩余空间,与右侧图标形成"主-辅"的空间分配关系。搜索框内部的图标和文字间距为6vp,整体内边距为9vp垂直、14vp水平,视觉比例协调。
@Builder
mallPage() {
Column() {
this.mallHeader()
Scroll() {
Column() {
if (this.activeTab === BookTab.RECOMMEND) {
RecommendContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
},
onAddShelf: () => {
this.showShelfModal = true
}
})
}
if (this.activeTab === BookTab.MALE) {
MaleContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
}
})
}
if (this.activeTab === BookTab.COMIC) {
ComicContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
}
})
}
if (this.activeTab === BookTab.AUDIO) {
AudioTabContent()
}
}
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
mallPage构建器组装了完整的书城页面,结构为"头部+可滚动内容区"。内容区使用Scroll组件包裹,.layoutWeight(1)填充头部下方的剩余空间,.scrollBar(BarState.Off)隐藏滚动条保持视觉简洁。
内容区通过一系列if条件判断,根据activeTab的值渲染对应的内容子组件。这里使用了ArkTS的条件渲染语法——if (this.activeTab === BookTab.RECOMMEND)判断为真时渲染RecommendContent组件。每个子组件通过参数传递回调函数:onTapBook回调在用户点击书籍时触发,将选中的书籍保存到selectedBook并打开详情弹框;onAddShelf回调在用户点击"加入书架"入口时触发打开书架弹框。
这种"回调传递"的组件通信模式是ArkTS中父子组件交互的标准方式。父组件(QQReadApp)将状态变更逻辑封装为回调函数,通过组件参数传递给子组件。子组件在特定事件发生时调用回调,间接修改父组件的状态。这种方式既保持了子组件的独立性(子组件不直接访问父组件状态),又实现了灵活的事件通信。
第二十段:书架页与听书页与我的页构建器
@Builder
shelfPage() {
Column() {
Row({ space: 12 }) {
Text('我的书架')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('⚙ 阅读设置')
.fontSize(13)
.fontColor(COLORS.teal)
.onClick(() => {
this.showSettingModal = true
})
}
.width('100%')
.padding(16)
Scroll() {
Column() {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('📚')
.fontSize(34)
Text('共' + this.shelfCount.toString() + '本')
.fontSize(12)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor(COLORS.paper)
.borderRadius(12)
shelfPage构建器渲染书架页面。顶部标题栏使用"左标题-右设置入口"的布局模式,点击设置入口打开阅读设置弹框。标题下方是三个统计卡片,分别显示书架总数(米纸色背景)、今日阅读时长(浅竹青背景)和连续打卡天数(浅粉色背景)。每个卡片使用.layoutWeight(1)等分宽度,34vp的emoji图标配合12vp的灰色说明文字,形成清晰的视觉层次。
Column({ space: 12 }) {
ForEach(SHELF_BOOKS, (sb: ShelfBook) => {
Row({ space: 12 }) {
Text(sb.icon)
.fontSize(28)
.width(56)
.height(74)
.textAlign(TextAlign.Center)
.backgroundColor(sb.color + '22')
.borderRadius(8)
Column({ space: 6 }) {
Text(sb.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(sb.progress)
.fontSize(12)
.fontColor(COLORS.teal)
Row() {
Column()
.width(120)
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.border)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🗑')
.fontSize(18)
.onClick(() => {
this.showDeleteModal = true
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
}, (sb: ShelfBook) => sb.name)
}
书架列表使用ForEach遍历SHELF_BOOKS数组渲染书籍卡片。每张卡片左侧是56x74vp的书封图标区,背景色通过sb.color + '22'拼接十六进制透明度后缀('22’约等于13%透明度),实现淡色背景效果。中间是书名、阅读进度和进度条,右侧是删除垃圾桶图标,点击触发删除确认弹框。
进度条是一个120x4vp的灰色矩形,当前设计仅展示固定宽度的背景条。在实际项目中,可以通过计算阅读百分比动态设置进度条的填充宽度,实现真实的进度可视化效果。
@Builder
listenPage() {
Column() {
Row({ space: 12 }) {
Text('🎧 听书')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('定时')
.fontSize(13)
.fontColor(COLORS.teal)
}
listenPage构建器渲染听书页面。顶部标题栏与书架页类似,右侧"定时"入口用于设置定时停止播放。页面主体包含播放器卡片(圆形播放按钮、书名、主播信息和播放控制按钮)、分类标签栏(小说、出版、儿童、相声、外语五个频道标签)和音频列表。播放器卡片中的播放控制按钮(⏪后退、⏸暂停、⏩快进)使用emoji图标,简洁直观。
@Builder
minePage() {
Column() {
Row({ space: 14 }) {
Text('🐧')
.fontSize(38)
.width(64)
.height(64)
.textAlign(TextAlign.Center)
.backgroundColor('#E5EEF5')
.borderRadius(32)
Column({ space: 6 }) {
Text('书虫小戴')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('LV.6 勤奋读者 · 连续阅读23天')
.fontSize(12)
.fontColor(COLORS.gray)
}
minePage构建器渲染"我的"个人中心页面。顶部用户信息区包含64x64vp的圆形头像(浅蓝背景配企鹅emoji)、用户名"书虫小戴"和等级信息"LV.6 勤奋读者 · 连续阅读23天"。下方是四列数据统计栏(读过、收获、书单、勋章),使用ForEach遍历二维数组渲染。最下方是设置菜单列表(钱包、优惠券、夜间模式、推送设置、意见反馈),每项包含图标、标题、状态文字和右箭头,点击任意项都会打开设置弹框。
第二十一段:主构建方法
build() {
Stack() {
Column() {
if (this.bottomTab === BottomTab.MALL) {
this.mallPage()
}
if (this.bottomTab === BottomTab.SHELF) {
this.shelfPage()
}
if (this.bottomTab === BottomTab.LISTEN) {
this.listenPage()
}
if (this.bottomTab === BottomTab.MINE) {
this.minePage()
}
this.bottomTabBar()
}
.width('100%')
.height('100%')
this.particleLayer()
if (this.showShelfModal) {
this.addShelfModal()
}
if (this.showSettingModal) {
this.readSettingModal()
}
if (this.showDeleteModal) {
this.deleteBookModal()
}
if (this.showDetailModal) {
this.bookDetailModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
build方法是整个组件的渲染入口,使用Stack堆叠容器组织所有UI层级。Stack的层叠顺序是后渲染的元素在上层,因此渲染顺序精心设计为三层:底层是主内容区(页面+底部导航栏),中层是粒子特效层,顶层是弹框层。
主内容区使用Column垂直布局,上方根据bottomTab状态条件渲染对应的页面(书城/书架/听书/我的),下方固定渲染底部导航栏。四个页面通过if条件渲染实现互斥显示——同一时刻只有一个页面可见。这种条件渲染比visibility属性切换更高效,因为未渲染的页面不会占用布局计算资源。
粒子层通过this.particleLayer()调用渲染,覆盖在主内容区之上。由于粒子透明度为0.35且使用✒字符渲染,不会遮挡下层内容的交互——Stack默认允许上层元素穿透点击到下层。但在实际项目中,如果粒子层影响了下层交互,可以通过.hitTestBehavior(HitTestMode.None)禁用粒子层的触摸响应。
弹框层通过四个独立的if条件渲染控制,每个弹框对应一个布尔状态变量。这种"独立控制"的设计意味着理论上可以同时显示多个弹框(如详情弹框和书架弹框叠加),但在实际交互流程中,通过合理的按钮回调链避免了多弹框叠加的混乱情况。所有弹框都使用全屏Stack包裹,遮罩层覆盖整个屏幕,确保用户必须先处理弹框才能继续操作。
上图展示了build方法的完整渲染流程。从入口出发,通过Stack三层堆叠,依次渲染主内容区、粒子层和弹框层,每层内部再通过状态条件判断决定具体渲染内容。
第二十二段:内容子组件群
@Component
struct RecommendContent {
onTapBook: (b: BookItem) => void = () => {}
onAddShelf: () => void = () => {}
build() {
Column({ space: 16 }) {
Scroll() {
Row({ space: 12 }) {
ForEach(BANNER_LIST, (bn: BannerInfo) => {
Column({ space: 8 }) {
Text(bn.icon)
.fontSize(40)
Text(bn.title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(bn.sub)
.fontSize(11)
.fontColor('rgba(255,255,255,0.85)')
Text(bn.tag)
.fontSize(10)
.fontColor(bn.bg)
.padding({ top: 3, bottom: 3, left: 10, right: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(10)
}
.width(220)
.padding(18)
.borderRadius(14)
.backgroundColor(bn.bg)
.alignItems(HorizontalAlign.Start)
}, (bn: BannerInfo) => bn.title)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
RecommendContent是推荐Tab的内容组件,包含三个核心模块:横滑Banner区、金刚区入口和每日精选书籍列表。横滑Banner使用Scroll配合.scrollable(ScrollDirection.Horizontal)实现水平滑动,每张Banner卡片宽220vp,包含图标、标题、副标题和标签。标签文字颜色使用Banner的背景色,在白色标签背景上形成"反色"效果,视觉醒目。
@Component
struct MaleContent {
onTapBook: (b: BookItem) => void = () => {}
build() {
Column({ space: 14 }) {
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(mockBooks, (b: BookItem) => {
Column({ space: 8 }) {
Text(b.icon)
.fontSize(34)
.width('100%')
.height(110)
.textAlign(TextAlign.Center)
.backgroundColor(b.coverColor + '22')
.borderRadius({ topLeft: 8, topRight: 8 })
Text(b.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('46%')
.margin(6)
.onClick(() => {
this.onTapBook(b)
})
}, (b: BookItem) => 'm' + b.id.toString())
}
MaleContent是男生频道Tab的内容组件,采用双列网格布局展示书籍。使用Flex容器的FlexWrap.Wrap自动换行模式,每个书籍卡片宽度46%,配合6vp的外边距,实现两列等宽排列。卡片上方是110vp高的封面区(顶部圆角),下方是书名和作者。书名使用.maxLines(1)限制单行显示,配合.textOverflow({ overflow: TextOverflow.Ellipsis })在文字超出时显示省略号,保证了网格布局的整齐美观。
@Component
struct FemaleContent {
onTapBook: (b: BookItem) => void = () => {}
build() {
Column({ space: 14 }) {
Column({ space: 12 }) {
ForEach(mockBooks, (b: BookItem, idx: number) => {
Row({ space: 14 }) {
Text((idx + 1).toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.red : COLORS.hint)
.width(28)
Text(b.icon)
.fontSize(28)
.width(54)
.height(72)
Column({ space: 5 }) {
Text(b.name)
Text(b.author + ' · ' + b.tags.join(' / '))
Text(ratingStars(b.rating) + ' ' + b.rating.toString())
}
}
}, (b: BookItem) => 'f' + b.id.toString())
}
FemaleContent是女生频道Tab的内容组件,采用排行榜列表布局。每行包含排名序号、书封图标和书籍信息。排名前三的序号使用红色(COLORS.red),其余使用浅灰色(COLORS.hint),通过颜色突出前三名的排名优势。b.tags.join(' / ')将标签数组用斜杠连接为字符串,简洁地展示多标签信息。ratingStars函数将评分转换为星级字符串,配合数值评分一起显示。
@Component
struct PublishContent {
build() {
Column({ space: 16 }) {
Row({ space: 0 }) {
ForEach(WEEK_STATS, (s: WeekStat) => {
Column({ space: 6 }) {
Text(s.minutes.toString())
.fontSize(10)
.fontColor(COLORS.primary)
Column()
.width(22)
.height(statBarHeight(s.minutes, 160))
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(COLORS.teal)
Text(s.day)
.fontSize(11)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (s: WeekStat) => s.day)
}
PublishContent是出版Tab的内容组件,包含柱状图和出版精品排行榜两部分。柱状图使用ForEach遍历WEEK_STATS数据,每根柱子宽度22vp,高度通过statBarHeight函数动态计算。柱子顶部圆角,竹青色填充,上方显示分钟数值,下方显示星期标签。七根柱子通过.layoutWeight(1)等分宽度,形成均匀分布的柱状图。
@Component
struct ComicContent {
onTapBook: (b: BookItem) => void = () => {}
build() {
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(mockBooks, (b: BookItem, idx: number) => {
Column({ space: 6 }) {
Text(b.icon)
.fontSize(40)
.width(96)
.height(96)
.textAlign(TextAlign.Center)
.backgroundColor(b.coverColor + '22')
.borderRadius(48)
Text('NO.' + (idx + 1).toString())
.fontColor(idx < 3 ? COLORS.red : COLORS.hint)
}
.width('30%')
ComicContent是漫画Tab的内容组件,采用圆形封面网格布局。每个漫画封面是96x96vp的圆形(borderRadius(48)实现圆形),配合"NO.1"排名标签。前三名排名用红色突出,使用FlexAlign.Center居中对齐,三列排列。圆形封面设计契合漫画的视觉风格,比矩形书封更有趣味性。
@Component
struct AudioTabContent {
build() {
Column({ space: 14 }) {
Row({ space: 12 }) {
Column({ space: 6 }) {
Text('📻')
Text('小说频道')
}
.layoutWeight(1)
.backgroundColor(COLORS.tealLight)
}
Column({ space: 10 }) {
ForEach(['第96回 · 雾锁申城', '第95回 · 失踪的舞女', '第94回 · 码头疑云'], (ch: string, idx: number) => {
Row() {
Text((idx + 1).toString())
.width(24)
Text(ch)
Column()
.layoutWeight(1)
Text('18:24')
}
}, (ch: string) => ch)
}
AudioTabContent是听书Tab的内容组件,包含频道入口卡片、播放条和章节列表。频道入口采用三列卡片布局(小说频道、出版精讲、哄睡电台),每个卡片有不同的背景色。章节列表使用ForEach遍历章节数组,每行包含序号、章节名和时长,简洁的列表布局便于用户快速定位收听位置。
组件生命周期与数据流分析
上图展示了组件从创建到销毁的完整生命周期,以及用户交互、状态变更和渲染更新之间的数据流转关系。整个应用的数据流遵循单向数据流原则:用户交互触发状态变更,状态变更驱动UI重渲染,UI更新反馈给用户。
对比表格
表格一:四种弹框技术方案对比
| 弹框类型 | 触发方式 | 宽度占比 | 核心功能 | 状态变量 | 交互复杂度 | 视觉风格 |
|---|---|---|---|---|---|---|
| 加入书架弹框 | 书籍详情页按钮 | 84% | 书籍信息展示 + 加入书架 | showShelfModal | 中等 | 墨蓝渐变头 + 白色卡片 |
| 阅读设置弹框 | 铃铛/设置入口 | 86% | 字号/背景/翻页模式选择 | showSettingModal | 高 | 竹青表单卡 + 分组设置 |
| 删除确认弹框 | 书架删除图标 | 72% | 警示确认 + 书架计数递减 | showDeleteModal | 低 | 小尺寸红色警示卡 |
| 书籍详情弹框 | 书籍卡片点击 | 88% | 详情/简介/目录/试读 | showDetailModal | 高 | 大卡片 + 可滚动内容 |
四种弹框在触发方式、尺寸占比和交互复杂度上各有差异。加入书架弹框和阅读设置弹框属于"功能型"弹框,需要用户进行选择操作;删除确认弹框属于"警示型"弹框,尺寸最小以聚焦用户注意力;书籍详情弹框属于"信息展示型"弹框,尺寸最大且支持滚动,承载最丰富的信息内容。
表格二:六大内容子组件布局方案对比
| 组件名称 | 布局模式 | 数据源 | 核心交互 | 回调函数 | ForEach键值策略 | 特色视觉元素 |
|---|---|---|---|---|---|---|
| RecommendContent | 纵向列表 + 横滑Banner | BANNER_LIST + mockBooks | 点击书籍/加入书架 | onTapBook, onAddShelf | bn.title / b.id | 横滑Banner + 金刚区 |
| MaleContent | Flex双列网格 | mockBooks | 点击书籍 | onTapBook | ‘m’ + b.id | 双列书封网格 |
| FemaleContent | 纵向排行榜列表 | mockBooks + idx | 点击播放/查看 | onTapBook | ‘f’ + b.id | 排名序号 + 星级评分 |
| PublishContent | 柱状图 + 排行榜 | WEEK_STATS + mockBooks | 无(静态展示) | 无 | s.day / ‘p’ + b.id | 柱状图 + 评分进度条 |
| ComicContent | Flex三列圆形网格 | mockBooks + idx | 点击漫画 | onTapBook | ‘c’ + b.id | 圆形封面 + NO.排名 |
| AudioTabContent | 频道卡 + 章节列表 | 静态数组 | 无(静态展示) | 无 | ch字符串 | 频道入口 + 播放条 |
六个子组件采用了四种不同的布局模式(纵向列表、双列网格、三列网格、排行榜+图表),充分展示了ArkTS布局组件的多样性。每个组件的ForEach键值策略都加入了前缀标识(‘m’、‘f’、‘p’、‘c’),避免了不同组件间键值冲突的问题,这是一个容易被忽略但非常重要的工程细节。
表格三:状态变量管理策略对比
| 状态变量 | 数据类型 | 初始值 | 触发UI更新范围 | 变更频率 | 生命周期归属 |
|---|---|---|---|---|---|
| activeTab | BookTab枚举 | RECOMMEND(0) | 书城页内容区切换 | 中等(用户主动切换) | 全局持久 |
| bottomTab | BottomTab枚举 | MALL(0) | 主页面切换 | 中等(用户主动切换) | 全局持久 |
| showShelfModal | boolean | false | 加入书架弹框显隐 | 低(偶发操作) | 全局持久 |
| showSettingModal | boolean | false | 阅读设置弹框显隐 | 低(偶发操作) | 全局持久 |
| showDeleteModal | boolean | false | 删除确认弹框显隐 | 低(偶发操作) | 全局持久 |
| showDetailModal | boolean | false | 书籍详情弹框显隐 | 中等(频繁查看) | 全局持久 |
| selectedBook | BookItem|null | null | 详情弹框内容 | 中等(每次查看变更) | 全局持久 |
| particles | ParticleDot[] | [] | 粒子特效层 | 极高(60ms间隔) | 全局持久 |
| fontSize | number | 16 | 设置弹框字号选项 | 极低(偶尔调整) | 全局持久 |
| readBg | string | ‘米黄’ | 设置弹框背景选项 | 极低(偶尔调整) | 全局持久 |
| pageMode | string | ‘仿真翻页’ | 设置弹框翻页选项 | 极低(偶尔调整) | 全局持久 |
| shelfCount | number | 12 | 书架页统计数字 | 低(增删时变更) | 全局持久 |
| todayMinutes | number | 42 | 书架页统计数字 | 极低(每日更新) | 全局持久 |
| timerId | number(private) | -1 | 无(纯逻辑变量) | 一次(创建时) | 全局持久 |
13个状态变量按照变更频率可以分为三组:极高频变更的particles(每60ms更新),中频变更的Tab和弹框状态变量,极低频变更的设置类变量。timerId作为唯一的private变量,不参与UI渲染,正确地使用了private而非@State修饰,避免了不必要的重渲染开销。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// QQ阅读·书城 —— 阅读App演示页面 (ArkTS / HarmonyOS)
// 场景:网文书城 / 听书 / 漫画 / 出版书
// 风格:蓝绿书卷风(墨蓝 + 竹青 + 米纸色)
// 结构:底部4Tab + 顶部6功能Tab + 4种弹框 + 柱状图 + 墨点粒子特效
// ============================================================
// ==================== 配色配置 ====================
interface ColorPalette {
primary: string
primaryDark: string
teal: string
tealLight: string
bg: string
cardBg: string
ink: string
gray: string
hint: string
border: string
white: string
red: string
gold: string
paper: string
}
const COLORS: ColorPalette = {
primary: '#2C5F8A',
primaryDark: '#1D4368',
teal: '#3AA6A0',
tealLight: '#E2F3F1',
bg: '#F4F7F5',
cardBg: '#FFFFFF',
ink: '#22333B',
gray: '#5C6B73',
hint: '#9AA8AF',
border: '#E3EAE7',
white: '#FFFFFF',
red: '#D94F4F',
gold: '#C9A063',
paper: '#F9F5EC'
}
// ==================== 元信息接口 ====================
interface BookCategoryMeta {
label: string
icon: string
color: string
bg: string
}
interface BookStatusMeta {
label: string
color: string
bg: string
icon: string
}
interface KingKongEntry {
label: string
icon: string
color: string
bg: string
}
interface BannerInfo {
title: string
sub: string
tag: string
bg: string
icon: string
}
interface ParticleDot {
x: number
y: number
size: number
color: string
speed: number
phase: number
}
interface WeekStat {
day: string
minutes: number
}
interface ShelfBook {
name: string
icon: string
color: string
progress: string
}
// ==================== 书籍数据模型 ====================
@Observed
class BookItem {
id: number = 0
name: string = ''
author: string = ''
category: string = ''
icon: string = ''
coverColor: string = ''
wordCount: number = 0
collection: number = 0
status: string = '连载中'
tags: string[] = []
desc: string = ''
rating: number = 0
chapterCount: number = 0
updateTime: string = ''
isFree: boolean = false
constructor(id: number, name: string, author: string, category: string, icon: string,
coverColor: string, wordCount: number, collection: number, status: string,
tags: string[], desc: string, rating: number, chapterCount: number,
updateTime: string, isFree: boolean) {
this.id = id
this.name = name
this.author = author
this.category = category
this.icon = icon
this.coverColor = coverColor
this.wordCount = wordCount
this.collection = collection
this.status = status
this.tags = tags
this.desc = desc
this.rating = rating
this.chapterCount = chapterCount
this.updateTime = updateTime
this.isFree = isFree
}
}
// ==================== 配置 Record ====================
const BOOK_CATEGORY_CONFIG: Record<string, BookCategoryMeta> = {
'玄幻': { label: '玄幻', icon: '⚡', color: '#2C5F8A', bg: '#E5EEF5' },
'都市': { label: '都市', icon: '🏙', color: '#3AA6A0', bg: '#E2F3F1' },
'悬疑': { label: '悬疑', icon: '🔍', color: '#6B5CA5', bg: '#ECE8F7' },
'言情': { label: '言情', icon: '💗', color: '#D94F7A', bg: '#FBE5EC' },
'科幻': { label: '科幻', icon: '🚀', color: '#2A7FB8', bg: '#E3F1FA' },
'历史': { label: '历史', icon: '📜', color: '#C9A063', bg: '#F6EEDD' },
'漫画': { label: '漫画', icon: '🎨', color: '#E07B39', bg: '#FCEBDD' },
'出版': { label: '出版', icon: '📚', color: '#4C7A5C', bg: '#E6F2EA' }
}
const BOOK_STATUS_CONFIG: Record<string, BookStatusMeta> = {
'连载中': { label: '连载中', color: '#3AA6A0', bg: '#E2F3F1', icon: '✍' },
'已完结': { label: '已完结', color: '#4C7A5C', bg: '#E6F2EA', icon: '✅' },
'限时免费': { label: '限时免费', color: '#D94F4F', bg: '#FAE5E5', icon: '🎁' }
}
const KINGKONG_ENTRIES: KingKongEntry[] = [
{ label: '免费', icon: '🆓', color: '#2C5F8A', bg: '#E5EEF5' },
{ label: '男生', icon: '⚔', color: '#3AA6A0', bg: '#E2F3F1' },
{ label: '女生', icon: '🌸', color: '#D94F7A', bg: '#FBE5EC' },
{ label: '出版', icon: '📚', color: '#C9A063', bg: '#F6EEDD' },
{ label: '听书', icon: '🎧', color: '#6B5CA5', bg: '#ECE8F7' },
{ label: '漫画', icon: '🎨', color: '#E07B39', bg: '#FCEBDD' },
{ label: '排行', icon: '🏆', color: '#B8860B', bg: '#F7EDD4' },
{ label: '书单', icon: '📋', color: '#4C7A5C', bg: '#E6F2EA' }
]
const BANNER_LIST: BannerInfo[] = [
{ title: '仙侠征文大赛', sub: '百万奖金 等你执笔', tag: '活动', bg: '#2C5F8A', icon: '🗡' },
{ title: '夜读计划', sub: '每晚八点 免费好书', tag: '福利', bg: '#3AA6A0', icon: '🌙' },
{ title: '新书首发', sub: '大神新作抢先看', tag: '首发', bg: '#6B5CA5', icon: '✨' }
]
const WEEK_STATS: WeekStat[] = [
{ day: '周一', minutes: 45 },
{ day: '周二', minutes: 80 },
{ day: '周三', minutes: 60 },
{ day: '周四', minutes: 120 },
{ day: '周五', minutes: 95 },
{ day: '周六', minutes: 160 },
{ day: '周日', minutes: 140 }
]
const SHELF_BOOKS: ShelfBook[] = [
{ name: '斗破苍穹', icon: '🔥', color: '#E07B39', progress: '已读 78%' },
{ name: '庆余年', icon: '📜', color: '#C9A063', progress: '已读 42%' },
{ name: '夜的命名术', icon: '🌙', color: '#2C5F8A', progress: '已读 65%' },
{ name: '诡秘之主', icon: '🎭', color: '#6B5CA5', progress: '已读 90%' },
{ name: '大奉打更人', icon: '🔔', color: '#3AA6A0', progress: '已读 23%' },
{ name: '道诡异仙', icon: '☯', color: '#4C7A5C', progress: '已读 55%' }
]
// ==================== 静态书籍数据(15本) ====================
const mockBooks: BookItem[] = [
new BookItem(1, '山海经异闻录', '墨白先生', '玄幻', '⚡', '#2C5F8A', 356, 1280, '连载中',
['东方玄幻', '热血'], '少年偶得上古山海残卷,驭异兽、掌神通,踏遍九州山海之界。', 4.6, 1268, '2小时前', false),
new BookItem(2, '都市超级医生', '柳叶刀客', '都市', '🏙', '#3AA6A0', 212, 860, '连载中',
['都市', '医术'], '实习医生觉醒古医传承,一根银针震惊整个医学界。', 4.3, 856, '5小时前', true),
new BookItem(3, '雾都疑案', '夜航西飞', '悬疑', '🔍', '#6B5CA5', 180, 640, '已完结',
['推理', '民国'], '雾锁申城,连环失踪案背后藏着二十年的惊天秘密。', 4.8, 620, '已完结', false),
new BookItem(4, '春风十里栀子香', '半盏清茶', '言情', '💗', '#D94F7A', 156, 980, '已完结',
['现言', '治愈'], '花店老板娘与急诊科医生的双向奔赴,温柔了整个春天。', 4.5, 512, '已完结', true),
new BookItem(5, '星际拓荒纪元', '光帆船长', '科幻', '🚀', '#2A7FB8', 420, 720, '连载中',
['硬科幻', '星际'], '人类第一艘世代飞船启航,目标:四光年外的第二地球。', 4.7, 934, '昨天', false),
new BookItem(6, '大唐御史台', '青史闲笔', '历史', '📜', '#C9A063', 388, 560, '连载中',
['权谋', '盛唐'], '寒门御史搅动朝堂风云,盛世之下暗流涌动。', 4.4, 1102, '3小时前', false),
new BookItem(7, '喵星人饲养手册', '鱼干大人', '漫画', '🎨', '#E07B39', 96, 1500, '连载中',
['萌宠', '日常'], '三只猫咪的爆笑日常,铲屎官的快乐源泉。', 4.9, 386, '1小时前', true),
new BookItem(8, '活着为了讲述', '加西亚', '出版', '📚', '#4C7A5C', 32, 300, '已完结',
['传记', '外国文学'], '诺贝尔文学奖得主的自传,魔幻现实主义的源头活水。', 4.8, 45, '出版精品', false),
new BookItem(9, '剑来第二卷', '烽火戏诸侯', '玄幻', '⚔', '#2C5F8A', 520, 2100, '连载中',
['仙侠', '群像'], '少年陈平安背剑远行,一步一拳,问剑天下。', 4.9, 2310, '6小时前', false),
new BookItem(10, '深夜食堂·城南站', '盐酥鸡老板', '都市', '🍜', '#3AA6A0', 128, 460, '连载中',
['美食', '温情'], '凌晨两点的小饭馆,收留每一个饥饿的灵魂。', 4.6, 402, '8小时前', true),
new BookItem(11, '时间的形状', '汪洁', '出版', '📐', '#2A7FB8', 28, 180, '已完结',
['科普', '物理'], '相对论入门经典,看懂爱因斯坦的宇宙。', 4.5, 36, '出版精品', false),
new BookItem(12, '诡秘之主·新篇', '爱潜水的乌贼', '悬疑', '🎭', '#6B5CA5', 460, 1800, '连载中',
['西幻', '克苏鲁'], '蒸汽与神秘的交汇处,愚者依旧凝视深渊。', 4.9, 1980, '12小时前', false),
new BookItem(13, '雪中悍刀行·续', '烽火戏诸侯', '玄幻', '❄', '#4C7A5C', 380, 1600, '连载中',
['武侠', '庙堂'], '北凉刀再出鞘,世子的江湖还未结束。', 4.7, 1520, '昨天', false),
new BookItem(14, '你好,旧时光', '八月长安', '言情', '📔', '#D94F7A', 88, 1200, '已完结',
['青春', '校园'], '振华三部曲之二,余周周的成长物语。', 4.6, 298, '已完结', true),
new BookItem(15, '宇宙尽头的书店', '陈春成', '出版', '🌠', '#6B5CA5', 22, 260, '已完结',
['短篇', '想象'], '一家开在宇宙尽头的书店,贩卖所有未曾写出的书。', 4.8, 30, '出版精品', false)
]
// ==================== 纯函数工具 ====================
function formatWan(n: number): string {
if (n >= 10000) {
return (n / 10000).toFixed(1) + '亿'
}
return n.toString() + '万'
}
function statBarHeight(m: number, maxM: number): string {
const h = Math.round(m / maxM * 88) + 8
return h.toString() + 'vp'
}
function ratingStars(r: number): string {
if (r >= 4.8) {
return '★★★★★'
} else if (r >= 4.5) {
return '★★★★☆'
} else if (r >= 4.0) {
return '★★★★'
}
return '★★★'
}
function topFiveBooks(): BookItem[] {
return mockBooks
}
// ==================== 粒子特效 ====================
function initParticles(): ParticleDot[] {
const list: ParticleDot[] = []
const colors: string[] = ['#2C5F8A', '#3AA6A0', '#C9A063', '#6B5CA5', '#D94F7A']
for (let i = 0; i < 18; i++) {
const dot: ParticleDot = {
x: Math.random() * 340,
y: Math.random() * 700,
size: 8 + Math.random() * 14,
color: colors[i % colors.length],
speed: 0.6 + Math.random() * 1.4,
phase: i
}
list.push(dot)
}
return list
}
function updateParticles(list: ParticleDot[]): ParticleDot[] {
const next: ParticleDot[] = []
for (let i = 0; i < list.length; i++) {
const p = list[i]
const ny = p.y - p.speed
const dot: ParticleDot = {
x: p.x,
y: ny < -20 ? 720 : ny,
size: p.size,
color: p.color,
speed: p.speed,
phase: p.phase
}
next.push(dot)
}
return next
}
// ==================== Tab 枚举 ====================
enum BookTab {
RECOMMEND = 0,
MALE = 1,
FEMALE = 2,
PUBLISH = 3,
COMIC = 4,
AUDIO = 5
}
enum BottomTab {
MALL = 0,
SHELF = 1,
LISTEN = 2,
MINE = 3
}
// ==================== 主页面 ====================
@Entry
@Component
struct QQReadApp {
@State activeTab: BookTab = BookTab.RECOMMEND
@State bottomTab: BottomTab = BottomTab.MALL
@State showShelfModal: boolean = false
@State showSettingModal: boolean = false
@State showDeleteModal: boolean = false
@State showDetailModal: boolean = false
@State selectedBook: BookItem | null = null
@State particles: ParticleDot[] = []
@State fontSize: number = 16
@State readBg: string = '米黄'
@State pageMode: string = '仿真翻页'
@State shelfCount: number = 12
@State todayMinutes: number = 42
private timerId: number = -1
aboutToAppear() {
this.particles = initParticles()
this.timerId = setInterval(() => {
this.particles = updateParticles(this.particles)
}, 60)
}
aboutToDisappear() {
if (this.timerId >= 0) {
clearInterval(this.timerId)
}
}
// ==================== 遮罩 ====================
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(24,40,52,0.55)')
.onClick(onClose)
}
// ==================== 粒子层 ====================
@Builder
particleLayer() {
ForEach(this.particles, (p: ParticleDot) => {
Text('✒')
.fontSize(p.size)
.fontColor(p.color)
.opacity(0.35)
.position({ x: p.x, y: p.y })
}, (p: ParticleDot) => p.phase.toString() + '_' + Math.round(p.y).toString())
}
// ==================== 弹框1:加入书架(墨蓝渐变头) ====================
@Builder
addShelfModal() {
Stack() {
this.modalOverlay(() => {
this.showShelfModal = false
})
Column() {
Column() {
Text('📥 加入书架')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('把好书装进口袋,随时随地续读')
.fontSize(12)
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 4 })
}
.width('100%')
.padding(20)
.linearGradient({
angle: 135,
colors: [['#1D4368', 0], ['#2C5F8A', 1]]
})
.borderRadius({ topLeft: 16, topRight: 16 })
Column({ space: 14 }) {
Row({ space: 12 }) {
Text('⚡')
.fontSize(34)
.width(64)
.height(84)
.textAlign(TextAlign.Center)
.backgroundColor('#E5EEF5')
.borderRadius(8)
Column({ space: 6 }) {
Text('山海经异闻录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('墨白先生 · 玄幻 · 连载中')
.fontSize(12)
.fontColor(COLORS.gray)
Row({ space: 4 }) {
Text('★ 4.6')
.fontSize(12)
.fontColor(COLORS.gold)
Text('356万字')
.fontSize(12)
.fontColor(COLORS.hint)
}
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Divider().color(COLORS.border)
Row({ space: 10 }) {
Text('同步进度')
.fontSize(13)
.fontColor(COLORS.gray)
Column()
.layoutWeight(1)
Text('已开启')
.fontSize(13)
.fontColor(COLORS.teal)
}
.width('100%')
Row({ space: 10 }) {
Text('更新提醒')
.fontSize(13)
.fontColor(COLORS.gray)
Column()
.layoutWeight(1)
Text('每小时')
.fontSize(13)
.fontColor(COLORS.teal)
}
.width('100%')
Row({ space: 12 }) {
Text('暂不')
.fontSize(14)
.fontColor(COLORS.gray)
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.showShelfModal = false
})
Text('加入书架')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.primary)
.borderRadius(20)
.onClick(() => {
this.shelfCount = this.shelfCount + 1
this.showShelfModal = false
})
}
.width('100%')
}
.padding(20)
}
.width('84%')
.borderRadius(16)
.backgroundColor(COLORS.white)
}
.width('100%')
.height('100%')
}
// ==================== 弹框2:阅读设置(竹青表单卡) ====================
@Builder
readSettingModal() {
Stack() {
this.modalOverlay(() => {
this.showSettingModal = false
})
Column() {
Row() {
Text('⚙ 阅读设置')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.hint)
.onClick(() => {
this.showSettingModal = false
})
}
.width('100%')
Column({ space: 16 }) {
Column({ space: 10 }) {
Text('字号')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 10 }) {
Text('小')
.fontSize(12)
.fontColor(this.fontSize === 14 ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 14, right: 14 })
.backgroundColor(this.fontSize === 14 ? COLORS.teal : COLORS.tealLight)
.borderRadius(16)
.onClick(() => {
this.fontSize = 14
})
Text('标准')
.fontSize(13)
.fontColor(this.fontSize === 16 ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 14, right: 14 })
.backgroundColor(this.fontSize === 16 ? COLORS.teal : COLORS.tealLight)
.borderRadius(16)
.onClick(() => {
this.fontSize = 16
})
Text('大')
.fontSize(15)
.fontColor(this.fontSize === 18 ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 14, right: 14 })
.backgroundColor(this.fontSize === 18 ? COLORS.teal : COLORS.tealLight)
.borderRadius(16)
.onClick(() => {
this.fontSize = 18
})
Text('特大')
.fontSize(17)
.fontColor(this.fontSize === 20 ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 14, right: 14 })
.backgroundColor(this.fontSize === 20 ? COLORS.teal : COLORS.tealLight)
.borderRadius(16)
.onClick(() => {
this.fontSize = 20
})
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.tealLight)
.borderRadius(12)
Column({ space: 10 }) {
Text('阅读背景')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 10 }) {
Text('米黄')
.fontSize(13)
.fontColor(this.readBg === '米黄' ? COLORS.teal : COLORS.gray)
.padding({ top: 8, bottom: 8, left: 16, right: 16 })
.backgroundColor(COLORS.paper)
.borderRadius(10)
.border({ width: this.readBg === '米黄' ? 2 : 1, color: this.readBg === '米黄' ? COLORS.teal : COLORS.border })
.onClick(() => {
this.readBg = '米黄'
})
Text('护眼绿')
.fontSize(13)
.fontColor(this.readBg === '护眼绿' ? COLORS.teal : COLORS.gray)
.padding({ top: 8, bottom: 8, left: 16, right: 16 })
.backgroundColor('#E2F3F1')
.borderRadius(10)
.border({ width: this.readBg === '护眼绿' ? 2 : 1, color: this.readBg === '护眼绿' ? COLORS.teal : COLORS.border })
.onClick(() => {
this.readBg = '护眼绿'
})
Text('夜间')
.fontSize(13)
.fontColor(this.readBg === '夜间' ? '#FFFFFF' : '#99AABB')
.padding({ top: 8, bottom: 8, left: 16, right: 16 })
.backgroundColor('#2B3A44')
.borderRadius(10)
.border({ width: this.readBg === '夜间' ? 2 : 1, color: this.readBg === '夜间' ? COLORS.teal : COLORS.border })
.onClick(() => {
this.readBg = '夜间'
})
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.bg)
.borderRadius(12)
Column({ space: 10 }) {
Text('翻页模式')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 8 }) {
Text('仿真翻页')
.fontSize(12)
.fontColor(this.pageMode === '仿真翻页' ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.backgroundColor(this.pageMode === '仿真翻页' ? COLORS.primary : COLORS.tealLight)
.borderRadius(14)
.onClick(() => {
this.pageMode = '仿真翻页'
})
Text('左右平移')
.fontSize(12)
.fontColor(this.pageMode === '左右平移' ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.backgroundColor(this.pageMode === '左右平移' ? COLORS.primary : COLORS.tealLight)
.borderRadius(14)
.onClick(() => {
this.pageMode = '左右平移'
})
Text('上下滚动')
.fontSize(12)
.fontColor(this.pageMode === '上下滚动' ? '#FFFFFF' : COLORS.gray)
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.backgroundColor(this.pageMode === '上下滚动' ? COLORS.primary : COLORS.tealLight)
.borderRadius(14)
.onClick(() => {
this.pageMode = '上下滚动'
})
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.backgroundColor(COLORS.tealLight)
.borderRadius(12)
Text('保存设置')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.teal)
.borderRadius(22)
.onClick(() => {
this.showSettingModal = false
})
}
.width('100%')
.margin({ top: 16 })
}
.width('86%')
.padding(20)
.borderRadius(16)
.backgroundColor(COLORS.white)
}
.width('100%')
.height('100%')
}
// ==================== 弹框3:删除确认(小警示卡) ====================
@Builder
deleteBookModal() {
Stack() {
this.modalOverlay(() => {
this.showDeleteModal = false
})
Column({ space: 14 }) {
Text('🗑')
.fontSize(40)
Text('移出书架')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('移出后阅读进度将不再同步,确定要移出《山海经异闻录》吗?')
.fontSize(13)
.fontColor(COLORS.gray)
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Text('再想想')
.fontSize(14)
.fontColor(COLORS.gray)
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.showDeleteModal = false
})
Text('确认移出')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.red)
.borderRadius(20)
.onClick(() => {
if (this.shelfCount > 0) {
this.shelfCount = this.shelfCount - 1
}
this.showDeleteModal = false
})
}
.width('100%')
.margin({ top: 6 })
}
.width('72%')
.padding(22)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
}
// ==================== 弹框4:书籍详情(大卡片) ====================
@Builder
bookDetailModal() {
Stack() {
this.modalOverlay(() => {
this.showDetailModal = false
})
Column() {
Scroll() {
Column() {
Row({ space: 14 }) {
Text('⚡')
.fontSize(38)
.width(88)
.height(118)
.textAlign(TextAlign.Center)
.backgroundColor('#E5EEF5')
.borderRadius(10)
Column({ space: 8 }) {
Text('山海经异闻录')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('墨白先生 著')
.fontSize(13)
.fontColor(COLORS.gray)
Row({ space: 6 }) {
Text('玄幻')
.fontSize(11)
.fontColor(COLORS.primary)
.padding({ top: 3, bottom: 3, left: 8, right: 8 })
.backgroundColor('#E5EEF5')
.borderRadius(4)
Text('连载中')
.fontSize(11)
.fontColor(COLORS.teal)
.padding({ top: 3, bottom: 3, left: 8, right: 8 })
.backgroundColor('#E2F3F1')
.borderRadius(4)
}
Row({ space: 8 }) {
Column() {
Text('★ 4.6')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('评分')
.fontSize(11)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Center)
Column() {
Text('356万')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('字数')
.fontSize(11)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Center)
Column() {
Text('1280万')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('收藏')
.fontSize(11)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Center)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Text('少年偶得上古山海残卷,驭异兽、掌神通,踏遍九州山海之界。上古神兽苏醒,人间格局将变,一段横跨山海的传奇就此展开……')
.fontSize(13)
.fontColor(COLORS.gray)
.lineHeight(22)
.width('100%')
.margin({ top: 16 })
.padding(14)
.backgroundColor(COLORS.paper)
.borderRadius(10)
Row() {
Text('目录')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('共1268章 · 2小时前更新')
.fontSize(12)
.fontColor(COLORS.hint)
}
.width('100%')
.margin({ top: 18 })
Column({ space: 0 }) {
ForEach(['第1268章 山海之门再开', '第1267章 四凶现世', '第1266章 归墟之谜', '第1265章 不周山顶的决战', '第1264章 烛龙苏醒'], (ch: string) => {
Row() {
Text(ch)
.fontSize(13)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('VIP')
.fontSize(10)
.fontColor(COLORS.gold)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.border({ width: 1, color: COLORS.border })
.borderRadius(8)
.margin({ bottom: 6 })
.padding({ left: 12, right: 12 })
}, (ch: string) => ch)
}
.width('100%')
Row({ space: 12 }) {
Text('免费试读')
.fontSize(14)
.fontColor(COLORS.primary)
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor('#E5EEF5')
.borderRadius(20)
.onClick(() => {
this.showDetailModal = false
})
Text('加入书架')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ top: 10, bottom: 10 })
.layoutWeight(1)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.primary)
.borderRadius(20)
.onClick(() => {
this.showDetailModal = false
this.showShelfModal = true
})
}
.width('100%')
.margin({ top: 18 })
}
.padding(20)
}
.constraintSize({ maxHeight: '80%' })
.scrollBar(BarState.Off)
}
.width('88%')
.borderRadius(16)
.backgroundColor(COLORS.white)
}
.width('100%')
.height('100%')
}
// ==================== 顶部Tab ====================
@Builder
topTabItem(label: string, tab: BookTab) {
Column({ space: 6 }) {
Text(label)
.fontSize(this.activeTab === tab ? 16 : 14)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.activeTab === tab ? COLORS.primary : COLORS.gray)
Column()
.width(this.activeTab === tab ? 20 : 0)
.height(3)
.borderRadius(2)
.backgroundColor(COLORS.primary)
}
.padding({ left: 12, right: 12 })
.onClick(() => {
this.activeTab = tab
})
}
// ==================== 底部Tab ====================
@Builder
bottomTabItem(icon: string, label: string, tab: BottomTab) {
Column({ space: 4 }) {
Text(icon)
.fontSize(22)
Text(label)
.fontSize(11)
.fontColor(this.bottomTab === tab ? COLORS.primary : COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.bottomTab = tab
})
}
@Builder
bottomTabBar() {
Row() {
this.bottomTabItem('🏬', '书城', BottomTab.MALL)
this.bottomTabItem('📖', '书架', BottomTab.SHELF)
this.bottomTabItem('🎧', '听书', BottomTab.LISTEN)
this.bottomTabItem('👤', '我的', BottomTab.MINE)
}
.width('100%')
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
}
// ==================== 书城头部 ====================
@Builder
mallHeader() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Column() {
Row({ space: 6 }) {
Text('🔍')
.fontSize(14)
Text('搜索书名、作者、类型')
.fontSize(13)
.fontColor(COLORS.hint)
}
.padding({ top: 9, bottom: 9, left: 14, right: 14 })
.layoutWeight(1)
.backgroundColor(COLORS.bg)
.borderRadius(20)
}
Text('🔔')
.fontSize(22)
.onClick(() => {
this.showSettingModal = true
})
}
.width('100%')
Row() {
this.topTabItem('推荐', BookTab.RECOMMEND)
this.topTabItem('男生', BookTab.MALE)
this.topTabItem('女生', BookTab.FEMALE)
this.topTabItem('出版', BookTab.PUBLISH)
this.topTabItem('漫画', BookTab.COMIC)
this.topTabItem('听书', BookTab.AUDIO)
}
.width('100%')
}
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
.backgroundColor(COLORS.white)
}
// ==================== 书城页 ====================
@Builder
mallPage() {
Column() {
this.mallHeader()
Scroll() {
Column() {
if (this.activeTab === BookTab.RECOMMEND) {
RecommendContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
},
onAddShelf: () => {
this.showShelfModal = true
}
})
}
if (this.activeTab === BookTab.MALE) {
MaleContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
}
})
}
if (this.activeTab === BookTab.FEMALE) {
FemaleContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
}
})
}
if (this.activeTab === BookTab.PUBLISH) {
PublishContent()
}
if (this.activeTab === BookTab.COMIC) {
ComicContent({
onTapBook: (b: BookItem) => {
this.selectedBook = b
this.showDetailModal = true
}
})
}
if (this.activeTab === BookTab.AUDIO) {
AudioTabContent()
}
}
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
// ==================== 书架页 ====================
@Builder
shelfPage() {
Column() {
Row({ space: 12 }) {
Text('我的书架')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('⚙ 阅读设置')
.fontSize(13)
.fontColor(COLORS.teal)
.onClick(() => {
this.showSettingModal = true
})
}
.width('100%')
.padding(16)
Scroll() {
Column() {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('📚')
.fontSize(34)
Text('共' + this.shelfCount.toString() + '本')
.fontSize(12)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor(COLORS.paper)
.borderRadius(12)
Column({ space: 4 }) {
Text('⏱')
.fontSize(34)
Text('今日阅读' + this.todayMinutes.toString() + '分钟')
.fontSize(12)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor(COLORS.tealLight)
.borderRadius(12)
Column({ space: 4 }) {
Text('🔥')
.fontSize(34)
Text('连续打卡23天')
.fontSize(12)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor('#FBE5EC')
.borderRadius(12)
}
.width('100%')
.padding({ left: 16, right: 16 })
Text('最近阅读')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.width('100%')
.padding({ left: 16, top: 20, bottom: 10 })
Column({ space: 12 }) {
ForEach(SHELF_BOOKS, (sb: ShelfBook) => {
Row({ space: 12 }) {
Text(sb.icon)
.fontSize(28)
.width(56)
.height(74)
.textAlign(TextAlign.Center)
.backgroundColor(sb.color + '22')
.borderRadius(8)
Column({ space: 6 }) {
Text(sb.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(sb.progress)
.fontSize(12)
.fontColor(COLORS.teal)
Row() {
Column()
.width(120)
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.border)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🗑')
.fontSize(18)
.onClick(() => {
this.showDeleteModal = true
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
}, (sb: ShelfBook) => sb.name)
}
.padding({ left: 16, right: 16 })
}
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
// ==================== 听书页 ====================
@Builder
listenPage() {
Column() {
Row({ space: 12 }) {
Text('🎧 听书')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('定时')
.fontSize(13)
.fontColor(COLORS.teal)
}
.width('100%')
.padding(16)
Scroll() {
Column({ space: 14 }) {
Row({ space: 14 }) {
Column({ space: 6 }) {
Text('🔊')
.fontSize(34)
.padding({ top: 14, bottom: 14, left: 22, right: 22 })
.backgroundColor(COLORS.primary)
.borderRadius(46)
Text('正在播放')
.fontSize(11)
.fontColor(COLORS.primary)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 6 }) {
Text('山海经异闻录 · 第856章')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('主播:青崖说书 · 已播 12:36')
.fontSize(12)
.fontColor(COLORS.gray)
Row({ space: 10 }) {
Text('⏪')
.fontSize(20)
Text('⏸')
.fontSize(24)
.onClick(() => {
})
Text('⏩')
.fontSize(20)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.white)
.borderRadius(14)
Row({ space: 12 }) {
ForEach(['小说', '出版', '儿童', '相声', '外语'], (c: string) => {
Text(c)
.fontSize(13)
.fontColor(COLORS.primary)
.padding({ top: 8, bottom: 8, left: 16, right: 16 })
.backgroundColor('#E5EEF5')
.borderRadius(18)
}, (c: string) => c)
}
.width('100%')
Column({ space: 10 }) {
ForEach(['夜航西飞 · 雾都疑案 全本', '青崖说书 · 山海经异闻录', '老白茶馆 · 大唐御史台', '哄睡电台 · 深夜食堂城南站', '历史频道 · 时间的形状精讲', '儿童频道 · 喵星人故事集'], (ch: string) => {
Row({ space: 12 }) {
Text('🎧')
.fontSize(24)
.width(44)
.height(44)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.tealLight)
.borderRadius(22)
Column({ space: 4 }) {
Text(ch)
.fontSize(14)
.fontColor(COLORS.ink)
Text('128集 · 已完结')
.fontSize(11)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('▶')
.fontSize(18)
.fontColor(COLORS.teal)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
}, (ch: string) => ch)
}
}
.padding({ left: 16, right: 16 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
// ==================== 我的页 ====================
@Builder
minePage() {
Column() {
Row({ space: 14 }) {
Text('🐧')
.fontSize(38)
.width(64)
.height(64)
.textAlign(TextAlign.Center)
.backgroundColor('#E5EEF5')
.borderRadius(32)
Column({ space: 6 }) {
Text('书虫小戴')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('LV.6 勤奋读者 · 连续阅读23天')
.fontSize(12)
.fontColor(COLORS.gray)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('编辑')
.fontSize(13)
.fontColor(COLORS.teal)
.onClick(() => {
this.showSettingModal = true
})
}
.width('100%')
.padding(18)
.backgroundColor(COLORS.white)
Row() {
ForEach([['356', '读过(本)'], ['1280', '收获(赞)'], ['96', '书单'], ['12', '勋章']], (st: string[]) => {
Column({ space: 4 }) {
Text(st[0])
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text(st[1])
.fontSize(11)
.fontColor(COLORS.hint)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (st: string[]) => st[0])
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.white)
.margin({ top: 10 })
Column({ space: 0 }) {
ForEach([['💰', '我的钱包', '书币 320'], ['🎫', '优惠券', '3张可用'], ['🌙', '夜间模式', '已关闭'], ['🔔', '推送设置', '已开启'], ['📮', '意见反馈', '']], (m: string[]) => {
Row({ space: 12 }) {
Text(m[0])
.fontSize(20)
Text(m[1])
.fontSize(14)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text(m[2])
.fontSize(12)
.fontColor(COLORS.hint)
Text('›')
.fontSize(16)
.fontColor(COLORS.hint)
}
.width('100%')
.padding({ top: 16, bottom: 16, left: 16, right: 16 })
.backgroundColor(COLORS.white)
.onClick(() => {
this.showSettingModal = true
})
}, (m: string[]) => m[1])
}
.margin({ top: 10 })
.borderRadius(12)
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build() {
Stack() {
Column() {
if (this.bottomTab === BottomTab.MALL) {
this.mallPage()
}
if (this.bottomTab === BottomTab.SHELF) {
this.shelfPage()
}
if (this.bottomTab === BottomTab.LISTEN) {
this.listenPage()
}
if (this.bottomTab === BottomTab.MINE) {
this.minePage()
}
this.bottomTabBar()
}
.width('100%')
.height('100%')
this.particleLayer()
if (this.showShelfModal) {
this.addShelfModal()
}
if (this.showSettingModal) {
this.readSettingModal()
}
if (this.showDeleteModal) {
this.deleteBookModal()
}
if (this.showDetailModal) {
this.bookDetailModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
// ==================== Tab内容:推荐(横滑大卡 + 每日推荐列表) ====================
@Component
struct RecommendContent {
onTapBook: (b: BookItem) => void = () => {}
onAddShelf: () => void = () => {}
build() {
Column({ space: 16 }) {
Scroll() {
Row({ space: 12 }) {
ForEach(BANNER_LIST, (bn: BannerInfo) => {
Column({ space: 8 }) {
Text(bn.icon)
.fontSize(40)
Text(bn.title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(bn.sub)
.fontSize(11)
.fontColor('rgba(255,255,255,0.85)')
Text(bn.tag)
.fontSize(10)
.fontColor(bn.bg)
.padding({ top: 3, bottom: 3, left: 10, right: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(10)
}
.width(220)
.padding(18)
.borderRadius(14)
.backgroundColor(bn.bg)
.alignItems(HorizontalAlign.Start)
}, (bn: BannerInfo) => bn.title)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
Row() {
ForEach(KINGKONG_ENTRIES, (kk: KingKongEntry) => {
Column({ space: 6 }) {
Text(kk.icon)
.fontSize(26)
.width(46)
.height(46)
.textAlign(TextAlign.Center)
.backgroundColor(kk.bg)
.borderRadius(23)
Text(kk.label)
.fontSize(11)
.fontColor(COLORS.gray)
}
.layoutWeight(1)
}, (kk: KingKongEntry) => kk.label)
}
.width('100%')
.padding({ left: 12, right: 12 })
Row() {
Text('📖 每日精选')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('查看全部 ›')
.fontSize(12)
.fontColor(COLORS.hint)
.onClick(this.onAddShelf)
}
.width('100%')
.padding({ left: 16, right: 16 })
Column({ space: 12 }) {
ForEach(mockBooks, (b: BookItem) => {
Row({ space: 12 }) {
Text(b.icon)
.fontSize(30)
.width(60)
.height(80)
.textAlign(TextAlign.Center)
.backgroundColor(b.coverColor + '1E')
.borderRadius(8)
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(b.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(b.isFree ? '免费' : 'VIP')
.fontSize(9)
.fontColor(b.isFree ? COLORS.teal : COLORS.gold)
.padding({ top: 2, bottom: 2, left: 6, right: 6 })
.backgroundColor(b.isFree ? '#E2F3F1' : '#F7EDD4')
.borderRadius(4)
}
Text(b.author + ' · ' + b.category + ' · ' + b.status)
.fontSize(12)
.fontColor(COLORS.gray)
Text(b.desc)
.fontSize(12)
.fontColor(COLORS.hint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text('★ ' + b.rating.toString())
.fontSize(11)
.fontColor(COLORS.gold)
Text(formatWan(b.collection) + '人在读')
.fontSize(11)
.fontColor(COLORS.hint)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
.onClick(() => {
this.onTapBook(b)
})
}, (b: BookItem) => b.id.toString())
}
.padding({ left: 16, right: 16 })
}
.width('100%')
.padding({ top: 12, bottom: 20 })
}
}
// ==================== Tab内容:男生(双列网格) ====================
@Component
struct MaleContent {
onTapBook: (b: BookItem) => void = () => {}
build() {
Column({ space: 14 }) {
Text('⚔ 男生频道 · 热血必读')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.width('100%')
.padding({ left: 16, top: 8 })
Row() {
ForEach([['玄幻', '⚡'], ['仙侠', '🗡'], ['科幻', '🚀'], ['悬疑', '🔍']], (c: string[]) => {
Column({ space: 4 }) {
Text(c[1])
.fontSize(24)
Text(c[0])
.fontSize(12)
.fontColor(COLORS.gray)
}
.layoutWeight(1)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.tealLight)
.borderRadius(12)
}, (c: string[]) => c[0])
}
.width('100%')
.padding({ left: 16, right: 16 })
.margin({ top: 6 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(mockBooks, (b: BookItem) => {
Column({ space: 8 }) {
Text(b.icon)
.fontSize(34)
.width('100%')
.height(110)
.textAlign(TextAlign.Center)
.backgroundColor(b.coverColor + '22')
.borderRadius({ topLeft: 8, topRight: 8 })
Text(b.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(b.author)
.fontSize(11)
.fontColor(COLORS.hint)
.maxLines(1)
Text('★' + b.rating.toString() + ' · ' + formatWan(b.collection) + '收藏')
.fontSize(10)
.fontColor(COLORS.gold)
}
.width('46%')
.padding(8)
.backgroundColor(COLORS.white)
.borderRadius(8)
.margin(6)
.onClick(() => {
this.onTapBook(b)
})
}, (b: BookItem) => 'm' + b.id.toString())
}
.width('94%')
}
.width('100%')
.padding({ bottom: 20 })
}
}
// ==================== Tab内容:女生(书封列表) ====================
@Component
struct FemaleContent {
onTapBook: (b: BookItem) => void = () => {}
build() {
Column({ space: 14 }) {
Row({ space: 10 }) {
Column({ space: 6 }) {
Text('🌸')
.fontSize(36)
Text('女生频道')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('本周上新 32 本')
.fontSize(11)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 20, bottom: 20 })
.backgroundColor('#FBE5EC')
.borderRadius(14)
Column({ space: 6 }) {
Text('💕')
.fontSize(36)
Text('甜宠榜')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('每日更新')
.fontSize(11)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.padding({ top: 20, bottom: 20 })
.backgroundColor('#ECE8F7')
.borderRadius(14)
}
.width('100%')
.padding({ left: 16, right: 16, top: 8 })
Column({ space: 12 }) {
ForEach(mockBooks, (b: BookItem, idx: number) => {
Row({ space: 14 }) {
Text((idx + 1).toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.red : COLORS.hint)
.width(28)
Text(b.icon)
.fontSize(28)
.width(54)
.height(72)
.textAlign(TextAlign.Center)
.backgroundColor(b.coverColor + '22')
.borderRadius(8)
Column({ space: 5 }) {
Text(b.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(b.author + ' · ' + b.tags.join(' / '))
.fontSize(11)
.fontColor(COLORS.gray)
Text(ratingStars(b.rating) + ' ' + b.rating.toString())
.fontSize(11)
.fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('▶')
.fontSize(16)
.fontColor(COLORS.hint)
.onClick(() => {
this.onTapBook(b)
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.white)
.borderRadius(12)
}, (b: BookItem) => 'f' + b.id.toString())
}
.padding({ left: 16, right: 16 })
}
.width('100%')
.padding({ bottom: 20 })
}
}
// ==================== Tab内容:出版(排行榜 + 柱状图) ====================
@Component
struct PublishContent {
build() {
Column({ space: 16 }) {
Text('📊 本周阅读时长(分钟)')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.width('100%')
.padding({ left: 16, top: 10 })
Row({ space: 0 }) {
ForEach(WEEK_STATS, (s: WeekStat) => {
Column({ space: 6 }) {
Text(s.minutes.toString())
.fontSize(10)
.fontColor(COLORS.primary)
Column()
.width(22)
.height(statBarHeight(s.minutes, 160))
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(COLORS.teal)
Text(s.day)
.fontSize(11)
.fontColor(COLORS.hint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (s: WeekStat) => s.day)
}
.width('100%')
.padding({ left: 16, right: 16, top: 6, bottom: 14 })
.backgroundColor(COLORS.white)
.borderRadius(14)
.margin({ left: 16, right: 16 })
Text('📚 出版精品榜')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.width('100%')
.padding({ left: 16 })
Column({ space: 12 }) {
ForEach(topFiveBooks(), (b: BookItem, idx: number) => {
Row({ space: 12 }) {
Text((idx + 1).toString())
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.gold : COLORS.hint)
.width(24)
Column({ space: 5 }) {
Text(b.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(b.author + ' · ' + formatWan(b.collection) + '人在读')
.fontSize(11)
.fontColor(COLORS.gray)
Row() {
Column()
.width(Math.round(b.rating / 5 * 140))
.height(5)
.borderRadius(3)
.backgroundColor(COLORS.teal)
Column()
.layoutWeight(1)
.height(5)
.borderRadius(3)
.backgroundColor(COLORS.border)
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(12)
}, (b: BookItem) => 'p' + b.id.toString())
}
.padding({ left: 16, right: 16 })
}
.width('100%')
.padding({ bottom: 20 })
}
}
// ==================== Tab内容:漫画(格子网格 + 人气榜) ====================
@Component
struct ComicContent {
onTapBook: (b: BookItem) => void = () => {}
build() {
Column({ space: 16 }) {
Text('🎨 漫画人气榜')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
.width('100%')
.padding({ left: 16, top: 8 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(mockBooks, (b: BookItem, idx: number) => {
Column({ space: 6 }) {
Text(b.icon)
.fontSize(40)
.width(96)
.height(96)
.textAlign(TextAlign.Center)
.backgroundColor(b.coverColor + '22')
.borderRadius(48)
Text('NO.' + (idx + 1).toString())
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.red : COLORS.hint)
Text(b.name)
.fontSize(12)
.fontColor(COLORS.ink)
.maxLines(1)
}
.width('30%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin(5)
.onClick(() => {
this.onTapBook(b)
})
}, (b: BookItem) => 'c' + b.id.toString())
}
.width('100%')
}
.width('100%')
.padding({ bottom: 20 })
}
}
// ==================== Tab内容:听书(频道卡 + 播放条) ====================
@Component
struct AudioTabContent {
build() {
Column({ space: 14 }) {
Row({ space: 12 }) {
Column({ space: 6 }) {
Text('📻')
.fontSize(34)
Text('小说频道')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor(COLORS.tealLight)
.borderRadius(12)
Column({ space: 6 }) {
Text('🎙')
.fontSize(34)
Text('出版精讲')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor('#ECE8F7')
.borderRadius(12)
Column({ space: 6 }) {
Text('🌙')
.fontSize(34)
Text('哄睡电台')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
}
.layoutWeight(1)
.padding({ top: 18, bottom: 18 })
.backgroundColor('#FBE5EC')
.borderRadius(12)
}
.width('100%')
.padding({ left: 16, right: 16, top: 8 })
Row({ space: 12 }) {
Text('🎧')
.fontSize(24)
.width(52)
.height(52)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.primary)
.borderRadius(26)
Column({ space: 4 }) {
Text('雾都疑案 全本 · 第96回')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('夜航西飞 · 悬疑频道')
.fontSize(11)
.fontColor(COLORS.gray)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('▶')
.fontSize(22)
.fontColor(COLORS.teal)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.white)
.borderRadius(12)
.margin({ left: 16, right: 16 })
Column({ space: 10 }) {
ForEach(['第96回 · 雾锁申城', '第95回 · 失踪的舞女', '第94回 · 码头疑云', '第93回 · 旧照片', '第92回 · 密室之钥', '第91回 · 深巷追踪'], (ch: string, idx: number) => {
Row() {
Text((idx + 1).toString())
.fontSize(12)
.fontColor(COLORS.hint)
.width(24)
Text(ch)
.fontSize(13)
.fontColor(COLORS.ink)
Column()
.layoutWeight(1)
Text('18:24')
.fontSize(11)
.fontColor(COLORS.hint)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(10)
}, (ch: string) => ch)
}
.padding({ left: 16, right: 16 })
}
.width('100%')
.padding({ bottom: 20 })
}
}

技术总结
本文以"QQ阅读·书城"阅读App为实战案例,完整剖析了基于HarmonyOS 6.1.1和ArkTS API 24构建复杂阅读类应用的全流程技术方案。从配色系统设计到数据模型构建,从粒子特效动画到多弹框交互系统,从两级Tab导航到六大内容子组件,涵盖了ArkTS应用开发的核心技术要点。
在架构设计层面,应用采用了"主组件 + 内容子组件 + 构建器"的三层架构模式。主组件QQReadApp通过@State状态变量集中管理全局状态,通过@Builder构建器封装可复用的UI片段,通过@Component子组件实现各Tab内容的独立渲染。这种分层设计保证了关注点分离,使得每个模块职责单一、易于维护。
在状态管理层面,13个状态变量覆盖了Tab导航、弹框控制、粒子动画、阅读设置和统计数据五大维度。@State装饰器的响应式机制确保了UI与数据的自动同步,开发者只需修改变量值,框架自动处理重渲染逻辑。纯函数工具集将数据格式化和计算逻辑与UI完全解耦,提升了代码的可测试性和可维护性。
更多推荐


所有评论(0)