技术引言

HarmonyOS 6.1.1 是华为鸿蒙操作系统的重要迭代版本,其内建的 HarmonyOS ArkTS API 24 为开发者提供了更加成熟、高效的声明式 UI 开发能力。ArkTS 在 TypeScript 的基础上进行了深度定制与扩展,引入了 @Component@State@Builder@Observed 等装饰器语法,使得状态驱动式界面开发变得直观且高性能。基于HarmonyOS API 24,开发者可以构建出兼具流畅动效与复杂业务逻辑的跨设备应用。本文以一个二次元追番社区App——"QQ追番·番剧社"为实战案例,完整剖析其从配色体系、数据模型、纯函数封装,到粒子动画、多场景内容区、四类弹框交互的全链路实现,涵盖番剧追番、更新时间表、圈子动态、个人中心四大核心场景,是学习ArkTS复杂页面架构的优质范本。


一、整体架构概览

在深入逐段代码之前,我们先通过架构图理解整个App的模块组成与层次关系。

弹框层

内容区

视图层

数据层

入口层

@Entry 入口组件

数据模型层
AnimeItem @Observed

配置层
COLORS / TYPE_CONFIG / STATUS_CONFIG

静态数据层
ANIME_LIST / CIRCLE_POSTS / WEEKDAY_SCHEDULES

纯函数层
getTypeMeta / filterByType / progressPercent

headerBar 顶部导航

topTabBar 分类Tab

bottomTabBar 底部Tab

particleLayer 粒子层

followContent 追番墙

scheduleContent 时间表

circleContent 圈子动态

mineContent 我的书架

addAnimeModal 添加追番

editProgressModal 编辑进度

deleteAnimeModal 删除确认

animeDetailModal 番剧详情

modalOverlay 遮罩

从架构图可以看出,整个App采用了清晰的分层设计:数据层负责模型定义与静态数据供给,纯函数层提供可复用的数据加工逻辑,视图层通过 @Builder 装饰器将界面拆分为独立的内容构建器,弹框层则通过统一的遮罩组件进行管理。这种分层方式使得各模块职责清晰、耦合度低,非常便于后续扩展与维护。


二、配色配置体系

interface ColorPalette {
  violet: string
  violetDark: string
  peach: string
  peachLight: string
  bg: string
  cardBg: string
  ink: string
  gray: string
  hint: string
  border: string
  white: string
  blue: string
  red: string
  starLight: string
}

const COLORS: ColorPalette = {
  violet: '#9B5DE5',
  violetDark: '#6C3FB5',
  peach: '#F15BB5',
  peachLight: '#FDE7F3',
  bg: '#F7F4FC',
  cardBg: '#FFFFFF',
  ink: '#3A2A55',
  gray: '#6F6489',
  hint: '#B0A8C4',
  border: '#EBE4F5',
  white: '#FFFFFF',
  blue: '#4C8BF5',
  red: '#E8506E',
  starLight: '#FBEFFA'
}

配色是任何一个视觉型App的基石。本段代码首先定义了 ColorPalette 接口,将所有颜色以类型安全的方式声明出来,确保每个颜色字段都有明确的类型约束。随后通过 COLORS 常量实例化这个接口,形成一个全局统一的配色中心。

