基于HarmonyOS API 24的ArkTS漫画社区App实战:HarmonyOS ArkTS API 24与HarmonyOS 6.1.1下QQ漫画条漫馆页面深度解析
技术引言
HarmonyOS 6.1.1 作为华为鸿蒙生态的最新演进版本,带来了更为完善的分布式架构能力和更强大的声明式UI框架。在这一版本中,HarmonyOS ArkTS API 24 提供了更丰富的组件接口、更精细的状态管理机制以及更流畅的动画编排能力,使开发者能够以纯声明式语法构建出复杂且高性能的移动应用界面。ArkTS 作为 TypeScript 的超集,在保留类型安全优势的同时,通过 @Entry、@Builder、@State、@Observed 等装饰器实现了与渲染引擎的深度绑定,让数据驱动视图的理念在鸿蒙平台上得到了充分释放。本文将以一个"QQ漫画·条漫馆"漫画阅读社区应用为例,基于HarmonyOS API 24 的最新组件能力,深入剖析从颜色体系设计、数据建模、粒子动画、多Tab导航、六种内容布局到四类弹窗交互的完整实现方案,帮助开发者全面掌握 HarmonyOS ArkTS 在内容社区类应用中的工程实践。
一、色彩体系设计:ColorPalette 接口与 COLORS 常量
interface ColorPalette {
cherry: string;
cherryLight: string;
cherryDeep: string;
inkGreen: string;
inkGreenLight: string;
bg: string;
cardBg: string;
cardBgDark: string;
textPrimary: string;
textSecondary: string;
textHint: string;
white: string;
gold: string;
border: string;
danger: string;
success: string;
}
const COLORS: ColorPalette = {
cherry: '#E85A8C',
cherryLight: '#FF8FBA',
cherryDeep: '#C73D6E',
inkGreen: '#2D7D5A',
inkGreenLight: '#4DA67E',
bg: '#FFF0F5',
cardBg: '#FFFFFF',
cardBgDark: '#F0E8E8',
textPrimary: '#3D2A32',
textSecondary: '#7A626E',
textHint: '#B8A8B0',
white: '#FFFFFF',
gold: '#F5A623',
border: '#F0D5DE',
danger: '#E84A4A',
success: '#4DA67E'
};

