技术引言

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 主页面
@Entry @Component

状态管理层
@State 状态变量

生命周期管理
aboutToAppear / aboutToDisappear

构建器层 @Builder

页面构建器
mallPage / shelfPage
listenPage / minePage

弹框构建器
addShelfModal / readSettingModal
deleteBookModal / bookDetailModal

辅助构建器
modalOverlay / particleLayer
topTabItem / bottomTabItem

内容子组件层 @Component

RecommendContent
推荐页

MaleContent
男生频道

FemaleContent
女生频道

PublishContent
出版页

ComicContent
漫画页

AudioTabContent
听书页

数据模型层

BookItem @Observed

静态数据 mockBooks

配置 Record 映射

纯函数工具集

粒子特效系统

从架构图中可以看出,整个应用围绕 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扮演着至关重要的角色——它不仅约束了数据结构,更充当了组件间通信的"协议书"。

BookCategoryMetaBookStatusMeta分别描述书籍分类和状态的元数据,每个接口都包含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编译器会检查所有值的类型是否符合BookCategoryMetaBookStatusMeta的结构定义,避免遗漏字段或类型错误。同时,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)是软件工程中的最佳实践。如果直接使用012等数字表示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组件的重渲染。activeTabbottomTab管理两级Tab的选中状态;四个show*Modal布尔变量控制四种弹框的显示隐藏;selectedBook使用联合类型BookItem | null,既能存储选中的书籍对象,又能用null表示未选中状态。

particles数组存储粒子特效数据,fontSizereadBgpageMode三个变量管理阅读设置——字号(14-20)、阅读背景(米黄/护眼绿/夜间)和翻页模式(仿真翻页/左右平移/上下滚动)。shelfCounttodayMinutes记录书架数量和今日阅读时长。

timerId使用private修饰而非@State,因为它是粒子动画的定时器ID,不涉及UI渲染,无需触发重渲染。这种区分@Stateprivate的做法是ArkTS性能优化的要点——只有需要驱动UI更新的变量才使用@State,纯逻辑变量使用private,避免不必要的重渲染开销。

应用启动

点击书架Tab

点击书城Tab

点击听书Tab

点击书城Tab

点击我的Tab

点击书城Tab

默认Tab

切换男生

切换女生

切换出版

切换漫画

切换听书

切换推荐

MALL

SHELF

LISTEN

MINE

RECOMMEND

MALE

FEMALE

PUBLISH

COMIC

AUDIO

弹框状态

关闭

加入书架弹框

阅读设置弹框

删除确认弹框

书籍详情弹框

上图展示了应用的核心状态流转关系。底部Tab在四个主页面间切换,顶部Tab在六个内容子组件间切换,弹框系统独立运作,通过布尔状态变量控制显示与隐藏。


第十二段:组件生命周期管理

aboutToAppear() {
    this.particles = initParticles()
    this.timerId = setInterval(() => {
      this.particles = updateParticles(this.particles)
    }, 60)
  }

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

aboutToAppearaboutToDisappear是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状态变量驱动。用户点击任一选项时,对应的状态变量(fontSizereadBgpageMode)被更新,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修改对应的状态变量(activeTabbottomTab),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渲染流程

MALL

SHELF

LISTEN

MINE

showShelfModal

showSettingModal

showDeleteModal

showDetailModal

build() 方法入口

Stack 堆叠容器

第一层:主内容区 Column

bottomTab 状态判断

mallPage 书城页

shelfPage 书架页

listenPage 听书页

minePage 我的页

bottomTabBar 底部导航

第二层:particleLayer 粒子特效

弹框状态判断

addShelfModal 加入书架弹框

readSettingModal 阅读设置弹框

deleteBookModal 删除确认弹框

bookDetailModal 书籍详情弹框

上图展示了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遍历章节数组,每行包含序号、章节名和时长,简洁的列表布局便于用户快速定位收听位置。


组件生命周期与数据流分析

渲染错误: Mermaid 渲染失败: Parse error on line 25: ...owDetailModal = true State->>Render: -----------------------^ Expecting '()', 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'SOLID_ARROW_TOP', 'SOLID_ARROW_BOTTOM', 'STICK_ARROW_TOP', 'STICK_ARROW_BOTTOM', 'SOLID_ARROW_TOP_DOTTED', 'SOLID_ARROW_BOTTOM_DOTTED', 'STICK_ARROW_TOP_DOTTED', 'STICK_ARROW_BOTTOM_DOTTED', 'SOLID_ARROW_TOP_REVERSE', 'SOLID_ARROW_BOTTOM_REVERSE', 'STICK_ARROW_TOP_REVERSE', 'STICK_ARROW_BOTTOM_REVERSE', 'SOLID_ARROW_TOP_REVERSE_DOTTED', 'SOLID_ARROW_BOTTOM_REVERSE_DOTTED', 'STICK_ARROW_TOP_REVERSE_DOTTED', 'STICK_ARROW_BOTTOM_REVERSE_DOTTED', 'BIDIRECTIONAL_SOLID_ARROW', 'DOTTED_ARROW', 'BIDIRECTIONAL_DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', 'SOLID_POINT', 'DOTTED_POINT', got 'NEWLINE'

上图展示了组件从创建到销毁的完整生命周期,以及用户交互、状态变更和渲染更新之间的数据流转关系。整个应用的数据流遵循单向数据流原则:用户交互触发状态变更,状态变更驱动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完全解耦,提升了代码的可测试性和可维护性。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