这套配色方案以"樱紫+桃粉"为核心基调,契合二次元社区的氛围。其中 violet(#9B5DE5)作为主品牌色用于按钮、选中态与渐变起点;peach(#F15BB5)作为辅色用于渐变终点与强调元素;bgcardBg 构成浅紫底色与白色卡片的层级对比。ink 作为正文文字色,是一种带有紫调的深色,比纯黑更柔和。

值得一提的是,starLight(#FBEFFA)是一个非常浅的紫粉色,在整个App中被大量用于标签背景、输入框背景和次级卡片,起到了统一"轻量元素"视觉风格的作用。通过接口约束常量,开发者可以在IDE中获得自动补全和类型检查,避免了魔法字符串散落各处的维护隐患。


三、元信息接口定义

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

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

interface NavEntry {
  label: string
  icon: string
  color: string
}

interface ParticleDot {
  x: number
  y: number
  size: number
  color: string
  speed: number
  phase: number
}

interface WeekStat {
  day: string
  episodes: number
}

interface CirclePost {
  author: string
  avatar: string
  content: string
  time: string
  likes: number
  replies: number
  tag: string
}

interface WeekdaySchedule {
  weekday: string
  animes: string[]
}

interface StudioItem {
  name: string
  icon: string
  color: string
}

这一段集中定义了整个App所使用的全部数据结构接口。在 ArkTS 中,interface 是定义数据形状的标准方式,与 TypeScript 的 interface 用法一致,但 ArkTS 对类型的要求更为严格,不允许使用 any 或隐式类型。

TypeMetaStatusMeta 是两个"元信息"接口,它们的作用是为番剧类型和播出状态建立"标签—图标—颜色"的映射关系。当我们在界面上需要显示一个"热血战斗"标签时,不仅需要文字,还需要对应的图标(⚔)和配色,这种"元信息"模式避免了在视图中硬编码这些关联。

ParticleDot 是粒子动画的核心数据结构,每个粒子拥有坐标(x, y)、尺寸(size)、颜色(color)、移动速度(speed)和相位偏移(phase)。相位偏移用于让不同粒子的横向摆动产生差异化,避免所有粒子以完全相同的方式运动。

CirclePost 定义了社区帖子的完整结构,包含作者、头像、内容、时间、点赞数、回复数和标签。WeekdaySchedule 将一周的番剧更新按星期分组。StudioItem 则定义了动画制作公司信息。这些接口的集中定义使得数据流在后续各模块间传递时有明确的类型契约。


四、番剧数据模型 AnimeItem

@Observed
class AnimeItem {
  id: number = 0
  title: string = ''
  studio: string = ''
  type: string = ''
  coverColor: string = ''
  episodes: number = 0
  currentEp: number = 0
  weekday: string = ''
  status: string = ''
  rating: number = 0
  season: string = ''
  tags: string[] = []
  desc: string = ''
  isFollowing: boolean = false

  constructor(id: number, title: string, studio: string, type: string, coverColor: string,
    episodes: number, currentEp: number, weekday: string, status: string, rating: number,
    season: string, tags: string[], desc: string, isFollowing: boolean) {
    this.id = id
    this.title = title
    this.studio = studio
    this.type = type
    this.coverColor = coverColor
    this.episodes = episodes
    this.currentEp = currentEp
    this.weekday = weekday
    this.status = status
    this.rating = rating
    this.season = season
    this.tags = tags
    this.desc = desc
    this.isFollowing = isFollowing
  }
}

AnimeItem 是整个App最核心的数据模型,它被 @Observed 装饰器修饰。在 HarmonyOS ArkTS API 24 中,@Observed 用于标记一个类为"可观察对象",当该类的属性发生变化时,绑定到该属性上的 UI 组件会自动刷新。这与 @State 的区别在于:@State 是组件内部的状态管理,而 @Observed 配合 @ObjectLink 可以实现跨组件的数据响应。

该类拥有15个属性,涵盖了番剧的完整信息维度。id 作为唯一标识,用于列表渲染时的 key 生成;titlestudio 是基础展示信息;typestatus 是两个用于关联元信息的关键字段,它们作为 key 去 TYPE_CONFIGSTATUS_CONFIG 中查找对应的图标和颜色。

episodescurrentEp 组成了进度追踪的核心数据对,用于计算观看进度百分比。coverColor 用于海报卡片的背景色,实现了无需真实图片即可呈现视觉层次的效果。isFollowing 是一个布尔标志,决定了该番剧是否出现在"我的在追书架"中。

构造函数接受全部14个参数并逐一赋值,这种"全参构造"的方式使得数据初始化时非常直观。在后续的数据列表中,我们可以看到通过 new AnimeItem(...) 直接创建实例的写法。


五、类型与状态配置 Record

const TYPE_CONFIG: Record<string, TypeMeta> = {
  '热血战斗': { label: '热血战斗', icon: '⚔', color: '#E8506E', bg: '#FDE7EA' },
  '恋爱日常': { label: '恋爱日常', icon: '💗', color: '#F15BB5', bg: '#FDE7F3' },
  '悬疑烧脑': { label: '悬疑烧脑', icon: '🔎', color: '#6C3FB5', bg: '#ECE5F7' },
  '异世界': { label: '异世界', icon: '🌀', color: '#4C8BF5', bg: '#E5EEFD' },
  '日常治愈': { label: '日常治愈', icon: '🍃', color: '#3BA99C', bg: '#E2F5F2' },
  '国漫崛起': { label: '国漫崛起', icon: '🐉', color: '#C77B3F', bg: '#FAEBDB' }
}

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '连载中': { label: '连载中', color: '#F15BB5', bg: '#FDE7F3', icon: '📡' },
  '已完结': { label: '已完结', color: '#3BA99C', bg: '#E2F5F2', icon: '🏁' },
  '即将开播': { label: '即将开播', color: '#4C8BF5', bg: '#E5EEFD', icon: '🔔' }
}

在这里插入图片描述

这两个 Record 常量是"配置即数据"设计模式的典型应用。Record<string, TypeMeta> 是 ArkTS/TypeScript 中的索引签名类型,表示一个以字符串为键、TypeMeta 为值的映射对象。通过这种方式,我们将"番剧类型"和"播出状态"的展示元信息从视图逻辑中完全剥离出来。

TYPE_CONFIG 定义了6种番剧类型,每种类型都关联了独立的图标和配色方案。例如"热血战斗"使用红色系(#E8506E)搭配⚔图标,传达战斗与热血的视觉暗示;"日常治愈"使用青绿色(#3BA99C)搭配🍃图标,营造清新治愈的氛围。这种配色与语义的绑定使得用户在浏览时能通过颜色快速识别类型。

STATUS_CONFIG 定义了3种播出状态。"连载中"用桃粉色和📡图标表示正在更新;"已完结"用青绿色和🏁图标表示收束完毕;"即将开播"用蓝色和🔔图标提示即将上线。每个状态都有独立的文字色和背景色,在UI上以小标签形式呈现时具有鲜明的视觉区分度。

这种配置式架构的最大优势在于可扩展性:当需要新增一种番剧类型时,只需在此处添加一行配置即可,无需修改任何视图代码。同时,配置集中管理也避免了颜色和图标在代码各处重复定义导致的维护困难。


六、导航与静态数据配置

const BOTTOM_TABS: NavEntry[] = [
  { label: '追番', icon: '📺', color: '#9B5DE5' },
  { label: '时间表', icon: '🗓', color: '#F15BB5' },
  { label: '圈子', icon: '💬', color: '#4C8BF5' },
  { label: '我的', icon: '👤', color: '#6C3FB5' }
]

const TOP_TABS: string[] = ['秋季新番', '热血战斗', '恋爱日常', '悬疑烧脑', '异世界', '国漫崛起']

const WEEK_UPDATE_STATS: WeekStat[] = [
  { day: '一', episodes: 4 },
  { day: '二', episodes: 3 },
  { day: '三', episodes: 5 },
  { day: '四', episodes: 2 },
  { day: '五', episodes: 6 },
  { day: '六', episodes: 8 },
  { day: '日', episodes: 7 }
]
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/05752c15928a44ad995010654ff34df2.png#pic_center)

const WEEKDAY_OPTIONS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']

这段代码定义了App的导航结构和静态展示数据。BOTTOM_TABS 是底部导航栏的配置数组,包含4个主入口:追番、时间表、圈子、我的。每个入口都有独立的图标和激活色,当用户切换Tab时,对应的 color 值会被用于文字着色,形成视觉反馈。

TOP_TABS 是追番页面顶部的分类筛选标签,共6个选项。其中"秋季新番"是一个特殊选项,它不对应 TYPE_CONFIG 中的任何类型,而是代表"全部",在后续的 filterByType 函数中会做特殊处理。

WEEK_UPDATE_STATS 是一周更新集数的柱状图数据源,7个数据点分别对应周一到周日的更新集数。从数据可以看出周六是更新高峰(8集),这与二次元番剧的排播规律一致——日本新番通常集中在周末更新。

WEEKDAY_OPTIONS 是一个简单的星期选项数组,用于添加追番弹框中的更新日选择器。将其定义为独立常量而非内联在视图中,是为了保持视图代码的简洁性,同时方便统一修改。

这些静态数据虽然在演示项目中以常量形式存在,但在真实工程中,它们通常来自网络请求或本地数据库。将数据与视图分离的设计使得未来替换为动态数据源时,只需修改数据获取逻辑,视图层无需改动。


七、社区与时间表静态数据

const CIRCLE_POSTS: CirclePost[] = [
  { author: '声优厨小桃', avatar: '🍑', content: '第七集作画回封神!原画师偷偷加的那段打斗,逐帧截图停不下来!', time: '5分钟前', likes: 1284, replies: 156, tag: '作画' },
  { author: '考据党阿紫', avatar: '🔮', content: '逐帧扒完了OP,镜头里有三处暗示结局的彩蛋,长文分析已发圈~', time: '23分钟前', likes: 892, replies: 203, tag: '考据' },
  { author: '追番十年君', avatar: '📺', content: '见证国漫从追赶到并跑,今年的3D渲染已经强到离谱了,泪目。', time: '1小时前', likes: 2310, replies: 341, tag: '国漫' },
  { author: '泪点低星人', avatar: '💧', content: '第九集看完在宿舍哭出声,室友以为我失恋了……致每一个平凡又闪光的角色。', time: '2小时前', likes: 1567, replies: 98, tag: '观后感' },
  { author: 'OST收集者', avatar: '🎧', content: '新OP单曲循环一整天,副歌的弦乐一进来直接起鸡皮疙瘩!', time: '3小时前', likes: 745, replies: 67, tag: '音乐' },
  { author: '手办党·星野', avatar: '🎀', content: '新谷子到货!这次的脸模比上一版精致太多,晒图+开箱视频~', time: '5小时前', likes: 1103, replies: 187, tag: '周边' }
]

const WEEKDAY_SCHEDULES: WeekdaySchedule[] = [
  { weekday: '周一', animes: ['迷宫饭', '我独自升级', '魔法科高中'] },
  { weekday: '周二', animes: ['葬送的芙莉莲', '间谍过家家'] },
  { weekday: '周三', animes: ['咒术回战', '排球少年', '蓝箱'] },
  { weekday: '周四', animes: ['地狱乐', '夏日重现'] },
  { weekday: '周五', animes: ['进击的巨人', '链锯人', '我心里危险的东西'] },
  { weekday: '周六', animes: ['鬼灭之刃', '芙莉莲', '药屋少女呢喃'] },
  { weekday: '周日', animes: ['海贼王', '柯南', '宝可梦', '新星合约'] }
]

const STUDIO_LIST: StudioItem[] = [
  { name: 'MAPPA', icon: '🎨', color: '#9B5DE5' },
  { name: '骨头社', icon: '🦴', color: '#3BA99C' },
  { name: '京阿尼', icon: '🌸', color: '#F15BB5' },
  { name: ' ufotable', icon: '✨', color: '#4C8BF5' },
  { name: '霸权社', icon: '👑', color: '#C77B3F' },
  { name: '飞碟桌', icon: '🛸', color: '#6C3FB5' }
]

这三组数据分别服务于圈子动态、时间表和制作公司展示三个功能模块。CIRCLE_POSTS 包含6条社区帖子,涵盖了二次元社区常见的讨论类型:作画分析、考据解读、国漫讨论、观后感分享、音乐推荐和周边晒单。每条帖子都有独立的 tag 字段,对应圈子页面顶部的标签筛选器。

WEEKDAY_SCHEDULES 将一周的番剧按星期分组,每天包含2-4部作品。周末(周六和周日)的更新数量最多,周日更是达到4部,这与 WEEK_UPDATE_STATS 中的统计数据形成了呼应。这些数据会在时间表页面以卡片列表的形式呈现。

STUDIO_LIST 列举了6家知名动画制作公司,包括日本顶级的 MAPPA、ufotable、京阿尼等。每家工作室都有独立的图标和品牌色,这些信息在追番页面的副Tab行中以横向滚动标签的形式展示,帮助用户按制作公司筛选番剧。

这三组数据虽然内容各异,但在代码组织上遵循了相同的原则:通过接口约束数据结构,通过常量数组集中管理,通过 ForEach 在视图中渲染。这种一致性使得整个代码库的风格高度统一。


八、番剧数据列表

const ANIME_LIST: AnimeItem[] = [
  new AnimeItem(1, '葬送的芙莉莲', 'Madhouse', '悬疑烧脑', '#6C3FB5', 28, 28, '周六', '已完结', 9.6, '2023秋', ['魔法', '旅途', '时间'], '千年精灵魔法使的追忆之旅,温柔又怅然', true),
  new AnimeItem(2, '咒术回战 第三季', 'MAPPA', '热血战斗', '#E8506E', 23, 17, '周三', '连载中', 9.1, '2024秋', ['咒术', '涩谷事变', '高燃'], '宿傩与虎杖的终极对决,作画经费在燃烧', true),
  new AnimeItem(3, '我独自升级 第二季', 'A-1', '热血战斗', '#4C8BF5', 13, 9, '周一', '连载中', 8.8, '2025冬', ['升级流', '暗影君主', '爽感'], '影君再临,红门副本的压迫感拉满', true),
  new AnimeItem(4, '我心里危险的东西', 'Shin-Ei', '恋爱日常', '#F15BB5', 25, 25, '周五', '已完结', 9.2, '2023春', ['社恐', '双向暗恋', '青春'], '市川与山田的别扭青春,甜到心尖发颤', false),
  new AnimeItem(5, '迷宫饭', 'Trigger', '异世界', '#C77B3F', 24, 20, '周一', '连载中', 9.0, '2024冬', ['美食', '迷宫', '九井谅子'], '用魔物做菜的硬核异世界美食番', true),
  new AnimeItem(6, '排球少年 垃圾场决战', 'Production I.G', '热血战斗', '#E8850C', 12, 12, '周三', '已完结', 9.4, '2024冬', ['乌野', '研磨', '青春'], '垃圾场决战的最后一球,看哭整个弹幕', false),
  new AnimeItem(7, '药屋少女呢喃', '东宝动画', '悬疑烧脑', '#3BA99C', 24, 22, '周六', '连载中', 9.0, '2024春', ['宫斗', '推理', '猫猫'], '后宫版名侦探,猫猫的毒舌与求知欲', true),
  new AnimeItem(8, '间谍过家家 第三季', 'WIT/霸权社', '恋爱日常', '#F15BB5', 12, 8, '周二', '连载中', 8.7, '2025秋', ['福杰一家', '阿尼亚', '温馨'], '阿尼亚天下第一可爱,无需多言', false),
  new AnimeItem(9, '时光代理人 第二季', 'bilibili', '国漫崛起', '#4C8BF5', 12, 12, '周日', '已完结', 9.3, '2023夏', ['时空', '悬疑', '国漫'], '程小时与陆光的羁绊,镜头语言国产天花板', true),
  new AnimeItem(10, '鬼灭之刃 柱训练篇', 'ufotable', '热血战斗', '#2E86DE', 8, 8, '周六', '已完结', 8.9, '2024春', ['全集中', '水之呼吸', '高清作画'], 'ufotable的光影依旧是业界标杆', false),
  new AnimeItem(11, '夏目友人帐 第七季', '朱夏', '日常治愈', '#3BA99C', 12, 10, '周四', '连载中', 9.1, '2024秋', ['妖怪', '温柔', '治愈'], '夏目与猫咪老师又回来了,温柔的日常', true),
  new AnimeItem(12, '链锯人 第二季', 'MAPPA', '热血战斗', '#E8506E', 12, 6, '周五', '连载中', 8.6, '2025夏', ['电次', '玛奇玛', '暴力美学'], '藤本树的脑洞配合MAPPA的演出', false),
  new AnimeItem(13, '芙莉莲 二季 感知之旅', 'Madhouse', '悬疑烧脑', '#6C3FB5', 12, 4, '周六', '连载中', 9.5, '2026冬', ['一级魔法使', '试炼', '回忆杀'], '一级魔法使考试篇,费伦大放异彩', true),
  new AnimeItem(14, '蓝箱', 'Telecom', '恋爱日常', '#F5A623', 25, 15, '周三', '连载中', 8.8, '2024秋', ['篮球', '初恋', '青春'], '运动×恋爱双线并行,纯度100%的糖', false),
  new AnimeItem(15, '大鱼海棠·归来', '彼岸天', '国漫崛起', '#2C7A7B', 12, 0, '待定', '即将开播', 0, '2026春', ['国产', '水墨', '期待'], '概念PV的水墨质感惊艳,静候开播', false)
]

在这里插入图片描述

ANIME_LIST 是整个App最重要的数据源,包含15条番剧数据。每条数据通过 new AnimeItem(...) 构造,传入了全部14个参数。这些番剧覆盖了6种类型、3种状态、7个更新日和15种不同的封面色,构成了一个内容丰富的演示数据集。

从数据设计角度来看,这15条数据经过精心编排:有7部 isFollowingtrue 的"在追"番剧,恰好与"我的"页面中显示的"7在追"统计数字对应;有4部已完结、8部连载中、1部即将开播、2部特殊状态,覆盖了所有状态类型;评分从0到9.6不等,为评分展示提供了丰富的层次。

每条数据的 coverColor 都不同,这使得海报墙在不使用真实图片的情况下,仅靠色彩就能产生丰富的视觉层次。tags 数组为每部番剧提供了3个关键词标签,用于详情弹框中的标签展示。desc 字段是一句精炼的剧情描述,在详情弹框中作为简介呈现。

在真实工程中,这类数据通常通过 HTTP 请求从后端 API 获取,并通过 @Observed 模型反序列化。此处的静态数据方式适合演示与原型开发,能够快速验证界面效果与交互逻辑。


九、全局纯函数封装

function getTypeMeta(type: string): TypeMeta {
  const meta: TypeMeta | undefined = TYPE_CONFIG[type]
  if (meta) {
    return meta
  }
  return { label: type, icon: '📺', color: '#6F6489', bg: '#EBE4F5' }
}

function getStatusMeta(status: string): StatusMeta {
  const meta: StatusMeta | undefined = STATUS_CONFIG[status]
  if (meta) {
    return meta
  }
  return { label: status, color: '#6F6489', bg: '#EBE4F5', icon: '📡' }
}

function barHeight(episodes: number): string {
  return (episodes * 10).toString() + 'vp'
}

function progressPercent(current: number, total: number): number {
  if (total <= 0) {
    return 0
  }
  return Math.round(current / total * 100)
}

function ratingText(rating: number): string {
  return rating.toFixed(1)
}

function filterByType(type: string): AnimeItem[] {
  if (type === '秋季新番') {
    return ANIME_LIST
  }
  const result: AnimeItem[] = []
  for (let i = 0; i < ANIME_LIST.length; i++) {
    if (ANIME_LIST[i].type === type) {
      result.push(ANIME_LIST[i])
    }
  }
  return result
}

在这里插入图片描述

这组纯函数是整个App的"工具层",它们不依赖任何组件状态,只接收输入并返回输出,是高度可测试、可复用的函数单元。在ArkTS中,将逻辑抽取为纯函数是一种推荐的工程实践,因为它使得逻辑与视图解耦,便于单元测试和后续重构。

getTypeMetagetStatusMeta 是两个"安全查找"函数。它们接收类型或状态字符串,从配置 Record 中查找对应的元信息。关键在于兜底逻辑:当传入的字符串在配置中找不到时,返回一个默认的灰色元信息对象,而不是抛出异常或返回 undefined。这种"防御性编程"确保了即使数据源中出现了未配置的类型,界面也能正常渲染而不会崩溃。

barHeight 函数将集数转换为柱状图的高度值。它使用了 ArkTS 的 vp(virtual pixel)单位,将集数乘以10得到像素高度。例如8集对应80vp的高度。字符串拼接的方式是为了适配 ArkTS 布局系统中接受字符串类型的尺寸值。

progressPercent 计算观看进度百分比,内置了除零保护:当 total 小于等于0时直接返回0。Math.round 确保结果是整数。ratingText 使用 toFixed(1) 将评分格式化为一位小数的字符串,如 “9.6”。

filterByType 是数据过滤函数。当传入"秋季新番"时返回全部列表,否则遍历 ANIME_LIST 筛选匹配类型的番剧。这里使用传统的 for 循环而非 filter 方法,是因为 ArkTS 对函数式 API 的支持可能有限,使用基础循环语法更加安全可靠。


十、组件状态与生命周期

@Entry
@Component
struct Index {
  @State bottomTab: string = '追番'
  @State topTab: string = '秋季新番'
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedAnime: AnimeItem = ANIME_LIST[0]
  @State deleteIndex: number = 0
  @State inputTitle: string = ''
  @State selectWeekday: string = '周六'
  @State remindOn: boolean = true
  @State editAnime: AnimeItem = ANIME_LIST[0]
  @State editEpisodes: string = ''
  @State followCount: number = 7
  @State particles: ParticleDot[] = []
  private timerId: number = -1

  aboutToAppear(): void {
    const initParticles: ParticleDot[] = []
    for (let i = 0; i < 16; i++) {
      initParticles.push({
        x: Math.random() * 100,
        y: Math.random() * 100,
        size: 5 + Math.random() * 9,
        color: i % 2 === 0 ? '#9B5DE5' : '#F15BB5',
        speed: 0.3 + Math.random() * 0.6,
        phase: Math.random() * 6.28
      })
    }
    this.particles = initParticles
    this.timerId = setInterval(() => {
      const nextParticles: ParticleDot[] = []
      for (let i = 0; i < this.particles.length; i++) {
        const p: ParticleDot = this.particles[i]
        let newY: number = p.y - p.speed
        if (newY < -5) {
          newY = 105
        }
        nextParticles.push({ x: p.x + Math.sin(newY / 20 + p.phase) * 0.4, y: newY, size: p.size, color: p.color, speed: p.speed, phase: p.phase })
      }
      this.particles = nextParticles
    }, 65)
  }

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

在这里插入图片描述

这是App主组件 Index 的开头部分,包含了状态声明和两个生命周期函数。@Entry 标记该组件为页面入口,@Component 声明它是一个ArkTS组件。struct 关键字是ArkTS特有的组件声明方式,不同于TypeScript的 classstruct 在编译时会被转换为特定的组件描述。

组件声明了15个 @State 状态变量和1个 private 成员。@State 装饰器是ArkTS状态管理的核心:当被装饰的变量值发生变化时,引用该变量的所有UI元素会自动重绘。这种"数据驱动视图"的机制使得开发者只需关注状态变化,无需手动操作DOM或调用刷新方法。

状态变量可分为三类。第一类是导航状态:bottomTab 控制底部Tab切换,topTab 控制追番页面的类型筛选。第二类是弹框状态:4个布尔变量分别控制4种弹框的显隐。第三类是表单与选中状态:selectedAnime 存储当前选中的番剧,inputTitleselectWeekday 用于添加表单,editAnimeeditEpisodes 用于编辑表单。

aboutToAppear 是组件创建后、UI渲染前调用的生命周期函数。它完成了两件事:初始化16个粒子数据和启动定时器。粒子初始化使用了 Math.random() 生成随机坐标、尺寸、速度和相位,颜色交替使用紫色和桃粉色。定时器每65毫秒执行一次,更新所有粒子的Y坐标(向上移动),当粒子移出顶部时重置到底部。X坐标通过 Math.sin 函数产生轻微的左右摆动,使粒子运动更具自然感。

aboutToDisappear 在组件销毁时调用,负责清理定时器。这是非常重要的内存管理操作——如果不清理定时器,组件销毁后定时器仍会持续执行,导致内存泄漏和无效的状态更新。通过 clearInterval(this.timerId) 确保定时器被正确终止。

UI渲染 Timer定时器 @State状态 Index组件 用户 UI渲染 Timer定时器 @State状态 Index组件 用户 打开页面 aboutToAppear: 初始化15个@State 生成16个粒子数据 setInterval(65ms) 每65ms更新particles @State变化触发重绘 粒子动画持续播放 切换底部Tab 更新bottomTab 触发内容区重绘 显示对应内容 点击海报卡 更新selectedAnime + showDetailModal 触发弹框渲染 显示详情弹框 关闭页面 clearInterval aboutToDisappear清理

上图展示了组件从创建到销毁的完整生命周期与状态驱动流程。可以看到,@State 变量的每次变更都会自动触发UI重绘,而定时器在组件存活期间持续驱动粒子动画,最终在组件销毁时被清理。这种"声明状态—自动渲染—生命周期清理"的模式,是ArkTS开发范式的核心精髓。


十一、星光粒子层

  @Builder
  particleLayer() {
    ForEach(this.particles, (p: ParticleDot) => {
      Text('✨')
        .fontSize(p.size)
        .opacity(0.45)
        .position({ x: p.x + '%', y: p.y + '%' })
    }, (p: ParticleDot) => p.phase.toString() + p.size.toString())
  }

在这里插入图片描述

particleLayer 是一个 @Builder 装饰的构建器方法。在ArkTS中,@Builder 用于将一段UI结构封装为可复用的构建函数,类似于其他框架中的"渲染函数"或"子组件模板"。与独立 @Component 不同的是,@Builder 不创建独立的组件实例,而是在调用处内联展开,因此可以直接访问父组件的 this 上下文。

这个粒子层通过 ForEach 遍历 this.particles 数组,为每个粒子渲染一个 ✨ 表情符号作为 Text 组件。fontSize 设置为粒子尺寸(5-14之间),opacity 设为0.45使粒子呈现半透明效果,避免过于抢眼而干扰主内容。position 使用百分比坐标定位,使粒子可以在整个页面范围内浮动。

ForEach 的第三个参数是键值生成器(keyGenerator),它接收每个元素并返回一个唯一字符串。这里使用 p.phase.toString() + p.size.toString() 作为键,因为粒子的相位和尺寸在初始化后不变,可以保证列表更新时的Diff效率。当定时器更新 this.particles 数组时,ArkTS会通过这个键来判断哪些元素是新增的、哪些是更新的,从而进行最小化DOM操作。

这个粒子层在主 build 方法中被放置在内容层之上,形成覆盖全页面的星光飘浮效果。由于粒子使用了百分比定位,无论屏幕尺寸如何,粒子都会均匀分布。65毫秒的刷新频率(约15fps)在视觉效果与性能之间取得了平衡——足够流畅以产生动画感,又不会过度消耗CPU资源。


十二、顶部导航栏

  @Builder
  headerBar() {
    Row({ space: 10 }) {
      Text('📺')
        .fontSize(24)
      Column({ space: 2 }) {
        Text('追番·番剧社')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('本周追番 ' + this.followCount.toString() + ' 部 · 更新 35 集')
          .fontSize(10)
          .fontColor('#E8D5FA')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('🔔')
        .fontSize(18)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(255,255,255,0.2)')
        .borderRadius(12)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .linearGradient({
      direction: GradientDirection.Right,
      colors: [[COLORS.violetDark, 0], [COLORS.violet, 0.6], [COLORS.peach, 1]]
    })
  }

在这里插入图片描述

headerBar 构建了App的顶部导航栏,采用了"图标+标题+副标题+操作按钮"的经典布局模式。外层 Row 设置了 space: 10 的元素间距,从左到右依次排列:番剧图标、标题区域和通知按钮。

标题区域使用 Column 纵向排列主标题和副标题。主标题"追番·番剧社"使用19号粗体白色字。副标题动态拼接了 this.followCount 的值——“本周追番 X 部 · 更新 35 集”,当用户添加新追番时,followCount 自增,副标题会实时更新。这是 @State 驱动UI的一个直观示例。alignItems(HorizontalAlign.Start) 使文字左对齐,layoutWeight(1) 使标题区域占据剩余空间。

通知按钮使用了一个半透明白色背景的圆形容器,rgba(255,255,255,0.2) 的透明度使其在渐变背景上呈现出"毛玻璃"般的质感。

最关键的是外层 RowlinearGradient 属性,它定义了一个从左到右的三段渐变:深紫(0%)→主紫(60%)→桃粉(100%)。GradientDirection.Right 指定渐变方向,colors 数组中每个元素是 [颜色值, 停止位置] 的元组。这种渐变效果是二次元App的标志性视觉元素,营造了梦幻而活泼的氛围。


十三、顶部分类Tab栏

  @Builder
  topTabBar() {
    Column() {
      Scroll() {
        Row({ space: 6 }) {
          ForEach(TOP_TABS, (tab: string) => {
            Column({ space: 4 }) {
              Text(getTypeMeta(tab).icon)
                .fontSize(14)
              Text(tab)
                .fontSize(12)
                .fontColor(this.topTab === tab ? COLORS.white : COLORS.gray)
            }
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(this.topTab === tab ? COLORS.violet : COLORS.cardBg)
            .borderRadius(12)
            .onClick(() => {
              this.topTab = tab
            })
          }, (tab: string) => tab)
        }
        .padding({ left: 12, right: 12, top: 10 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

      Scroll() {
        Row({ space: 6 }) {
          ForEach(STUDIO_LIST, (studio: StudioItem) => {
            Row({ space: 4 }) {
              Text(studio.icon)
                .fontSize(10)
              Text(studio.name)
                .fontSize(10)
                .fontColor(COLORS.gray)
            }
            .padding({ left: 8, right: 8, top: 5, bottom: 5 })
            .backgroundColor(COLORS.starLight)
            .borderRadius(10)
          }, (studio: StudioItem) => studio.name)
        }
        .padding({ left: 12, right: 12, top: 8, bottom: 10 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .backgroundColor(COLORS.bg)
  }

在这里插入图片描述

topTabBar 实现了双排横向滚动的分类导航。第一排是主分类Tab(秋季新番、热血战斗等6项),第二排是制作公司标签(MAPPA、骨头社等6项)。两排都使用 Scroll 组件实现横向滚动,并通过 scrollBar(BarState.Off) 隐藏滚动条,使界面更加干净。

主分类Tab的每个标签是一个 Column,上方显示图标(通过 getTypeMeta(tab).icon 从元信息中获取),下方显示文字。选中态通过三元运算符动态切换:选中时背景为紫色、文字为白色;未选中时背景为白色卡片、文字为灰色。onClick 中执行 this.topTab = tab,这一赋值会触发 @State 更新,自动重绘所有依赖 topTab 的UI元素。

第二排制作公司标签是纯展示性质的,不绑定点击事件。每个标签使用 COLORS.starLight 作为背景色,与主Tab形成视觉层级区分。标签内 Row 横向排列图标和名称,字号为10,比主Tab更小。

将两个 Scroll 嵌套在 Column 中,形成"主Tab在上、副Tab在下"的双层结构。backgroundColor(COLORS.bg) 使整个区域呈现浅紫底色,与下方内容区形成自然的分隔。这种双排Tab设计在二次元内容App中非常常见,能够在有限的屏幕空间内展示更多的分类入口。


十四、番剧海报卡片

  @Builder
  animePoster(item: AnimeItem) {
    Column({ space: 7 }) {
      Stack({ alignContent: Alignment.TopStart }) {
        Column({ space: 4 }) {
          Text(getTypeMeta(item.type).icon)
            .fontSize(36)
        }
        .width('100%')
        .height(96)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(item.coverColor)
        .borderRadius(12)

        Row({ space: 4 }) {
          Text('⭐ ' + ratingText(item.rating))
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor('rgba(0,0,0,0.4)')
            .borderRadius(8)
        }
        .padding(6)
      }
      .width('100%')

      Text(item.title)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
        .maxLines(1)
        .width('100%')

      Row({ space: 6 }) {
        Text(item.currentEp.toString() + '/' + item.episodes.toString() + '集')
          .fontSize(10)
          .fontColor(COLORS.gray)
        Column().layoutWeight(1)
        Text(getStatusMeta(item.status).icon + item.status)
          .fontSize(9)
          .fontColor(getStatusMeta(item.status).color)
          .padding({ left: 5, right: 5, top: 2, bottom: 2 })
          .backgroundColor(getStatusMeta(item.status).bg)
          .borderRadius(6)
      }
      .width('100%')

      Row() {
        Column()
          .height(4)
          .borderRadius(2)
          .backgroundColor(COLORS.peach)
          .width(progressPercent(item.currentEp, item.episodes).toString() + '%')
        Column()
          .height(4)
          .borderRadius(2)
          .backgroundColor(COLORS.border)
          .layoutWeight(1)
      }
      .width('100%')
    }
    .padding(9)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .onClick(() => {
      this.selectedAnime = item
      this.showDetailModal = true
    })
  }

animePoster 是追番页面海报墙中的单个卡片构建器,接收一个 AnimeItem 参数。这是整个App中最复杂、视觉信息最密集的UI单元之一。

卡片结构分为四层。第一层是封面区,使用 Stack 叠加布局。底层是一个96vp高的 Column,背景色为 item.coverColor(番剧专属颜色),居中显示36号类型图标。上层左上角是一个评分标签,显示"⭐ 9.6"格式的评分,使用半透明黑色背景和白色文字,形成角标效果。Alignment.TopStart 使评分标签定位在左上角。

第二层是标题行,显示番剧名称,13号粗体,maxLines(1) 确保超长标题只显示一行。第三层是信息行,左侧显示"17/23集"的进度文字,右侧显示状态标签。状态标签通过 getStatusMeta(item.status) 获取对应的图标、文字色和背景色,以小药丸形式呈现。

第四层是进度条,使用两个 Column 拼接:左侧桃粉色柱的宽度通过 progressPercent 计算为百分比字符串,右侧灰色柱通过 layoutWeight(1) 填充剩余空间。这种"百分比宽度+弹性宽度"的组合是ArkTS中实现进度条的经典技巧。

卡片的 onClick 将当前 item 赋给 selectedAnime 并打开详情弹框。这个交互入口使得用户可以通过点击海报卡查看番剧的完整信息。整个卡片以白色背景、14号圆角呈现,在海报墙的网格布局中形成清晰的视觉单元。


十五、追番内容区与柱状图

  @Builder
  followContent() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Text('📊 本周新番更新日历')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
          Row({ space: 10 }) {
            ForEach(WEEK_UPDATE_STATS, (stat: WeekStat) => {
              Column({ space: 5 }) {
                Text(stat.episodes.toString() + '集')
                  .fontSize(9)
                  .fontColor(COLORS.gray)
                Column()
                  .width(16)
                  .height(barHeight(stat.episodes))
                  .backgroundColor(stat.episodes >= 6 ? COLORS.peach : COLORS.violet)
                  .borderRadius(8)
                Text(stat.day)
                  .fontSize(10)
                  .fontColor(COLORS.gray)
              }
            }, (stat: WeekStat) => stat.day)
          }
          .alignItems(VerticalAlign.Bottom)
          Text('周六是更新高峰,记得屯好零食 🍿')
            .fontSize(11)
            .fontColor(COLORS.hint)
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)

        Row({ space: 8 }) {
          Text('🎞 本季追番墙')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('+ 添加追番')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS.violet)
            .borderRadius(14)
            .onClick(() => {
              this.showAddModal = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14 })

        Grid() {
          ForEach(filterByType(this.topTab), (item: AnimeItem) => {
            GridItem() {
              this.animePoster(item)
            }
          }, (item: AnimeItem) => this.topTab + item.id.toString())
        }
        .columnsTemplate('1fr 1fr 1fr')
        .columnsGap(8)
        .rowsGap(10)
        .padding({ left: 14, right: 14 })
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

followContent 是追番Tab的主内容区,包含三个核心区块:周更柱状图、追番墙标题栏和海报网格。

柱状图区域是一个白色卡片,内含标题、7根柱子和底部提示文字。每根柱子由三部分组成:上方的集数标签、中间的柱体和下方的星期文字。柱体高度通过 barHeight(stat.episodes) 计算(集数×10vp),背景色根据集数动态选择——大于等于6集用桃粉色高亮,其余用紫色。alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,形成标准柱状图效果。这种纯ArkTS布局实现的柱状图,无需引入图表库,非常轻量。

标题栏使用 Row 横向排列标题和"添加追番"按钮。按钮使用紫色背景和白色文字,点击后设置 showAddModal = true 打开添加弹框。layoutWeight(1) 使标题占据左侧空间,按钮靠右排列。

海报网格使用 Grid 组件,columnsTemplate('1fr 1fr 1fr') 定义了三列等宽布局。数据源通过 filterByType(this.topTab) 动态获取——当用户切换顶部Tab时,topTab 变化触发 filterByType 重新执行,返回不同类型的番剧列表,Grid 内容自动更新。ForEach 的键值生成器使用 this.topTab + item.id.toString(),加入了 topTab 前缀,确保切换分类时列表完全重建而非复用旧元素,避免渲染错乱。

整个内容区被 Scroll 包裹,layoutWeight(1) 使其占据底部Tab栏以上的全部剩余空间。scrollBar(BarState.Off) 隐藏滚动条保持界面整洁。


十六、更新时间表内容区

  @Builder
  scheduleContent() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Text('🗓 一周更新时间表')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('订阅提醒')
            .fontSize(11)
            .fontColor(COLORS.violet)
            .onClick(() => {
              this.showEditModal = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        ForEach(WEEKDAY_SCHEDULES, (sched: WeekdaySchedule) => {
          Column({ space: 10 }) {
            Row({ space: 8 }) {
              Text(sched.weekday)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .backgroundColor(sched.weekday === '周六' || sched.weekday === '周日' ? COLORS.peach : COLORS.violet)
                .borderRadius(10)
              Text(sched.animes.length.toString() + '部更新')
                .fontSize(10)
                .fontColor(COLORS.hint)
                .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 8 }) {
              ForEach(sched.animes, (title: string) => {
                Column({ space: 4 }) {
                  Text('🎬')
                    .fontSize(18)
                  Text(title)
                    .fontSize(10)
                    .fontColor(COLORS.ink)
                    .maxLines(1)
                    .textAlign(TextAlign.Center)
                }
                .padding({ top: 10, bottom: 10, left: 6, right: 6 })
                .backgroundColor(COLORS.starLight)
                .borderRadius(10)
                .layoutWeight(1)
              }, (title: string) => sched.weekday + title)
            }
            .width('100%')
          }
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(14)
          .margin({ left: 14, right: 14 })
        }, (sched: WeekdaySchedule) => sched.weekday)
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

scheduleContent 是时间表Tab的内容区,以"按星期分组卡片列表"的形式呈现一周番剧更新安排。整体结构为标题行 + 7个星期卡片的纵向列表。

标题行右侧的"订阅提醒"文字绑定了点击事件,点击后打开编辑进度弹框。这个入口让用户可以快速更新自己的观看进度。

每个星期卡片是一个白色圆角容器,内部分为两行。第一行是星期标签和更新数量文字:星期标签使用紫色(工作日)或桃粉色(周末)背景配白色文字,通过条件表达式 sched.weekday === '周六' || sched.weekday === '周日' 进行颜色区分,使周末在视觉上更加突出。第二行是一个横向排列的番剧卡片行,每张卡片包含🎬图标和番剧名称,使用浅紫背景和等宽布局(layoutWeight(1)),自适应填满整行宽度。

ForEach 遍历 WEEKDAY_SCHEDULES 数组生成7个卡片,键值为星期名称。内层 ForEach 遍历当天的番剧数组,键值为 sched.weekday + title,确保跨星期的同名番剧不会产生键冲突。番剧名称使用 maxLines(1)textAlign(TextAlign.Center) 确保长名称单行居中显示。

这种"分组卡片列表"的布局方式非常适合时间维度有序的数据展示,每个卡片是一个独立的信息单元,用户可以快速浏览整周的更新安排。


十七、圈子动态内容区

  @Builder
  circleContent() {
    Scroll() {
      Column({ space: 10 }) {
        Row({ space: 6 }) {
          ForEach(['全部', '作画', '考据', '国漫', '周边', '音乐'], (tag: string) => {
            Text(tag)
              .fontSize(11)
              .fontColor(tag === '全部' ? COLORS.white : COLORS.gray)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(tag === '全部' ? COLORS.violet : COLORS.cardBg)
              .borderRadius(14)
          }, (tag: string) => tag)
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        ForEach(CIRCLE_POSTS, (post: CirclePost, index: number) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Text(post.avatar)
                .fontSize(20)
                .width(36)
                .height(36)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.starLight)
                .borderRadius(18)
              Column({ space: 2 }) {
                Text(post.author)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                Text(post.time)
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('#' + post.tag)
                .fontSize(9)
                .fontColor(COLORS.violet)
                .padding({ left: 6, right: 6, top: 3, bottom: 3 })
                .backgroundColor(COLORS.starLight)
                .borderRadius(8)
            }
            .width('100%')

            Text(post.content)
              .fontSize(12)
              .fontColor(COLORS.gray)
              .lineHeight(19)

            Row({ space: 16 }) {
              Text('❤ ' + post.likes.toString())
                .fontSize(11)
                .fontColor(COLORS.peach)
              Text('💬 ' + post.replies.toString())
                .fontSize(11)
                .fontColor(COLORS.hint)
              Column().layoutWeight(1)
              Text('···')
                .fontSize(14)
                .fontColor(COLORS.hint)
                .onClick(() => {
                  this.deleteIndex = index
                  this.showDeleteModal = true
                })
            }
            .width('100%')
          }
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(14)
          .margin({ left: 14, right: 14 })
        }, (post: CirclePost, index: number) => post.author + index.toString())
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .backgroundColor(COLORS.bg)
  }

circleContent 是圈子Tab的内容区,模拟了一个二次元社区的帖子信息流。整体结构为标签筛选行 + 帖子卡片列表。

顶部的标签筛选行展示了6个话题标签,其中"全部"为选中态(紫色背景白色文字),其余为未选中态(白色背景灰色文字)。这种内联数组的 ForEach 写法在标签数量固定且不需要复用时非常方便。虽然此处标签为静态数据且没有绑定点击切换逻辑,但它展示了标签筛选器的UI原型。

每个帖子卡片是一个白色圆角容器,内部分为三层。第一层是用户信息行:左侧是36×36的圆形头像(使用emoji作为头像替代),中间是作者名和发布时间(纵向排列),右侧是话题标签。第二层是帖子正文,12号灰色文字,lineHeight(19) 设置了较舒适的行高。第三层是互动数据行:点赞数(桃粉色心形)、评论数(灰色气泡),以及右侧的"···"更多按钮。

"···"按钮的 onClick 将当前帖子的索引赋给 deleteIndex,并打开删除确认弹框。这是帖子管理的交互入口。ForEach 的键值使用 post.author + index.toString(),加入索引后缀确保即使有同名作者也不会产生键冲突。

整个内容区使用浅紫底色,与白色卡片形成层次感。layoutWeight(1) 确保内容区填满中间区域。


十八、我的个人中心内容区

  @Builder
  mineContent() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 12 }) {
            Text('🌟')
              .fontSize(30)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor('rgba(255,255,255,0.25)')
              .borderRadius(28)
            Column({ space: 4 }) {
              Text('追番大师·星见')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('追番时长 1284 小时 · 老宅了')
                .fontSize(11)
                .fontColor('#E8D5FA')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%')
          Text('🏆 2025年度追番成就:全勤奖')
            .fontSize(11)
            .fontColor(COLORS.white)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .backgroundColor('rgba(255,255,255,0.2)')
            .borderRadius(10)
        }
        .width('100%')
        .padding(16)
        .linearGradient({
          direction: GradientDirection.RightBottom,
          colors: [[COLORS.violet, 0], [COLORS.peach, 1]]
        })
        .borderRadius(16)

        Row({ space: 0 }) {
          Column({ space: 3 }) {
            Text('7')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.violet)
            Text('在追')
              .fontSize(10)
              .fontColor(COLORS.gray)
          }
          .layoutWeight(1)
          Column({ space: 3 }) {
            Text('236')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.peach)
            Text('看过')
              .fontSize(10)
              .fontColor(COLORS.gray)
          }
          .layoutWeight(1)
          Column({ space: 3 }) {
            Text('48')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.blue)
            Text('想看')
              .fontSize(10)
              .fontColor(COLORS.gray)
          }
          .layoutWeight(1)
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)

        Text('📚 在追书架')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')

mineContent 是"我的"Tab的内容区,以个人资料卡、数据统计和书架横排为主要内容。这段代码展示了前两个区块:用户卡片和统计面板。

用户卡片使用了一个右下方向的紫粉渐变背景,内部包含56×56的圆形头像(半透明白色背景+🌟emoji)和用户信息列。用户信息包括昵称"追番大师·星见"和追番时长副标题。卡片底部还有一个成就标签——“2025年度追番成就:全勤奖”。这个卡片的设计语言与顶部导航栏保持一致,都使用了紫粉渐变和白色文字,形成视觉统一性。

统计面板是一个白色卡片,内部三列等宽分布"在追7"、“看过236”、"想看48"三组数据。每列使用不同的大数字颜色(紫、桃、蓝),配以灰色小标签文字。layoutWeight(1) 使三列等分宽度。这种"三宫格统计"是个人中心页面的经典布局模式。

"在追书架"标题之后,将继续接横向滚动的书架区域和设置项列表。这部分代码在下一节继续分析。


十九、我的书架与设置项

        Scroll() {
          Row({ space: 10 }) {
            ForEach(ANIME_LIST, (item: AnimeItem) => {
              if (item.isFollowing) {
                Column({ space: 6 }) {
                  Text(getTypeMeta(item.type).icon)
                    .fontSize(28)
                    .width(80)
                    .height(106)
                    .textAlign(TextAlign.Center)
                    .backgroundColor(item.coverColor)
                    .borderRadius(10)
                  Text(item.title)
                    .fontSize(10)
                    .fontColor(COLORS.ink)
                    .maxLines(1)
                    .width(80)
                }
                .onClick(() => {
                  this.editAnime = item
                  this.selectedAnime = item
                  this.showDetailModal = true
                })
              }
            }, (item: AnimeItem) => 'shelf' + item.id.toString())
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        Column({ space: 0 }) {
          Row({ space: 10 }) {
            Text('🔔')
              .fontSize(16)
            Text('更新提醒设置')
              .fontSize(13)
              .fontColor(COLORS.ink)
              .layoutWeight(1)
            Text('已开启 ▸')
              .fontSize(11)
              .fontColor(COLORS.violet)
              .onClick(() => {
                this.showEditModal = true
              })
          }
          .width('100%')
          .padding(14)

          Divider().color(COLORS.border)

          Row({ space: 10 }) {
            Text('🎨')
              .fontSize(16)
            Text('主题皮肤')
              .fontSize(13)
              .fontColor(COLORS.ink)
              .layoutWeight(1)
            Text('樱紫 ▸')
              .fontSize(11)
              .fontColor(COLORS.hint)
          }
          .width('100%')
          .padding(14)
        }
        .width('100%')
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

这段代码是 mineContent 的后半部分,包含在追书架的横向滚动列表和设置项面板。

书架区域使用横向 Scroll 包裹 Row,通过 ForEach 遍历 ANIME_LIST 并使用 if (item.isFollowing) 条件过滤,只展示标记为"在追"的番剧。每本书是一个80×106的彩色竖条(使用番剧封面色),下方显示标题。ForEach 的键值使用 'shelf' + item.id.toString() 前缀,与其他使用 item.id 的地方区分开,避免键冲突。点击书架中的任意番剧,会同时设置 editAnimeselectedAnime,并打开详情弹框。

设置面板是一个白色圆角卡片,内部包含两行设置项,中间用 Divider 分隔。第一行是"更新提醒设置",右侧显示"已开启 ▸",点击后打开编辑进度弹框。第二行是"主题皮肤",右侧显示"樱紫 ▸",当前为静态展示。每行的 layoutWeight(1) 使标题占据中间空间,右侧的状态文字靠右对齐。

整个"我的"页面从上到下依次为:渐变用户卡→统计面板→书架标题→横向书架→设置面板,层次清晰,信息密度适中,符合个人中心页面的常见设计范式。


二十、底部导航栏

  @Builder
  bottomTabBar() {
    Row() {
      ForEach(BOTTOM_TABS, (tab: NavEntry) => {
        Column({ space: 3 }) {
          Text(tab.icon)
            .fontSize(22)
          Text(tab.label)
            .fontSize(10)
            .fontColor(this.bottomTab === tab.label ? tab.color : COLORS.hint)
        }
        .layoutWeight(1)
        .onClick(() => {
          this.bottomTab = tab.label
        })
      }, (tab: NavEntry) => tab.label)
    }
    .width('100%')
    .padding({ top: 8, bottom: 10 })
    .backgroundColor(COLORS.cardBg)
  }

bottomTabBar 是App的底部导航栏,包含4个Tab入口。整体结构非常简洁:一个 Row 内嵌 ForEach,为每个 NavEntry 渲染一个图标+文字的纵向 Column

每个Tab的选中状态通过 this.bottomTab === tab.label 判断。选中时文字颜色使用 tab.color(每个Tab有自己的品牌色:追番紫色、时间表桃色、圈子蓝色、我的深紫),未选中时统一使用 COLORS.hint 灰色。图标本身不随选中状态变色,保持emoji的原色显示。

onClick 中执行 this.bottomTab = tab.label,这一赋值是整个Tab切换的核心驱动。由于 bottomTab@State 变量,赋值后会自动触发主 build 方法中的条件渲染逻辑,切换显示对应的内容区。layoutWeight(1) 使4个Tab等分底部栏宽度。

底部栏使用白色背景,与内容区的浅紫底色形成视觉分隔。上下内边距分别为8和10vp,为底部安全区留出空间。这是整个App中最频繁交互的组件之一,设计上保持了极简风格,确保用户可以快速切换场景。


二十一、添加追番弹框

  @Builder
  addAnimeModal() {
    Column() {
      Column({ space: 6 }) {
        Text('🎬 添加新追番')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('不错过每一集的感动')
          .fontSize(11)
          .fontColor('#F3DFFB')
      }
      .width('100%')
      .padding({ top: 18, bottom: 16 })
      .linearGradient({
        direction: GradientDirection.Right,
        colors: [[COLORS.violet, 0], [COLORS.peach, 1]]
      })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Column({ space: 14 }) {
        Column({ space: 6 }) {
          Text('番剧名称')
            .fontSize(12)
            .fontColor(COLORS.gray)
          TextInput({ placeholder: '例如:葬送的芙莉莲', text: this.inputTitle })
            .fontSize(14)
            .height(42)
            .backgroundColor(COLORS.starLight)
            .borderRadius(10)
            .onChange((value: string) => {
              this.inputTitle = value
            })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Column({ space: 8 }) {
          Text('更新日')
            .fontSize(12)
            .fontColor(COLORS.gray)
          Row({ space: 6 }) {
            ForEach(WEEKDAY_OPTIONS, (d: string) => {
              Text(d)
                .fontSize(11)
                .fontColor(this.selectWeekday === d ? COLORS.white : COLORS.gray)
                .padding({ left: 8, right: 8, top: 6, bottom: 6 })
                .backgroundColor(this.selectWeekday === d ? COLORS.violet : COLORS.starLight)
                .borderRadius(10)
                .onClick(() => {
                  this.selectWeekday = d
                })
            }, (d: string) => d)
          }
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Row({ space: 10 }) {
          Text('更新提醒')
            .fontSize(13)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text(this.remindOn ? '🔔 已开启' : '🔕 已关闭')
            .fontSize(12)
            .fontColor(this.remindOn ? COLORS.violet : COLORS.hint)
            .backgroundColor(COLORS.starLight)
            .borderRadius(12)
            .onClick(() => {
              this.remindOn = !this.remindOn
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.peachLight)
        .borderRadius(12)

        Row({ space: 10 }) {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.gray)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.starLight)
            .borderRadius(20)
            .onClick(() => {
              this.showAddModal = false
            })
          Column().layoutWeight(1)
          Text('加入追番 ✨')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.violet)
            .borderRadius(20)
            .onClick(() => {
              this.followCount = this.followCount + 1
              this.showAddModal = false
            })
        }
        .width('100%')
      }
      .padding(16)
    }
    .width('88%')
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

addAnimeModal 是四种弹框中最复杂的一种,实现了"添加新追番"的完整表单交互。弹框结构分为渐变头部和表单内容区两部分。

渐变头部使用紫粉右向渐变,内部居中显示标题"🎬 添加新追番"和副标题"不错过每一集的感动"。borderRadius 只设置了左上和左上圆角,使头部与下方内容区平滑衔接。

表单区包含三个输入模块。第一是番剧名称输入框,使用 TextInput 组件,onChange 回调将输入值同步到 this.inputTitle 状态变量。TextInput 是ArkTS内置的表单输入组件,支持 placeholder 提示文字和 text 双向绑定。第二是更新日选择器,通过7个可点击的星期标签实现单选交互,选中态为紫色背景白色文字。第三是更新提醒开关,通过 this.remindOn 的布尔值切换"已开启/已关闭"状态,点击时执行 this.remindOn = !this.remindOn 取反操作。

底部操作栏包含"取消"和"加入追番"两个按钮。"加入追番"按钮的 onClick 执行两个操作:this.followCount 自增1(更新顶部导航栏的追番计数),然后关闭弹框。这种"数据更新+关闭弹框"的组合操作是表单提交的典型模式。

弹框宽度为88%,constraintSize({ maxHeight: '80%' }) 限制了最大高度为屏幕的80%,确保在小屏幕设备上不会溢出。这种弹框设计在ArkTS中通过条件渲染 + 绝对定位实现,无需引入额外的弹框组件库。


二十二、编辑进度与删除确认弹框

  @Builder
  editProgressModal() {
    Column({ space: 14 }) {
      Row({ space: 8 }) {
        Text('⏱')
          .fontSize(20)
        Text('更新观看进度')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.hint)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')

      Row({ space: 10 }) {
        Text(getTypeMeta(this.editAnime.type).icon)
          .fontSize(24)
          .width(48)
          .height(48)
          .textAlign(TextAlign.Center)
          .backgroundColor(this.editAnime.coverColor + '22')
          .borderRadius(12)
        Column({ space: 3 }) {
          Text(this.editAnime.title)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
          Text('当前 ' + this.editAnime.currentEp.toString() + ' / ' + this.editAnime.episodes.toString() + ' 集')
            .fontSize(11)
            .fontColor(COLORS.gray)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')

      Column({ space: 8 }) {
        Text('看到第几集')
          .fontSize(12)
          .fontColor(COLORS.violetDark)
          .fontWeight(FontWeight.Bold)
        TextInput({ placeholder: '输入集数', text: this.editEpisodes })
          .fontSize(14)
          .height(42)
          .backgroundColor(COLORS.starLight)
          .borderRadius(10)
          .onChange((value: string) => {
            this.editEpisodes = value
          })
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding(14)
      .border({ width: 1, color: COLORS.violet, radius: 12 })

      Row({ space: 10 }) {
        Text('标记完结 🏁')
          .fontSize(13)
          .fontColor(COLORS.gray)
          .padding({ left: 18, right: 18, top: 10, bottom: 10 })
          .backgroundColor(COLORS.starLight)
          .borderRadius(18)
          .onClick(() => {
            this.editAnime.currentEp = this.editAnime.episodes
            this.showEditModal = false
          })
        Column().layoutWeight(1)
        Text('保存进度 💾')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 18, right: 18, top: 10, bottom: 10 })
          .backgroundColor(COLORS.peach)
          .borderRadius(18)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')
    }
    .width('88%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  deleteAnimeModal() {
    Column({ space: 14 }) {
      Text('🗑')
        .fontSize(34)
      Text('移出追番列表?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
      Text('「' + CIRCLE_POSTS[this.deleteIndex].author + '」的这条帖子将不再展示,互动记录一并清除')
        .fontSize(12)
        .fontColor(COLORS.gray)
        .textAlign(TextAlign.Center)
      Row({ space: 12 }) {
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.gray)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.starLight)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
        Text('删除')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.red)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
      }
    }
    .width('72%')
    .padding({ top: 24, bottom: 22, left: 18, right: 18 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
  }

这两个弹框分别处理观看进度编辑和删除确认两种交互场景。

editProgressModal 编辑进度弹框的顶部有标题行和关闭按钮(✕),点击关闭按钮设置 showEditModal = false。信息展示区显示当前编辑的番剧信息,包括48×48的类型图标(背景色为封面色+22后缀表示16进制透明度后缀,即13%不透明度)和标题+进度文字。输入区使用带紫色边框的 TextInputborder({ width: 1, color: COLORS.violet, radius: 12 }) 为输入框添加了紫色描边,形成视觉焦点。

底部操作区提供两个按钮。"标记完结"按钮直接将 editAnime.currentEp 设为 episodes(总集数),一步完成追番完结操作。"保存进度"按钮仅关闭弹框。这里体现了 @Observed 类的特性——直接修改 editAnime.currentEp 的属性值会触发关联UI的更新。

deleteAnimeModal 删除确认弹框是一个简洁的警示卡片。顶部显示🗑图标,中部是确认标题和说明文字(动态引用了 CIRCLE_POSTS[this.deleteIndex].author 显示帖子作者名),底部是"取消"和"删除"两个按钮。"删除"按钮使用红色背景以传达警示语义。弹框宽度为72%,比其他弹框更窄,符合"确认对话框"的视觉惯例。


二十三、番剧详情弹框

  @Builder
  animeDetailModal() {
    Column() {
      Column({ space: 8 }) {
        Text(getTypeMeta(this.selectedAnime.type).icon)
          .fontSize(42)
        Text(this.selectedAnime.title)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text(this.selectedAnime.studio + ' · ' + this.selectedAnime.season + '番')
          .fontSize(12)
          .fontColor('#F3DFFB')
      }
      .width('100%')
      .padding({ top: 20, bottom: 18 })
      .backgroundColor(this.selectedAnime.coverColor)
      .borderRadius({ topLeft: 16, topRight: 16 })

      Scroll() {
        Column({ space: 12 }) {
          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('⭐ ' + ratingText(this.selectedAnime.rating))
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.violet)
              Text('评分')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(this.selectedAnime.episodes.toString() + '集')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.peach)
              Text('总集数')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(this.selectedAnime.currentEp.toString() + '集')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.blue)
              Text('已看')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(this.selectedAnime.weekday)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gray)
              Text('更新日')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.starLight)
          .borderRadius(12)

          Column({ space: 6 }) {
            Text('📖 剧情简介')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text(this.selectedAnime.desc + '。制作组稳定发挥,节奏张弛有度,是本季不容错过的作品。')
              .fontSize(12)
              .fontColor(COLORS.gray)
              .lineHeight(19)
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)

          Column({ space: 8 }) {
            Text('🎙 主要声优')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              ForEach(['🎤', '🎧', '🎹', '🎭'], (mic: string) => {
                Column({ space: 4 }) {
                  Text(mic)
                    .fontSize(20)
                    .width(44)
                    .height(44)
                    .textAlign(TextAlign.Center)
                    .backgroundColor(COLORS.starLight)
                    .borderRadius(22)
                }
                .layoutWeight(1)
              }, (mic: string) => mic)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Column({ space: 8 }) {
            Text('🏷 作品标签')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              ForEach(this.selectedAnime.tags, (t: string) => {
                Text('#' + t)
                  .fontSize(11)
                  .fontColor(COLORS.violetDark)
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .backgroundColor(COLORS.starLight)
                  .borderRadius(10)
              }, (t: string) => this.selectedAnime.id.toString() + t)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 10 }) {
            Text('✕ 关闭')
              .fontSize(13)
              .fontColor(COLORS.gray)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.starLight)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
            Column().layoutWeight(1)
            Text('继续追番 📺')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.violet)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
          }
          .width('100%')
        }
        .padding(14)
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('88%')
    .height('78%')
    .backgroundColor(COLORS.bg)
    .borderRadius(16)
  }

animeDetailModal 是四种弹框中尺寸最大、信息最丰富的一个,实现了番剧详情的完整展示。弹框宽度88%、高度78%,几乎占据屏幕大部分空间。

头部区域使用 selectedAnime.coverColor 作为背景色,居中显示42号类型图标、18号粗体白色标题和制作公司+季度信息。头部仅设置上方两个圆角,与下方内容区平滑衔接。

内容区使用 Scroll 包裹,支持纵向滚动。内部依次排列四块内容。第一块是四宫格统计面板,分别展示评分(紫)、总集数(桃)、已看(蓝)、更新日(灰),每格使用独立的大数字+小标签结构。第二块是剧情简介,将 selectedAnime.desc 拼接补充文字后展示,lineHeight(19) 提供舒适阅读行距。第三块是声优展示,使用4个emoji图标占位,以44×44圆形容器呈现。第四块是标签列表,遍历 selectedAnime.tags 数组生成带#前缀的标签药丸。

底部操作栏提供"关闭"和"继续追番"两个按钮,点击后都关闭弹框。这种详情弹框是内容型App的标准交互模式——从列表点击进入详情,查看完整信息后返回。


二十四、弹框遮罩与主构建方法

  @Builder
  modalOverlay() {
    Column() {
      Column().layoutWeight(1)
      if (this.showAddModal) {
        Column() {
          this.addAnimeModal()
        }
      }
      if (this.showEditModal) {
        Column() {
          this.editProgressModal()
        }
      }
      if (this.showDeleteModal) {
        Column() {
          this.deleteAnimeModal()
        }
      }
      if (this.showDetailModal) {
        Column() {
          this.animeDetailModal()
        }
      }
      Column().layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(40,20,60,0.55)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.showAddModal = false
      this.showEditModal = false
      this.showDeleteModal = false
      this.showDetailModal = false
    })
  }

  build() {
    Stack() {
      Column() {
        this.headerBar()
        if (this.bottomTab === '追番') {
          this.topTabBar()
          this.followContent()
        }
        if (this.bottomTab === '时间表') {
          this.scheduleContent()
        }
        if (this.bottomTab === '圈子') {
          this.circleContent()
        }
        if (this.bottomTab === '我的') {
          this.mineContent()
        }
        this.bottomTabBar()
      }
      .width('100%')
      .height('100%')

      this.particleLayer()

      if (this.showAddModal || this.showEditModal || this.showDeleteModal || this.showDetailModal) {
        Column() {
          this.modalOverlay()
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

这两段代码构成了App的最终组装层——弹框遮罩和主 build 方法。

modalOverlay 是统一的弹框遮罩组件,使用半透明深紫色背景 rgba(40,20,60,0.55) 模拟遮罩效果。内部通过4个 if 条件块分别判断4个弹框状态变量,哪个为 true 就渲染对应的弹框构建器。上下各有一个 Column().layoutWeight(1) 作为弹性空白,配合 justifyContent(FlexAlign.Center) 使弹框垂直居中。遮罩的 onClick 将4个弹框状态全部设为 false,实现"点击遮罩空白处关闭弹框"的交互。

build 方法是组件的渲染入口,使用 Stack 叠加三层。底层是主内容区 Column:从上到下依次排列 headerBar、条件渲染的内容区(根据 bottomTab 值选择4个内容构建器之一)和 bottomTabBar。4个 if 条件块互斥执行,每次只有1个内容区被渲染,实现了Tab切换的核心逻辑。值得注意的是,追番Tab额外渲染了 topTabBar,因为只有追番页面需要分类筛选。

中间层是 particleLayer 粒子层,覆盖在内容区之上。最上层是条件渲染的弹框遮罩——当任意一个弹框状态为 true 时,渲染 modalOverlay 覆盖全屏。Stack 的叠加特性使得这三层互不干扰,粒子始终在内容上方飘浮,弹框则覆盖一切。

这种"Stack三层叠加"的架构是ArkTS中实现"内容+浮层+弹框"的经典模式。每一层都有独立的渲染条件和生命周期,通过 @State 变量统一驱动。

弹框条件渲染

内容区条件渲染

Stack叠加层

第三层:弹框遮罩
条件:任一show*Modal为true

第二层:粒子层
始终渲染,定时器驱动

第一层:主内容
headerBar + 内容区 + bottomTabBar

bottomTab == '追番'?

bottomTab == '时间表'?

bottomTab == '圈子'?

bottomTab == '我的'?

topTabBar + followContent

scheduleContent

circleContent

mineContent

showAddModal?

addAnimeModal

showEditModal?

editProgressModal

showDeleteModal?

deleteAnimeModal

showDetailModal?

animeDetailModal

上图展示了 build 方法中的两层条件渲染逻辑。内容区通过 bottomTab 状态变量进行四选一互斥渲染,弹框层通过4个独立的布尔状态变量进行叠加渲染。这种"单一状态驱动内容切换 + 多状态驱动弹框叠加"的设计,既保证了Tab切换的高效性,又允许弹框在未来需要时支持多弹框叠加场景。


数据流与状态管理分析

UI重绘范围

状态变更

用户交互事件

点击底部Tab

点击顶部分类Tab

点击海报卡片

点击添加追番按钮

提交表单

点击遮罩关闭

bottomTab = '时间表'

topTab = '热血战斗'

selectedAnime = item
showDetailModal = true

showAddModal = true

followCount += 1
showAddModal = false

4个show*Modal = false

内容区切换为scheduleContent

海报墙Grid重新过滤渲染

详情弹框animeDetailModal显示

添加弹框addAnimeModal显示

导航栏计数+1,弹框消失

弹框消失,遮罩移除

上图完整描绘了从用户交互到状态变更再到UI重绘的完整数据流。在ArkTS的状态管理模型中,一切UI变化都由 @State 变量的赋值触发。开发者无需手动调用 setStateinvalidate,只需修改变量值,框架会自动计算Diff并更新受影响的UI区域。这种"单向数据流"的模式使得状态变化可追踪、可预测,大大降低了复杂交互场景下的调试难度。


对比表格

表格一:四种弹框功能对比

弹框名称 触发入口 宽度 状态变量 核心功能 交互组件
添加追番弹框 追番墙"+"按钮 88% showAddModal 添加新番剧到追番列表 TextInput输入框、星期选择器、提醒开关
编辑进度弹框 时间表"订阅提醒"、我的"更新提醒设置" 88% showEditModal 更新番剧观看进度 TextInput输入框、标记完结按钮
删除确认弹框 圈子帖子"···"按钮 72% showDeleteModal 确认删除社区帖子 取消/删除二选一按钮
番剧详情弹框 海报卡片点击、书架点击 88%/78% showDetailModal 展示番剧完整信息 关闭/继续追番按钮

从表格可以看出,四种弹框在功能定位上各有侧重:添加弹框侧重表单输入,编辑弹框侧重数据修改,删除弹框侧重风险确认,详情弹框侧重信息展示。它们的宽度也根据内容量进行了差异化设计——删除确认弹框最窄(72%),因为其内容最少;详情弹框最高(78%),因为其信息最丰富。

表格二:四大内容区架构对比

内容区 构建器名称 底部Tab触发 数据源 核心布局组件 滚动方式
追番墙 followContent bottomTab=‘追番’ filterByType(topTab) Grid三列网格 + 柱状图 纵向Scroll
时间表 scheduleContent bottomTab=‘时间表’ WEEKDAY_SCHEDULES 分组卡片列表 纵向Scroll
圈子动态 circleContent bottomTab=‘圈子’ CIRCLE_POSTS 帖子信息流列表 纵向Scroll
个人中心 mineContent bottomTab=‘我的’ ANIME_LIST(isFollowing) 渐变卡+统计+横向书架 纵向Scroll+横向Scroll

四大内容区虽然都是纵向可滚动的内容页面,但在数据源获取方式、布局组件选择和滚动策略上各有不同。追番墙的数据源是动态的(依赖 topTab 的值通过 filterByType 过滤),而其他三个内容区的数据源是静态的。追番墙使用 Grid 网格布局展示海报卡片,时间表使用分组 Column 列表,圈子使用信息流列表,个人中心则混合了渐变卡、统计面板和横向书架,布局最为复杂。

表格三:ArkTS状态装饰器对比

装饰器 适用对象 作用范围 响应式行为 典型使用场景
@State 组件内部变量 当前组件 变量赋值时触发当前组件UI重绘 Tab切换、弹框显隐、表单输入
@Observed 类声明 跨组件 类属性变更时通知所有@ObjectLink 数据模型类(AnimeItem)
@ObjectLink 组件变量 当前组件 接收@Observed类实例的变化 子组件引用父组件传入的模型
@Builder 组件方法 当前组件 无直接响应式行为,被调用处内联展开 UI片段复用(海报卡、弹框等)
@Entry 组件struct 页面入口 标记为页面根组件 App页面入口声明
@Component 组件struct 当前组件 声明为ArkTS组件 所有自定义组件声明

本项目中大量使用了 @State 进行组件内状态管理,使用 @Observed 标记 AnimeItem 数据模型,使用 @Builder 封装了11个可复用的UI构建器。@ObjectLink 在本项目中未直接使用(因为所有内容都在单一组件内渲染),但在将海报卡或帖子卡拆分为独立子组件时,就需要配合 @Observed + @ObjectLink 实现跨组件响应式。理解这些装饰器的差异和适用场景,是掌握ArkTS开发的关键。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// QQ追番·番剧社 —— 追番社区App演示页面 (ArkTS / HarmonyOS)
// 场景:番剧追番 / 更新时间表 / 圈子动态 / 我的
// 风格:樱紫+桃粉 二次元风(紫罗兰 + 桃粉 + 星白)
// 结构:底部4Tab + 顶部6功能Tab + 4种弹框 + 柱状图 + 星光粒子特效
// ============================================================

// ==================== 配色配置 ====================
interface ColorPalette {
  violet: string
  violetDark: string
  peach: string
  peachLight: string
  bg: string
  cardBg: string
  ink: string
  gray: string
  hint: string
  border: string
  white: string
  blue: string
  red: string
  starLight: string
}

const COLORS: ColorPalette = {
  violet: '#9B5DE5',
  violetDark: '#6C3FB5',
  peach: '#F15BB5',
  peachLight: '#FDE7F3',
  bg: '#F7F4FC',
  cardBg: '#FFFFFF',
  ink: '#3A2A55',
  gray: '#6F6489',
  hint: '#B0A8C4',
  border: '#EBE4F5',
  white: '#FFFFFF',
  blue: '#4C8BF5',
  red: '#E8506E',
  starLight: '#FBEFFA'
}

// ==================== 元信息接口 ====================
interface TypeMeta {
  label: string
  icon: string
  color: string
  bg: string
}

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

interface NavEntry {
  label: string
  icon: string
  color: string
}

interface ParticleDot {
  x: number
  y: number
  size: number
  color: string
  speed: number
  phase: number
}

interface WeekStat {
  day: string
  episodes: number
}

interface CirclePost {
  author: string
  avatar: string
  content: string
  time: string
  likes: number
  replies: number
  tag: string
}

interface WeekdaySchedule {
  weekday: string
  animes: string[]
}

interface StudioItem {
  name: string
  icon: string
  color: string
}

// ==================== 番剧数据模型 ====================
@Observed
class AnimeItem {
  id: number = 0
  title: string = ''
  studio: string = ''
  type: string = ''
  coverColor: string = ''
  episodes: number = 0
  currentEp: number = 0
  weekday: string = ''
  status: string = ''
  rating: number = 0
  season: string = ''
  tags: string[] = []
  desc: string = ''
  isFollowing: boolean = false

  constructor(id: number, title: string, studio: string, type: string, coverColor: string,
    episodes: number, currentEp: number, weekday: string, status: string, rating: number,
    season: string, tags: string[], desc: string, isFollowing: boolean) {
    this.id = id
    this.title = title
    this.studio = studio
    this.type = type
    this.coverColor = coverColor
    this.episodes = episodes
    this.currentEp = currentEp
    this.weekday = weekday
    this.status = status
    this.rating = rating
    this.season = season
    this.tags = tags
    this.desc = desc
    this.isFollowing = isFollowing
  }
}

// ==================== 配置 Record ====================
const TYPE_CONFIG: Record<string, TypeMeta> = {
  '热血战斗': { label: '热血战斗', icon: '⚔', color: '#E8506E', bg: '#FDE7EA' },
  '恋爱日常': { label: '恋爱日常', icon: '💗', color: '#F15BB5', bg: '#FDE7F3' },
  '悬疑烧脑': { label: '悬疑烧脑', icon: '🔎', color: '#6C3FB5', bg: '#ECE5F7' },
  '异世界': { label: '异世界', icon: '🌀', color: '#4C8BF5', bg: '#E5EEFD' },
  '日常治愈': { label: '日常治愈', icon: '🍃', color: '#3BA99C', bg: '#E2F5F2' },
  '国漫崛起': { label: '国漫崛起', icon: '🐉', color: '#C77B3F', bg: '#FAEBDB' }
}

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '连载中': { label: '连载中', color: '#F15BB5', bg: '#FDE7F3', icon: '📡' },
  '已完结': { label: '已完结', color: '#3BA99C', bg: '#E2F5F2', icon: '🏁' },
  '即将开播': { label: '即将开播', color: '#4C8BF5', bg: '#E5EEFD', icon: '🔔' }
}

const BOTTOM_TABS: NavEntry[] = [
  { label: '追番', icon: '📺', color: '#9B5DE5' },
  { label: '时间表', icon: '🗓', color: '#F15BB5' },
  { label: '圈子', icon: '💬', color: '#4C8BF5' },
  { label: '我的', icon: '👤', color: '#6C3FB5' }
]

const TOP_TABS: string[] = ['秋季新番', '热血战斗', '恋爱日常', '悬疑烧脑', '异世界', '国漫崛起']

const WEEK_UPDATE_STATS: WeekStat[] = [
  { day: '一', episodes: 4 },
  { day: '二', episodes: 3 },
  { day: '三', episodes: 5 },
  { day: '四', episodes: 2 },
  { day: '五', episodes: 6 },
  { day: '六', episodes: 8 },
  { day: '日', episodes: 7 }
]

const CIRCLE_POSTS: CirclePost[] = [
  { author: '声优厨小桃', avatar: '🍑', content: '第七集作画回封神!原画师偷偷加的那段打斗,逐帧截图停不下来!', time: '5分钟前', likes: 1284, replies: 156, tag: '作画' },
  { author: '考据党阿紫', avatar: '🔮', content: '逐帧扒完了OP,镜头里有三处暗示结局的彩蛋,长文分析已发圈~', time: '23分钟前', likes: 892, replies: 203, tag: '考据' },
  { author: '追番十年君', avatar: '📺', content: '见证国漫从追赶到并跑,今年的3D渲染已经强到离谱了,泪目。', time: '1小时前', likes: 2310, replies: 341, tag: '国漫' },
  { author: '泪点低星人', avatar: '💧', content: '第九集看完在宿舍哭出声,室友以为我失恋了……致每一个平凡又闪光的角色。', time: '2小时前', likes: 1567, replies: 98, tag: '观后感' },
  { author: 'OST收集者', avatar: '🎧', content: '新OP单曲循环一整天,副歌的弦乐一进来直接起鸡皮疙瘩!', time: '3小时前', likes: 745, replies: 67, tag: '音乐' },
  { author: '手办党·星野', avatar: '🎀', content: '新谷子到货!这次的脸模比上一版精致太多,晒图+开箱视频~', time: '5小时前', likes: 1103, replies: 187, tag: '周边' }
]

const WEEKDAY_SCHEDULES: WeekdaySchedule[] = [
  { weekday: '周一', animes: ['迷宫饭', '我独自升级', '魔法科高中'] },
  { weekday: '周二', animes: ['葬送的芙莉莲', '间谍过家家'] },
  { weekday: '周三', animes: ['咒术回战', '排球少年', '蓝箱'] },
  { weekday: '周四', animes: ['地狱乐', '夏日重现'] },
  { weekday: '周五', animes: ['进击的巨人', '链锯人', '我心里危险的东西'] },
  { weekday: '周六', animes: ['鬼灭之刃', '芙莉莲', '药屋少女呢喃'] },
  { weekday: '周日', animes: ['海贼王', '柯南', '宝可梦', '新星合约'] }
]

const STUDIO_LIST: StudioItem[] = [
  { name: 'MAPPA', icon: '🎨', color: '#9B5DE5' },
  { name: '骨头社', icon: '🦴', color: '#3BA99C' },
  { name: '京阿尼', icon: '🌸', color: '#F15BB5' },
  { name: ' ufotable', icon: '✨', color: '#4C8BF5' },
  { name: '霸权社', icon: '👑', color: '#C77B3F' },
  { name: '飞碟桌', icon: '🛸', color: '#6C3FB5' }
]

const WEEKDAY_OPTIONS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']

// ==================== 15条番剧数据 ====================
const ANIME_LIST: AnimeItem[] = [
  new AnimeItem(1, '葬送的芙莉莲', 'Madhouse', '悬疑烧脑', '#6C3FB5', 28, 28, '周六', '已完结', 9.6, '2023秋', ['魔法', '旅途', '时间'], '千年精灵魔法使的追忆之旅,温柔又怅然', true),
  new AnimeItem(2, '咒术回战 第三季', 'MAPPA', '热血战斗', '#E8506E', 23, 17, '周三', '连载中', 9.1, '2024秋', ['咒术', '涩谷事变', '高燃'], '宿傩与虎杖的终极对决,作画经费在燃烧', true),
  new AnimeItem(3, '我独自升级 第二季', 'A-1', '热血战斗', '#4C8BF5', 13, 9, '周一', '连载中', 8.8, '2025冬', ['升级流', '暗影君主', '爽感'], '影君再临,红门副本的压迫感拉满', true),
  new AnimeItem(4, '我心里危险的东西', 'Shin-Ei', '恋爱日常', '#F15BB5', 25, 25, '周五', '已完结', 9.2, '2023春', ['社恐', '双向暗恋', '青春'], '市川与山田的别扭青春,甜到心尖发颤', false),
  new AnimeItem(5, '迷宫饭', 'Trigger', '异世界', '#C77B3F', 24, 20, '周一', '连载中', 9.0, '2024冬', ['美食', '迷宫', '九井谅子'], '用魔物做菜的硬核异世界美食番', true),
  new AnimeItem(6, '排球少年 垃圾场决战', 'Production I.G', '热血战斗', '#E8850C', 12, 12, '周三', '已完结', 9.4, '2024冬', ['乌野', '研磨', '青春'], '垃圾场决战的最后一球,看哭整个弹幕', false),
  new AnimeItem(7, '药屋少女呢喃', '东宝动画', '悬疑烧脑', '#3BA99C', 24, 22, '周六', '连载中', 9.0, '2024春', ['宫斗', '推理', '猫猫'], '后宫版名侦探,猫猫的毒舌与求知欲', true),
  new AnimeItem(8, '间谍过家家 第三季', 'WIT/霸权社', '恋爱日常', '#F15BB5', 12, 8, '周二', '连载中', 8.7, '2025秋', ['福杰一家', '阿尼亚', '温馨'], '阿尼亚天下第一可爱,无需多言', false),
  new AnimeItem(9, '时光代理人 第二季', 'bilibili', '国漫崛起', '#4C8BF5', 12, 12, '周日', '已完结', 9.3, '2023夏', ['时空', '悬疑', '国漫'], '程小时与陆光的羁绊,镜头语言国产天花板', true),
  new AnimeItem(10, '鬼灭之刃 柱训练篇', 'ufotable', '热血战斗', '#2E86DE', 8, 8, '周六', '已完结', 8.9, '2024春', ['全集中', '水之呼吸', '高清作画'], 'ufotable的光影依旧是业界标杆', false),
  new AnimeItem(11, '夏目友人帐 第七季', '朱夏', '日常治愈', '#3BA99C', 12, 10, '周四', '连载中', 9.1, '2024秋', ['妖怪', '温柔', '治愈'], '夏目与猫咪老师又回来了,温柔的日常', true),
  new AnimeItem(12, '链锯人 第二季', 'MAPPA', '热血战斗', '#E8506E', 12, 6, '周五', '连载中', 8.6, '2025夏', ['电次', '玛奇玛', '暴力美学'], '藤本树的脑洞配合MAPPA的演出', false),
  new AnimeItem(13, '芙莉莲 二季 感知之旅', 'Madhouse', '悬疑烧脑', '#6C3FB5', 12, 4, '周六', '连载中', 9.5, '2026冬', ['一级魔法使', '试炼', '回忆杀'], '一级魔法使考试篇,费伦大放异彩', true),
  new AnimeItem(14, '蓝箱', 'Telecom', '恋爱日常', '#F5A623', 25, 15, '周三', '连载中', 8.8, '2024秋', ['篮球', '初恋', '青春'], '运动×恋爱双线并行,纯度100%的糖', false),
  new AnimeItem(15, '大鱼海棠·归来', '彼岸天', '国漫崛起', '#2C7A7B', 12, 0, '待定', '即将开播', 0, '2026春', ['国产', '水墨', '期待'], '概念PV的水墨质感惊艳,静候开播', false)
]

// ==================== 全局纯函数 ====================
function getTypeMeta(type: string): TypeMeta {
  const meta: TypeMeta | undefined = TYPE_CONFIG[type]
  if (meta) {
    return meta
  }
  return { label: type, icon: '📺', color: '#6F6489', bg: '#EBE4F5' }
}

function getStatusMeta(status: string): StatusMeta {
  const meta: StatusMeta | undefined = STATUS_CONFIG[status]
  if (meta) {
    return meta
  }
  return { label: status, color: '#6F6489', bg: '#EBE4F5', icon: '📡' }
}

function barHeight(episodes: number): string {
  return (episodes * 10).toString() + 'vp'
}

function progressPercent(current: number, total: number): number {
  if (total <= 0) {
    return 0
  }
  return Math.round(current / total * 100)
}

function ratingText(rating: number): string {
  return rating.toFixed(1)
}

function filterByType(type: string): AnimeItem[] {
  if (type === '秋季新番') {
    return ANIME_LIST
  }
  const result: AnimeItem[] = []
  for (let i = 0; i < ANIME_LIST.length; i++) {
    if (ANIME_LIST[i].type === type) {
      result.push(ANIME_LIST[i])
    }
  }
  return result
}

@Entry
@Component
struct Index {
  @State bottomTab: string = '追番'
  @State topTab: string = '秋季新番'
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedAnime: AnimeItem = ANIME_LIST[0]
  @State deleteIndex: number = 0
  @State inputTitle: string = ''
  @State selectWeekday: string = '周六'
  @State remindOn: boolean = true
  @State editAnime: AnimeItem = ANIME_LIST[0]
  @State editEpisodes: string = ''
  @State followCount: number = 7
  @State particles: ParticleDot[] = []
  private timerId: number = -1

  aboutToAppear(): void {
    const initParticles: ParticleDot[] = []
    for (let i = 0; i < 16; i++) {
      initParticles.push({
        x: Math.random() * 100,
        y: Math.random() * 100,
        size: 5 + Math.random() * 9,
        color: i % 2 === 0 ? '#9B5DE5' : '#F15BB5',
        speed: 0.3 + Math.random() * 0.6,
        phase: Math.random() * 6.28
      })
    }
    this.particles = initParticles
    this.timerId = setInterval(() => {
      const nextParticles: ParticleDot[] = []
      for (let i = 0; i < this.particles.length; i++) {
        const p: ParticleDot = this.particles[i]
        let newY: number = p.y - p.speed
        if (newY < -5) {
          newY = 105
        }
        nextParticles.push({ x: p.x + Math.sin(newY / 20 + p.phase) * 0.4, y: newY, size: p.size, color: p.color, speed: p.speed, phase: p.phase })
      }
      this.particles = nextParticles
    }, 65)
  }

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

  // ==================== 粒子层(星光✨) ====================
  @Builder
  particleLayer() {
    ForEach(this.particles, (p: ParticleDot) => {
      Text('✨')
        .fontSize(p.size)
        .opacity(0.45)
        .position({ x: p.x + '%', y: p.y + '%' })
    }, (p: ParticleDot) => p.phase.toString() + p.size.toString())
  }

  // ==================== 顶部导航(应援棒风格) ====================
  @Builder
  headerBar() {
    Row({ space: 10 }) {
      Text('📺')
        .fontSize(24)
      Column({ space: 2 }) {
        Text('追番·番剧社')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('本周追番 ' + this.followCount.toString() + ' 部 · 更新 35 集')
          .fontSize(10)
          .fontColor('#E8D5FA')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('🔔')
        .fontSize(18)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(255,255,255,0.2)')
        .borderRadius(12)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .linearGradient({
      direction: GradientDirection.Right,
      colors: [[COLORS.violetDark, 0], [COLORS.violet, 0.6], [COLORS.peach, 1]]
    })
  }

  // ==================== 顶部Tab(双排:主tab+副tab) ====================
  @Builder
  topTabBar() {
    Column() {
      Scroll() {
        Row({ space: 6 }) {
          ForEach(TOP_TABS, (tab: string) => {
            Column({ space: 4 }) {
              Text(getTypeMeta(tab).icon)
                .fontSize(14)
              Text(tab)
                .fontSize(12)
                .fontColor(this.topTab === tab ? COLORS.white : COLORS.gray)
            }
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(this.topTab === tab ? COLORS.violet : COLORS.cardBg)
            .borderRadius(12)
            .onClick(() => {
              this.topTab = tab
            })
          }, (tab: string) => tab)
        }
        .padding({ left: 12, right: 12, top: 10 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

      Scroll() {
        Row({ space: 6 }) {
          ForEach(STUDIO_LIST, (studio: StudioItem) => {
            Row({ space: 4 }) {
              Text(studio.icon)
                .fontSize(10)
              Text(studio.name)
                .fontSize(10)
                .fontColor(COLORS.gray)
            }
            .padding({ left: 8, right: 8, top: 5, bottom: 5 })
            .backgroundColor(COLORS.starLight)
            .borderRadius(10)
          }, (studio: StudioItem) => studio.name)
        }
        .padding({ left: 12, right: 12, top: 8, bottom: 10 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .backgroundColor(COLORS.bg)
  }

  // ==================== 番剧海报卡(样式A:双列海报墙) ====================
  @Builder
  animePoster(item: AnimeItem) {
    Column({ space: 7 }) {
      Stack({ alignContent: Alignment.TopStart }) {
        Column({ space: 4 }) {
          Text(getTypeMeta(item.type).icon)
            .fontSize(36)
        }
        .width('100%')
        .height(96)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(item.coverColor)
        .borderRadius(12)

        Row({ space: 4 }) {
          Text('⭐ ' + ratingText(item.rating))
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor('rgba(0,0,0,0.4)')
            .borderRadius(8)
        }
        .padding(6)
      }
      .width('100%')

      Text(item.title)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
        .maxLines(1)
        .width('100%')

      Row({ space: 6 }) {
        Text(item.currentEp.toString() + '/' + item.episodes.toString() + '集')
          .fontSize(10)
          .fontColor(COLORS.gray)
        Column().layoutWeight(1)
        Text(getStatusMeta(item.status).icon + item.status)
          .fontSize(9)
          .fontColor(getStatusMeta(item.status).color)
          .padding({ left: 5, right: 5, top: 2, bottom: 2 })
          .backgroundColor(getStatusMeta(item.status).bg)
          .borderRadius(6)
      }
      .width('100%')

      Row() {
        Column()
          .height(4)
          .borderRadius(2)
          .backgroundColor(COLORS.peach)
          .width(progressPercent(item.currentEp, item.episodes).toString() + '%')
        Column()
          .height(4)
          .borderRadius(2)
          .backgroundColor(COLORS.border)
          .layoutWeight(1)
      }
      .width('100%')
    }
    .padding(9)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .onClick(() => {
      this.selectedAnime = item
      this.showDetailModal = true
    })
  }

  // ==================== 追番内容(海报墙+周更图表) ====================
  @Builder
  followContent() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Text('📊 本周新番更新日历')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
          Row({ space: 10 }) {
            ForEach(WEEK_UPDATE_STATS, (stat: WeekStat) => {
              Column({ space: 5 }) {
                Text(stat.episodes.toString() + '集')
                  .fontSize(9)
                  .fontColor(COLORS.gray)
                Column()
                  .width(16)
                  .height(barHeight(stat.episodes))
                  .backgroundColor(stat.episodes >= 6 ? COLORS.peach : COLORS.violet)
                  .borderRadius(8)
                Text(stat.day)
                  .fontSize(10)
                  .fontColor(COLORS.gray)
              }
            }, (stat: WeekStat) => stat.day)
          }
          .alignItems(VerticalAlign.Bottom)
          Text('周六是更新高峰,记得屯好零食 🍿')
            .fontSize(11)
            .fontColor(COLORS.hint)
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)

        Row({ space: 8 }) {
          Text('🎞 本季追番墙')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('+ 添加追番')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS.violet)
            .borderRadius(14)
            .onClick(() => {
              this.showAddModal = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14 })

        Grid() {
          ForEach(filterByType(this.topTab), (item: AnimeItem) => {
            GridItem() {
              this.animePoster(item)
            }
          }, (item: AnimeItem) => this.topTab + item.id.toString())
        }
        .columnsTemplate('1fr 1fr 1fr')
        .columnsGap(8)
        .rowsGap(10)
        .padding({ left: 14, right: 14 })
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 时间表内容(样式B:按星期分组列表) ====================
  @Builder
  scheduleContent() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Text('🗓 一周更新时间表')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('订阅提醒')
            .fontSize(11)
            .fontColor(COLORS.violet)
            .onClick(() => {
              this.showEditModal = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        ForEach(WEEKDAY_SCHEDULES, (sched: WeekdaySchedule) => {
          Column({ space: 10 }) {
            Row({ space: 8 }) {
              Text(sched.weekday)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .backgroundColor(sched.weekday === '周六' || sched.weekday === '周日' ? COLORS.peach : COLORS.violet)
                .borderRadius(10)
              Text(sched.animes.length.toString() + '部更新')
                .fontSize(10)
                .fontColor(COLORS.hint)
                .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 8 }) {
              ForEach(sched.animes, (title: string) => {
                Column({ space: 4 }) {
                  Text('🎬')
                    .fontSize(18)
                  Text(title)
                    .fontSize(10)
                    .fontColor(COLORS.ink)
                    .maxLines(1)
                    .textAlign(TextAlign.Center)
                }
                .padding({ top: 10, bottom: 10, left: 6, right: 6 })
                .backgroundColor(COLORS.starLight)
                .borderRadius(10)
                .layoutWeight(1)
              }, (title: string) => sched.weekday + title)
            }
            .width('100%')
          }
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(14)
          .margin({ left: 14, right: 14 })
        }, (sched: WeekdaySchedule) => sched.weekday)
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 圈子内容(样式C:社区帖子流) ====================
  @Builder
  circleContent() {
    Scroll() {
      Column({ space: 10 }) {
        Row({ space: 6 }) {
          ForEach(['全部', '作画', '考据', '国漫', '周边', '音乐'], (tag: string) => {
            Text(tag)
              .fontSize(11)
              .fontColor(tag === '全部' ? COLORS.white : COLORS.gray)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(tag === '全部' ? COLORS.violet : COLORS.cardBg)
              .borderRadius(14)
          }, (tag: string) => tag)
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        ForEach(CIRCLE_POSTS, (post: CirclePost, index: number) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Text(post.avatar)
                .fontSize(20)
                .width(36)
                .height(36)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.starLight)
                .borderRadius(18)
              Column({ space: 2 }) {
                Text(post.author)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                Text(post.time)
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('#' + post.tag)
                .fontSize(9)
                .fontColor(COLORS.violet)
                .padding({ left: 6, right: 6, top: 3, bottom: 3 })
                .backgroundColor(COLORS.starLight)
                .borderRadius(8)
            }
            .width('100%')

            Text(post.content)
              .fontSize(12)
              .fontColor(COLORS.gray)
              .lineHeight(19)

            Row({ space: 16 }) {
              Text('❤ ' + post.likes.toString())
                .fontSize(11)
                .fontColor(COLORS.peach)
              Text('💬 ' + post.replies.toString())
                .fontSize(11)
                .fontColor(COLORS.hint)
              Column().layoutWeight(1)
              Text('···')
                .fontSize(14)
                .fontColor(COLORS.hint)
                .onClick(() => {
                  this.deleteIndex = index
                  this.showDeleteModal = true
                })
            }
            .width('100%')
          }
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(14)
          .margin({ left: 14, right: 14 })
        }, (post: CirclePost, index: number) => post.author + index.toString())
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .backgroundColor(COLORS.bg)
  }

  // ==================== 我的内容(样式D:书架横排+徽章) ====================
  @Builder
  mineContent() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 12 }) {
            Text('🌟')
              .fontSize(30)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor('rgba(255,255,255,0.25)')
              .borderRadius(28)
            Column({ space: 4 }) {
              Text('追番大师·星见')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('追番时长 1284 小时 · 老宅了')
                .fontSize(11)
                .fontColor('#E8D5FA')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%')
          Text('🏆 2025年度追番成就:全勤奖')
            .fontSize(11)
            .fontColor(COLORS.white)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .backgroundColor('rgba(255,255,255,0.2)')
            .borderRadius(10)
        }
        .width('100%')
        .padding(16)
        .linearGradient({
          direction: GradientDirection.RightBottom,
          colors: [[COLORS.violet, 0], [COLORS.peach, 1]]
        })
        .borderRadius(16)

        Row({ space: 0 }) {
          Column({ space: 3 }) {
            Text('7')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.violet)
            Text('在追')
              .fontSize(10)
              .fontColor(COLORS.gray)
          }
          .layoutWeight(1)
          Column({ space: 3 }) {
            Text('236')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.peach)
            Text('看过')
              .fontSize(10)
              .fontColor(COLORS.gray)
          }
          .layoutWeight(1)
          Column({ space: 3 }) {
            Text('48')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.blue)
            Text('想看')
              .fontSize(10)
              .fontColor(COLORS.gray)
          }
          .layoutWeight(1)
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)

        Text('📚 在追书架')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')

        Scroll() {
          Row({ space: 10 }) {
            ForEach(ANIME_LIST, (item: AnimeItem) => {
              if (item.isFollowing) {
                Column({ space: 6 }) {
                  Text(getTypeMeta(item.type).icon)
                    .fontSize(28)
                    .width(80)
                    .height(106)
                    .textAlign(TextAlign.Center)
                    .backgroundColor(item.coverColor)
                    .borderRadius(10)
                  Text(item.title)
                    .fontSize(10)
                    .fontColor(COLORS.ink)
                    .maxLines(1)
                    .width(80)
                }
                .onClick(() => {
                  this.editAnime = item
                  this.selectedAnime = item
                  this.showDetailModal = true
                })
              }
            }, (item: AnimeItem) => 'shelf' + item.id.toString())
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        Column({ space: 0 }) {
          Row({ space: 10 }) {
            Text('🔔')
              .fontSize(16)
            Text('更新提醒设置')
              .fontSize(13)
              .fontColor(COLORS.ink)
              .layoutWeight(1)
            Text('已开启 ▸')
              .fontSize(11)
              .fontColor(COLORS.violet)
              .onClick(() => {
                this.showEditModal = true
              })
          }
          .width('100%')
          .padding(14)

          Divider().color(COLORS.border)

          Row({ space: 10 }) {
            Text('🎨')
              .fontSize(16)
            Text('主题皮肤')
              .fontSize(13)
              .fontColor(COLORS.ink)
              .layoutWeight(1)
            Text('樱紫 ▸')
              .fontSize(11)
              .fontColor(COLORS.hint)
          }
          .width('100%')
          .padding(14)
        }
        .width('100%')
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 底部Tab栏 ====================
  @Builder
  bottomTabBar() {
    Row() {
      ForEach(BOTTOM_TABS, (tab: NavEntry) => {
        Column({ space: 3 }) {
          Text(tab.icon)
            .fontSize(22)
          Text(tab.label)
            .fontSize(10)
            .fontColor(this.bottomTab === tab.label ? tab.color : COLORS.hint)
        }
        .layoutWeight(1)
        .onClick(() => {
          this.bottomTab = tab.label
        })
      }, (tab: NavEntry) => tab.label)
    }
    .width('100%')
    .padding({ top: 8, bottom: 10 })
    .backgroundColor(COLORS.cardBg)
  }

  // ==================== 弹框1:添加追番(紫粉渐变头+提醒开关) ====================
  @Builder
  addAnimeModal() {
    Column() {
      Column({ space: 6 }) {
        Text('🎬 添加新追番')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('不错过每一集的感动')
          .fontSize(11)
          .fontColor('#F3DFFB')
      }
      .width('100%')
      .padding({ top: 18, bottom: 16 })
      .linearGradient({
        direction: GradientDirection.Right,
        colors: [[COLORS.violet, 0], [COLORS.peach, 1]]
      })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Column({ space: 14 }) {
        Column({ space: 6 }) {
          Text('番剧名称')
            .fontSize(12)
            .fontColor(COLORS.gray)
          TextInput({ placeholder: '例如:葬送的芙莉莲', text: this.inputTitle })
            .fontSize(14)
            .height(42)
            .backgroundColor(COLORS.starLight)
            .borderRadius(10)
            .onChange((value: string) => {
              this.inputTitle = value
            })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Column({ space: 8 }) {
          Text('更新日')
            .fontSize(12)
            .fontColor(COLORS.gray)
          Row({ space: 6 }) {
            ForEach(WEEKDAY_OPTIONS, (d: string) => {
              Text(d)
                .fontSize(11)
                .fontColor(this.selectWeekday === d ? COLORS.white : COLORS.gray)
                .padding({ left: 8, right: 8, top: 6, bottom: 6 })
                .backgroundColor(this.selectWeekday === d ? COLORS.violet : COLORS.starLight)
                .borderRadius(10)
                .onClick(() => {
                  this.selectWeekday = d
                })
            }, (d: string) => d)
          }
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Row({ space: 10 }) {
          Text('更新提醒')
            .fontSize(13)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text(this.remindOn ? '🔔 已开启' : '🔕 已关闭')
            .fontSize(12)
            .fontColor(this.remindOn ? COLORS.violet : COLORS.hint)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS.starLight)
            .borderRadius(12)
            .onClick(() => {
              this.remindOn = !this.remindOn
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.peachLight)
        .borderRadius(12)

        Row({ space: 10 }) {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.gray)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.starLight)
            .borderRadius(20)
            .onClick(() => {
              this.showAddModal = false
            })
          Column().layoutWeight(1)
          Text('加入追番 ✨')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.violet)
            .borderRadius(20)
            .onClick(() => {
              this.followCount = this.followCount + 1
              this.showAddModal = false
            })
        }
        .width('100%')
      }
      .padding(16)
    }
    .width('88%')
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

  // ==================== 弹框2:编辑观看进度(进度条表单) ====================
  @Builder
  editProgressModal() {
    Column({ space: 14 }) {
      Row({ space: 8 }) {
        Text('⏱')
          .fontSize(20)
        Text('更新观看进度')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.hint)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')

      Row({ space: 10 }) {
        Text(getTypeMeta(this.editAnime.type).icon)
          .fontSize(24)
          .width(48)
          .height(48)
          .textAlign(TextAlign.Center)
          .backgroundColor(this.editAnime.coverColor + '22')
          .borderRadius(12)
        Column({ space: 3 }) {
          Text(this.editAnime.title)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
          Text('当前 ' + this.editAnime.currentEp.toString() + ' / ' + this.editAnime.episodes.toString() + ' 集')
            .fontSize(11)
            .fontColor(COLORS.gray)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')

      Column({ space: 8 }) {
        Text('看到第几集')
          .fontSize(12)
          .fontColor(COLORS.violetDark)
          .fontWeight(FontWeight.Bold)
        TextInput({ placeholder: '输入集数', text: this.editEpisodes })
          .fontSize(14)
          .height(42)
          .backgroundColor(COLORS.starLight)
          .borderRadius(10)
          .onChange((value: string) => {
            this.editEpisodes = value
          })
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding(14)
      .border({ width: 1, color: COLORS.violet, radius: 12 })

      Row({ space: 10 }) {
        Text('标记完结 🏁')
          .fontSize(13)
          .fontColor(COLORS.gray)
          .padding({ left: 18, right: 18, top: 10, bottom: 10 })
          .backgroundColor(COLORS.starLight)
          .borderRadius(18)
          .onClick(() => {
            this.editAnime.currentEp = this.editAnime.episodes
            this.showEditModal = false
          })
        Column().layoutWeight(1)
        Text('保存进度 💾')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 18, right: 18, top: 10, bottom: 10 })
          .backgroundColor(COLORS.peach)
          .borderRadius(18)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')
    }
    .width('88%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

  // ==================== 弹框3:删除追番(小警示卡) ====================
  @Builder
  deleteAnimeModal() {
    Column({ space: 14 }) {
      Text('🗑')
        .fontSize(34)
      Text('移出追番列表?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
      Text('「' + CIRCLE_POSTS[this.deleteIndex].author + '」的这条帖子将不再展示,互动记录一并清除')
        .fontSize(12)
        .fontColor(COLORS.gray)
        .textAlign(TextAlign.Center)
      Row({ space: 12 }) {
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.gray)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.starLight)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
        Text('删除')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.red)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
      }
    }
    .width('72%')
    .padding({ top: 24, bottom: 22, left: 18, right: 18 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
  }

  // ==================== 弹框4:番剧详情(大卡+声优+相似推荐) ====================
  @Builder
  animeDetailModal() {
    Column() {
      Column({ space: 8 }) {
        Text(getTypeMeta(this.selectedAnime.type).icon)
          .fontSize(42)
        Text(this.selectedAnime.title)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text(this.selectedAnime.studio + ' · ' + this.selectedAnime.season + '番')
          .fontSize(12)
          .fontColor('#F3DFFB')
      }
      .width('100%')
      .padding({ top: 20, bottom: 18 })
      .backgroundColor(this.selectedAnime.coverColor)
      .borderRadius({ topLeft: 16, topRight: 16 })

      Scroll() {
        Column({ space: 12 }) {
          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('⭐ ' + ratingText(this.selectedAnime.rating))
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.violet)
              Text('评分')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(this.selectedAnime.episodes.toString() + '集')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.peach)
              Text('总集数')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(this.selectedAnime.currentEp.toString() + '集')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.blue)
              Text('已看')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(this.selectedAnime.weekday)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gray)
              Text('更新日')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.starLight)
          .borderRadius(12)

          Column({ space: 6 }) {
            Text('📖 剧情简介')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text(this.selectedAnime.desc + '。制作组稳定发挥,节奏张弛有度,是本季不容错过的作品。')
              .fontSize(12)
              .fontColor(COLORS.gray)
              .lineHeight(19)
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)

          Column({ space: 8 }) {
            Text('🎙 主要声优')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              ForEach(['🎤', '🎧', '🎹', '🎭'], (mic: string) => {
                Column({ space: 4 }) {
                  Text(mic)
                    .fontSize(20)
                    .width(44)
                    .height(44)
                    .textAlign(TextAlign.Center)
                    .backgroundColor(COLORS.starLight)
                    .borderRadius(22)
                  Text('声优')
                    .fontSize(9)
                    .fontColor(COLORS.gray)
                }
                .layoutWeight(1)
              }, (mic: string) => mic)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Column({ space: 8 }) {
            Text('🏷 作品标签')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              ForEach(this.selectedAnime.tags, (t: string) => {
                Text('#' + t)
                  .fontSize(11)
                  .fontColor(COLORS.violetDark)
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .backgroundColor(COLORS.starLight)
                  .borderRadius(10)
              }, (t: string) => this.selectedAnime.id.toString() + t)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 10 }) {
            Text('✕ 关闭')
              .fontSize(13)
              .fontColor(COLORS.gray)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.starLight)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
            Column().layoutWeight(1)
            Text('继续追番 📺')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.violet)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
          }
          .width('100%')
        }
        .padding(14)
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('88%')
    .height('78%')
    .backgroundColor(COLORS.bg)
    .borderRadius(16)
  }

  // ==================== 弹框遮罩 ====================
  @Builder
  modalOverlay() {
    Column() {
      Column().layoutWeight(1)
      if (this.showAddModal) {
        Column() {
          this.addAnimeModal()
        }
      }
      if (this.showEditModal) {
        Column() {
          this.editProgressModal()
        }
      }
      if (this.showDeleteModal) {
        Column() {
          this.deleteAnimeModal()
        }
      }
      if (this.showDetailModal) {
        Column() {
          this.animeDetailModal()
        }
      }
      Column().layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(40,20,60,0.55)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.showAddModal = false
      this.showEditModal = false
      this.showDeleteModal = false
      this.showDetailModal = false
    })
  }

  // ==================== 主构建 ====================
  build() {
    Stack() {
      Column() {
        this.headerBar()
        if (this.bottomTab === '追番') {
          this.topTabBar()
          this.followContent()
        }
        if (this.bottomTab === '时间表') {
          this.scheduleContent()
        }
        if (this.bottomTab === '圈子') {
          this.circleContent()
        }
        if (this.bottomTab === '我的') {
          this.mineContent()
        }
        this.bottomTabBar()
      }
      .width('100%')
      .height('100%')

      this.particleLayer()

      if (this.showAddModal || this.showEditModal || this.showDeleteModal || this.showDetailModal) {
        Column() {
          this.modalOverlay()
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


在这里插入图片描述

技术总结

本文以"QQ追番·番剧社"二次元追番社区App为完整案例,深度剖析了基于HarmonyOS 6.1.1 和 HarmonyOS ArkTS API 24 的复杂页面开发实践。从技术层面来看,这个项目展现了ArkTS声明式UI开发的多个核心能力。

第一,分层架构的工程实践。项目将代码清晰地分为配色配置层、数据接口层、数据模型层、纯函数工具层和视图构建层。每一层都有明确的职责边界,通过类型安全的接口和 Record 配置实现松耦合。这种分层方式使得代码可读性强、维护成本低,是ArkTS工程化的推荐模式。

第二,状态驱动的高效渲染。通过15个 @State 变量和1个 @Observed 数据模型,实现了Tab切换、弹框管理、表单输入、进度更新、粒子动画等多种交互场景的状态管理。ArkTS的自动Diff算法确保了每次状态变更只更新受影响的UI区域,避免了全量重绘的性能开销。aboutToAppear/aboutToDisappear 生命周期函数的正确使用,确保了定时器等资源的及时清理。

第三,@Builder 的UI复用能力。项目将11个复杂的UI片段封装为独立的 @Builder 方法,包括导航栏、Tab栏、海报卡、4个内容区、4种弹框和遮罩层。这种封装方式既保持了代码的模块化,又避免了独立组件的实例化开销,在性能与可维护性之间取得了良好平衡。

第四,纯ArkTS实现复杂视觉效果。项目未引入任何第三方图表库或动画库,仅使用ArkTS内置的布局组件和属性方法,就实现了柱状图(Column 高度计算)、进度条(百分比宽度+弹性宽度)、粒子动画(setInterval + position 定位)、渐变背景(linearGradient)、圆角阴影等丰富的视觉效果。这证明了ArkTS API 24 在UI表达能力上的成熟度。

第五,Stack三层叠加的架构设计。主 build 方法将页面分为内容层、粒子层和弹框层,通过 Stack 组件叠加渲染。每一层有独立的渲染条件和生命周期,通过 @State 变量统一驱动。这种架构模式在处理"内容+浮层+弹框"的复杂页面时非常实用,是ArkTS页面架构的典型范式。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