在 HarmonyOS ArkTS 的工程实践中,色彩体系是整个应用视觉一致性的基石。本段代码首先定义了一个 ColorPalette 接口,将应用所需的全部色彩语义化地枚举出来。这种做法的核心优势在于类型安全:任何使用 COLORS 常量的地方都会受到 TypeScript 类型检查器的约束,如果拼错了某个色值键名,编译阶段就会报错,而不是等到运行时才发现视觉异常。
从配色策略来看,这套色板采用了"樱粉 + 墨绿"双主色方案,这在漫画类应用中是一种经典且富有辨识度的选择。cherryDeep(深樱粉 #C73D6E)用于标题和强调文字,cherry(标准樱粉 #E85A8C)用于按钮背景和选中态,而 cherryLight(浅樱粉 #FF8FBA)则承担边框和辅助点缀。与之对应的墨绿色系(inkGreen / inkGreenLight)则用于次要操作按钮和成功态,与樱粉形成冷暖对比,避免页面色彩过于单一。
背景色 bg 采用了极浅的粉调 #FFF0F5(即薰衣草 blush 色),这种低饱和度的暖色调背景在长时间阅读场景下对用户眼睛更加友好。文字层级被精细划分为三档:textPrimary(深褐紫 #3D2A32)用于主标题、textSecondary(灰紫 #7A626E)用于副文本、textHint(浅灰 #B8A8B0)用于提示和时间戳。此外,gold 用于评分和等级展示,danger 用于删除操作,success 用于完成态,border 用于分隔线——每一个色值都有明确的语义角色,这种设计使得后续所有 @Builder 方法在引用颜色时都能保持高度一致。
二、导航配置:TabItem 接口与底部/顶部标签常量
interface TabItem {
label: string;
icon: string;
}
const BOTTOM_TABS: TabItem[] = [
{ label: '漫画', icon: '📚' },
{ label: '书架', icon: '🔖' },
{ label: '发现', icon: '🔍' },
{ label: '我的', icon: '👤' }
];
const TOP_TABS: TabItem[] = [
{ label: '推荐', icon: '🔥' },
{ label: '热血', icon: '⚔️' },
{ label: '恋爱', icon: '💕' },
{ label: '搞笑', icon: '😂' },
{ label: '悬疑', icon: '🔍' },
{ label: '奇幻', icon: '🐉' }
];

导航系统的设计是内容社区类应用的核心骨架。本段代码通过 TabItem 接口定义了一个极简但充分的数据结构——仅包含 label(文本标签)和 icon(图标),将导航项的配置数据与应用的视图逻辑彻底解耦。这种"配置驱动"的设计模式在 ArkTS 中尤为常见,因为声明式 UI 的本质就是数据驱动渲染,将导航项抽象为数据数组后,只需一个 ForEach 就能完成全部标签的渲染。
底部导航栏 BOTTOM_TABS 定义了四个一级页面入口:漫画(主内容流)、书架(个人收藏)、发现(探索发现)和我的(个人中心)。这四个 Tab 覆盖了一个漫画社区用户从浏览到收藏、从探索到个人管理的完整使用闭环。顶部导航栏 TOP_TABS 则定义了六个内容分类标签:推荐、热血、恋爱、搞笑、悬疑和奇幻,分别对应漫画的主要题材分类。
值得注意的是,这里的图标采用了 Emoji 字符而非图片资源。在 HarmonyOS ArkTS 中,Text 组件可以直接渲染 Emoji,这在原型开发和演示场景中极大简化了资源管理成本——无需维护大量的 PNG/SVG 图标文件,也无需引入图标字体库。每个 TabItem 同时携带 label 和 icon,使得在渲染时可以为每个标签呈现"图标 + 文字"的双行布局,既保证了视觉丰富度,又通过文字标签确保了语义清晰度。当用户切换底部 Tab 时,顶部标签栏也会联动切换为对应页面的标题栏,这种联动逻辑将在后续的 build() 方法中通过 if-else 分支实现。
三、数据可视化数据源:阅读时长常量
const READING_DAYS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const READING_MINUTES: number[] = [45, 60, 30, 80, 120, 180, 90];

虽然这两行代码看似简单,但它们承载着应用中唯一的数据可视化模块——"一周阅读时长柱状图"的全部数据源。在 HarmonyOS ArkTS API 24 中,系统并未提供开箱即用的图表组件(如 MPAndroidChart 那样的三方库),因此开发者需要利用基础布局组件(Column、Row、Text)来手动搭建图表。
READING_DAYS 数组定义了横轴的七个标签,对应一周七天;READING_MINUTES 数组则定义了每天对应的阅读分钟数。从数据本身来看,周末(周六 180 分钟、周日 90 分钟)的阅读时长明显高于工作日,这符合典型的用户行为画像——用户在休息日有更多时间沉浸式阅读漫画。工作日中周四达到峰值 80 分钟,可能是当天有热门作品更新。
这两个数组通过索引一一对应,在渲染时通过 ForEach 同时遍历,将 READING_MINUTES[idx] 的值经过 barHeight() 函数转换为柱体高度、经过 hotColor() 函数转换为柱体颜色。这种将数据与视图分离的做法,使得未来如果接入真实后端数据,只需替换这两个常量为接口返回值即可,无需修改任何渲染逻辑。这也是声明式 UI 框架的核心优势之一:数据即视图,视图即数据的映射。
四、可观测数据模型:@Observed ComicItem 类
@Observed
class ComicItem {
id: number;
title: string;
author: string;
cover: string;
genre: string;
status: string;
chapters: number;
rating: number;
views: string;
intro: string;
tags: string;
hotValue: number;
constructor(id: number, title: string, author: string, cover: string,
genre: string, status: string, chapters: number, rating: number,
views: string, intro: string, tags: string, hotValue: number) {
this.id = id;
this.title = title;
this.author = author;
this.cover = cover;
this.genre = genre;
this.status = status;
this.chapters = chapters;
this.rating = rating;
this.views = views;
this.intro = intro;
this.tags = tags;
this.hotValue = hotValue;
}
}

@Observed 装饰器是 HarmonyOS ArkTS 状态管理体系中的关键一环。当一个类被 @Observed 修饰后,该类的实例对象就具备了"可被观察"的能力——任何对其属性的修改都会被 ArkTS 框架的响应式系统捕获,并自动触发依赖该属性的 @Builder 方法或组件的重新渲染。
ComicItem 类封装了一部漫画作品的全部信息维度,共计 12 个属性。id 作为唯一标识符,在 ForEach 的键值生成函数中发挥关键作用;cover 使用 Emoji 字符作为封面占位符;genre 存储题材分类(热血/恋爱/搞笑/悬疑/奇幻),用于在详情弹窗中渲染分类色块;status 区分"连载中"与"完结"两种状态;chapters 记录总章节数;rating 是十分制评分;views 存储阅读量字符串(如"3.8亿");intro 是一句话简介;tags 以竖线分隔存储多个标签(如"热血|冒险|修仙");hotValue 是数值化的人气值,用于排序和热度颜色映射。
构造函数采用全参数列表形式,确保每个实例在创建时就拥有完整的数据。虽然字段较多,但这种"胖模型"设计避免了后续频繁的属性赋值操作,也符合 ArkTS 推荐的不可变数据模式。在实际业务场景中,这类模型通常会配合网络请求层使用——从后端 JSON 反序列化后直接构造实例,然后交给 @State 管理的数组进行存储和渲染。
五、数据工厂模式:buildComics() 与 COMICS 常量
function buildComics(): ComicItem[] {
return [
new ComicItem(1, '剑破苍穹录', '墨白', '⚔️', '热血', '连载中', 1280, 9.2, '3.8亿', '少年执剑行走江湖,一剑破开万古苍穹', '热血|冒险|修仙', 9870),
new ComicItem(2, '樱花树下等你', '小鹿', '🌸', '恋爱', '连载中', 86, 9.5, '2.1亿', '每年樱花季,我都会在老地方等你回来', '校园|治愈|暗恋', 8520),
new ComicItem(3, '厨神打工记', '肥猫', '🍳', '搞笑', '完结', 312, 8.8, '1.5亿', '米其林厨师穿越到路边摊,手忙脚乱笑料百出', '美食|穿越|搞笑', 7240),
new ComicItem(4, '深夜档案室', '黑墨', '🔍', '悬疑', '连载中', 96, 9.0, '9800万', '每份档案背后,都藏着一个不为人知的秘密', '推理|悬疑|都市', 6880),
new ComicItem(5, '龙族纪元', '苍龙', '🐉', '奇幻', '连载中', 520, 9.3, '4.2亿', '巨龙苏醒,世界格局重塑,少年踏上屠龙之路', '奇幻|冒险|热血', 9920),
new ComicItem(6, '球场少年的夏天', '阿泽', '⚽', '热血', '连载中', 210, 8.6, '6800万', '从替补到王牌,一群少年的篮球追梦路', '运动|青春|热血', 5340),
new ComicItem(7, '星辰与你皆可爱', '糖糖', '⭐', '恋爱', '完结', 156, 9.1, '7800万', '天文社的暗恋物语,每颗星都替我说爱你', '校园|恋爱|治愈', 6210),
new ComicItem(8, '社畜爆笑日常', '茶茶', '😂', '搞笑', '连载中', 89, 8.9, '1.2亿', '打工人的血泪化为笑料,笑着笑着就哭了', '职场|搞笑|日常', 5890),
new ComicItem(9, '密室逃脱研究所', '谜题', '🗝️', '悬疑', '连载中', 68, 8.7, '5600万', '密室设计师的反套路推理,每一关都出乎意料', '推理|悬疑|密室', 4980),
new ComicItem(10, '万界修仙传', '青云', '⛰️', '奇幻', '连载中', 890, 9.4, '5.1亿', '一人一剑闯万界,修仙路漫漫其修远兮', '奇幻|修仙|热血', 9780),
new ComicItem(11, '咖啡店的心跳', '小满', '☕', '恋爱', '连载中', 72, 9.0, '4300万', '咖啡师与常客的暧昧日常,甜到冒泡', '都市|恋爱|日常', 4520),
new ComicItem(12, '武林外传新编', '老白', '🥋', '搞笑', '连载中', 145, 8.5, '9200万', '同福客栈重出江湖,江湖儿女笑料升级', '武侠|搞笑|群像', 4310),
new ComicItem(13, '都市怪谈录', '夜行人', '🌃', '悬疑', '完结', 288, 9.1, '8900万', '城市角落的都市传说,每一则都令人毛骨悚然', '都市|悬疑|怪谈', 5780),
new ComicItem(14, '山海异闻录', '山海君', '🏔️', '奇幻', '连载中', 330, 9.8, '7600万', '山海经里的神兽们,来到现代都市会怎样', '奇幻|神话|冒险', 5120),
new ComicItem(15, '那年的盛夏蝉鸣', '回音', '🍃', '恋爱', '完结', 120, 9.3, '1.1亿', '高中三年的暗恋,在毕业那天终于说出口', '校园|恋爱|怀旧', 6490)
];
}
const COMICS: ComicItem[] = buildComics();
buildComics() 函数采用了经典的工厂方法模式,将 15 部漫画作品的实例化过程封装在一个函数内部,最终返回一个 ComicItem[] 数组。之所以使用函数而非直接声明数组字面量,是因为 ComicItem 的构造函数需要通过 new 关键字调用——如果直接在模块顶层写 const COMICS = [new ComicItem(...), ...],虽然语法可行,但将构造逻辑封装在函数中更利于后续维护和测试,也便于将来替换为异步数据加载逻辑。
从数据内容来看,这 15 部作品覆盖了五大题材分类:热血(剑破苍穹录、球场少年的夏天)、恋爱(樱花树下等你、星辰与你皆可爱、咖啡店的心跳、那年的盛夏蝉鸣)、搞笑(厨神打工记、社畜爆笑日常、武林外传新编)、悬疑(深夜档案室、密室逃脱研究所、都市怪谈录)和奇幻(龙族纪元、万界修仙传、山海异闻录)。每部作品的简介都精心编写,既有文学性又能在两行内传达核心设定。
hotValue(人气值)的分布在 4310 到 9920 之间,这个数值将在后续的 hotColor() 函数中被映射为三种颜色梯度:超过 8000 显示深樱粉(爆款)、超过 5000 显示标准樱粉(热门)、其余显示墨绿色(常规)。tags 字段使用竖线 | 作为分隔符,在奇幻页面的标签云渲染时会通过 split('|') 拆解为独立的标签组件。const COMICS 作为模块级常量,在整个应用的生命周期内保持不变,充当虚拟数据库的角色。
六、书架数据模型:ShelfItem 接口与 SHELF_DATA
interface ShelfItem {
id: number;
title: string;
cover: string;
readChapter: number;
totalChapter: number;
lastRead: string;
inShelf: boolean;
}
const SHELF_DATA: ShelfItem[] = [
{ id: 1, title: '剑破苍穹录', cover: '⚔️', readChapter: 856, totalChapter: 1280, lastRead: '2小时前', inShelf: true },
{ id: 2, title: '樱花树下等你', cover: '🌸', readChapter: 86, totalChapter: 86, lastRead: '昨天', inShelf: true },
{ id: 5, title: '龙族纪元', cover: '🐉', readChapter: 490, totalChapter: 520, lastRead: '3天前', inShelf: true },
{ id: 7, title: '星辰与你皆可爱', cover: '⭐', readChapter: 120, totalChapter: 156, lastRead: '1周前', inShelf: true },
{ id: 10, title: '万界修仙传', cover: '⛰️', readChapter: 750, totalChapter: 890, lastRead: '刚刚', inShelf: true },
{ id: 15, title: '那年的盛夏蝉鸣', cover: '🍃', readChapter: 120, totalChapter: 120, lastRead: '已读完', inShelf: true }
];

书架是漫画阅读应用中用户使用频率最高的页面之一,它承载着"继续阅读"这一核心场景。ShelfItem 接口定义了书架中每一项所需的六个字段:id 与 COMICS 中的作品 id 对应,使得书架项可以关联到完整的漫画信息;cover 和 title 冗余存储了封面和标题,避免在渲染书架列表时需要反查 COMICS 数组。
核心字段是 readChapter(已读章节数)和 totalChapter(总章节数),这两个值通过 shelfProgress() 函数计算出阅读进度百分比,以进度条的形式直观展示在每条书架记录中。从数据来看,“樱花树下等你”(86/86)和"那年的盛夏蝉鸣"(120/120)已读完全部章节,lastRead 分别显示"昨天"和"已读完",表明用户已完成这两部作品;“剑破苍穹录”(856/1280)阅读进度约 67%,是当前正在追更的主力的作品。
lastRead 字段采用了人类可读的相对时间格式(“刚刚”、“2小时前”、“昨天”、“3天前”、“1周前”、“已读完”),而非时间戳。在演示场景中这种设计简化了时间格式化逻辑,但在真实业务中,这里通常会存储 ISO 时间字符串,然后在渲染时通过 relativeTime() 之类的工具函数进行格式化。inShelf 布尔字段预留了"加入/移出书架"的切换能力,虽然当前所有数据都设为 true,但它为后续的动态管理提供了数据基础。
七、详情数据模型:ChapterInfo 与 CommentItem
interface ChapterInfo {
chNo: number;
title: string;
date: string;
isFree: boolean;
isRead: boolean;
}
const CHAPTERS: ChapterInfo[] = [
{ chNo: 1280, title: '第1280话 · 一剑封神', date: '今日更新', isFree: false, isRead: false },
{ chNo: 1279, title: '第1279话 · 破境', date: '昨天', isFree: false, isRead: false },
{ chNo: 1278, title: '第1278话 · 天劫降临', date: '2天前', isFree: true, isRead: true },
{ chNo: 1277, title: '第1277话 · 宗门危机', date: '3天前', isFree: true, isRead: true },
{ chNo: 1276, title: '第1276话 · 故人重逢', date: '4天前', isFree: true, isRead: true },
{ chNo: 1275, title: '第1275话 · 暗流涌动', date: '5天前', isFree: true, isRead: true },
{ chNo: 1274, title: '第1274话 · 试炼之地', date: '6天前', isFree: true, isRead: true },
{ chNo: 1273, title: '第1273话 · 密境探险', date: '1周前', isFree: true, isRead: true }
];
interface CommentItem {
id: number;
user: string;
avatar: string;
content: string;
likes: number;
time: string;
}
const COMMENTS: CommentItem[] = [
{ id: 1, user: '漫画精', avatar: '🎨', content: '这画风绝了!每帧都能当壁纸', likes: 1280, time: '2小时前' },
{ id: 2, user: '追更大军', avatar: '🏃', content: '求更新!等不及了啊啊啊', likes: 860, time: '5小时前' },
{ id: 3, user: '墨白的小迷弟', avatar: '⚔️', content: '主角终于开挂了,爽!', likes: 540, time: '昨天' },
{ id: 4, user: '条漫爱好者', avatar: '📚', content: '竖屏阅读体验太好了,通勤必备', likes: 420, time: '昨天' },
{ id: 5, user: '催更仙人', avatar: '🗡️', content: '周更变月更?作者你解释一下', likes: 890, time: '2天前' }
];

漫画详情弹窗是信息密度最高的页面之一,它需要同时展示章节列表和用户评论两大数据模块。ChapterInfo 接口为章节列表提供了完整的数据结构:chNo 是章节编号,作为 ForEach 的键值;title 包含"第N话 · 副标题"的完整格式;date 使用相对时间描述;isFree 和 isRead 两个布尔字段分别控制免费/付费状态和已读/未读状态。
从 CHAPTERS 数据可以看出,最新的两章(1280 和 1279)是付费章节(isFree: false)且未读(isRead: false),而较早的章节都是免费的且已读过。这模拟了典型的漫画平台付费模式——最新章节需要付费或会员,而较早的章节免费阅读。在渲染时,付费章节显示锁定图标,免费章节显示解锁图标;已读章节文字变灰,未读章节保持正常颜色。
CommentItem 接口则定义了评论区的数据结构,包含用户名、Emoji 头像、评论内容、点赞数和发布时间。五条评论数据模拟了不同类型用户的真实评论场景:有称赞画风的"漫画精"、催更的"追更大军"、为角色喝彩的"墨白的小迷弟"、夸赞阅读体验的"条漫爱好者"以及质疑更新频率的"催更仙人"。这些评论内容的多样性使得详情弹窗的评论区看起来更加真实和生动。
八、粒子系统初始化:ParticleItem 与 buildParticles()
interface ParticleItem {
id: number;
x: number;
y: number;
size: number;
opacity: number;
icon: string;
}
const PARTICLE_ICONS: string[] = ['🌸', '❀', '✿', '🌺', '🌷'];
function buildParticles(): ParticleItem[] {
const arr: ParticleItem[] = [];
for (let i = 0; i < 16; i++) {
arr.push({
id: i,
x: (i * 41) % 340 + 10,
y: 80 + (i * 67) % 560,
x: (i * 41) % 340 + 10,
y: 80 + (i * 67) % 560,
size: 10 + (i * 5) % 14,
opacity: 0.2 + (i % 4) * 0.1,
icon: PARTICLE_ICONS[i % PARTICLE_ICONS.length]
});
}
return arr;
}

樱花瓣粒子特效是本应用最具辨识度的视觉元素之一。ParticleItem 接口定义了每个粒子的六个属性:id 作为唯一标识;x 和 y 是粒子在屏幕上的坐标位置;size 是字体大小(因为粒子本质上是通过 Text 组件渲染 Emoji 字符);opacity 控制透明度,实现远近层次感;icon 从 PARTICLE_ICONS 数组中取值,包含五种樱花相关字符。
buildParticles() 函数采用确定性的伪随机算法生成 16 个粒子的初始状态。之所以说是"确定性"而非真正随机,是因为所有坐标和尺寸都通过整数运算公式推导:x = (i * 41) % 340 + 10 将粒子水平分布在 10 到 350 之间;y = 80 + (i * 67) % 560 将粒子垂直分布在 80 到 640 之间。这种基于索引的分布方式确保了粒子在初始化时不会重叠聚集,而是均匀散布在屏幕区域中。
size 的范围是 10 到 23(10 + (i*5) % 14),opacity 的范围是 0.2 到 0.5(0.2 + (i%4) * 0.1),四个透明度梯度模拟了花瓣的远近层次——透明度低的粒子像是远处的花瓣,透明度高的粒子像是近处的花瓣。icon 通过 i % 5 循环取值,确保五种花瓣字符均匀出现。这套粒子系统在 @State 中初始化为 buildParticles() 的返回值,然后通过定时器驱动 driftParticles() 函数不断更新位置,形成持续飘动的动画效果。
九、粒子动画驱动:driftParticles() 漂移函数
function driftParticles(list: ParticleItem[]): ParticleItem[] {
const next: ParticleItem[] = [];
for (let i = 0; i < list.length; i++) {
const p = list[i];
const ny = p.y - 4;
next.push({
id: p.id,
x: p.x + Math.sin(p.id + p.y / 50) * 3,
y: ny < 60 ? 640 : ny,
size: p.size,
opacity: p.opacity,
icon: p.icon
});
}
return next;
}

driftParticles() 是粒子动画的核心驱动函数,它接收当前的粒子数组,返回一个新的粒子数组——这种"不可变更新"的模式是 ArkTS 响应式系统的推荐做法。因为 particles 被声明为 @State,当 this.particles = driftParticles(this.particles) 执行时,框架检测到引用变化,会触发 ForEach 重新渲染所有粒子,从而产生动画效果。
每帧的位移逻辑非常精妙:垂直方向上,每个粒子的 y 坐标减少 4(ny = p.y - 4),模拟花瓣向上飘动。当粒子飘出屏幕顶部(ny < 60)时,将其 y 重置为 640(屏幕底部),实现循环往复的效果。水平方向上,x = p.x + Math.sin(p.id + p.y / 50) * 3 引入了正弦波动,使粒子在上升过程中左右摇摆,模拟花瓣随风飘动的自然轨迹。不同的 p.id 产生不同的正弦相位,确保每个粒子的摇摆节奏不同,整体效果更加自然。
这个函数每 130 毫秒被调用一次(由 aboutToAppear 中的 setInterval 驱动),每次调用都会生成全新的粒子数组。由于 ArkTS 的 ForEach 使用键值函数 (p) => p.id.toString() + '_' + p.y.toFixed(0) 来判断元素的增删和移动,当 y 坐标变化导致键值变化时,框架会高效地更新对应粒子的位置属性,而非销毁重建所有 Text 组件。这种设计在保证视觉效果的同时,尽可能降低了渲染开销。
十、工具函数集:颜色映射与格式化
function hotColor(hot: number): string {
if (hot > 8000) {
return COLORS.cherryDeep;
}
if (hot > 5000) {
return COLORS.cherry;
}
return COLORS.inkGreen;
}
function barHeight(v: number): string {
return (v * 0.8).toFixed(0) + 'vp';
}
function genreColor(genre: string): string {
if (genre === '热血') { return '#E85A3C'; }
if (genre === '恋爱') { return COLORS.cherry; }
if (genre === '搞笑') { return '#F5A623'; }
if (genre === '悬疑') { return '#5A3D7A'; }
if (genre === '奇幻') { return COLORS.inkGreen; }
return COLORS.cherryLight;
}
function formatHot(hot: number): string {
return (hot / 1000).toFixed(1) + 'k';
}
function shelfProgress(read: number, total: number): number {
if (total === 0) { return 0; }
return Math.round(read / total * 100);
}
这五个工具函数虽然各自短小,但它们在整个应用的多个 @Builder 方法中被反复调用,是连接数据与视觉表现的桥梁。
hotColor() 实现了三段式热度颜色映射:人气值超过 8000 返回深樱粉(爆款级)、超过 5000 返回标准樱粉(热门级)、其余返回墨绿色(常规级)。这种梯度映射在排行榜页面中用于人气数值的着色,让用户一眼就能识别出热门程度。
barHeight() 将阅读分钟数乘以 0.8 后拼接 vp 单位字符串。例如 180 分钟对应 144vp 的高度,180 分钟对应 144vp 的高度。vp(virtual pixel)是 HarmonyOS 的响应式长度单位,会根据屏幕密度自动缩放,确保在不同设备上视觉效果一致。
genreColor() 为五种题材分类各自指定了主题色:热血用橙红、恋爱用樱粉、搞笑用金色、悬疑用深紫、奇幻用墨绿。这些颜色的选择既有色彩心理学依据(红色代表热血激情、紫色代表神秘悬疑),也保证了在浅色背景上的可读性。
formatHot() 将数值化的人气值转换为 “9.9k” 格式的字符串,这是社区类应用中常见的数字简化方案。shelfProgress() 计算阅读进度百分比,并做了除零保护——当 totalChapter 为 0 时返回 0%,避免出现 NaN。
十一、状态管理:@Entry struct Index 与 @State 声明
@Entry
struct Index {
@State currentBottomTab: number = 0;
@State currentTopTab: number = 0;
@State showPublishModal: boolean = false;
@State showShelfModal: boolean = false;
@State showRemoveModal: boolean = false;
@State showDetailModal: boolean = false;
@State selectedComic: ComicItem | null = null;
@State particles: ParticleItem[] = buildParticles();
@State publishTitle: string = '';
@State publishGenre: number = 0;
@State publishStatus: number = 0;
@State shelfRating: number = 5;
@State shelfGroup: number = 0;
private timerId: number = -1;
@Entry 装饰器将 Index 结构体标记为应用的入口组件——每个 HarmonyOS ArkTS 页面有且仅有一个 @Entry 组件,它是整个页面组件树的根节点。struct 关键字定义了一个值类型结构体,但在 ArkTS 的语境中,struct 实际上被框架当作组件类来处理,拥有 build() 方法和生命周期回调。
13 个 @State 变量构成了应用全部的响应式状态空间,可以按功能分为四组。第一组是导航状态:currentBottomTab(当前底部 Tab 索引,初始为 0 即"漫画"页)和 currentTopTab(当前顶部 Tab 索引,初始为 0 即"推荐"分类)。第二组是弹窗状态:四个布尔变量分别控制上架弹窗、书架管理弹窗、下架确认弹窗和漫画详情弹窗的显示与隐藏。第三组是选中数据:selectedComic 使用联合类型 ComicItem | null,初始为 null,当用户点击某部漫画时被赋值,详情弹窗通过非空断言 ! 读取其属性。第四组是表单状态:publishTitle(上架表单的作品名称)、publishGenre(选中的题材索引)、publishStatus(连载状态索引)、shelfRating(书架评分星级)和 shelfGroup(书架分组索引)。
particles 是唯一一个在初始化时即调用函数的 @State——buildParticles() 在组件实例化时执行,生成 16 个粒子的初始数组。timerId 是一个普通的 private 成员变量(非 @State),用于保存定时器 ID,在组件销毁时用于清理。这种区分体现了 ArkTS 的设计哲学:只有需要驱动视图更新的数据才标记为 @State,纯逻辑变量使用普通声明即可,避免不必要的渲染开销。
十二、生命周期管理:aboutToAppear 与 aboutToDisappear
aboutToAppear() {
this.timerId = setInterval(() => {
this.particles = driftParticles(this.particles);
}, 130);
}
aboutToDisappear() {
if (this.timerId >= 0) {
clearInterval(this.timerId);
}
}
aboutToAppear 和 aboutToDisappear 是 HarmonyOS ArkTS 组件生命周期的两个关键回调。aboutToAppear 在组件创建后、build() 方法执行前调用,适合进行数据初始化、定时器注册、事件订阅等操作;aboutToDisappear 在组件销毁前调用,用于资源清理和反订阅。
在本应用中,aboutToAppear 注册了一个 130 毫秒间隔的定时器,回调函数中调用 driftParticles(this.particles) 计算粒子的下一帧位置,并将结果赋值给 this.particles。由于 particles 是 @State 变量,每次赋值都会触发 ForEach 的增量更新,使 16 个粒子 Text 组件的位置属性被高效更新,产生连续的飘动动画效果。
130 毫秒的间隔约等于每秒 7.7 帧,这个帧率对于花瓣飘动这种缓慢、柔和的动画来说足够流畅,同时又比标准的 60fps(16ms)低得多,大幅降低了 CPU 消耗和电池消耗。这是一种在视觉效果和性能之间做出的工程权衡。
aboutToDisappear 中的清理逻辑至关重要:如果不在组件销毁时调用 clearInterval,定时器会在组件不可见后继续运行,造成内存泄漏和无效的渲染计算。代码中通过 this.timerId >= 0 的判断确保只在定时器确实存在时才执行清理,这是一种防御性编程的实践。在 HarmonyOS 的页面栈模型中,当用户导航到其他页面或应用退到后台时,当前页面的组件可能被销毁,此时生命周期回调确保了资源的正确释放。
十三、主布局骨架:build() 方法与 Stack 容器
build() {
Stack() {
Column() {
this.headerBuilder()
if (this.currentBottomTab === 0) {
this.comicTopTabs()
} else if (this.currentBottomTab === 1) {
this.shelfTopBar()
} else if (this.currentBottomTab === 2) {
this.discoverTopBar()
} else {
this.mineTopBar()
}
Scroll() {
Column() {
if (this.currentBottomTab === 0) {
this.comicContent()
} else if (this.currentBottomTab === 1) {
this.shelfContent()
} else if (this.currentBottomTab === 2) {
this.discoverContent()
} else {
this.mineContent()
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
this.bottomTabs()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
ForEach(this.particles, (p: ParticleItem) => {
Text(p.icon)
.fontSize(p.size)
.opacity(p.opacity)
.position({ x: p.x, y: p.y })
}, (p: ParticleItem) => p.id.toString() + '_' + p.y.toFixed(0))
if (this.showPublishModal) { this.publishComicModal() }
if (this.showShelfModal) { this.shelfManageModal() }
if (this.showRemoveModal) { this.removeComicModal() }
if (this.showDetailModal) { this.comicDetailModal() }
}
.width('100%')
.height('100%')
}
build() 方法是每个 ArkTS 组件的核心——它以声明式语法定义了组件的 UI 结构。本应用的 build() 方法使用 Stack(层叠布局)作为最外层容器,Stack 的子元素按声明顺序从底层到顶层叠加,后声明的元素覆盖在先声明的元素之上。
第一层是 Column(纵向布局),包含了三个区块:顶部的 headerBuilder()(应用头部信息栏)、中间的导航栏(根据 currentBottomTab 的值在四种顶部栏之间切换)、以及可滚动的内容区域。内容区域使用 Scroll 组件包裹 Column,.layoutWeight(1) 使其占据头部和底部导航栏之间的全部剩余空间,.scrollable(ScrollDirection.Vertical) 启用纵向滚动,.scrollBar(BarState.Off) 隐藏滚动条以获得更干净的视觉效果。内容区域内部同样通过 if-else 链根据 currentBottomTab 选择对应的 @Builder 方法渲染。
第二层是粒子层——ForEach 遍历 this.particles 数组,为每个粒子创建一个 Text 组件,通过 .position({ x, y }) 进行绝对定位。由于这一层声明在 Column 之后,粒子会覆盖在主内容之上,形成花瓣飘浮在页面前景的视觉效果。键值函数 p.id.toString() + '_' + p.y.toFixed(0) 将 id 和 y 坐标组合为唯一键,当 y 变化时键值变化,框架会更新对应粒子的 position 属性。
第三层是弹窗层——四个 if 条件判断分别控制四种弹窗的渲染。这种条件渲染模式确保了只有当前需要显示的弹窗才会被构建和渲染,未激活的弹窗不会占用渲染资源。Stack 容器使弹窗自然覆盖在所有内容之上,配合弹窗自身的半透明背景色(99000000)实现遮罩效果。
十四、粒子渲染层:ForEach 与 Text 绝对定位
ForEach(this.particles, (p: ParticleItem) => {
Text(p.icon)
.fontSize(p.size)
.opacity(p.opacity)
.position({ x: p.x, y: p.y })
}, (p: ParticleItem) => p.id.toString() + '_' + p.y.toFixed(0))
虽然在上一段中已经提到了粒子渲染层,但这里值得单独深入分析其技术细节。ForEach 是 ArkTS 中用于列表渲染的核心组件,它接收三个参数:数据源数组、子项生成函数和键值生成函数。
子项生成函数为每个 ParticleItem 创建一个 Text 组件,渲染 Emoji 花瓣字符。.fontSize(p.size) 根据粒子的 size 属性设置字体大小(10-23),大小不同的花瓣形成视觉层次。.opacity(p.opacity) 设置透明度(0.2-0.5),远处的花瓣若隐若现,近处的花瓣清晰可见。.position({ x: p.x, y: p.y }) 是关键——它将 Text 组件从文档流中脱离出来,使用绝对定位放置在 Stack 容器内的指定坐标点。
键值生成函数 (p) => p.id.toString() + '_' + p.y.toFixed(0) 的设计非常巧妙。如果仅使用 p.id.toString() 作为键值,那么当 driftParticles() 更新粒子位置时,虽然数组内容变了,但 ForEach 看到的键值列表没有变化(id 不变),框架可能不会触发位置更新。通过将 y 坐标也纳入键值(toFixed(0) 取整避免键值频繁变化),当 y 坐标取整值发生变化时,键值随之改变,框架会识别到这个变化并更新对应 Text 组件的 .position() 属性。
这种"键值驱动更新"的机制是 ArkTS ForEach 高性能渲染的基础。框架通过 Diff 算法比较前后两次的键值列表,只对发生变化的项进行 DOM 级别的属性更新,而非销毁重建组件。对于 16 个粒子、每秒约 7.7 次更新的场景,这种机制确保了动画的流畅性。
十五、弹窗条件渲染:四类弹窗的显示控制
if (this.showPublishModal) {
this.publishComicModal()
}
if (this.showShelfModal) {
this.shelfManageModal()
}
if (this.showRemoveModal) {
this.removeComicModal()
}
if (this.showDetailModal) {
this.comicDetailModal()
}
这四行代码虽然简短,但它们构成了应用弹窗系统的调度中枢。每个 if 语句独立判断一个布尔类型的 @State 变量,当变量为 true 时调用对应的 @Builder 方法渲染弹窗。这种设计模式有几个值得注意的技术要点。
首先,四个条件之间使用的是独立的 if 而非 if-else if 链。这意味着理论上多个弹窗可以同时显示——虽然在实际业务中通常不会同时出现多个弹窗,但保留这种可能性为复杂交互场景(如弹窗叠加)提供了灵活性。例如,用户在详情弹窗中点击"加入书架"按钮时,书架管理弹窗可以在详情弹窗之上叠加显示。
其次,弹窗的显示和隐藏完全由 @State 布尔变量驱动。当用户点击"上架"按钮时,this.showPublishModal = true 触发 build() 重新执行,新增的 if 分支使弹窗组件被创建并插入到 Stack 的顶层。当用户点击弹窗内的"取消"或"保存"按钮时,对应的布尔变量被设为 false,if 条件不满足,弹窗组件从渲染树中移除。这种"声明式弹窗"模式比传统的命令式弹窗(如 Android 的 Dialog.show()/dismiss())更加直观和可维护。
最后,由于弹窗层声明在 Stack 的最后(最高层),它们自然覆盖在所有内容之上。每个弹窗的根容器都设置了 backgroundColor('99000000')(99% 不透明度的黑色背景),这形成了一个半透明遮罩,使下层内容变暗,引导用户注意力集中在弹窗上。点击遮罩区域(弹窗根容器的 onClick)会关闭弹窗,这是移动端弹窗交互的标准模式。
十六、头部信息栏:headerBuilder()
@Builder
headerBuilder() {
Column() {
Row() {
Column() {
Text('📖 QQ漫画')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Text('今日更新 248 部作品 · 2.6万人在读')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Column() {
Text('✏️')
.fontSize(20)
.fontColor(COLORS.white)
Text('上架')
.fontSize(10)
.fontColor(COLORS.white)
}
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(COLORS.cherry)
.onClick(() => {
this.showPublishModal = true;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 10, bottom: 8 })
Row() {
Column() {
Text('📚 在读作品')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('1,280')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherry)
}
.alignItems(HorizontalAlign.Start)
Divider()
.vertical(true)
.height(26)
.color(COLORS.border)
.margin({ left: 14, right: 14 })
Column() {
Text('⏱ 本周阅读')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('10.2h')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.inkGreen)
}
.alignItems(HorizontalAlign.Start)
Divider()
.vertical(true)
.height(26)
.color(COLORS.border)
.margin({ left: 14, right: 14 })
Column() {
Text('🔖 我的追更')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('23部')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
Column() {
Text('🏆 创作等级')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('Lv.8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 10 })
}
.width('100%')
.backgroundColor(COLORS.white)
}
@Builder 装饰器是 ArkTS 中用于提取和复用 UI 片段的核心机制。被 @Builder 修饰的方法返回一个声明式 UI 片段,可以在 build() 方法中通过 this.methodName() 的方式引用,相当于传统编程中的"子函数"或"模板片段"。
headerBuilder() 构建了应用顶部的品牌信息栏和数据概览栏,分为上下两个 Row。上方的 Row 使用 FlexAlign.SpaceBetween 两端对齐布局:左侧是应用名称"📖 QQ漫画"和副标题"今日更新 248 部作品 · 2.6万人在读",右侧是"上架"按钮。上架按钮是一个带圆角和樱粉背景的 Column,内含"✏️"图标和"上架"文字,点击后设置 this.showPublishModal = true 弹出上架表单。
下方的 Row 展示了四个关键数据指标:在读作品数(1,280)、本周阅读时长(10.2h)、追更数量(23部)和创作等级(Lv.8)。四个指标之间使用 Divider 组件的 .vertical(true) 模式插入竖直分隔线,高度 26,颜色为浅粉边框色。每个指标由两行文字组成:上方是小字号的标签(10vp),下方是大字号的数值(15vp,加粗),数值分别使用樱粉、墨绿、金色和成功绿四种颜色,与各自语义对应。
这种"品牌 + 数据概览"的头部设计在内容社区类应用中非常常见,它让用户在进入应用的第一时间就能获取到核心运营数据和自身状态。通过 @Builder 提取后,头部逻辑独立于主布局,便于单独维护和测试。
十七、顶部导航栏:comicTopTabs() 与顶部栏组件
@Builder
comicTopTabs() {
Scroll() {
Row() {
ForEach(TOP_TABS, (t: TabItem, idx: number) => {
Column() {
Text(t.icon)
.fontSize(16)
Text(t.label)
.fontSize(11)
.fontColor(this.currentTopTab === idx ? COLORS.cherryDeep : COLORS.textSecondary)
.margin({ top: 2 })
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor(this.currentTopTab === idx ? '#FFE0EC' : 'transparent')
.margin({ right: 4 })
.onClick(() => {
this.currentTopTab = idx;
})
}, (t: TabItem) => t.label)
}
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.backgroundColor(COLORS.white)
}
漫画页的顶部标签栏需要支持横向滚动——因为六个分类标签在小屏设备上可能无法全部平铺显示。comicTopTabs() 使用 Scroll 组件包裹 Row,通过 .scrollable(ScrollDirection.Horizontal) 启用横向滚动,.scrollBar(BarState.Off) 隐藏滚动条。
每个标签是一个 Column,上方是 Emoji 图标(16vp),下方是文字标签(11vp)。选中态和非选中态的视觉差异通过两个属性实现:文字颜色在选中时为 cherryDeep(深樱粉)、非选中时为 textSecondary(灰紫);背景色在选中时为 #FFE0EC(浅粉底)、非选中时为 transparent(透明)。这种"浅色底 + 深色字"的选中态设计比单纯的文字变色更加醒目,用户能够清晰地识别当前所在分类。
点击事件的回调函数 this.currentTopTab = idx 将选中索引赋值给 @State 变量,触发 build() 重新执行。在 comicContent() 中,currentTopTab 的值决定了渲染哪一个内容 @Builder:0 对应推荐、1 对应热血排行榜、2 对应恋爱、3 对应搞笑、4 对应悬疑、5 对应奇幻。整个切换过程是声明式的——开发者只需修改状态,框架自动处理 UI 的销毁和重建。
除了 comicTopTabs(),应用还定义了 shelfTopBar()、discoverTopBar() 和 mineTopBar() 三个顶部栏构建器,分别用于书架页(显示"我的书架"标题和"管理"入口)、发现页(显示"发现好漫画"标题和"随机一本"按钮)和个人页(显示"个人中心"标题)。这三个顶部栏不需要横向滚动,结构更简单,但它们与 comicTopTabs() 一样都通过 currentBottomTab 的值在 build() 中进行调度切换。
十八、漫画内容路由与推荐页:comicContent() 与 recommendContent()
@Builder
comicContent() {
Column() {
if (this.currentTopTab === 0) {
this.recommendContent()
} else if (this.currentTopTab === 1) {
this.hotBloodRank()
} else if (this.currentTopTab === 2) {
this.romanceContent()
} else if (this.currentTopTab === 3) {
this.comedyGrid()
} else if (this.currentTopTab === 4) {
this.mysteryTimeline()
} else {
this.fantasyContent()
}
}
.width('100%')
.padding(12)
.alignItems(HorizontalAlign.Start)
}
comicContent() 是漫画页面的内容路由器。它本身不生成具体内容,而是根据 currentTopTab 的值将渲染委托给六个专门的 @Builder 方法。这种"路由器 + 内容构建器"的分层设计使代码结构清晰:路由逻辑集中在一处,内容实现分散在各自的方法中,互不干扰。
当 currentTopTab === 0(推荐分类)时,recommendContent() 被调用。推荐页是漫画模块的首页,信息密度最高,包含三个区块:今日精选大卡片、编辑精选横滑列表和热门连载瀑布流。
今日精选卡片展示了 COMICS[0](剑破苍穹录)的详细信息,包括大号封面 Emoji(48vp)、“🏆 今日精选"标签、标题、作者/题材/状态信息和简介。卡片底部有两个操作按钮:“📖 立即阅读”(樱粉实底按钮,点击后设置 selectedComic 并弹出详情弹窗)和"🔖 加入书架”(墨绿描边按钮,点击后弹出书架管理弹窗)。
编辑精选推荐使用横向 Scroll + Row + ForEach 渲染全部 15 部漫画的卡片列表,每张卡片宽度 100vp,包含封面 Emoji、标题和评分。热门连载则使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现两列瀑布流布局,每张卡片宽度为 48.5%,展示封面、标题、题材色块、评分和阅读量。三种布局方式(大卡片、横滑列表、瀑布流)在同一页面中组合使用,既丰富了视觉层次,又为不同优先级的内容提供了差异化的展示方式。
十九、热血排行榜:hotBloodRank() 与柱状图
@Builder
hotBloodRank() {
Column() {
Column() {
Text('📈 一周阅读时长')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Row() {
ForEach(READING_DAYS, (d: string, idx: number) => {
Column() {
Column() {
Text('')
.width('100%')
.height(1)
}
.width(18)
.height(barHeight(READING_MINUTES[idx]))
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(hotColor(READING_MINUTES[idx] * 10))
.justifyContent(FlexAlign.End)
Text(d)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.margin({ left: 8, right: 8 })
.justifyContent(FlexAlign.End)
}, (d: string) => d)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
Text('⚔️ 热血漫画 · 战力榜')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
ForEach(COMICS, (c: ComicItem, idx: number) => {
Row() {
Text(idx < 3 ? (idx === 0 ? '🥇' : (idx === 1 ? '🥈' : '🥉')) : (idx + 1).toString())
.fontSize(idx < 3 ? 20 : 14)
.fontColor(idx < 3 ? COLORS.gold : COLORS.textHint)
.width(32)
.textAlign(TextAlign.Center)
Text(c.cover)
.fontSize(24)
.margin({ right: 10 })
Column() {
Text(c.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(c.author + ' · ' + c.chapters + '话 · ★' + c.rating)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(formatHot(c.hotValue))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(hotColor(c.hotValue))
Text('人气')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(idx < 3 ? '#FFF5F8' : COLORS.white)
.border({
width: idx < 3 ? 1 : 0,
color: COLORS.cherryLight
})
.margin({ bottom: 8 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
热血排行榜页面展示了本应用中技术复杂度最高的两个组件:手工柱状图和带奖牌排名的列表。
柱状图的实现完全基于 Column 和 Text 基础组件。每个柱体是一个 Column,宽度固定为 18vp,高度由 barHeight(READING_MINUTES[idx]) 动态计算——例如周三 30 分钟对应 24vp、周六 180 分钟对应 144vp。柱体颜色通过 hotColor(READING_MINUTES[idx] * 10) 映射:180 分钟对应 1800 的人气值(低于 5000),显示墨绿色;但如果数值放大后超过阈值,则会显示樱粉色。柱体底部圆角通过 .borderRadius({ topLeft: 4, topRight: 4 }) 只设置上方两角,模拟真实柱状图的视觉效果。柱体下方是星期标签,整个 Row 使用 VerticalAlign.Bottom 底部对齐,确保所有柱体从同一基线向上生长。
排行榜列表使用 ForEach 遍历 COMICS 数组,每行展示排名、封面、标题信息和人气值。排名前三的行使用奖牌 Emoji(🥇🥈🥉)代替数字,字体放大到 20vp 并使用金色;第四名以后显示数字编号,字体 14vp、灰色。前三名的行还额外添加了浅粉背景和樱粉边框,使"Top 3"在视觉上与普通排名区分开来。每行的点击事件都会设置 selectedComic 并弹出详情弹窗,实现了从排行榜到详情页的跳转。
二十、恋爱与搞笑内容页:romanceContent() 与 comedyGrid()
@Builder
romanceContent() {
Column() {
Text('💕 恋爱漫画 · 心动推荐')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
Scroll() {
Row() {
ForEach(COMICS, (c: ComicItem) => {
Column() {
Text(c.cover)
.fontSize(36)
.width(120)
.height(90)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor('#FFE0EC')
Text(c.title)
.fontSize(12)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.margin({ top: 6 })
Text('★' + c.rating + ' · ' + c.status)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.width(120)
.borderRadius(14)
.backgroundColor(COLORS.white)
.padding({ bottom: 8 })
.margin({ right: 10 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
// ... 条漫速览列表省略
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
comedyGrid() {
Column() {
Text('😂 搞笑漫画 · 笑到肚子疼')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
Grid() {
ForEach(COMICS, (c: ComicItem) => {
GridItem() {
Column() {
Text(c.cover)
.fontSize(30)
Text(c.title)
.fontSize(11)
.fontColor(COLORS.textPrimary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
.textAlign(TextAlign.Center)
Text('★' + c.rating)
.fontSize(10)
.fontColor(COLORS.gold)
.margin({ top: 4 })
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}
}, (c: ComicItem) => c.id.toString())
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.height(560)
// ... 今日笑点合集列表省略
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
恋爱页面和搞笑页面展示了两种截然不同的布局策略。romanceContent() 的上半部分使用横向滚动的卡片轮播——每张卡片宽度固定为 120vp,封面区域高度 90vp 并使用浅粉背景 #FFE0EC,形成统一的视觉风格。下半部分则是"条漫速览"列表,每行包含小型封面、标题、简介、标签和章节数,采用 Row + layoutWeight(1) 的经典列表项布局。这种"横滑卡片 + 纵向列表"的组合在内容类应用中非常流行,上半部分用于快速浏览,下半部分用于深入阅读。
comedyGrid() 则展示了 Grid 组件的使用方式——这是 HarmonyOS ArkTS 中专门用于网格布局的容器。.columnsTemplate('1fr 1fr 1fr') 定义了三列等宽模板,.rowsGap(10) 和 .columnsGap(10) 分别设置行间距和列间距,.height(560) 固定网格区域高度。每个 GridItem 内部是一个 Column,包含封面 Emoji(30vp)、标题(最多两行,超出省略)和评分。三列网格布局使搞笑漫画以紧凑的卡片墙形式呈现,比两列瀑布流更加规整,适合在分类浏览场景中快速扫描大量作品。
两种布局方式的对比体现了 ArkTS 的灵活性——同一个数据源(COMICS)可以根据不同的分类语境以完全不同的布局方式呈现,而所有布局的差异都仅体现在 @Builder 方法的声明中,数据层无需任何改动。
二十一、悬疑时间轴与奇幻标签云:mysteryTimeline() 与 fantasyContent()
@Builder
mysteryTimeline() {
Column() {
Text('🔍 悬疑漫画 · 推理时间轴')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
ForEach(COMICS, (c: ComicItem, idx: number) => {
Row() {
Column() {
Text(idx === 0 ? '🔴' : '⚫')
.fontSize(14)
if (idx < COMICS.length - 1) {
Column() {
Text('')
.width(2)
.height(80)
}
.width(2)
.height(80)
.backgroundColor(COLORS.border)
}
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(c.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(c.intro)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
Text(c.genre)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor(genreColor(c.genre))
Text(c.chapters + '话 · ' + c.status)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
}
.width('100%')
.margin({ bottom: 4 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => 'mystery_' + c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
fantasyContent() {
Column() {
Text('🐉 奇幻漫画 · 世界观标签')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['修仙', '魔法', '龙族', '精灵', '炼金', '神兽', '秘境', '时空', '圣战', '诅咒', '神器', '冒险'], (tag: string) => {
Text('#' + tag)
.fontSize(12)
.fontColor(COLORS.inkGreen)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#E8F5EE')
.border({ width: 1, color: COLORS.inkGreenLight })
.margin({ right: 8, top: 6 })
}, (tag: string) => tag)
}
.width('100%')
// ... 奇幻长篇连载列表省略
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
悬疑页面采用了独特的"时间轴"布局,这种布局方式在叙事性较强的内容场景中特别有效。时间轴的实现核心在于左侧的时间节点列:第一项使用红色圆点 Emoji(🔴)表示当前最新,其余项使用黑色圆点(⚫)。在圆点下方,通过条件判断 if (idx < COMICS.length - 1) 渲染一条高度 80vp、宽度 2vp 的竖线(Column + Text),颜色为边框色,将各个节点串联成一条连续的时间轴。右侧是每个节点的详情卡片,包含标题、简介、题材色块和章节数。
奇幻页面则以"世界观标签云"作为视觉焦点。使用 Flex({ wrap: FlexWrap.Wrap }) 实现自动换行的标签流式布局,12 个世界观关键词(修仙、魔法、龙族、精灵、炼金、神兽、秘境、时空、圣战、诅咒、神器、冒险)以墨绿色描边的圆角胶囊形式排列。FlexWrap.Wrap 确保标签在行末自动折行,适应不同屏幕宽度。这种标签云设计在奇幻类内容中特别合适,因为奇幻作品的世界观本身就是由多个关键概念组成的。
两个页面的键值函数也值得关注:悬疑列表使用 'mystery_' + c.id.toString() 作为键值前缀,奇幻列表使用 'fantasy_' + c.id.toString()。这种前缀策略确保了即使同一个 ComicItem 在不同页面的 ForEach 中出现,其键值也是唯一的,避免了框架在 Diff 时产生混淆。
二十二、书架与发现页:shelfContent() 与 discoverContent()
@Builder
shelfContent() {
Column() {
Text('📖 我的书架 · ' + SHELF_DATA.length.toString() + ' 部')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
ForEach(SHELF_DATA, (s: ShelfItem) => {
Row() {
Text(s.cover)
.fontSize(26)
.width(46)
.height(60)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#FFF0F5')
Column() {
Text(s.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('已读 ' + s.readChapter + '/' + s.totalChapter + '话 · ' + s.lastRead)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Column() {
Text('')
.width('100%')
.height(1)
}
.width('100%')
.height(4)
.borderRadius(2)
.backgroundColor('#FFE0EC')
.margin({ top: 6 })
Column() {
Text('')
.width('100%')
.height(1)
}
.width(shelfProgress(s.readChapter, s.totalChapter).toString() + '%')
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.cherry)
Text(shelfProgress(s.readChapter, s.totalChapter).toString() + '% · 继续阅读')
.fontSize(9)
.fontColor(COLORS.cherry)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.margin({ bottom: 8 })
.onClick(() => {
this.showDetailModal = true;
})
}, (s: ShelfItem) => s.id.toString())
Text('🗑 从书架移除将不再保留阅读进度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 6 })
.onClick(() => {
this.showRemoveModal = true;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
书架页面的核心视觉元素是"双层进度条"——通过两个叠加的 Column 实现。底层是一个宽度 100%、高度 4vp 的浅粉色 Column(#FFE0EC),作为进度条的轨道;上层是一个宽度等于阅读进度百分比、高度同样 4vp 的樱粉色 Column(COLORS.cherry),作为进度条的填充部分。宽度通过 shelfProgress(s.readChapter, s.totalChapter).toString() + '%' 动态计算,例如"剑破苍穹录"的进度为 67%,则上层 Column 宽度为 67%。进度条下方还显示百分比文字和"继续阅读"提示,形成完整的阅读进度展示。
发现页面 discoverContent() 则包含三个区块:随机推荐卡片(包含"换一本"按钮)、按标签发现(12 个标签的流式布局)和类型分布条形图。类型分布通过 ForEach 遍历五种题材,每行包含题材名、背景条(60% 宽度的浅粉 Column)和前景条(宽度递减的彩色 Column),前景条宽度通过 (100 - idx * 15).toString() + '%' 计算,形成递减的分布效果。前景条颜色通过 genreColor(g) 映射为各题材的主题色,使分布图既有数据表达力又有色彩辨识度。
二十三、个人中心与底部导航:mineContent() 与 bottomTabs()
@Builder
mineContent() {
Column() {
Column() {
Row() {
Text('🎨')
.fontSize(40)
.width(64)
.height(64)
.textAlign(TextAlign.Center)
.borderRadius(32)
.backgroundColor('#FFE0EC')
Column() {
Text('条漫爱好者')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Text('Lv.8 · 漫画达人 · 追更23部')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Text('📖 阅读时长 312h · 收藏 156话')
.fontSize(10)
.fontColor(COLORS.cherry)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Column() {
Text('编辑')
.fontSize(12)
.fontColor(COLORS.white)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(COLORS.cherry)
.onClick(() => {
this.showShelfModal = true;
})
}
.width('100%')
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Start)
Row() {
Column() {
Text('23').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.cherry)
Text('追更').fontSize(11).fontColor(COLORS.textSecondary).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('156').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.inkGreen)
Text('收藏').fontSize(11).fontColor(COLORS.textSecondary).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('8').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
Text('书单').fontSize(11).fontColor(COLORS.textSecondary).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('2,460').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.success)
Text('漫豆').fontSize(11).fontColor(COLORS.textSecondary).margin({ top: 2 })
}.layoutWeight(1)
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.white)
.margin({ top: 10 })
// ... 成就、书单等省略
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
bottomTabs() {
Row() {
ForEach(BOTTOM_TABS, (t: TabItem, idx: number) => {
Column() {
Text(t.icon)
.fontSize(20)
.opacity(this.currentBottomTab === idx ? 1 : 0.5)
Text(t.label)
.fontSize(10)
.fontColor(this.currentBottomTab === idx ? COLORS.cherryDeep : COLORS.textHint)
.margin({ top: 3 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.currentBottomTab = idx;
this.currentTopTab = 0;
})
}, (t: TabItem) => t.label)
}
.width('100%')
.backgroundColor(COLORS.white)
.border({ width: 1, color: COLORS.border })
}
个人中心页面是用户身份和成就的集中展示区。顶部是用户资料卡片:圆形头像(64x64,浅粉背景)、用户名(18vp 加粗)、等级和标签信息、阅读统计。右侧的"编辑"按钮点击后弹出书架管理弹窗。资料卡片下方是四列等宽数据条——追更数(23)、收藏数(156)、书单数(8)和漫豆数(2460),每列使用 layoutWeight(1) 等分宽度,数值使用不同颜色(樱粉、墨绿、金色、成功绿)区分。页面还包含六宫格成就墙和书单列表。
底部导航栏 bottomTabs() 是整个应用的导航中枢。ForEach 遍历四个 Tab 项,每个 Tab 使用 layoutWeight(1) 等分宽度。选中态通过两个维度区分:图标透明度(选中 1.0、未选中 0.5)和文字颜色(选中深樱粉、未选中浅灰)。点击事件的回调函数同时更新两个状态变量:this.currentBottomTab = idx 切换底部页面,this.currentTopTab = 0 重置顶部分类为推荐。这种"切换底部 Tab 时重置顶部 Tab"的设计确保了用户每次进入漫画页时都从推荐分类开始浏览,符合常见的内容消费习惯。
二十四、上架弹窗与书架管理弹窗:publishComicModal() 与 shelfManageModal()
@Builder
publishComicModal() {
Column() {
Column() {
Column() {
Text('✏️ 上架新作品')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('填写作品信息,开始你的创作之旅')
.fontSize(11)
.fontColor('#FFD3E8')
.margin({ top: 4 })
}
.width('100%')
.padding(16)
.alignItems(HorizontalAlign.Start)
.linearGradient({
angle: 135,
colors: [['#E85A8C', 0], ['#C73D6E', 1]]
})
Scroll() {
Column() {
Text('作品名称')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ top: 14 })
TextInput({ placeholder: '例如:星辰物语', text: this.publishTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.placeholderColor(COLORS.textHint)
.backgroundColor('#FFF0F5')
.borderRadius(10)
.padding({ left: 12, right: 12 })
.height(42)
.margin({ top: 6 })
.onChange((v: string) => {
this.publishTitle = v;
})
// ... 类型选择、状态选择省略
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 16, right: 16 })
}
.constraintSize({ maxHeight: '55%' })
.scrollBar(BarState.Off)
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.onClick(() => {
this.showPublishModal = false;
})
Text('立即上架')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.linearGradient({
angle: 90,
colors: [['#E85A8C', 0], ['#C73D6E', 1]]
})
.onClick(() => {
this.showPublishModal = false;
})
}
.width('100%')
.border({ width: 1, color: COLORS.border })
}
.width('90%')
.borderRadius(16)
.backgroundColor(COLORS.white)
.clip(true)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('99000000')
.onClick(() => {
this.showPublishModal = false;
})
}
上架弹窗是应用中交互最复杂的表单弹窗。弹窗结构分为三个垂直区块:渐变色头部(使用 .linearGradient({ angle: 135, colors: [['#E85A8C', 0], ['#C73D6E', 1]] }) 实现 135 度角的樱粉渐变背景)、可滚动的表单区域和底部操作按钮栏。
表单区域包含三个字段:作品名称使用 TextInput 组件,通过 onChange 回调将输入值同步到 this.publishTitle;作品类型使用 Flex + ForEach 渲染六个可选标签,选中态通过 this.publishGenre 索引控制背景色和文字色;连载状态使用两个 Column 卡片实现"连载中/已完结"的二选一,选中态通过边框颜色和背景色区分。表单区域使用 Scroll 包裹并设置 .constraintSize({ maxHeight: '55%' }),确保在小屏设备上表单内容过多时可以滚动查看。
底部操作栏使用 Row + layoutWeight(1) 实现左右等分按钮。"取消"按钮为灰色文字,"立即上架"按钮使用 90 度角的渐变背景(从樱粉到深樱粉)。弹窗根容器的 backgroundColor('99000000') 创建半透明遮罩,onClick 点击遮罩区域关闭弹窗。.clip(true) 确保弹窗内部内容不会溢出圆角边界。
书架管理弹窗 shelfManageModal() 则采用了不同的视觉风格——以墨绿色为主题的表单弹窗。包含评分星级(五颗星,通过 shelfRating 控制金色/灰色)和书架分组(四个选项,通过 shelfGroup 控制选中态)。底部有"恢复默认"和"保存设置"两个按钮,恢复默认按钮重置 shelfRating = 5 和 shelfGroup = 0。弹窗使用墨绿色边框 COLORS.inkGreenLight,与上架弹窗的樱粉风格形成对比。
二十五、下架确认与漫画详情弹窗:removeComicModal() 与 comicDetailModal()
@Builder
removeComicModal() {
Column() {
Column() {
Text('🗑')
.fontSize(36)
Text('确定从书架移除吗?')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 8 })
Text('移除后阅读进度将清除,无法恢复')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 6 })
Row() {
Text('再想想')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.borderRadius(20)
.border({ width: 1, color: COLORS.border })
Text('确认移除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.borderRadius(20)
.backgroundColor(COLORS.danger)
.margin({ left: 10 })
.onClick(() => {
this.showRemoveModal = false;
})
}
.width('100%')
.margin({ top: 18 })
}
.width('76%')
.padding(20)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('99000000')
.onClick(() => {
this.showRemoveModal = false;
})
}
下架确认弹窗是一个居中小型警告卡片,宽度仅 76%,是四个弹窗中最紧凑的。弹窗内容简洁明了:大号垃圾桶 Emoji(36vp)、确认问题标题、风险提示文字和两个操作按钮。"再想想"按钮使用描边样式(灰色边框),"确认移除"按钮使用红色实底背景(COLORS.danger),两种按钮的视觉权重差异引导用户谨慎操作。这种"轻量级确认弹窗"是删除类操作的标准交互模式,既起到了二次确认的防护作用,又不会过度打断用户操作流。
漫画详情弹窗 comicDetailModal() 则是信息量最大的弹窗,宽度 90%,最大高度 60% 可滚动。弹窗分为头部标题栏(浅粉背景 #FFF0F5,包含标题和关闭按钮)和可滚动内容区。内容区依次展示:作品信息卡片(封面、标题、作者/章节/状态、题材色块/评分/阅读量)、简介文本块、章节列表(通过 ForEach 遍历 CHAPTERS,每行包含免费/锁定图标、章节标题、日期和阅读/解锁按钮)和评论区(通过 ForEach 遍历 COMMENTS,每行包含头像、用户名、评论内容和点赞数)。
详情弹窗中大量使用了 this.selectedComic!.cover 这样的非空断言操作符 !。因为 selectedComic 的类型是 ComicItem | null,在 TypeScript 严格模式下直接访问属性会报错。通过 ! 断言告诉编译器"此处不为 null"——这是安全的,因为弹窗的渲染条件是 if (this.showDetailModal),而 showDetailModal 只有在 selectedComic 被赋值后才会被设为 true。底部还有"加入书架"和"开始阅读"两个操作按钮,点击"开始阅读"后关闭详情弹窗。
架构流程图
1. 整体页面架构图
2. 数据流向图
3. 组件生命周期与定时器流程图
对比表格
表格一:四种弹窗的技术特征对比
| 弹窗类型 | 触发入口 | 宽度占比 | 视觉主题色 | 核心交互 | 表单字段数 | 底部按钮 |
|---|---|---|---|---|---|---|
| 上架弹窗(publishComicModal) | 头部"上架"按钮 | 90% | 樱粉渐变 | 新作品创建表单 | 3个(名称/类型/状态) | 取消 + 立即上架 |
| 书架管理弹窗(shelfManageModal) | 书架"管理"/个人"编辑" | 86% | 墨绿色描边 | 评分与分组编辑 | 2个(评分/分组) | 恢复默认 + 保存设置 |
| 下架确认弹窗(removeComicModal) | 书架"移除"提示 | 76% | 红色警示 | 删除二次确认 | 0个 | 再想想 + 确认移除 |
| 漫画详情弹窗(comicDetailModal) | 任意漫画点击 | 90% | 樱粉头部 | 内容浏览与阅读 | 0个 | 加入书架 + 开始阅读 |
表格二:六大分类内容页的布局策略对比
| 分类页 | 标题 | 核心布局组件 | 数据可视化 | 列表项键值前缀 | 点击行为 |
|---|---|---|---|---|---|
| 推荐页(recommendContent) | 编辑精选推荐 | 大卡片 + 横滑Scroll + Flex瀑布流 | 无 | c.id.toString() | 弹出详情 |
| 热血页(hotBloodRank) | 热血漫画战力榜 | Column柱状图 + Row排行列表 | 一周阅读时长柱状图 | c.id.toString() | 弹出详情 |
| 恋爱页(romanceContent) | 恋爱漫画心动推荐 | 横滑Scroll卡片 + Row列表 | 无 | c.id.toString() | 弹出详情 |
| 搞笑页(comedyGrid) | 搞笑漫画笑到肚子疼 | Grid三列网格 + Row列表 | 无 | c.id.toString() | 弹出详情 |
| 悬疑页(mysteryTimeline) | 悬疑漫画推理时间轴 | Row时间轴布局 | 无 | mystery_ + id | 弹出详情 |
| 奇幻页(fantasyContent) | 奇幻漫画世界观标签 | Flex标签云 + Row列表 | 无 | fantasy_ + id | 弹出详情 |
表格三:状态变量与渲染驱动关系对比
| @State 变量 | 数据类型 | 初始值 | 驱动的渲染区域 | 更新频率 | 驱动方式 |
|---|---|---|---|---|---|
| currentBottomTab | number | 0 | 顶部栏切换 + 内容区切换 + 底部选中态 | 用户点击时 | 直接赋值 |
| currentTopTab | number | 0 | comicContent内六种内容切换 + 顶部标签选中态 | 用户点击时 | 直接赋值 |
| particles | ParticleItem[] | buildParticles() | ForEach粒子层Text组件 | 每130ms一次 | 定时器驱动 |
| selectedComic | ComicItem | null | null | comicDetailModal弹窗内容 | 用户点击漫画时 | 直接赋值 |
| showPublishModal | boolean | false | publishComicModal弹窗显示/隐藏 | 按钮点击时 | 直接赋值 |
| showShelfModal | boolean | false | shelfManageModal弹窗显示/隐藏 | 按钮点击时 | 直接赋值 |
| showRemoveModal | boolean | false | removeComicModal弹窗显示/隐藏 | 按钮点击时 | 直接赋值 |
| showDetailModal | boolean | false | comicDetailModal弹窗显示/隐藏 | 按钮点击时 | 直接赋值 |
| publishTitle | string | ‘’ | TextInput输入框内容 | 用户输入时 | onChange回调 |
| publishGenre | number | 0 | 上架弹窗类型标签选中态 | 用户点击时 | 直接赋值 |
| publishStatus | number | 0 | 上架弹窗状态卡片选中态 | 用户点击时 | 直接赋值 |
| shelfRating | number | 5 | 书架管理弹窗星级选中态 | 用户点击时 | 直接赋值 |
| shelfGroup | number | 0 | 书架管理弹窗分组选中态 | 用户点击时 | 直接赋值 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 风格:樱粉 + 墨绿 · 漫画风
// 底部4tab:漫画 / 书架 / 发现 / 我的
// 顶部6tab:推荐 / 热血 / 恋爱 / 搞笑 / 悬疑 / 奇幻
// 弹框:上架(新增) / 书架管理(编辑) / 下架(删除) / 漫画详情
// 特效:樱花瓣粒子 + 周阅读时长柱状图
// ============================================================
interface ColorPalette {
cherry: string;
cherryLight: string;
cherryDeep: string;
inkGreen: string;
inkGreenLight: string;
bg: string;
cardBg: string;
cardBgDark: string;
textPrimary: string;
textSecondary: string;
textHint: string;
white: string;
gold: string;
border: string;
danger: string;
success: string;
}
const COLORS: ColorPalette = {
cherry: '#E85A8C',
cherryLight: '#FF8FBA',
cherryDeep: '#C73D6E',
inkGreen: '#2D7D5A',
inkGreenLight: '#4DA67E',
bg: '#FFF0F5',
cardBg: '#FFFFFF',
cardBgDark: '#F0E8E8',
textPrimary: '#3D2A32',
textSecondary: '#7A626E',
textHint: '#B8A8B0',
white: '#FFFFFF',
gold: '#F5A623',
border: '#F0D5DE',
danger: '#E84A4A',
success: '#4DA67E'
};
interface TabItem {
label: string;
icon: string;
}
const BOTTOM_TABS: TabItem[] = [
{ label: '漫画', icon: '📚' },
{ label: '书架', icon: '🔖' },
{ label: '发现', icon: '🔍' },
{ label: '我的', icon: '👤' }
];
const TOP_TABS: TabItem[] = [
{ label: '推荐', icon: '🔥' },
{ label: '热血', icon: '⚔️' },
{ label: '恋爱', icon: '💕' },
{ label: '搞笑', icon: '😂' },
{ label: '悬疑', icon: '🔍' },
{ label: '奇幻', icon: '🐉' }
];
const READING_DAYS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const READING_MINUTES: number[] = [45, 60, 30, 80, 120, 180, 90];
@Observed
class ComicItem {
id: number;
title: string;
author: string;
cover: string;
genre: string;
status: string;
chapters: number;
rating: number;
views: string;
intro: string;
tags: string;
hotValue: number;
constructor(id: number, title: string, author: string, cover: string,
genre: string, status: string, chapters: number, rating: number,
views: string, intro: string, tags: string, hotValue: number) {
this.id = id;
this.title = title;
this.author = author;
this.cover = cover;
this.genre = genre;
this.status = status;
this.chapters = chapters;
this.rating = rating;
this.views = views;
this.intro = intro;
this.tags = tags;
this.hotValue = hotValue;
}
}
function buildComics(): ComicItem[] {
return [
new ComicItem(1, '剑破苍穹录', '墨白', '⚔️', '热血', '连载中', 1280, 9.2, '3.8亿', '少年执剑行走江湖,一剑破开万古苍穹', '热血|冒险|修仙', 9870),
new ComicItem(2, '樱花树下等你', '小鹿', '🌸', '恋爱', '连载中', 86, 9.5, '2.1亿', '每年樱花季,我都会在老地方等你回来', '校园|治愈|暗恋', 8520),
new ComicItem(3, '厨神打工记', '肥猫', '🍳', '搞笑', '完结', 312, 8.8, '1.5亿', '米其林厨师穿越到路边摊,手忙脚乱笑料百出', '美食|穿越|搞笑', 7240),
new ComicItem(4, '深夜档案室', '黑墨', '🔍', '悬疑', '连载中', 96, 9.0, '9800万', '每份档案背后,都藏着一个不为人知的秘密', '推理|悬疑|都市', 6880),
new ComicItem(5, '龙族纪元', '苍龙', '🐉', '奇幻', '连载中', 520, 9.3, '4.2亿', '巨龙苏醒,世界格局重塑,少年踏上屠龙之路', '奇幻|冒险|热血', 9920),
new ComicItem(6, '球场少年的夏天', '阿泽', '⚽', '热血', '连载中', 210, 8.6, '6800万', '从替补到王牌,一群少年的篮球追梦路', '运动|青春|热血', 5340),
new ComicItem(7, '星辰与你皆可爱', '糖糖', '⭐', '恋爱', '完结', 156, 9.1, '7800万', '天文社的暗恋物语,每颗星都替我说爱你', '校园|恋爱|治愈', 6210),
new ComicItem(8, '社畜爆笑日常', '茶茶', '😂', '搞笑', '连载中', 89, 8.9, '1.2亿', '打工人的血泪化为笑料,笑着笑着就哭了', '职场|搞笑|日常', 5890),
new ComicItem(9, '密室逃脱研究所', '谜题', '🗝️', '悬疑', '连载中', 68, 8.7, '5600万', '密室设计师的反套路推理,每一关都出乎意料', '推理|悬疑|密室', 4980),
new ComicItem(10, '万界修仙传', '青云', '⛰️', '奇幻', '连载中', 890, 9.4, '5.1亿', '一人一剑闯万界,修仙路漫漫其修远兮', '奇幻|修仙|热血', 9780),
new ComicItem(11, '咖啡店的心跳', '小满', '☕', '恋爱', '连载中', 72, 9.0, '4300万', '咖啡师与常客的暧昧日常,甜到冒泡', '都市|恋爱|日常', 4520),
new ComicItem(12, '武林外传新编', '老白', '🥋', '搞笑', '连载中', 145, 8.5, '9200万', '同福客栈重出江湖,江湖儿女笑料升级', '武侠|搞笑|群像', 4310),
new ComicItem(13, '都市怪谈录', '夜行人', '🌃', '悬疑', '完结', 288, 9.1, '8900万', '城市角落的都市传说,每一则都令人毛骨悚然', '都市|悬疑|怪谈', 5780),
new ComicItem(14, '山海异闻录', '山海君', '🏔️', '奇幻', '连载中', 330, 8.8, '7600万', '山海经里的神兽们,来到现代都市会怎样', '奇幻|神话|冒险', 5120),
new ComicItem(15, '那年的盛夏蝉鸣', '回音', '🍃', '恋爱', '完结', 120, 9.3, '1.1亿', '高中三年的暗恋,在毕业那天终于说出口', '校园|恋爱|怀旧', 6490)
];
}
const COMICS: ComicItem[] = buildComics();
interface ShelfItem {
id: number;
title: string;
cover: string;
readChapter: number;
totalChapter: number;
lastRead: string;
inShelf: boolean;
}
const SHELF_DATA: ShelfItem[] = [
{ id: 1, title: '剑破苍穹录', cover: '⚔️', readChapter: 856, totalChapter: 1280, lastRead: '2小时前', inShelf: true },
{ id: 2, title: '樱花树下等你', cover: '🌸', readChapter: 86, totalChapter: 86, lastRead: '昨天', inShelf: true },
{ id: 5, title: '龙族纪元', cover: '🐉', readChapter: 490, totalChapter: 520, lastRead: '3天前', inShelf: true },
{ id: 7, title: '星辰与你皆可爱', cover: '⭐', readChapter: 120, totalChapter: 156, lastRead: '1周前', inShelf: true },
{ id: 10, title: '万界修仙传', cover: '⛰️', readChapter: 750, totalChapter: 890, lastRead: '刚刚', inShelf: true },
{ id: 15, title: '那年的盛夏蝉鸣', cover: '🍃', readChapter: 120, totalChapter: 120, lastRead: '已读完', inShelf: true }
];
interface ChapterInfo {
chNo: number;
title: string;
date: string;
isFree: boolean;
isRead: boolean;
}
const CHAPTERS: ChapterInfo[] = [
{ chNo: 1280, title: '第1280话 · 一剑封神', date: '今日更新', isFree: false, isRead: false },
{ chNo: 1279, title: '第1279话 · 破境', date: '昨天', isFree: false, isRead: false },
{ chNo: 1278, title: '第1278话 · 天劫降临', date: '2天前', isFree: true, isRead: true },
{ chNo: 1277, title: '第1277话 · 宗门危机', date: '3天前', isFree: true, isRead: true },
{ chNo: 1276, title: '第1276话 · 故人重逢', date: '4天前', isFree: true, isRead: true },
{ chNo: 1275, title: '第1275话 · 暗流涌动', date: '5天前', isFree: true, isRead: true },
{ chNo: 1274, title: '第1274话 · 试炼之地', date: '6天前', isFree: true, isRead: true },
{ chNo: 1273, title: '第1273话 · 密境探险', date: '1周前', isFree: true, isRead: true }
];
interface CommentItem {
id: number;
user: string;
avatar: string;
content: string;
likes: number;
time: string;
}
const COMMENTS: CommentItem[] = [
{ id: 1, user: '漫画精', avatar: '🎨', content: '这画风绝了!每帧都能当壁纸', likes: 1280, time: '2小时前' },
{ id: 2, user: '追更大军', avatar: '🏃', content: '求更新!等不及了啊啊啊', likes: 860, time: '5小时前' },
{ id: 3, user: '墨白的小迷弟', avatar: '⚔️', content: '主角终于开挂了,爽!', likes: 540, time: '昨天' },
{ id: 4, user: '条漫爱好者', avatar: '📚', content: '竖屏阅读体验太好了,通勤必备', likes: 420, time: '昨天' },
{ id: 5, user: '催更仙人', avatar: '🗡️', content: '周更变月更?作者你解释一下', likes: 890, time: '2天前' }
];
interface ParticleItem {
id: number;
x: number;
y: number;
size: number;
opacity: number;
icon: string;
}
const PARTICLE_ICONS: string[] = ['🌸', '❀', '✿', '🌺', '🌷'];
function buildParticles(): ParticleItem[] {
const arr: ParticleItem[] = [];
for (let i = 0; i < 16; i++) {
arr.push({
id: i,
x: (i * 41) % 340 + 10,
y: 80 + (i * 67) % 560,
size: 10 + (i * 5) % 14,
opacity: 0.2 + (i % 4) * 0.1,
icon: PARTICLE_ICONS[i % PARTICLE_ICONS.length]
});
}
return arr;
}
function driftParticles(list: ParticleItem[]): ParticleItem[] {
const next: ParticleItem[] = [];
for (let i = 0; i < list.length; i++) {
const p = list[i];
const ny = p.y - 4;
next.push({
id: p.id,
x: p.x + Math.sin(p.id + p.y / 50) * 3,
y: ny < 60 ? 640 : ny,
size: p.size,
opacity: p.opacity,
icon: p.icon
});
}
return next;
}
function hotColor(hot: number): string {
if (hot > 8000) {
return COLORS.cherryDeep;
}
if (hot > 5000) {
return COLORS.cherry;
}
return COLORS.inkGreen;
}
function barHeight(v: number): string {
return (v * 0.8).toFixed(0) + 'vp';
}
function genreColor(genre: string): string {
if (genre === '热血') {
return '#E85A3C';
}
if (genre === '恋爱') {
return COLORS.cherry;
}
if (genre === '搞笑') {
return '#F5A623';
}
if (genre === '悬疑') {
return '#5A3D7A';
}
if (genre === '奇幻') {
return COLORS.inkGreen;
}
return COLORS.cherryLight;
}
function formatHot(hot: number): string {
return (hot / 1000).toFixed(1) + 'k';
}
function shelfProgress(read: number, total: number): number {
if (total === 0) {
return 0;
}
return Math.round(read / total * 100);
}
@Entry
struct Index {
@State currentBottomTab: number = 0;
@State currentTopTab: number = 0;
@State showPublishModal: boolean = false;
@State showShelfModal: boolean = false;
@State showRemoveModal: boolean = false;
@State showDetailModal: boolean = false;
@State selectedComic: ComicItem | null = null;
@State particles: ParticleItem[] = buildParticles();
@State publishTitle: string = '';
@State publishGenre: number = 0;
@State publishStatus: number = 0;
@State shelfRating: number = 5;
@State shelfGroup: number = 0;
private timerId: number = -1;
aboutToAppear() {
this.timerId = setInterval(() => {
this.particles = driftParticles(this.particles);
}, 130);
}
aboutToDisappear() {
if (this.timerId >= 0) {
clearInterval(this.timerId);
}
}
build() {
Stack() {
Column() {
this.headerBuilder()
if (this.currentBottomTab === 0) {
this.comicTopTabs()
} else if (this.currentBottomTab === 1) {
this.shelfTopBar()
} else if (this.currentBottomTab === 2) {
this.discoverTopBar()
} else {
this.mineTopBar()
}
Scroll() {
Column() {
if (this.currentBottomTab === 0) {
this.comicContent()
} else if (this.currentBottomTab === 1) {
this.shelfContent()
} else if (this.currentBottomTab === 2) {
this.discoverContent()
} else {
this.mineContent()
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
this.bottomTabs()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
ForEach(this.particles, (p: ParticleItem) => {
Text(p.icon)
.fontSize(p.size)
.opacity(p.opacity)
.position({ x: p.x, y: p.y })
}, (p: ParticleItem) => p.id.toString() + '_' + p.y.toFixed(0))
if (this.showPublishModal) {
this.publishComicModal()
}
if (this.showShelfModal) {
this.shelfManageModal()
}
if (this.showRemoveModal) {
this.removeComicModal()
}
if (this.showDetailModal) {
this.comicDetailModal()
}
}
.width('100%')
.height('100%')
}
@Builder
headerBuilder() {
Column() {
Row() {
Column() {
Text('📖 QQ漫画')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Text('今日更新 248 部作品 · 2.6万人在读')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Column() {
Text('✏️')
.fontSize(20)
.fontColor(COLORS.white)
Text('上架')
.fontSize(10)
.fontColor(COLORS.white)
}
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(COLORS.cherry)
.onClick(() => {
this.showPublishModal = true;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 10, bottom: 8 })
Row() {
Column() {
Text('📚 在读作品')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('1,280')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherry)
}
.alignItems(HorizontalAlign.Start)
Divider()
.vertical(true)
.height(26)
.color(COLORS.border)
.margin({ left: 14, right: 14 })
Column() {
Text('⏱ 本周阅读')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('10.2h')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.inkGreen)
}
.alignItems(HorizontalAlign.Start)
Divider()
.vertical(true)
.height(26)
.color(COLORS.border)
.margin({ left: 14, right: 14 })
Column() {
Text('🔖 我的追更')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('23部')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
Column() {
Text('🏆 创作等级')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('Lv.8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 10 })
}
.width('100%')
.backgroundColor(COLORS.white)
}
@Builder
comicTopTabs() {
Scroll() {
Row() {
ForEach(TOP_TABS, (t: TabItem, idx: number) => {
Column() {
Text(t.icon)
.fontSize(16)
Text(t.label)
.fontSize(11)
.fontColor(this.currentTopTab === idx ? COLORS.cherryDeep : COLORS.textSecondary)
.margin({ top: 2 })
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor(this.currentTopTab === idx ? '#FFE0EC' : 'transparent')
.margin({ right: 4 })
.onClick(() => {
this.currentTopTab = idx;
})
}, (t: TabItem) => t.label)
}
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.backgroundColor(COLORS.white)
}
@Builder
shelfTopBar() {
Row() {
Text('我的书架')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Text('管理')
.fontSize(12)
.fontColor(COLORS.cherry)
.margin({ left: 10 })
.onClick(() => {
this.showShelfModal = true;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.white)
}
@Builder
discoverTopBar() {
Row() {
Text('🔍 发现好漫画')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Text('🎲 随机一本')
.fontSize(12)
.fontColor(COLORS.inkGreen)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.border({ width: 1, color: COLORS.inkGreenLight })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.white)
}
@Builder
mineTopBar() {
Row() {
Text('个人中心')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.white)
}
@Builder
comicContent() {
Column() {
if (this.currentTopTab === 0) {
this.recommendContent()
} else if (this.currentTopTab === 1) {
this.hotBloodRank()
} else if (this.currentTopTab === 2) {
this.romanceContent()
} else if (this.currentTopTab === 3) {
this.comedyGrid()
} else if (this.currentTopTab === 4) {
this.mysteryTimeline()
} else {
this.fantasyContent()
}
}
.width('100%')
.padding(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
recommendContent() {
Column() {
Column() {
Row() {
Text(COMICS[0].cover)
.fontSize(48)
.margin({ right: 12 })
Column() {
Text('🏆 今日精选')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text(COMICS[0].title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 4 })
Text(COMICS[0].author + ' · ' + COMICS[0].genre + ' · ' + COMICS[0].status)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text(COMICS[0].intro)
.fontSize(11)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row() {
Text('📖 立即阅读')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(20)
.backgroundColor(COLORS.cherry)
.onClick(() => {
this.selectedComic = COMICS[0];
this.showDetailModal = true;
})
Text('🔖 加入书架')
.fontSize(13)
.fontColor(COLORS.inkGreen)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.borderRadius(20)
.border({ width: 1, color: COLORS.inkGreenLight })
.margin({ left: 10 })
.onClick(() => {
this.showShelfModal = true;
})
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Start)
Text('✨ 编辑精选推荐')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
Scroll() {
Row() {
ForEach(COMICS, (c: ComicItem) => {
Column() {
Text(c.cover)
.fontSize(32)
Text(c.title)
.fontSize(11)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.margin({ top: 6 })
Text('★' + c.rating)
.fontSize(10)
.fontColor(COLORS.gold)
}
.width(100)
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.margin({ right: 10 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
Text('🔥 热门连载')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(COMICS, (c: ComicItem) => {
Column() {
Text(c.cover)
.fontSize(28)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.borderRadius({ topLeft: 12, topRight: 12 })
.backgroundColor('#FFF0F5')
Text(c.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.padding({ left: 6, right: 6, top: 6 })
Row() {
Text(c.genre)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor(genreColor(c.genre))
Text('★' + c.rating)
.fontSize(9)
.fontColor(COLORS.gold)
.margin({ left: 6 })
}
.width('100%')
.padding({ left: 6, right: 6, bottom: 8 })
Text('👁 ' + c.views)
.fontSize(9)
.fontColor(COLORS.textHint)
.padding({ left: 6, right: 6, bottom: 8 })
}
.width('48.5%')
.borderRadius(12)
.backgroundColor(COLORS.white)
.margin({ bottom: 10 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
hotBloodRank() {
Column() {
Column() {
Text('📈 一周阅读时长')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Row() {
ForEach(READING_DAYS, (d: string, idx: number) => {
Column() {
Column() {
Text('')
.width('100%')
.height(1)
}
.width(18)
.height(barHeight(READING_MINUTES[idx]))
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(hotColor(READING_MINUTES[idx] * 10))
.justifyContent(FlexAlign.End)
Text(d)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.margin({ left: 8, right: 8 })
.justifyContent(FlexAlign.End)
}, (d: string) => d)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Bottom)
.padding({ top: 16, bottom: 6 })
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
Text('⚔️ 热血漫画 · 战力榜')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
ForEach(COMICS, (c: ComicItem, idx: number) => {
Row() {
Text(idx < 3 ? (idx === 0 ? '🥇' : (idx === 1 ? '🥈' : '🥉')) : (idx + 1).toString())
.fontSize(idx < 3 ? 20 : 14)
.fontColor(idx < 3 ? COLORS.gold : COLORS.textHint)
.width(32)
.textAlign(TextAlign.Center)
Text(c.cover)
.fontSize(24)
.margin({ right: 10 })
Column() {
Text(c.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(c.author + ' · ' + c.chapters + '话 · ★' + c.rating)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(formatHot(c.hotValue))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(hotColor(c.hotValue))
Text('人气')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(idx < 3 ? '#FFF5F8' : COLORS.white)
.border({
width: idx < 3 ? 1 : 0,
color: COLORS.cherryLight
})
.margin({ bottom: 8 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
romanceContent() {
Column() {
Text('💕 恋爱漫画 · 心动推荐')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
Scroll() {
Row() {
ForEach(COMICS, (c: ComicItem) => {
Column() {
Text(c.cover)
.fontSize(36)
.width(120)
.height(90)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor('#FFE0EC')
Text(c.title)
.fontSize(12)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.margin({ top: 6 })
Text('★' + c.rating + ' · ' + c.status)
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.width(120)
.borderRadius(14)
.backgroundColor(COLORS.white)
.padding({ bottom: 8 })
.margin({ right: 10 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
Text('💌 条漫速览(竖屏阅读)')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
ForEach(COMICS, (c: ComicItem) => {
Row() {
Text(c.cover)
.fontSize(28)
.width(50)
.height(70)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#FFF0F5')
Column() {
Text(c.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(c.intro)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
Text(c.tags)
.fontSize(9)
.fontColor(COLORS.cherryDeep)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor('#FFE0EC')
Text(c.chapters + '话')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('▶')
.fontSize(14)
.fontColor(COLORS.cherry)
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.margin({ bottom: 8 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
comedyGrid() {
Column() {
Text('😂 搞笑漫画 · 笑到肚子疼')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
Grid() {
ForEach(COMICS, (c: ComicItem) => {
GridItem() {
Column() {
Text(c.cover)
.fontSize(30)
Text(c.title)
.fontSize(11)
.fontColor(COLORS.textPrimary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
.textAlign(TextAlign.Center)
Text('★' + c.rating)
.fontSize(10)
.fontColor(COLORS.gold)
.margin({ top: 4 })
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}
}, (c: ComicItem) => c.id.toString())
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.height(560)
Text('😆 今日笑点合集')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
ForEach(COMICS, (c: ComicItem, idx: number) => {
Row() {
Text((idx + 1).toString())
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.cherry : COLORS.textHint)
.width(24)
Text(c.cover)
.fontSize(20)
.margin({ right: 8 })
Text(c.title + ' · ' + c.views + '阅读')
.fontSize(11)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('😂' + (c.hotValue / 100).toFixed(0))
.fontSize(10)
.fontColor(COLORS.gold)
}
.width('100%')
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.white)
.margin({ bottom: 6 })
}, (c: ComicItem) => 'comedy_' + c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
mysteryTimeline() {
Column() {
Text('🔍 悬疑漫画 · 推理时间轴')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
ForEach(COMICS, (c: ComicItem, idx: number) => {
Row() {
Column() {
Text(idx === 0 ? '🔴' : '⚫')
.fontSize(14)
if (idx < COMICS.length - 1) {
Column() {
Text('')
.width(2)
.height(80)
}
.width(2)
.height(80)
.backgroundColor(COLORS.border)
}
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(c.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(c.intro)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
Text(c.genre)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor(genreColor(c.genre))
Text(c.chapters + '话 · ' + c.status)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
}
.width('100%')
.margin({ bottom: 4 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => 'mystery_' + c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
fantasyContent() {
Column() {
Text('🐉 奇幻漫画 · 世界观标签')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['修仙', '魔法', '龙族', '精灵', '炼金', '神兽', '秘境', '时空', '圣战', '诅咒', '神器', '冒险'], (tag: string) => {
Text('#' + tag)
.fontSize(12)
.fontColor(COLORS.inkGreen)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#E8F5EE')
.border({ width: 1, color: COLORS.inkGreenLight })
.margin({ right: 8, top: 6 })
}, (tag: string) => tag)
}
.width('100%')
Text('📚 奇幻长篇连载')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
ForEach(COMICS, (c: ComicItem) => {
Row() {
Text(c.cover)
.fontSize(28)
.width(50)
.height(70)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#E8F5EE')
Column() {
Text(c.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(c.author + ' · ' + c.chapters + '话')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
Row() {
ForEach(c.tags.split('|'), (tag: string) => {
Text(tag)
.fontSize(9)
.fontColor(COLORS.inkGreen)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.borderRadius(5)
.backgroundColor('#E8F5EE')
.margin({ right: 4 })
}, (tag: string) => tag)
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text('★' + c.rating)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('追更')
.fontSize(10)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor(COLORS.inkGreen)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.margin({ bottom: 8 })
.onClick(() => {
this.selectedComic = c;
this.showDetailModal = true;
})
}, (c: ComicItem) => 'fantasy_' + c.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
shelfContent() {
Column() {
Text('📖 我的书架 · ' + SHELF_DATA.length.toString() + ' 部')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ bottom: 10 })
ForEach(SHELF_DATA, (s: ShelfItem) => {
Row() {
Text(s.cover)
.fontSize(26)
.width(46)
.height(60)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#FFF0F5')
Column() {
Text(s.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('已读 ' + s.readChapter + '/' + s.totalChapter + '话 · ' + s.lastRead)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Column() {
Text('')
.width('100%')
.height(1)
}
.width('100%')
.height(4)
.borderRadius(2)
.backgroundColor('#FFE0EC')
.margin({ top: 6 })
Column() {
Text('')
.width('100%')
.height(1)
}
.width(shelfProgress(s.readChapter, s.totalChapter).toString() + '%')
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.cherry)
Text(shelfProgress(s.readChapter, s.totalChapter).toString() + '% · 继续阅读')
.fontSize(9)
.fontColor(COLORS.cherry)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor(COLORS.white)
.margin({ bottom: 8 })
.onClick(() => {
this.showDetailModal = true;
})
}, (s: ShelfItem) => s.id.toString())
Text('🗑 从书架移除将不再保留阅读进度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 6 })
.onClick(() => {
this.showRemoveModal = true;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
discoverContent() {
Column() {
Column() {
Text('🎲 随机推荐')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
Text('不知道看什么?试试随机翻一本')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
Text('🔄 换一本')
.fontSize(13)
.fontColor(COLORS.white)
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(18)
.backgroundColor(COLORS.cherry)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.white)
.alignItems(HorizontalAlign.Start)
Text('🏷 按标签发现')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['校园', '都市', '古风', '末日', '机甲', '运动', '音乐', '美食', '旅行', '职场', '治愈', '暗黑'], (tag: string) => {
Text('#' + tag)
.fontSize(12)
.fontColor(COLORS.cherryDeep)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#FFE0EC')
.margin({ right: 8, top: 6 })
.onClick(() => {
this.showDetailModal = true;
})
}, (tag: string) => tag)
}
.width('100%')
Text('📊 类型分布')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cherryDeep)
.margin({ top: 14, bottom: 8 })
ForEach(['热血', '恋爱', '搞笑', '悬疑', '奇幻'], (g: string, idx: number) => {
Row() {
Text(g)
.fontSize(12)
.fontColor(COLORS.textPrimary)
.width(40)
Column() {
Text('')
.width('100%')
.height(1)
}
.width('60%')
.height(14)
.borderRadius(7)
.backgroundColor('#FFE0EC')
Column() {
Text('')
.width('100%')
.height(1)
}
.width((100 - idx * 15).toString() + '%')
.height(14)
.borderRadius(7)
.backgroundColor(genreColor(g))
.margin({ left: 4 })
Text((100 - idx * 15).toString() + '部')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
}
.padding(8)
.borderRadius(8)
.backgroundColor('#FFF0F5')
.margin({ bottom: 4 })
}, (cm: CommentItem) => cm.id.toString())
Row() {
Text('🔖 加入书架')
.fontSize(13)
.fontColor(COLORS.inkGreen)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.borderRadius(18)
.border({ width: 1, color: COLORS.inkGreenLight })
Text('📖 开始阅读')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.borderRadius(18)
.backgroundColor(COLORS.cherry)
.margin({ left: 10 })
.onClick(() => {
this.showDetailModal = false;
})
}
.margin({ top: 14, bottom: 10 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 16, right: 16 })
}
.constraintSize({ maxHeight: '60%' })
.scrollBar(BarState.Off)
}
.width('90%')
.borderRadius(16)
.backgroundColor(COLORS.white)
.clip(true)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('99000000')
.onClick(() => {
this.showDetailModal = false;
})
}
}

总结
本文基于 HarmonyOS 6.1.1 平台和 HarmonyOS ArkTS API 24 的组件体系,完整剖析了一个漫画阅读社区应用演示页面的实现方案。从工程维度看,这个页面涵盖了内容社区类应用的几乎所有核心交互模式:多级Tab导航(底部四Tab + 顶部六Tab)、六种差异化内容布局(大卡片推荐、柱状图排行、横滑卡片、三列网格、时间轴、标签云)、四类弹窗交互(表单创建、属性编辑、删除确认、内容详情)、粒子动画特效(樱花瓣飘浮)以及数据可视化(阅读时长柱状图、类型分布条形图、阅读进度条)。
从技术架构维度看,该实现充分体现了 ArkTS 声明式 UI 范式的核心优势:通过 @State 变量驱动视图更新,通过 @Builder 方法实现 UI 片段的复用与组合,通过 @Observed 类实现可观察数据模型,通过 ForEach 的键值Diff机制实现高效列表渲染。13个 @State 变量构成了完整的响应式状态空间,覆盖了导航、弹窗、表单、选中数据和粒子动画五个维度。生命周期回调 aboutToAppear/aboutToDisappear 的正确使用确保了定时器资源的注册与释放,避免了内存泄漏。
从设计模式维度看,代码采用了"配置驱动渲染"(TabItem数据数组 + ForEach)、“工厂方法”(buildComics函数)、“路由器-构建器分层”(comicContent路由 + 六个独立Builder)、“不可变更新”(driftParticles返回新数组而非原地修改)等多种工程实践。色彩体系通过 ColorPalette 接口实现了类型安全的统一管理,工具函数将数据到视觉的映射逻辑集中封装,便于维护和扩展。整体代码结构清晰、职责分明,展示了基于HarmonyOS API 24构建内容社区类应用的良好工程范本。
更多推荐



所有评论(0)