鸿蒙操作系统(HarmonyOS)是面向万物互联时代的分布式操作系统,其应用开发框架 ArkUI 基于 TypeScript 扩展语言 ArkTS 构建。ArkTS 在保留 TypeScript 类型系统优势的同时,引入了声明式 UI 语法、状态管理装饰器、组件化模型等特性,使开发者能够以简洁、高效、类型安全的方式构建跨设备应用界面。理解 ArkTS 的核心机制——包括组件声明、状态驱动、布局容器、列表渲染、动画系统——是掌握鸿蒙原生开发的关键。

声明式 UI 是 ArkUI 框架的灵魂所在。与传统的命令式 UI(如 Android 的 View 体系)不同,声明式 UI 要求开发者描述界面"是什么"而非"怎么做"。开发者只需声明界面结构与状态之间的映射关系,框架自动负责在状态变化时高效地重新渲染受影响的部分。这种范式大幅减少了手写 DOM 操作或视图更新的样板代码,让开发者专注于业务逻辑与数据建模,同时保证了渲染性能的可预测性。

一、技术背景与开发范式

鸿蒙 HarmonyOS 的应用开发体系建立在 ArkUI 声明式框架之上,其核心编程语言 ArkTS 是 TypeScript 的超集。TypeScript 作为 JavaScript 的类型化扩展,为大型应用提供了静态类型检查能力,而 ArkTS 在此基础上进一步强化了类型约束的严格程度。在鸿蒙开发中,所有接口定义必须使用对象字面量形式(interface),所有组件内部禁止在 UI 构建方法中声明变量,所有可滚动区域必须使用单子组件包裹——这些约束并非随意制定,而是为了配合 ArkUI 编译器的静态分析能力,使其能够在编译期完成视图差分树的优化。

组件化开发思想是鸿蒙 ArkUI 的架构基石。在 ArkTS 中,每一个可复用的界面单元被封装为一个 @Component struct,组件拥有自己的状态(@State)、属性(普通成员变量)、构建方法(build())以及自定义构建片段(@Builder)。组件之间通过属性传递进行数据通信,通过回调函数实现事件冒泡。这种模型与 React 的函数组件、Flutter 的 Widget 体系在理念上相通,但在实现细节上具有鸿蒙独有的编译优化路径。

ArkTS 的状态管理系统是整个框架响应能力的核心。@State 装饰器标记的变量是组件的本地可变状态,当该变量的值发生变化时,ArkUI 框架会自动触发该组件 build() 方法的重新执行,并采用虚拟 DOM 差分算法,仅更新真正发生变化的 UI 节点。这种精细化的更新策略避免了全量重绘带来的性能开销。除了 @State,ArkTS 还提供了 @Prop@Link@Provide/@Consume@Observed/@ObjectLink 等多层级状态装饰器,分别用于父到子的单向同步、父子双向同步、跨组件树注入、嵌套对象观察等场景。

在布局系统方面,ArkUI 提供了 Column(纵向线性布局)、Row(横向线性布局)、Stack(堆叠布局)、Flex(弹性布局)四类核心容器。Column 将子组件沿主轴(垂直方向)依次排列,适用于列表、表单、信息流等纵向内容组织。Row 将子组件沿水平方向排列,适用于工具栏、标签栏、横向滚动卡片等场景。Stack 允许多个子组件在同一坐标系内堆叠,后入的组件覆盖在先入组件之上,常用于弹窗遮罩、浮层、角标等场景。Flex 则提供了更灵活的对齐与换行能力,适用于瀑布流、网格等自适应布局。

动画与过渡是提升应用质感的重要手段。ArkUI 内置了属性动画系统,通过链式调用 .animation() 方法,开发者可以为组件的 scale(缩放)、opacity(透明度)、rotate(旋转)、translate(平移)等可动画属性设置过渡曲线与时长。当这些属性的值因状态变化而改变时,框架自动插入平滑的过渡帧,而非瞬间的跳变。这种隐式动画模型与 CSS Transition 的理念类似,但在编译层面与渲染管线深度集成,性能表现更加优异。

HOME

CHART

FUND

WORKS

CLUB

ME

应用入口 @Entry

主组件 FanlightApp

状态: activeTab

contentArea 内容区域

bottomTabItem 底部导航

activeTab 判断

HomeContent 应援主页

ChartContent 打榜页

FundContent 集资页

WorksContent 二创页

ClubContent 应援会页

MePageContent 我的页

6个底部Tab项

上图展示了该应用的整体组件架构。主入口组件 FanlightApp 通过一个 @State 状态变量 activeTab 控制六个内容区域的条件渲染,配合底部导航栏实现页面切换。这种"状态驱动路由"的模式是 ArkUI 单页面应用的典型实现方式——无需引入路由框架,仅靠组件状态与条件分支即可完成多视图的组织。每个 Tab 对应一个独立的 @Component struct,内部再嵌套各自的滚动列表与弹窗系统,形成清晰的组件层次结构。

二、类型定义与数据建模

在深入 UI 代码之前,应用首先建立了一套完整的类型体系。类型定义是 ArkTS 强类型开发的基础,它约束了数据流的形状,使编译器能够在编译期捕获类型不匹配的错误。

2.1 艺人卡片接口

interface ArtistCard {
  id: number;
  name: string;
  fandom: string;
  color: string;
  light: string;
  birthday: string;
  fans: number;
  todayRank: number;
  trending: string;
}

在这里插入图片描述

ArtistCard 接口定义了艺人数据卡片的结构。它包含九个字段:id 是唯一标识符,name 是艺人名称,fandom 是粉丝群体的专属称呼,color 是该艺人的应援主题色,light 是对应的浅色背景色,birthday 是生日日期,fans 是粉丝数量,todayRank 是当日排名,trending 是动态趋势描述。

在 ArkTS 中,interface 用于定义对象字面量的形状契约。与 TypeScript 的 interface 不同,ArkTS 的接口严格要求所有属性都有明确的类型标注,不允许使用 any,也不支持合并声明(declaration merging)。这种严格性确保了数据模型的可预测性——当接口被 implements 或作为类型约束使用时,编译器会逐一检查字段是否齐全、类型是否匹配。

colorlight 这两个字段的设计值得特别关注。应用为每位艺人配置了一个主色(深色调)和一个浅色(浅色调),二者构成"应援色"的完整表达。主色用于文字强调、进度条填充、按钮背景等视觉重点区域,浅色则用于卡片背景、图标底色等大面积柔和区域。这种"主色 + 浅色"的双色配置是移动端配色设计的常见模式,既能保持品牌一致性,又能避免大面积高饱和度色彩带来的视觉疲劳。

trending 字段存储的是动态趋势文本,如"↑ 新专辑预售"“↓ 综艺播出”。这里的箭头符号(↑↓)用于在前端直观表达上升或下降趋势,而中横线(-)表示持平。这种将语义信息编码为可显示文本的设计简化了前端的渲染逻辑——无需再维护一套状态枚举与图标映射表,直接展示字符串即可。

2.2 集资项目接口

interface SupportProject {
  id: number;
  title: string;
  artist: string;
  target: number;
  raised: number;
  backers: number;
  deadline: string;
  category: string;
  verified: boolean;
  joined: boolean;
}

SupportProject 接口描述了集资项目的完整数据结构。title 是项目标题,artist 是关联的艺人名称,target 是集资目标金额,raised 是已筹金额,backers 是支持者人数,deadline 是截止日期,category 是分类标签,verified 表示是否经过平台认证,joined 表示当前用户是否已参与支持。

targetraised 的组合在前端用于计算进度百分比 (raised / target * 100),该百分比直接驱动进度条的宽度。verifiedjoined 两个布尔字段则控制条件渲染分支——认证标志("已认证"标签)仅在 verified 为真时显示,已参与状态会改变按钮的文案与配色。这种将业务语义编码为布尔标志的做法在状态管理中非常实用,因为它使 UI 条件分支只需一个简单的 if 判断即可完成。

2.3 打榜任务、二创作品与应援会接口

interface TaskItem {
  id: number;
  name: string;
  points: number;
  desc: string;
  done: boolean;
  icon: string;
}

interface FanWork {
  id: number;
  title: string;
  author: string;
  type: string;
  likes: number;
  tall: boolean;
  color: string;
}

interface FanClub {
  id: number;
  name: string;
  artist: string;
  members: number;
  level: number;
  icon: string;
  joined: boolean;
}

在这里插入图片描述

TaskItem 接口定义了打榜任务的数据结构,points 是任务奖励的星力值,done 标记完成状态,icon 使用 Emoji 字符作为图标——这是一种轻量级的图标方案,无需引入图片资源,直接使用 Unicode 字符渲染。

FanWork 接口定义了同人二创作品,其中 tall 布尔字段用于区分"高"作品和"矮"作品,在前端瀑布流中控制不同的卡片高度(130 或 84),从而形成错落有致的视觉效果。color 字段携带每个作品的主题色,用于卡片头部的背景着色。

FanClub 接口定义了应援会组织,level 表示粉丝团等级(数值越高越资深),members 是成员数量。这三个接口共同覆盖了应用的业务数据全貌。

艺人关联项目

艺人对应应援会

艺人相关二创

任务服务于打榜

ArtistCard

number

id

PK

string

name

string

fandom

string

color

number

fans

number

todayRank

SupportProject

number

id

PK

string

title

string

artist

FK

number

target

number

raised

boolean

verified

FanClub

number

id

PK

string

name

string

artist

FK

number

level

boolean

joined

FanWork

number

id

PK

string

title

string

type

boolean

tall

string

color

TaskItem

number

id

PK

string

name

number

points

boolean

done

上图为应用数据模型的实体关系图。ArtistCard(艺人)是核心实体,与 SupportProject(集资项目)、FanClub(应援会)、FanWork(二创作品)构成一对多关系。TaskItem(打榜任务)相对独立,服务于打榜排行榜的运营逻辑。在实际应用中,这些关联通过 artist 字符串字段进行软关联——即通过艺人名称匹配而非外键 ID,这种设计在 Mock 数据阶段简化了数据构建,但在生产环境中应替换为严格的外键引用以避免数据不一致。

三、数据类实现与 Mock 数据构建

3.1 应援色配置

const ARTIST_COLORS: string[] = ['#7C3AED', '#FB7185', '#F59E0B', '#06B6D4', '#10B981', '#F43F5E'];

在这里插入图片描述

ARTIST_COLORS 是一个全局常量数组,存储了六组主题色值。这些颜色采用十六进制 RGB 格式(如 #7C3AED 表示紫罗兰色)。const 声明确保该数组引用不可变,但需注意 ArkTS 中 const 数组的元素仍可被修改——若要完全冻结,需使用 readonly 修饰符或运行时 Object.freeze。在应用的实际使用中,每位艺人的 color 字段已独立携带主题色,因此这个全局数组更多是作为备用色板或设计参考存在。

色彩系统在视觉设计中的地位至关重要。本应用采用"星云紫粉风"配色策略:以薰衣草色为底色(#F7F5FF),紫罗兰色为主色(#7C3AED),玫红色为点缀色(#FB7185),鎏金色为强调色(#F59E0B)。这种四色体系在情感上传递了"梦幻、热情、珍贵"的应援氛围,在功能上区分了不同信息层级——主色用于核心交互元素,点缀色用于警示与进度,强调色用于数值与奖励标识。

3.2 艺人数据类与构造函数

class ArtistCardData implements ArtistCard {
  id: number = 0;
  name: string = '';
  fandom: string = '';
  color: string = '';
  light: string = '';
  birthday: string = '';
  fans: number = 0;
  todayRank: number = 0;
  trending: string = '';

  constructor(id: number, name: string, fandom: string, color: string, light: string,
    birthday: string, fans: number, todayRank: number, trending: string) {
    this.id = id;
    this.name = name;
    // ... 赋值所有字段
  }
}

在这里插入图片描述

ArtistCardData 是一个实现了 ArtistCard 接口的具体类。implements 关键字表明该类必须包含接口中定义的所有属性和方法,否则编译报错。每个字段在声明时都赋予了默认值(如 id: number = 0),这是 ArkTS 的良好实践——它确保了即使构造函数遗漏某个字段的赋值,对象也不会处于"未初始化"的危险状态。

构造函数 constructor 接收九个参数并逐一赋值给实例属性。这种"全参数构造函数"的模式适合 Mock 数据的批量创建,但在真实业务中,当字段过多时可以考虑使用 Builder 模式或对象展开语法来提升可读性。值得注意的是,ArkTS 在类成员初始化方面比 TypeScript 更严格——不允许使用 ! 非空断言来跳过初始化,所有非可选属性必须在声明处或构造函数中完成赋值。

3.3 艺人 Mock 数据数组

const mockArtists: ArtistCard[] = [
  new ArtistCardData(1, '月见凛', '望月人', '#7C3AED', '#EDE9FE', '04-17', 8200000, 1, '↑ 新专辑预售'),
  new ArtistCardData(2, '苏晚晴', '晴雨伞', '#FB7185', '#FFE4E6', '09-02', 6300000, 2, '↑ 巡演开票'),
  // ... 共16位艺人
];

mockArtists 数组包含 16 位艺人的模拟数据。每条数据通过 new ArtistCardData(...) 构造,参数顺序与接口字段顺序对应。数组类型标注为 ArtistCard[]——即以接口作为元素类型,这利用了 ArkTS 的结构化类型特性:任何实现 ArtistCard 接口的类实例都可以作为该数组的合法元素。

Mock 数据在前端开发中扮演着至关重要的角色。在没有后端 API 或后端尚未就绪的阶段,前端开发者通过 Mock 数据模拟真实数据流,验证 UI 渲染、交互逻辑、边界条件处理等。优秀的 Mock 数据应当覆盖各种边界情况——本数据中既有粉丝数千万的头部艺人,也有几十万的中尾部艺人;既有上升趋势,也有持平与下降趋势;既有已加入的应援会,也有未加入的。这种数据多样性使 UI 在各种状态下都能得到充分测试。

16 位艺人的 todayRank 从 1 到 16 连续排列,意味着这是一个完整的排名榜单。前端在打榜页会筛选 todayRank <= 5 的艺人来展示"实时 TOP5"——这种"全量数据 + 前端筛选"的模式在数据量较小时效率可接受,但当数据规模增大时,应考虑在数据层或后端进行分页与过滤。

3.4 集资项目与任务数据类

class SupportProjectData implements SupportProject {
  id: number = 0;
  title: string = '';
  // ... 字段定义
  verified: boolean = false;
  joined: boolean = false;

  constructor(id: number, title: string, artist: string, target: number, raised: number,
    backers: number, deadline: string, category: string, verified: boolean, joined: boolean) {
    // ... 赋值
  }
}

const mockProjects: SupportProject[] = [
  new SupportProjectData(1, '月见凛生日地铁应援', '月见凛', 80000, 66420, 2140, '09-12', '生日应援', true, true),
  // ... 共18个项目
];

SupportProjectData 类遵循与 ArtistCardData 相同的设计模式——实现接口、定义默认值、构造函数全参数赋值。18 个集资项目涵盖了生日应援、演出应援、打榜、公益应援、线下应援、作品应援、音乐支持七种分类,verifiedjoined 字段在不同项目中取值不同,确保了筛选与条件渲染逻辑能够被充分验证。

deadline 字段存储的是日期字符串(如 '09-12'),仅包含月和日,省略了年份。这种设计假设所有项目都在当前年度内,适用于运营场景明确的项目类型。但在需要跨年比较的场景下,应补全年份信息以避免排序歧义。

class TaskItemData implements TaskItem {
  id: number = 0;
  name: string = '';
  points: number = 0;
  desc: string = '';
  done: boolean = false;
  icon: string = '';

  constructor(id: number, name: string, points: number, desc: string, done: boolean, icon: string) {
    // ... 赋值
  }
}

const mockTasks: TaskItem[] = [
  new TaskItemData(1, '每日签到', 10, '连续7天有额外宝箱', true, '📅'),
  new TaskItemData(2, '播放主打歌30次', 30, '今日已播 22/30', false, '🎵'),
  // ... 共10条任务
];

在这里插入图片描述

mockTasks 数组的 10 条任务覆盖了签到、播放、分享、评论、观看 MV、购买专辑、二创制作、邀请好友、直播互动、粉丝问答等典型粉丝运营场景。points 字段的值从 10 到 100 不等,代表了任务难度与奖励价值的梯度。done 布尔值在不同任务中分布不同,使 UI 能够同时呈现"已完成"和"待完成"两种状态样式的渲染效果。

3.5 二创作品与应援会数据

class FanWorkData implements FanWork {
  id: number = 0;
  title: string = '';
  author: string = '';
  type: string = '';
  likes: number = 0;
  tall: boolean = false;
  color: string = '';

  constructor(id: number, title: string, author: string, type: string, likes: number, tall: boolean, color: string) {
    // ... 赋值
  }
}

const mockWorks: FanWork[] = [
  new FanWorkData(1, '月见凛·星轨拟人立绘', '画师小鹿', '插画', 12840, true, '#7C3AED'),
  // ... 共16件作品
];

mockWorks 的 16 件二创作品覆盖了插画、视频、书法、设计、表情、音乐、摄影、手工、攻略、漫画、技术等多种创作类型。tall 字段的分布使瀑布流布局产生高低交错的视觉效果,这是 CSS Grid 与 Flex Wrap 布局中常见的"砌墙"美学的数据驱动实现。

class FanClubData implements FanClub {
  id: number = 0;
  name: string = '';
  artist: string = '';
  members: number = 0;
  level: number = 0;
  icon: string = '';
  joined: boolean = false;

  constructor(id: number, name: string, artist: string, members: number, level: number, icon: string, joined: boolean) {
    // ... 赋值
  }
}

const mockClubs: FanClub[] = [
  new FanClubData(1, '望月人总部', '月见凛', 48210, 10, '🌙', true),
  // ... 共12个应援会
];

在这里插入图片描述

mockClubs 包含 12 个应援会,level 从 5 到 10 分布,joined 字段前两个为 true,其余为 falseicon 字段使用 Emoji 字符(如 🌙☂️🌹)作为应援会的图标标识,这种方案无需加载图片资源,在性能上具有优势,同时也赋予每个应援会独特的视觉符号。

四、工具函数与枚举定义

4.1 类型标签与数字格式化

function getTypeBadge(type: string): string {
  if (type === '视频') {
    return '#7C3AED';
  } else if (type === '插画' || type === '漫画') {
    return '#FB7185';
  } else if (type === '音乐') {
    return '#F59E0B';
  } else if (type === '设计') {
    return '#06B6D4';
  } else {
    return '#10B981';
  }
}

getTypeBadge 是一个全局工具函数,根据二创作品的类型字符串返回对应的主题色值。函数使用 if-else if 链式判断,将五种主要类型(视频、插画/漫画、音乐、设计)映射到不同颜色,其余类型回退到默认绿色。这种"类型到颜色"的映射在前端常用于标签、徽章等视觉元素的动态着色。

全局函数在 ArkTS 中定义于组件外部,使用 function 关键字声明。与组件方法不同,全局函数不绑定组件实例,没有 this 上下文,因此适合处理纯逻辑性的、不依赖组件状态的运算。当函数需要被多个组件复用时,提取为全局函数是避免代码重复的有效手段。不过,ArkTS 也支持 @Builder 修饰的"全局构建函数",用于复用 UI 片段。

function formatWan(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1) + '万';
  }
  return n.toString();
}

formatWan 函数实现中文数字的"万"单位格式化。当数字大于等于 10000 时,除以 10000 并保留一位小数,拼接"万"字后缀(如 8200000"820.0万")。小于 10000 的数字直接转为字符串。这种格式化在中文互联网产品中极为常见,因为直接显示完整数字(如"8200000 粉丝")在小屏幕上会占用过多宽度,而"820.0万"的写法既简洁又符合中文阅读习惯。

toFixed(1) 方法是 JavaScript/TypeScript 内置的数字格式化方法,它将数字四舍五入到指定小数位并返回字符串。例如 (8200000 / 10000).toFixed(1) 的结果为 "820.0"。需注意 toFixed 存在浮点精度问题(如 (0.1 + 0.2).toFixed(1) 可能产生意外结果),但在本场景的整数除法中不会触发该问题。

4.2 集资档位工具函数

function getFundTierName(v: number): string {
  if (v === 20) {
    return '小行星';
  } else if (v === 50) {
    return '卫星级';
  } else if (v === 100) {
    return '行星级';
  } else {
    return '恒星级';
  }
}

getFundTierName 函数将集资金额档位映射为天体等级名称。20 元对应"小行星"、50 元对应"卫星级"、100 元对应"行星级"、300 元(else 分支)对应"恒星级"。这种命名策略将抽象的金额数字与用户熟悉的宇宙天体概念关联,利用了"金额越大、天体越大"的认知隐喻,降低了用户理解档位差异的认知成本。

4.3 Tab 枚举定义

enum FanTab {
  HOME,
  CHART,
  FUND,
  WORKS,
  CLUB,
  ME
}

在这里插入图片描述

FanTab 是一个枚举类型,定义了应用底部导航的六个 Tab 标签。在 ArkTS 中,enum 是 TypeScript 枚举的子集,默认从 0 开始递增赋值。使用枚举而非魔法数字(如 012)来标识 Tab 状态,显著提升了代码的可读性——activeTab === FanTab.HOMEactiveTab === 0 更能表达意图。

枚举在状态管理中的优势在于类型安全与可扩展性。当需要新增 Tab 时,只需在枚举中添加一个成员,IDE 会提示所有未处理该枚举值的 switchif-else 链,帮助开发者避免遗漏分支。本应用使用 if-else if-else 链而非 switch 来判断 activeTab,两种方式在功能上等价,但 switch 语句在枚举分支较多时通常更清晰。

对应组件

枚举值

HOME = 0

CHART = 1

FUND = 2

WORKS = 3

CLUB = 4

ME = 5

HomeContent

ChartContent

FundContent

WorksContent

ClubContent

MePageContent

五、主入口组件与导航架构

5.1 主入口声明

@Entry
@Component
struct FanlightApp {
  @State activeTab: FanTab = FanTab.HOME

在这里插入图片描述

@Entry 装饰器标记 FanlightApp 为应用的入口组件——即页面渲染树的根节点。一个页面只能有一个 @Entry 组件,它由框架自动实例化并挂载到根容器。@Component 装饰器声明该 struct 是一个可复用的 UI 组件,它必须实现 build() 方法来描述界面结构。

@Entry@Component 是鸿蒙 ArkUI 中最重要的两个装饰器。@Component 负责将一个 struct 注册到组件系统中,赋予它状态管理、生命周期、构建方法等能力。@Entry 则在 @Component 基础上额外标记该组件为页面入口,框架会为其创建独立的渲染上下文。理解这两个装饰器的分工,是理解 ArkUI 组件模型的第一步。

@State activeTab: FanTab = FanTab.HOME 声明了一个本地状态变量。@State 装饰器使该变量具备响应性——当 activeTab 的值被重新赋值时,ArkUI 框架会自动触发组件 build() 方法的重新执行,更新依赖该变量的 UI 节点。初始值设为 FanTab.HOME,意味着应用启动时默认展示应援主页。

5.2 内容区域构建器

@Builder contentArea() {
  Column() {
    if (this.activeTab === FanTab.HOME) {
      HomeContent()
    } else if (this.activeTab === FanTab.CHART) {
      ChartContent()
    } else if (this.activeTab === FanTab.FUND) {
      FundContent()
    } else if (this.activeTab === FanTab.WORKS) {
      WorksContent()
    } else if (this.activeTab === FanTab.CLUB) {
      ClubContent()
    } else {
      MePageContent()
    }
  }
  .layoutWeight(1)
}

@Builder 装饰器将一个方法标记为"自定义构建片段"。与 build() 方法不同,@Builder 方法不能独立成为组件,但可以被组件的其他构建方法调用,实现 UI 结构的复用与拆分。contentArea() 方法封装了 Tab 内容区域的条件渲染逻辑——根据 activeTab 的当前值,选择性地实例化对应的内容组件。

@Builder 装饰器是 ArkUI 中实现 UI 片段复用的核心机制。它类似于其他框架中的"渲染函数"或"无状态组件"——接收参数、返回 UI 结构,但本身不维护状态。@Builder 方法的优势在于:它可以将大型 build() 方法拆分为多个语义化的片段,提升代码可读性;它还可以被多个组件调用,避免 UI 代码重复。需要注意的是,@Builder 方法内部不能使用 this 访问外部局部变量,但可以访问组件的成员。

Column 容器包裹了条件分支,并使用 .layoutWeight(1) 占据剩余空间。layoutWeight 是 ArkUI 线性布局的权重属性——当父容器分配空间时,设置了 layoutWeight 的子组件会按权重比例瓜分剩余空间。layoutWeight(1) 意味着该 Column 会填满除底部导航栏之外的全部高度。

条件渲染使用 if-else if-else 链而非 switch。在 ArkUI 中,if 语句的每个分支会被编译为独立的渲染节点子树,当条件变化时,框架会卸载旧分支的节点、挂载新分支的节点。这种"条件渲染"模式确保了同一时刻只有一个 Tab 的内容组件被实例化和渲染,避免了全部 Tab 同时存在的内存开销。

5.3 底部导航项构建器

@Builder bottomTabItem(icon: string, label: string, tab: FanTab) {
  Column() {
    Text(icon)
      .fontSize(20)
      .opacity(this.activeTab === tab ? 1.0 : 0.4)
      .scale({ x: this.activeTab === tab ? 1.2 : 1.0, y: this.activeTab === tab ? 1.2 : 1.0 })
      .animation({ duration: 180, curve: Curve.EaseOut })
    Text(label)
      .fontSize(9)
      .fontColor(this.activeTab === tab ? '#7C3AED' : '#A1A1B5')
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 6, bottom: 6 })
  .onClick(() => { this.activeTab = tab })
}

bottomTabItem 是一个参数化的 @Builder 方法,接收三个参数:icon(Emoji 图标)、label(文字标签)、tab(对应的枚举值)。这种参数化设计使六个底部导航项可以共享同一套渲染逻辑,仅通过参数区分内容——这体现了 DRY(Don’t Repeat Yourself)原则在 UI 开发中的应用。

每个导航项内部是一个 Column,垂直排列图标 Text 和文字 Text。图标使用 .fontSize(20) 设置为较大字号,文字使用 .fontSize(9) 设置为较小字号,形成视觉层级。.opacity.scale 属性根据 this.activeTab === tab 的判断结果动态取值——选中状态时透明度为 1.0(完全不透明)、缩放为 1.2(放大 20%),未选中时透明度为 0.4(半透明)、缩放为 1.0(原始大小)。

.animation({ duration: 180, curve: Curve.EaseOut }) 为上述可动画属性(opacity、scale)设置了 180 毫秒的过渡动画,缓动曲线为 EaseOut(先快后慢)。当用户切换 Tab 时,新旧图标的透明度和缩放会平滑过渡,而非瞬间跳变,这种微交互反馈显著提升了应用的精致感。

.onClick(() => { this.activeTab = tab }) 绑定了点击事件处理器。用户点击导航项时,activeTab 被赋值为该项对应的枚举值,触发 @State 响应链——contentArea() 重新执行切换内容区域、bottomTabItem 内的样式判断重新计算选中状态。

5.4 主构建方法

build() {
  Column() {
    this.contentArea()
    Row() {
      this.bottomTabItem('⭐', '应援', FanTab.HOME)
      this.bottomTabItem('📈', '打榜', FanTab.CHART)
      this.bottomTabItem('💰', '集资', FanTab.FUND)
      this.bottomTabItem('🎨', '二创', FanTab.WORKS)
      this.bottomTabItem('🏰', '应援会', FanTab.CLUB)
      this.bottomTabItem('👤', '我的', FanTab.ME)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .padding({ top: 5, bottom: 7 })
    .shadow({ radius: 14, color: '#267C3AED', offsetY: -4 })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F7F5FF')
}

build() 方法是组件的核心,描述了界面的完整结构。外层 Column 将内容区域和底部导航栏垂直排列,设置全屏尺寸(width('100%')height('100%'))和薰衣草底色(#F7F5FF)。

Row 容器横向排列六个 bottomTabItem,每个导航项通过 .layoutWeight(1) 等分宽度,形成均匀分布的底部栏。.shadow 属性为导航栏添加阴影效果——radius: 14 控制模糊半径、color: '#267C3AED' 使用主色加透明度前缀(26 即约 15% 不透明度)模拟紫色投影、offsetY: -4 使阴影向上偏移,营造导航栏"悬浮"于内容之上的视觉层次感。

阴影是表达界面层次的重要视觉语言。ArkUI 的 .shadow() 方法接受一个对象参数,包含 radius(模糊半径)、color(阴影颜色)、offsetX/offsetY(偏移量)三个维度。在实际设计中,阴影颜色不宜使用纯黑,而应采用主题色的低透明度版本——这样阴影既能传达层次信息,又与整体色调协调。本例用 #267C3AED(紫色 15% 透明度)替代纯黑阴影,正是这种设计理念的体现。

六、公共头部组件

6.1 StarHeader 组件声明

@Component
struct StarHeader {
  title: string = '星海应援';
  subtitle: string = '';
  badgeText: string = '粉丝团';
  onBadgeTap: () => void = () => {};
  onSearchTap: () => void = () => {};

StarHeader 是一个可复用的公共头部组件,用于在多个 Tab 页面顶部统一展示。它声明了五个成员变量作为组件的输入属性:title(主标题)、subtitle(副标题)、badgeText(徽章文字)、onBadgeTap(徽章点击回调)、onSearchTap(搜索点击回调)。

在 ArkUI 中,组件的输入属性是普通成员变量(非 @State)。父组件在实例化子组件时通过构造参数传递数据,如 StarHeader({ title: '...', subtitle: '...' })。这些属性在子组件内部是只读的——子组件不应直接修改传入的属性值。当需要双向同步时,应使用 @Prop(单向)或 @Link(双向)替代普通变量。本组件作为纯展示型组件,所有属性只用于渲染,不涉及修改,因此使用普通成员变量即可。

onBadgeTaponSearchTap 两个回调属性采用了 () => void 函数类型。默认值设为空箭头函数 () => {},确保即使父组件未传入回调,点击操作也不会抛出异常。这种"默认空函数"的防御式编程在组件库设计中尤为重要——它使组件在各种使用场景下都保持健壮。

6.2 头部构建方法

build() {
  Column() {
    Row() {
      Column() {
        Text('💜 ' + this.badgeText)
          .fontSize(13)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Medium)
        Text('我的粉丝团')
          .fontSize(8)
          .fontColor('#C4B5FD')
          .margin({ top: 1 })
      }
      .alignItems(HorizontalAlign.Start)
      .onClick(() => { this.onBadgeTap() })

      Row() {
        Text('🔍')
          .fontSize(13)
          .margin({ left: 10 })
        Text('搜索偶像 / 应援项目 / 二创')
          .fontSize(11)
          .fontColor('#9CA3AF')
          .margin({ left: 6 })
      }
      .layoutWeight(1)
      .height(34)
      .backgroundColor('#8B5CF633')
      .borderRadius(17)
      .margin({ left: 12 })
      .onClick(() => { this.onSearchTap() })

      Text('🔔')
        .fontSize(16)
        .width(34)
        .height(34)
        .textAlign(TextAlign.Center)
        .backgroundColor('#8B5CF633')
        .borderRadius(17)
        .margin({ left: 8 })
        .onClick(() => { this.onBadgeTap() })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 10 })

头部的第一行 Row 横向排列了三个元素:左侧的粉丝团徽章(Column 包裹两行文字)、中间的搜索框(Row 包裹图标和提示文字)、右侧的通知铃铛(Text)。

搜索框使用 .layoutWeight(1) 占据中间的弹性空间,高度设为 34(绝对像素),背景色 #8B5CF633(紫色加 20% 透明度),圆角 borderRadius(17) 形成药丸形状。圆角值为高度的一半,这是创建完全圆角的标准做法。左侧徽章和右侧铃铛使用固定宽度,构成"固定 - 弹性 - 固定"的经典三段式布局。

圆角(borderRadius)是移动端设计中无处不在的视觉元素。ArkUI 的 .borderRadius() 方法接受数字或对象参数。数字形式如 .borderRadius(17) 将四个角统一设为 17;对象形式如 .borderRadius({ topLeft: 24, topRight: 24 }) 可单独控制每个角——本应用在底部抽屉弹窗中使用了对象形式,仅对顶部两角设置大圆角,模拟从底部滑出的抽屉形态。

    Row() {
      Column() {
        Text(this.title)
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        if (this.subtitle !== '') {
          Text(this.subtitle)
            .fontSize(10)
            .fontColor('#C4B5FD')
            .margin({ top: 2 })
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 10 })
  }
  .width('100%')
  .linearGradient({
    angle: 135,
    colors: [['#7C3AED', 0], ['#4C1D95', 1]]
  })
}

头部的第二行 Row 展示主标题和副标题。主标题 this.title 使用 22 号粗体白色字,副标题 this.subtitle 使用 10 号浅紫色字。副标题使用条件渲染 if (this.subtitle !== '') 控制显示——当父组件传入空字符串时,副标题不渲染,避免出现空白区域。

整个 StarHeader 的最外层 Column 应用 .linearGradient() 方法设置线性渐变背景。渐变方向 angle: 135(从左上到右下的 135 度角),颜色从 #7C3AED(紫罗兰)在 0% 位置到 #4C1D95(深紫)在 100% 位置。这种深色渐变背景使白色文字获得高对比度,同时赋予头部丰富的视觉深度。

线性渐变是增强界面视觉表现力的重要手段。ArkUI 的 .linearGradient() 方法接受一个配置对象:angle 定义渐变角度(0 度向上,90 度向右,依此类推),colors 是一个颜色断点数组,每个元素为 [颜色值, 位置] 的元组,位置从 0 到 1。通过设置多个断点,可以创建多色渐变效果。渐变背景与纯色背景相比,能传达更丰富的情感与品牌调性。

StarHeader 公共头部

第一行: 功能操作栏

第二行: 标题区域

粉丝团徽章 Column

搜索框 Row layoutWeight1

通知铃铛 Text

主标题 Text 22号粗体

subtitle 非空?

副标题 Text 10号

不渲染

linearGradient 紫色渐变背景

七、应援主页(HomeContent)深度解析

7.1 组件状态与弹窗构建器

@Component
struct HomeContent {
  @State showArtistModal: boolean = false
  @State showThanksModal: boolean = false
  @State pickedArtist: ArtistCard | null = null

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(60,40,100,0.45)')
      .onClick(onClose)
  }

HomeContent 组件管理三个状态变量:showArtistModal 控制艺人详情弹窗的显示、showThanksModal 控制感谢弹窗的显示、pickedArtist 存储当前被点击选中的艺人数据。pickedArtist 的类型为 ArtistCard | null——使用联合类型允许该变量为 null,表示"未选中任何艺人"的初始状态。

联合类型与空值处理是 ArkTS 类型安全的重要组成。ArtistCard | null 表示变量可以是 ArtistCard 实例或 null。在访问 pickedArtist 的属性前,必须进行空值检查(如 this.pickedArtist === null ? '' : this.pickedArtist.name),否则编译器会报错。这种"强制空安全"设计从源头消除了空指针异常的风险,是 ArkTS 相比 JavaScript 的核心优势之一。

modalOverlay 是一个参数化的 @Builder,接收 onClose 回调函数,构建弹窗的半透明遮罩层。遮罩是一个全屏 Column,背景色使用 rgba(60,40,100,0.45)——一种深紫色加 45% 透明度的覆盖色。点击遮罩触发 onClose 回调关闭弹窗,这是移动端弹窗交互的标准模式。

7.2 艺人详情弹窗

@Builder artistModal() {
  Column() {
    this.modalOverlay(() => { this.showArtistModal = false })
    Column() {
      Row() {
        Text('⭐')
          .fontSize(38)
          .width(72)
          .height(72)
          .textAlign(TextAlign.Center)
          .backgroundColor(this.pickedArtist === null ? '#EDE9FE' : this.pickedArtist.light)
          .borderRadius(24)
        Column() {
          Text(this.pickedArtist === null ? '' : this.pickedArtist.name)
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1F2937')
          Text(this.pickedArtist === null ? '' : this.pickedArtist.fandom + ' · ' +
            formatWan(this.pickedArtist === null ? 0 : this.pickedArtist.fans) + ' 粉丝')
            .fontSize(10)
            .fontColor('#6B7280')
            .margin({ top: 3 })
          Text('应援色 · 生日 ' + (this.pickedArtist === null ? '' : this.pickedArtist.birthday))
            .fontSize(9)
            .fontColor(this.pickedArtist === null ? '#7C3AED' : this.pickedArtist.color)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 14 })
      }
      .width('100%')
      .padding(16)

artistModal 构建了艺人应援详情弹窗的完整结构。弹窗由两层 Column 嵌套构成——外层 Column 全屏覆盖并居中内容,内层 Column 是白色圆角卡片本体。

弹窗顶部是艺人的头像区域,使用 Row 横向排列星形图标和艺人信息。图标背景色使用三元表达式 this.pickedArtist === null ? '#EDE9FE' : this.pickedArtist.light——当未选中艺人时回退到默认浅紫色,选中后使用该艺人的专属浅色。这种"动态着色"使每个艺人的卡片都有独特的视觉标识。

艺人信息区域的三个 Text 分别展示姓名、粉丝数(经过 formatWan 格式化)和应援色/生日信息。第三个 TextfontColor 使用艺人主色 this.pickedArtist.color,使文字颜色与艺人应援色一致,强化品牌关联。所有属性访问都通过三元表达式进行空值保护,确保在 pickedArtistnull 时不会崩溃。

弹窗是移动应用中频繁使用的交互模式。ArkUI 没有内置 Modal 组件,开发者通过 Stack 堆叠布局 + 条件渲染来自行实现。模式:将弹窗内容作为 Stack 的后续子节点(覆盖在主内容之上),用 @State 布尔变量控制其挂载/卸载,遮罩层处理点击关闭。这种"手搓弹窗"的方式虽然需要更多代码,但提供了完全的定制自由度。

7.3 艺人日程与应援按钮

      Text('近期日程')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
        .width('100%')
        .margin({ top: 14 })

      Column() {
        Row() {
          Text('09-02')
            .fontSize(10)
            .fontColor('#7C3AED')
            .width(44)
          Text('新专辑《引力波》预售开启')
            .fontSize(11)
            .fontColor('#374151')
            .layoutWeight(1)
          Text('预热')
            .fontSize(9)
            .fontColor('#9CA3AF')
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        // ... 更多日程行
      }
      .width('100%')
      .padding({ left: 16, right: 16 })

      Text('去应援')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('90%')
        .textAlign(TextAlign.Center)
        .padding({ top: 13, bottom: 13 })
        .backgroundColor('#7C3AED')
        .borderRadius(22)
        .margin({ top: 16, bottom: 20 })
        .onClick(() => {
          this.showArtistModal = false
          this.showThanksModal = true
        })

日程区域使用多个 Row 纵向排列,每行包含日期、事件描述和状态标签三列。日期列固定宽度 44,事件描述列使用 .layoutWeight(1) 填充剩余空间,状态标签列右对齐。这种"日期 - 内容 - 状态"三列结构是日程列表的经典布局。

"去应援"按钮是一个 Text 组件,通过样式属性模拟按钮外观——.backgroundColor 设置紫色背景、.borderRadius(22) 设置大圆角、.padding 设置内边距。点击按钮时执行两个状态变更:先关闭艺人详情弹窗(showArtistModal = false),再打开感谢弹窗(showThanksModal = true)。这种"关闭一个弹窗同时打开另一个"的模式实现了弹窗的链式流转。

7.4 感谢弹窗与星形旋转动画

@Builder thanksModal() {
  Column() {
    this.modalOverlay(() => { this.showThanksModal = false })
    Column() {
      Text('🌟')
        .fontSize(52)
        .rotate({ angle: 360 })
        .animation({ duration: 900, curve: Curve.EaseOut })
      Text('感谢你的应援!')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
        .margin({ top: 12 })

感谢弹窗展示应援成功后的反馈信息。星形 Emoji 使用 .rotate({ angle: 360 }) 设置旋转 360 度,配合 .animation({ duration: 900, curve: Curve.EaseOut }) 实现 900 毫秒的一圈旋转动画。Curve.EaseOut 缓动曲线使旋转在开始时快速、结束时减速,模拟自然旋转的物理感。

.rotate() 是 ArkUI 的变换属性,接受一个 { angle: 度数 } 对象。正值表示顺时针旋转。与 .scale().translate() 类似,.rotate() 是一个可动画属性——当 angle 值变化时,若配合 .animation() 声明,框架会自动插入旋转过渡帧。在弹窗显示瞬间,angle 从 0 变为 360,触发一次完整的旋转动画,给予用户"庆祝"的视觉仪式感。

感谢弹窗还包含一个进度条区域,展示"当前星力 1240 / 2000"。进度条使用嵌套 Column 实现——外层 Column 是灰色轨道(backgroundColor('#EDE9FE')),内层 Column 是紫色填充(backgroundColor('#7C3AED')),宽度设为 '62%'。这种"轨道 + 填充"的双层结构是进度条的标准实现方式。

7.5 应援主页主体构建

build() {
  Stack() {
    Scroll() {
      Column() {
        StarHeader({ title: '星海应援', subtitle: '今日星力榜已更新 · 为爱发电', onBadgeTap: () => {}, onSearchTap: () => {} })

        Text('🔥 我的偶像')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .width('100%')
          .padding({ left: 16, top: 14 })

HomeContentbuild() 方法使用 Stack 作为根容器。Stack 的作用是允许弹窗层覆盖在主内容层之上——主内容(Scroll)作为 Stack 的第一个子节点,弹窗(artistModalthanksModal)作为后续子节点,后者覆盖在前者之上。

Scroll 容器包裹了页面的可滚动内容。根据 ArkUI 的约束,Scroll 只能有一个直接子组件,因此内部使用 Column 作为唯一子节点,所有内容纵向排列在 Column 中。.scrollBar(BarState.Off) 隐藏滚动条,使界面更加简洁。.layoutWeight(1) 使 Scroll 占据 Stack 内除弹窗外的全部空间。

Scroll 组件是 ArkUI 中实现可滚动内容区的核心容器。它只能包含一个直接子组件(通常是 ColumnRow),通过 scrollable 属性指定滚动方向(ScrollDirection.HorizontalVertical),通过 scrollBar 属性控制滚动条显示。Scroll 还支持嵌套——横向 Scroll 可以嵌套在纵向 Scroll 内部,实现"纵向上嵌套横向滚动卡片"的常见信息流布局。

页面首先实例化 StarHeader 公共头部,传入标题和副标题。然后是一个区域标题"🔥 我的偶像",使用 15 号粗体字。区域标题之后是艺人横向滚动卡片区。

7.6 艺人横向滚动卡片

        Scroll() {
          Row() {
            ForEach(mockArtists, (a: ArtistCard) => {
              Column() {
                Text('⭐')
                  .fontSize(36)
                  .width(84)
                  .height(84)
                  .textAlign(TextAlign.Center)
                  .backgroundColor(a.light)
                  .borderRadius(24)
                Text(a.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1F2937')
                  .margin({ top: 8 })
                Text(a.fandom)
                  .fontSize(9)
                  .fontColor(a.color)
                  .margin({ top: 2 })
                Text('今日 #' + a.todayRank)
                  .fontSize(9)
                  .fontColor('#9CA3AF')
                  .margin({ top: 3 })
              }
              .width(108)
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 14, bottom: 14 })
              .backgroundColor('#FFFFFF')
              .borderRadius(18)
              .margin({ left: 6, right: 6 })
              .scale({ x: 1.0, y: 1.0 })
              .animation({ duration: 130, curve: Curve.EaseOut })
              .onClick(() => {
                this.pickedArtist = a
                this.showArtistModal = true
              })
            }, (a: ArtistCard) => a.id.toString())
          }
          .padding({ left: 10, right: 10 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

艺人卡片区域使用嵌套 Scroll(横向)+ Row + ForEach 的组合实现横向滚动的卡片列表。ForEach 是 ArkUI 的列表渲染指令,它遍历 mockArtists 数组,为每个元素生成一组 UI 节点。

ForEach 是 ArkUI 中实现列表渲染的核心 API。它接受三个参数:数据源数组、项目生成函数(item generator)、键值生成函数(key generator)。项目生成函数接收当前元素及其索引,返回一个 UI 结构。键值生成函数为每个元素生成唯一标识符,框架利用该标识符进行 diff 优化——当数组元素增删或重排时,仅更新发生变化的节点,而非重建整个列表。键值的唯一性至关重要,本例使用 a.id.toString() 作为键值,确保每个艺人卡片有稳定的身份标识。

每张艺人卡片是一个固定宽度 108 的 Column,内部纵向排列星形头像、姓名、粉丝称呼和当日排名。头像背景色使用 a.light(艺人专属浅色),粉丝称呼文字颜色使用 a.color(艺人专属主色),使每张卡片呈现不同的配色主题。

.scale({ x: 1.0, y: 1.0 }) 配合 .animation({ duration: 130, curve: Curve.EaseOut }) 预设了缩放动画。虽然当前缩放值为 1.0(无变化),但 .animation() 的声明使后续缩放变化能够触发过渡动画。点击卡片时,pickedArtist 被赋值为当前艺人 ashowArtistModal 设为 true,触发展示艺人详情弹窗。

7.7 热门集资项目列表

        Row() {
          Text('💰 热门集资项目')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1F2937')
            .layoutWeight(1)
          Text('全部 >')
            .fontSize(11)
            .fontColor('#7C3AED')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 20 })

        ForEach(mockProjects, (p: SupportProject) => {
          if (p.raised > 60000) {
            Column() {
              Row() {
                Column() {
                  Text(p.title)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1F2937')
                  Row() {
                    Text(p.category)
                      .fontSize(8)
                      .fontColor('#7C3AED')
                      .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                      .backgroundColor('#EDE9FE')
                      .borderRadius(7)
                    if (p.verified) {
                      Text('已认证')
                        .fontSize(8)
                        .fontColor('#10B981')
                        .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                        .backgroundColor('#D1FAE5')
                        .borderRadius(7)
                        .margin({ left: 6 })
                    }
                    Text('· ' + p.artist)
                      .fontSize(9)
                      .fontColor('#9CA3AF')
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column() {
                  Text('¥' + formatWan(p.raised))
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#F59E0B')
                  Text('目标 ¥' + formatWan(p.target))
                    .fontSize(9)
                    .fontColor('#9CA3AF')
                    .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')

热门项目区域首先是一个 Row,左侧是"💰 热门集资项目"标题(.layoutWeight(1) 占据左侧空间),右侧是"全部 >"链接文字。这种"标题 + 更多"的行布局在信息流应用中极为常见。

项目列表使用 ForEach 遍历 mockProjects,但通过 if (p.raised > 60000) 条件筛选仅展示已筹金额超过 6 万的项目。这种在 ForEach 内部嵌套 if 的模式使筛选逻辑内联于渲染过程,无需预先过滤数组。当筛选条件依赖响应式状态时(如分类标签),筛选结果会随状态变化自动更新。

每张项目卡片包含项目标题行和金额信息行。项目标题行使用 Row 横向排列分类标签、认证标志和艺人名称。分类标签是一个小号药丸(padding 内边距 + borderRadius(7) + 浅紫色背景),认证标志仅在 p.verified 为真时渲染。金额信息行右对齐,展示已筹金额(金色粗体)和目标金额(灰色小字)。

条件渲染(if 在 UI 构建方法中)是 ArkUI 响应式渲染的基础机制。当条件表达式的值变化时,框架会比较新旧条件的差异,精确地添加或移除对应的 UI 节点。对于 if (p.verified),当 verifiedfalse 变为 true 时,认证标签节点被插入;反之则被移除。这种节点级别的精细增删避免了不必要的全量重绘。

7.8 项目进度条与底部信息

              Column() {
                Column()
                  .width((p.raised / p.target * 100).toFixed(0) + '%')
                  .height(7)
                  .backgroundColor(p.raised / p.target > 0.9 ? '#FB7185' : '#7C3AED')
                  .borderRadius(4)
              }
              .width('100%')
              .height(7)
              .backgroundColor('#F3F0FA')
              .borderRadius(4)
              .margin({ top: 10 })

              Row() {
                Text(formatWan(p.backers) + ' 人支持')
                  .fontSize(9)
                  .fontColor('#9CA3AF')
                  .layoutWeight(1)
                Text('距结束 ' + p.deadline + ' 天')
                  .fontSize(9)
                  .fontColor('#FB7185')
              }
              .width('100%')
              .margin({ top: 8 })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(16)
            .margin({ top: 8, left: 12, right: 12 })
          }
        }, (p: SupportProject) => p.id.toString())

进度条区域使用双层 Column 结构:外层是灰色轨道(backgroundColor('#F3F0FA'),高度 7),内层是彩色填充(宽度由百分比计算,高度 7)。填充宽度通过 (p.raised / p.target * 100).toFixed(0) + '%' 动态计算——例如已筹 66420、目标 80000,则 66420/80000*100 = 83.025toFixed(0) 四舍五入为 "83",拼接 % 后为 "83%"

填充颜色使用条件表达式 p.raised / p.target > 0.9 ? '#FB7185' : '#7C3AED'——当完成度超过 90% 时变为玫红色(表示即将达成目标,制造紧迫感),否则为紫色。这种基于数据阈值的动态配色使进度条具备语义传达能力。

进度条是集资类应用的核心可视化元素。它将抽象的金额比例转化为直观的视觉长度,使用户无需心算即可感知项目完成度。ArkUI 中没有内置 Progress 组件的复杂定制能力,开发者通过"轨道 + 填充"的双层 Column 即可灵活实现各种风格的进度条。这种"原子化"的组件设计理念是 ArkUI 的特色——基础组件简单,复杂效果由基础组件组合而成。

八、打榜页(ChartContent)深度解析

8.1 签到打卡弹窗

@Component
struct ChartContent {
  @State showCheckModal: boolean = false
  @State showTaskModal: boolean = false
  @State pickedTask: TaskItem | null = null
  @State totalPoints: number = 185

ChartContent 管理四个状态变量。totalPoints 初始值为 185,代表用户今日已获得的星力值。签到成功后通过 this.totalPoints += 10 增加 10 点,该赋值触发 @State 响应,UI 中的星力数字自动更新。

  @Builder checkModal() {
    Column() {
      this.modalOverlay(() => { this.showCheckModal = false })
      Column() {
        Text('📅')
          .fontSize(46)
          .scale({ x: 1.15, y: 1.15 })
          .animation({ duration: 300, curve: Curve.EaseOut })
        Text('今日签到成功')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 10 })
        Text('星力 +10 · 已连续签到 14 天')
          .fontSize(11)
          .fontColor('#7C3AED')
          .margin({ top: 6 })
        Row() {
          ForEach([1, 2, 3, 4, 5, 6, 7], (d: number) => {
            Column() {
              Text(d <= 5 ? '✓' : d.toString())
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(d <= 5 ? '#FFFFFF' : '#C4B5FD')
              Text(d === 7 ? '宝箱' : '+' + (d * 10))
                .fontSize(8)
                .fontColor(d <= 5 ? '#10B981' : '#9CA3AF')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(d <= 5 ? '#7C3AED' : '#F3F0FA')
            .borderRadius(10)
            .margin({ left: 3, right: 3 })
          }, (d: number) => d.toString())
        }
        .width('90%')
        .margin({ top: 18 })

签到弹窗展示连续签到七天的日历格子。ForEach 遍历数字数组 [1, 2, 3, 4, 5, 6, 7],每个数字代表签到天数。已签到天数(d <= 5)显示勾号"✓"、紫色背景、白色文字;未签到天数显示数字、浅灰背景。第七天特殊处理为"宝箱"奖励。

这种"连续签到日历"是游戏化运营的典型设计——通过可视化签到进度(前 5 天已完成,还差 2 天可得宝箱),激励用户保持每日活跃。每个格子使用 .layoutWeight(1) 等分宽度,配合外层 Row 形成 7 列均匀网格。

签到按钮在弹窗外的主体中,点击时执行两个操作:this.totalPoints += 10(增加星力)和 this.showCheckModal = true(打开弹窗)。状态更新与弹窗打开同时发生,用户在弹窗中看到的是已增加后的星力值。

8.2 任务详情弹窗

  @Builder taskModal() {
    Column() {
      this.modalOverlay(() => { this.showTaskModal = false })
      Column() {
        Row() {
          Text(this.pickedTask === null ? '⭐' : this.pickedTask.icon)
            .fontSize(34)
            .width(62)
            .height(62)
            .textAlign(TextAlign.Center)
            .backgroundColor('#EDE9FE')
            .borderRadius(18)
          Column() {
            Text(this.pickedTask === null ? '' : this.pickedTask.name)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
            Text('+' + (this.pickedTask === null ? 0 : this.pickedTask.points) + ' 星力')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F59E0B')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 14 })
        }
        .width('100%')
        .padding(16)

任务详情弹窗展示选中任务的完整信息。弹窗顶部使用 Row 横向排列任务图标和任务名称/星力值。任务名称和星力值都从 this.pickedTask 中读取,同样使用三元表达式进行空值保护。

弹窗还包含任务说明区域和规则提示文本。规则提示使用多行文本(\n 换行)展示"任务每日 00:00 重置"“星力值实时计入偶像总榜”"严禁刷量,违规将扣除双倍星力"三条规则。底部按钮根据任务完成状态显示不同文案——已完成时显示绿色"已完成 ✓"按钮,未完成时显示紫色"去完成"按钮。

打榜页 ChartContent

StarHeader 头部

星力汇总卡

今日星力数字

签到按钮

本周柱状图

任务清单 ForEach

实时榜 TOP5

点击任务

taskModal 任务详情

点击签到

totalPoints += 10

checkModal 签到成功

8.3 星力汇总卡与柱状图

  build() {
    Stack() {
      Scroll() {
        Column() {
          StarHeader({ title: '打榜中心', subtitle: '月见凛 · 本周星力 32.4万', onBadgeTap: () => {}, onSearchTap: () => {} })

          Column() {
            Row() {
              Column() {
                Text(this.totalPoints.toString())
                  .fontSize(26)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#7C3AED')
                Text('今日已获星力')
                  .fontSize(10)
                  .fontColor('#6B7280')
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('📅 签到')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .padding({ left: 22, right: 22, top: 10, bottom: 10 })
                .backgroundColor('#7C3AED')
                .borderRadius(18)
                .scale({ x: 1.0, y: 1.0 })
                .animation({ duration: 140, curve: Curve.EaseOut })
                .onClick(() => {
                  this.totalPoints += 10
                  this.showCheckModal = true
                })
            }
            .width('100%')

星力汇总卡左侧展示今日星力数字(this.totalPoints.toString() 转为字符串),右侧是签到按钮。totalPoints 初始值为 185,使用 toString() 方法转为字符串后渲染。由于 totalPoints@State 变量,每次签到后 += 10 赋值都会触发该 Text 的重新渲染,数字实时更新。

签到按钮预设了 .scale({ x: 1.0, y: 1.0 }).animation(),为后续可能的按下缩放效果预留动画声明。

            Text('本周星力产出')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#374151')
              .width('100%')
              .margin({ top: 16 })
            Row() {
              ForEach([120, 260, 185, 310, 240, 380, 185], (v: number) => {
                Column() {
                  Column()
                    .width(16)
                    .height((v * 0.32).toFixed(0) + 'vp')
                    .backgroundColor(v >= 350 ? '#FB7185' : '#7C3AED')
                    .borderRadius({ topLeft: 4, topRight: 4 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
                .justifyContent(FlexAlign.End)
                .height(125)
              }, (v: number) => v.toString())
            }
            .width('100%')
            .alignItems(VerticalAlign.Bottom)
            .margin({ top: 8 })
            Row() {
              ForEach(['一', '二', '三', '四', '五', '六', '日'], (d: string) => {
                Text(d)
                  .fontSize(9)
                  .fontColor('#9CA3AF')
                  .layoutWeight(1)
                  .textAlign(TextAlign.Center)
              }, (d: string) => d)
            }
            .width('100%')
            .margin({ top: 6 })

柱状图使用 ForEach 遍历数据数组 [120, 260, 185, 310, 240, 380, 185],为每个值生成一个柱子。每根柱子是一个 Column,内部嵌套一个填充 Column——填充的高度通过 (v * 0.32).toFixed(0) + 'vp' 动态计算,vp 是 ArkUI 的虚拟像素单位。

柱子颜色使用条件表达式 v >= 350 ? '#FB7185' : '#7C3AED'——值大于等于 350 时为玫红色(高亮峰值),否则为紫色。这种数据驱动的配色使柱状图具备数据故事的讲述能力。外层 Column 高度固定为 125,使用 .justifyContent(FlexAlign.End) 使内部柱子从底部对齐生长。

FlexAlign 是 ArkUI 弹性布局的对齐枚举,包含 StartCenterEndSpaceBetweenSpaceAroundSpaceEvenly 等值。在 Column 中,FlexAlign.End 使子组件向底部对齐;在 Row 中则使子组件向右对齐。柱状图利用该属性实现柱子从底向上的生长效果——即使各柱子高度不同,底部也保持齐平。

8.4 任务列表与实时榜

          ForEach(mockTasks, (t: TaskItem) => {
            Row() {
              Text(t.icon)
                .fontSize(22)
                .width(44)
                .height(44)
                .textAlign(TextAlign.Center)
                .backgroundColor('#F3F0FA')
                .borderRadius(14)
              Column() {
                Text(t.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1F2937')
                Text(t.desc)
                  .fontSize(9)
                  .fontColor('#9CA3AF')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })

              Column() {
                Text('+' + t.points)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(t.done ? '#10B981' : '#F59E0B')
                Text(t.done ? '已完成' : '去完成')
                  .fontSize(9)
                  .fontColor(t.done ? '#10B981' : '#FFFFFF')
                  .padding({ left: 10, right: 10, top: 3, bottom: 3 })
                  .backgroundColor(t.done ? '#D1FAE5' : '#7C3AED')
                  .borderRadius(9)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding(13)
            .backgroundColor('#FFFFFF')
            .borderRadius(16)
            .margin({ top: 8, left: 12, right: 12 })
            .onClick(() => {
              this.pickedTask = t
              this.showTaskModal = true
            })
          }, (t: TaskItem) => t.id.toString())

任务列表每行使用 Row 横向排列图标、任务信息和奖励/状态。任务信息的颜色根据 t.done 动态变化——已完成时星力数字为绿色、状态标签为绿色"已完成";未完成时星力数字为金色、状态标签为紫色"去完成"。这种基于状态的配色变化使用户无需阅读文字即可快速识别任务完成度。

点击任务行时,pickedTask 被赋值为当前任务 tshowTaskModal 设为 true,打开任务详情弹窗。

          ForEach(mockArtists, (a: ArtistCard) => {
            if (a.todayRank <= 5) {
              Row() {
                Text('#' + a.todayRank)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(a.todayRank === 1 ? '#F59E0B' : '#9CA3AF')
                  .width(36)
                Text('⭐')
                  .fontSize(20)
                  .width(36)
                  .height(36)
                  .textAlign(TextAlign.Center)
                  .backgroundColor(a.light)
                  .borderRadius(18)
                Column() {
                  Text(a.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1F2937')
                  Text(a.trending)
                    .fontSize(9)
                    .fontColor(a.color)
                    .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })
                Text(formatWan(a.fans * 4) + ' 星力')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#7C3AED')
              }
              .width('100%')
              .padding(12)
              .backgroundColor('#FFFFFF')
              .borderRadius(14)
              .margin({ top: 6, left: 12, right: 12 })
            }
          }, (a: ArtistCard) => 'chart' + a.id.toString())

实时榜使用 ForEach 遍历全部 16 位艺人,但通过 if (a.todayRank <= 5) 仅渲染排名前五的艺人。排名数字 #1 使用金色(#F59E0B),其余使用灰色,突出冠军的特殊地位。星力值通过 a.fans * 4 计算模拟(粉丝数乘以 4 作为星力估算),再经 formatWan 格式化。

键值生成函数使用 'chart' + a.id.toString()——拼接了前缀 "chart" 以区分于其他使用艺人 idForEach。在同一组件中多次 ForEach 使用相同键值函数可能导致框架的 diff 混淆,因此为不同列表的键值添加前缀是一种良好的实践。

九、集资页(FundContent)深度解析

9.1 分类筛选与项目管理

@Component
struct FundContent {
  @State pickedCat: string = '全部'
  @State showSupportModal: boolean = false
  @State showCreateModal: boolean = false
  @State showEditModal: boolean = false
  @State showCancelModal: boolean = false
  @State pickedProject: SupportProject | null = null
  @State amountPicked: number = 50
  @State projName: string = ''
  @State projTarget: string = ''
  @State projDesc: string = ''
  catList: string[] = ['全部', '生日应援', '演出应援', '打榜', '公益应援', '线下应援', '作品应援', '音乐支持']

FundContent 是状态最丰富的组件,管理九个 @State 变量和一个普通成员变量。pickedCat 控制分类筛选,初始为"全部"。amountPicked 跟踪支持金额档位选择,初始为 50。projNameprojTargetprojDesc 三个字符串变量存储新建项目表单的输入内容,通过 TextInputonChange 回调双向绑定。

当组件状态变量较多时,合理的状态分组与管理变得关键。本组件的九个状态可以按功能分组:弹窗开关组(四个 boolean)、表单数据组(三个 string)、选中数据组(pickedProject、amountPicked)、筛选状态(pickedCat)。虽然 ArkUI 不强制使用状态管理库,但在复杂组件中,开发者应注意状态变更的因果链路,避免不必要的渲染开销。

9.2 支持集资弹窗(底部抽屉)

  @Builder supportModal() {
    Column() {
      this.modalOverlay(() => { this.showSupportModal = false })
      Column() {
        Row() {
          Text('支持这个项目')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1F2937')
          Text('❌')
            .fontSize(14)
            .fontColor('#9CA3AF')
            .onClick(() => { this.showSupportModal = false })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 16, right: 16, top: 16 })

支持弹窗采用"底部抽屉"形态——不同于居中弹窗,抽屉从屏幕底部滑入,顶部两角为大圆角。抽屉的外层 Column 使用 .justifyContent(FlexAlign.End) 使内容贴底排列。

标题行使用 .justifyContent(FlexAlign.SpaceBetween) 使标题和关闭按钮分列两端。SpaceBetween 对齐模式使子组件之间的间距均等,首尾贴边,这是工具栏布局的标准选择。

        Row() {
          ForEach([20, 50, 100, 300], (v: number) => {
            Column() {
              Text('¥' + v.toString())
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.amountPicked === v ? '#FFFFFF' : '#1F2937')
              Text(getFundTierName(v))
                .fontSize(8)
                .fontColor(this.amountPicked === v ? '#EDE9FE' : '#9CA3AF')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 14, bottom: 14 })
            .backgroundColor(this.amountPicked === v ? '#7C3AED' : '#F3F0FA')
            .borderRadius(14)
            .margin({ left: 4, right: 4 })
            .scale({
              x: this.amountPicked === v ? 1.05 : 1.0,
              y: this.amountPicked === v ? 1.05 : 1.0
            })
            .animation({ duration: 140, curve: Curve.EaseOut })
            .onClick(() => { this.amountPicked = v })
          }, (v: number) => v.toString())
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 16 })

金额档位选择器使用 ForEach 遍历 [20, 50, 100, 300] 四个档位。每个档位卡片显示金额和天体名称(通过 getFundTierName 转换)。选中状态的卡片背景为紫色、文字为白色,并放大 5%(.scale({ x: 1.05, y: 1.05 })),未选中时背景为浅灰、文字为深色。选中状态的缩放变化配合 .animation() 实现平滑的选中反馈动画。

点击档位卡片时,amountPicked 被赋值为对应金额,触发 @State 响应,四个卡片的样式重新计算——选中态转移到被点击的卡片。这种"单选切换"模式在 ArkUI 中通过共享一个状态变量加条件样式即可实现,无需额外的 RadioGroup 组件。

.scale() 接受一个 { x, y } 对象,分别控制水平和垂直方向的缩放比例。值为 1.0 表示原始大小,1.05 表示放大 5%,0.4 表示缩小到 40%。scale 是变换属性,不影响布局——缩放后的组件仍占据原始尺寸的空间,只是视觉上变大或变小。这种特性使缩放动画不会引起周围元素的位移抖动。

9.3 新建集资项目表单

  @Builder createModal() {
    Column() {
      this.modalOverlay(() => { this.showCreateModal = false })
      Column() {
        Text('🚀 新建集资项目')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 20 })
        Text('需粉丝团等级 Lv.5 以上,平台审核通过后上线')
          .fontSize(9)
          .fontColor('#9CA3AF')
          .margin({ top: 4 })

        Column() {
          Text('项目名称')
            .fontSize(11)
            .fontColor('#6B7280')
            .width('100%')
          TextInput({ placeholder: '例:生日地铁灯箱应援', text: this.projName })
            .fontSize(12)
            .fontColor('#1F2937')
            .placeholderColor('#C4B5FD')
            .backgroundColor('#F7F5FF')
            .borderRadius(10)
            .padding({ left: 12, right: 12 })
            .height(38)
            .margin({ top: 6 })
            .onChange((v: string) => { this.projName = v })
        }
        .width('100%')
        .padding(13)
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .border({ width: 1, color: '#EDE9FE' })
        .margin({ top: 14 })

新建项目弹窗包含一个多字段表单。每个表单字段使用 Column 包裹标签 Text 和输入框 TextInputTextInput 是 ArkUI 内置的文本输入组件,通过构造参数接收配置——placeholder 是占位提示文字,text 是当前值。

onChange 回调是 TextInput 的核心事件——当用户输入文本时,每次按键都会触发该回调,参数 v 是最新的输入值。在回调中执行 this.projName = v,将输入值同步到 @State 变量。这种"输入即更新状态"的模式实现了表单的双向绑定——虽然 TextInputtext 参数也引用了 this.projName,但真正的驱动是 onChange 回调中的状态赋值。

TextInput 是 ArkUI 中处理用户文本输入的基础组件。它与 Text(只读文本展示)的区别在于可编辑性。TextInputonChange 回调是响应用户输入的主要通道。需注意 ArkUI 的 TextInput 不支持 v-model 指令式的自动双向绑定,开发者必须手动在 onChange 中同步状态。这种"显式绑定"虽然代码稍多,但使数据流向完全透明,便于调试。

表单字段容器使用 .border({ width: 1, color: '#EDE9FE' }) 添加 1 像素浅紫色边框,配合白色背景和圆角,形成清晰的输入区域视觉边界。

9.4 编辑与取消弹窗

  @Builder editModal() {
    // 编辑已有项目信息的表单
  }

  @Builder cancelModal() {
    Column() {
      this.modalOverlay(() => { this.showCancelModal = false })
      Column() {
        Text('⚠️')
          .fontSize(40)
          .margin({ top: 22 })
        Text('确认取消该集资项目?')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E11D48')
          .margin({ top: 10 })
        Text('已有支持者的资金将原路退回(1-3个工作日),取消记录将影响发起人信用分。')
          .fontSize(10)
          .fontColor('#6B7280')
          .textAlign(TextAlign.Center)
          .margin({ top: 10, left: 20, right: 20 })
        Row() {
          Text('再想想')
            .fontSize(13)
            .fontColor('#6B7280')
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .backgroundColor('#F3F0FA')
            .borderRadius(18)
            .onClick(() => { this.showCancelModal = false })
          Text('确认取消')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 26, right: 26, top: 10, bottom: 10 })
            .backgroundColor('#E11D48')
            .borderRadius(18)
            .margin({ left: 12 })
            .onClick(() => { this.showCancelModal = false })
        }
        .margin({ top: 22, bottom: 22 })
      }
      .width('80%')
      .backgroundColor('#FFFFFF')
      .borderRadius(22)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

取消弹窗是一个"危险确认"对话框,使用警告色 #E11D48(深玫红)作为标题和确认按钮的颜色。危险确认弹窗的设计原则是:标题使用警示色、描述说明后果、提供"取消"(安全操作)和"确认"(危险操作)两个按钮,且安全操作按钮通常在左侧(符合阅读习惯,避免误触)。

9.5 集资页主体与分类筛选

  build() {
    Stack() {
      Column() {
        StarHeader({ title: '集资中心', subtitle: '全部资金透明公示 · 平台监管', onBadgeTap: () => { this.showCreateModal = true }, onSearchTap: () => {} })

        Scroll() {
          Row() {
            ForEach(this.catList, (c: string) => {
              Text(c)
                .fontSize(11)
                .fontColor(this.pickedCat === c ? '#FFFFFF' : '#6B7280')
                .fontWeight(this.pickedCat === c ? FontWeight.Bold : FontWeight.Normal)
                .padding({ left: 13, right: 13, top: 7, bottom: 7 })
                .backgroundColor(this.pickedCat === c ? '#7C3AED' : '#FFFFFF')
                .borderRadius(15)
                .margin({ right: 8 })
                .onClick(() => { this.pickedCat = c })
            }, (c: string) => c)
          }
          .padding({ left: 14, right: 14 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

分类筛选栏使用横向 Scroll + Row + ForEach 渲染八个分类标签。每个标签根据 this.pickedCat === c 的判断结果切换选中态样式——选中时白色粗体紫色背景,未选中时灰色常规白色背景。点击标签时 pickedCat 被赋值为该分类,触发下方项目列表的重新筛选渲染。

分类标签 ForEach

pickedCat === c?

选中样式: 白字紫底

未选中: 灰字白底

pickedCat = c

项目列表重新筛选

pickedCat === '全部' OR p.category === pickedCat?

渲染项目卡片

跳过该项目

9.6 项目列表与筛选逻辑

        Scroll() {
          Column() {
            Row() {
              Text('🛠️ 我发起的项目(1)')
                .fontSize(12)
                .fontColor('#7C3AED')
                .padding({ left: 14, right: 14, top: 9, bottom: 9 })
                .backgroundColor('#EDE9FE')
                .borderRadius(14)
                .onClick(() => { this.showEditModal = true })
              Text('🗑️ 取消集资')
                .fontSize(12)
                .fontColor('#E11D48')
                .padding({ left: 14, right: 14, top: 9, bottom: 7 })
                .backgroundColor('#FFE4E6')
                .borderRadius(14)
                .margin({ left: 10 })
                .onClick(() => { this.showCancelModal = true })
            }
            .width('100%')
            .padding({ top: 4, bottom: 4, left: 12, right: 12 })

            ForEach(mockProjects, (p: SupportProject) => {
              if (this.pickedCat === '全部' || p.category === this.pickedCat) {
                Column() {
                  // 项目卡片内容
                }
                .width('100%')
                .padding(14)
                .backgroundColor('#FFFFFF')
                .borderRadius(16)
                .margin({ top: 10, left: 12, right: 12 })
                .onClick(() => {
                  this.pickedProject = p
                  this.showSupportModal = true
                })
              }
            }, (p: SupportProject) => p.id.toString() + this.pickedCat)
          }
          .width('100%')
        }
        .scrollBar(BarState.Off)
        .layoutWeight(1)

项目列表的筛选逻辑位于 ForEach 内部:if (this.pickedCat === '全部' || p.category === this.pickedCat)。当选中"全部"时展示所有项目,否则仅展示分类匹配的项目。这种"短路或"逻辑使"全部"成为一个通配标签。

键值生成函数使用 p.id.toString() + this.pickedCat——拼接了当前选中分类。这意味着当分类切换时,所有项目的键值都发生变化,框架会将其视为"新节点"重新创建。这种设计虽然牺牲了一定的 diff 效率,但确保了筛选切换时的干净渲染,避免了旧分类下的卡片残留。

ForEach 的键值策略是一个需要权衡的设计决策。稳定的键值使框架能够高效复用节点,但当筛选条件改变导致列表内容大幅变化时,拼接筛选条件到键值可以强制重新创建节点,避免潜在的脏状态问题。开发者应根据具体场景选择"稳定键值 + diff 更新"或"变化键值 + 重建"策略。

十、二创页(WorksContent)深度解析

10.1 二创详情弹窗

@Component
struct WorksContent {
  @State pickedType: string = '全部'
  @State showDetailModal: boolean = false
  @State showLikeModal: boolean = false
  @State showReportModal: boolean = false
  @State pickedWork: FanWork | null = null
  typeList: string[] = ['全部', '插画', '视频', '音乐', '设计', '漫画', '摄影', '手工']

WorksContent 管理五个 @State 变量和一个类型列表。pickedType 控制创作类型筛选,pickedWork 存储选中作品数据。

  @Builder workDetailModal() {
    Column() {
      this.modalOverlay(() => { this.showDetailModal = false })
      Column() {
        Column() {
          Text('🎨')
            .fontSize(60)
            .width('100%')
            .textAlign(TextAlign.Center)
        }
        .width('100%')
        .height(140)
        .backgroundColor(this.pickedWork === null ? '#EDE9FE' : this.pickedWork.color + '33')
        .borderRadius({ topLeft: 22, topRight: 22 })

        Column() {
          Row() {
            Text(this.pickedWork === null ? '' : this.pickedWork.title)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
              .layoutWeight(1)
            Text('❌')
              .fontSize(14)
              .fontColor('#9CA3AF')
              .onClick(() => { this.showDetailModal = false })
          }
          .width('100%')

作品详情弹窗顶部是一个 140 高度的彩色区域,背景色使用 this.pickedWork.color + '33'——将作品的 6 位十六进制颜色值拼接 '33'(约 20% 透明度)后缀,形成浅色调。这种"颜色 + 透明度后缀"的技巧是 ArkUI 中动态生成半透明色的常用方法,无需调用 rgba() 函数。

顶部区域仅对顶部两角设置大圆角 .borderRadius({ topLeft: 22, topRight: 22 }),底部两角为直角,与下方的白色内容区域无缝衔接。这种"顶部圆角"处理使弹窗顶部呈现卡片头部的视觉感。

10.2 点赞与举报弹窗

  @Builder likeModal() {
    Column() {
      this.modalOverlay(() => { this.showLikeModal = false })
      Column() {
        Text('💖')
          .fontSize(54)
          .scale({
            x: this.showLikeModal ? 1.0 : 0.4,
            y: this.showLikeModal ? 1.0 : 0.4
          })
          .animation({ duration: 320, curve: Curve.EaseOut })
        Text('已为作者点赞!')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 12 })

点赞弹窗的爱心 Emoji 使用 .scale() 配合 this.showLikeModal 状态实现弹跳动画。当弹窗打开时 showLikeModaltrue,缩放为 1.0;弹窗关闭时为 false,缩放为 0.4。配合 320 毫秒的 EaseOut 动画,爱心从缩小状态弹跳到原始大小,模拟"爱心弹出"的欢快效果。

弹跳动画是社交类应用中常见的情感化设计。通过缩放从 0.4 到 1.0 的弹性过渡,配合 EaseOut 缓动曲线(开始快、结束慢),模拟物理弹簧的回弹效果。这种微妙的动画细节能够强化用户的情感反馈——点赞不仅是一个功能操作,更是一次情感表达,动画为其赋予了仪式感。

  @Builder reportModal() {
    Column() {
      this.modalOverlay(() => { this.showReportModal = false })
      Column() {
        Text('举报该作品')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 18 })
        ForEach(['涉嫌抄袭他人作品', '未经授权商用', '内容不当', '恶意引战', '其他问题'], (r: string) => {
          Text(r)
            .fontSize(13)
            .fontColor('#374151')
            .width('100%')
            .padding({ top: 13, bottom: 13 })
            .textAlign(TextAlign.Center)
            .onClick(() => { this.showReportModal = false })
        }, (r: string) => r)
        Text('取消')
          .fontSize(12)
          .fontColor('#9CA3AF')
          .padding({ top: 10, bottom: 16 })
          .onClick(() => { this.showReportModal = false })
      }
      .width('76%')
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      .alignItems(HorizontalAlign.Center)
    }

举报弹窗使用 ForEach 渲染五个举报原因选项,每个选项是一个全宽居中的 Text,点击后关闭弹窗。这种"选项列表"式弹窗是 Action Sheet(操作表)的典型实现。

10.3 二创瀑布流布局

  build() {
    Stack() {
      Column() {
        StarHeader({ title: '二创宇宙', subtitle: '同人创作 · 为爱发电', onBadgeTap: () => {}, onSearchTap: () => {} })

        Scroll() {
          Row() {
            ForEach(this.typeList, (t: string) => {
              Text(t)
                .fontSize(11)
                .fontColor(this.pickedType === t ? '#FFFFFF' : '#6B7280')
                .fontWeight(this.pickedType === t ? FontWeight.Bold : FontWeight.Normal)
                .padding({ left: 13, right: 13, top: 7, bottom: 7 })
                .backgroundColor(this.pickedType === t ? '#7C3AED' : '#FFFFFF')
                .borderRadius(15)
                .margin({ right: 8 })
                .onClick(() => { this.pickedType = t })
            }, (t: string) => t)
          }
          .padding({ left: 14, right: 14 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        Scroll() {
          Flex({  }) {
            ForEach(mockWorks, (w: FanWork) => {
              if (this.pickedType === '全部' || w.type === this.pickedType) {
                Column() {
                  Column() {
                    Text('🖼️')
                      .fontSize(38)
                  }
                  .width('100%')
                  .height(w.tall ? 130 : 84)
                  .backgroundColor(w.color + '26')
                  .borderRadius({ topLeft: 14, topRight: 14 })

                  Column() {
                    Text(w.title)
                      .fontSize(12)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#1F2937')
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                    Row() {
                      Text(w.type)
                        .fontSize(8)
                        .fontColor('#FFFFFF')
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                        .backgroundColor(getTypeBadge(w.type))
                        .borderRadius(6)
                      Text(w.author)
                        .fontSize(9)
                        .fontColor('#9CA3AF')
                        .margin({ left: 6 })
                        .layoutWeight(1)
                        .maxLines(1)
                      Text('❤️' + formatWan(w.likes))
                        .fontSize(9)
                        .fontColor('#FB7185')
                    }
                    .margin({ top: 5 })
                  }
                  .alignItems(HorizontalAlign.Start)
                  .padding({ left: 10, right: 10, top: 8, bottom: 10 })
                }
                .width('45%')
                .margin(6)
                .backgroundColor('#FFFFFF')
                .borderRadius(14)
                .onClick(() => {
                  this.pickedWork = w
                  this.showDetailModal = true
                })
              }
            }, (w: FanWork) => w.id.toString() + this.pickedType)
          }
          .width('100%')
          .padding({ left: 8, right: 8, top: 10, bottom: 16 })
        }
        .scrollBar(BarState.Off)
        .layoutWeight(1)

二创列表使用 Flex 容器实现瀑布流/网格布局。Flex({ }) 是 ArkUI 的弹性布局容器,默认沿主轴(水平方向)排列子组件,当子组件总宽度超过容器宽度时自动换行。每个作品卡片宽度设为 45%,两列排列时刚好填满屏幕宽度(含间距)。

Flex 是 ArkUI 中最灵活的布局容器。它结合了 Row/Column 的线性排列能力与 CSS Flexbox 的弹性特性。Flex 的构造参数接受一个配置对象,可设置 direction(主轴方向)、wrap(换行模式)、justifyContent(主轴对齐)、alignItems(交叉轴对齐)。本例的 Flex({ }) 使用空配置对象,采用默认值——水平排列、自动换行。配合子组件固定百分比宽度,实现了两列网格布局。

作品卡片的图片区域高度由 w.tall ? 130 : 84 决定——talltrue 的作品图片更高,形成高低交错。maxLines(1) 限制标题最多一行,.textOverflow({ overflow: TextOverflow.Ellipsis }) 在溢出时显示省略号,避免长标题撑破布局。

十一、应援会页(ClubContent)深度解析

11.1 加入应援会弹窗

@Component
struct ClubContent {
  @State showJoinModal: boolean = false
  @State showManageModal: boolean = false
  @State pickedClub: FanClub | null = null

  @Builder joinModal() {
    Column() {
      this.modalOverlay(() => { this.showJoinModal = false })
      Column() {
        Text('🏰 加入 ' + (this.pickedClub === null ? '' : this.pickedClub.name))
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 20 })
        Text('选择你的粉丝身份,不同身份享有不同权限')
          .fontSize(10)
          .fontColor('#9CA3AF')
          .margin({ top: 4 })

        ForEach([['🌟 核心应援', '可参与集资管理与线下应援'], ['💜 普通成员', '可参与任务打榜与抽奖'], ['🌱 萌新观察员', '仅可浏览与完成入门任务']], (r: string[]) => {
          Row() {
            Column() {
              Text(r[0])
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1F2937')
              Text(r[1])
                .fontSize(9)
                .fontColor('#9CA3AF')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('加入')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .padding({ left: 16, right: 16, top: 7, bottom: 7 })
              .backgroundColor('#7C3AED')
              .borderRadius(13)
              .onClick(() => { this.showJoinModal = false })
          }
          .width('100%')
          .padding(13)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#EDE9FE' })
          .margin({ top: 10 })
        }, (r: string[]) => r[0])

加入弹窗使用 ForEach 遍历一个二维数组——每个元素是一个 string[](字符串数组),包含身份名称和权限描述两项。ForEach 的项目生成函数参数 r 类型标注为 string[],通过 r[0]r[1] 访问数组元素。键值函数使用 r[0](身份名称)作为唯一标识。

这种"数组字面量作为数据源"的写法适合小型静态列表,无需定义额外的接口或类。但当数据结构复杂时,应考虑定义接口以获得类型安全。

11.2 管理面板弹窗

  @Builder manageModal() {
    Column() {
      this.modalOverlay(() => { this.showManageModal = false })
      Column() {
        Text('🛠️ 我的管理面板')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 20 })

        Row() {
          Column() {
            Text('2')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#7C3AED')
            Text('已加入应援会')
              .fontSize(9)
              .fontColor('#9CA3AF')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('Lv.7')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F59E0B')
            Text('粉丝团等级')
              .fontSize(9)
              .fontColor('#9CA3AF')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('3420')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FB7185')
            Text('累计贡献')
              .fontSize(9)
              .fontColor('#9CA3AF')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 14 })

管理面板弹窗展示用户的应援会管理数据概览。顶部是三列统计卡——已加入应援会数、粉丝团等级、累计贡献值。每列使用 Column + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) 实现等宽居中排列。数值使用不同颜色(紫色、金色、玫红色)区分统计维度。

11.3 应援会列表

  build() {
    Stack() {
      Scroll() {
        Column() {
          StarHeader({ title: '应援会', subtitle: '和同好一起,把热爱组织起来', onBadgeTap: () => { this.showManageModal = true }, onSearchTap: () => {} })

          Row() {
            Text('💜 我加入的应援会')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
              .layoutWeight(1)
            Text('管理 ›')
              .fontSize(11)
              .fontColor('#7C3AED')
              .onClick(() => { this.showManageModal = true })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          Scroll() {
            Row() {
              ForEach(mockClubs, (c: FanClub) => {
                if (c.joined) {
                  Column() {
                    Text(c.icon)
                      .fontSize(28)
                      .width(60)
                      .height(60)
                      .textAlign(TextAlign.Center)
                      .backgroundColor('#EDE9FE')
                      .borderRadius(20)
                    Text(c.name)
                      .fontSize(11)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#1F2937')
                      .margin({ top: 8 })
                    Text(formatWan(c.members) + ' 成员')
                      .fontSize(9)
                      .fontColor('#9CA3AF')
                      .margin({ top: 3 })
                  }
                  .width(110)
                  .alignItems(HorizontalAlign.Center)
                  .padding({ top: 12, bottom: 12 })
                  .backgroundColor('#FFFFFF')
                  .borderRadius(16)
                  .margin({ left: 6, right: 6 })
                  .onClick(() => { this.showManageModal = true })
                }
              }, (c: FanClub) => 'my' + c.id.toString())
            }
            .padding({ left: 8, right: 8 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 8 })

"我加入的应援会"使用横向滚动列表,通过 if (c.joined) 筛选已加入的应援会。键值使用 'my' + c.id.toString() 拼接前缀,与下方全部应援会列表的键值区分。

          ForEach(mockClubs, (c: FanClub) => {
            Row() {
              Text(c.icon)
                .fontSize(24)
                .width(46)
                .height(46)
                .textAlign(TextAlign.Center)
                .backgroundColor('#F3F0FA')
                .borderRadius(14)
              Column() {
                Text(c.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1F2937')
                Text(c.artist + ' · ' + formatWan(c.members) + ' 成员')
                  .fontSize(9)
                  .fontColor('#9CA3AF')
                  .margin({ top: 3 })
                Text('粉丝团 Lv.' + c.level)
                  .fontSize(8)
                  .fontColor('#F59E0B')
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                  .backgroundColor('#FEF3C7')
                  .borderRadius(7)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })

              Text(c.joined ? '已加入' : '+ 加入')
                .fontSize(11)
                .fontColor(c.joined ? '#10B981' : '#FFFFFF')
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .backgroundColor(c.joined ? '#D1FAE5' : '#7C3AED')
                .borderRadius(14)
            }
            .width('100%')
            .padding(13)
            .backgroundColor('#FFFFFF')
            .borderRadius(16)
            .margin({ top: 8, left: 12, right: 12 })
            .onClick(() => {
              this.pickedClub = c
              this.showJoinModal = true
            })
          }, (c: FanClub) => c.id.toString())

全部应援会列表纵向排列,每行展示一个应援会。右侧的加入按钮根据 c.joined 状态显示"已加入"(绿色)或"+ 加入"(紫色)。点击应援会行时打开加入弹窗。

十二、我的页(MePageContent)深度解析

12.1 提现弹窗

@Component
struct MePageContent {
  @State showWithdrawModal: boolean = false
  @State showBadgeModal: boolean = false
  @State showAboutModal: boolean = false
  @State withdrawAmount: string = ''

  @Builder withdrawModal() {
    Column() {
      this.modalOverlay(() => { this.showWithdrawModal = false })
      Column() {
        Text('💸 提现我的应援收益')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 20 })
        Text('可提现余额 ¥286.40(二创打赏收益)')
          .fontSize(10)
          .fontColor('#F59E0B')
          .margin({ top: 6 })

        Column() {
          Text('提现金额(¥)')
            .fontSize(11)
            .fontColor('#6B7280')
            .width('100%')
          TextInput({ placeholder: '最低 10 元', text: this.withdrawAmount })
            .fontSize(13)
            .fontColor('#1F2937')
            .placeholderColor('#C4B5FD')
            .backgroundColor('#F7F5FF')
            .borderRadius(10)
            .padding({ left: 12, right: 12 })
            .height(40)
            .margin({ top: 6 })
            .onChange((v: string) => { this.withdrawAmount = v })
        }
        .width('100%')
        .padding(13)
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .border({ width: 1, color: '#EDE9FE' })
        .margin({ top: 14 })

提现弹窗包含金额输入表单和手续费说明。withdrawAmount 状态变量通过 TextInputonChange 双向绑定。表单结构与新建项目弹窗一致——标签 + 输入框包裹在带边框的 Column 中。

12.2 勋章墙弹窗

  @Builder badgeModal() {
    Column() {
      this.modalOverlay(() => { this.showBadgeModal = false })
      Column() {
        Text('🏅 我的勋章墙')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 20 })
        Text('已点亮 7 / 20')
          .fontSize(10)
          .fontColor('#9CA3AF')
          .margin({ top: 4 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach([['🌟', '1'], ['💫', '1'], ['🌙', '1'], ['🚀', '1'], ['🎆', '1'], ['💝', '1'], ['🏆', '1'], ['👑', '0'], ['🎇', '0'], ['🌠', '0'], ['🎈', '0'], ['🎁', '0']], (b: string[]) => {
            Column() {
              Text(b[0])
                .fontSize(28)
                .opacity(b[1] === '1' ? 1.0 : 0.25)
                .scale({
                  x: b[1] === '1' ? 1.05 : 0.95,
                  y: b[1] === '1' ? 1.05 : 0.95
                })
                .animation({ duration: 200, curve: Curve.EaseOut })
              Text(b[1] === '1' ? '已点亮' : '未解锁')
                .fontSize(8)
                .fontColor(b[1] === '1' ? '#7C3AED' : '#D1D5DB')
                .margin({ top: 4 })
            }
            .width('25%')
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 12, bottom: 12 })
          }, (b: string[]) => b[0])
        }
        .width('92%')
        .margin({ top: 10 })

勋章墙使用 Flex({ wrap: FlexWrap.Wrap }) 实现自动换行的网格布局。FlexWrap.Wrap 是 Flex 的换行模式枚举,使子组件在超出主轴宽度时自动换到下一行。每个勋章宽度设为 25%,形成四列网格。

FlexWrap 是 ArkUI Flex 容器的换行枚举,包含 NoWrap(不换行,子组件溢出)和 Wrap(换行)两个值。Wrap 模式使 Flex 容器具备类似 CSS flex-wrap: wrap 的行为——子组件按顺序排列,超出容器宽度时自动换行。配合子组件固定百分比宽度,可以实现任意列数的响应式网格。

勋章数据使用二维数组——每个元素 ['🌟', '1'] 包含 Emoji 和点亮状态(‘1’ 为已点亮,‘0’ 为未解锁)。已点亮的勋章 opacity 为 1.0、缩放为 1.05;未解锁的 opacity 为 0.25、缩放为 0.95。这种视觉差异使用户一眼区分已获得与未获得的勋章。

12.3 关于弹窗

  @Builder aboutModal() {
    Column() {
      this.modalOverlay(() => { this.showAboutModal = false })
      Column() {
        Text('💜')
          .fontSize(44)
          .margin({ top: 22 })
        Text('星海应援 FANLIGHT')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .margin({ top: 10 })
        Text('Version 2.4.0 (Build 1180)')
          .fontSize(10)
          .fontColor('#9CA3AF')
          .margin({ top: 4 })
        Text('让每一份热爱都被看见\n累计促成应援项目 42,000+ 个')
          .fontSize(11)
          .fontColor('#6B7280')
          .textAlign(TextAlign.Center)
          .margin({ top: 12 })
        Text('用户协议 · 隐私政策 · 客服中心')
          .fontSize(10)
          .fontColor('#7C3AED')
          .margin({ top: 14 })
        Text('关闭')
          .fontSize(13)
          .fontColor('#9CA3AF')
          .padding({ left: 30, right: 30, top: 9, bottom: 9 })
          .backgroundColor('#F3F0FA')
          .borderRadius(16)
          .margin({ top: 14, bottom: 20 })
          .onClick(() => { this.showAboutModal = false })
      }
      .width('78%')
      .backgroundColor('#FFFFFF')
      .borderRadius(22)
      .alignItems(HorizontalAlign.Center)
    }

关于弹窗展示应用信息,包括 Logo、名称、版本号、品牌标语和法律链接。版本号文本 Version 2.4.0 (Build 1180) 使用灰色小字。品牌标语使用 \n 换行的双行文本。法律链接使用主题色,暗示可点击跳转。

12.4 我的页主体构建

  build() {
    Stack() {
      Scroll() {
        Column() {
          Column() {
            Row() {
              Text('🪐')
                .fontSize(32)
                .width(66)
                .height(66)
                .textAlign(TextAlign.Center)
                .backgroundColor('#FFFFFF33')
                .borderRadius(33)
              Column() {
                Text('星河守望者')
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                Text('望月人 · Lv.7 闪耀粉')
                  .fontSize(10)
                  .fontColor('#E9D5FF')
                  .margin({ top: 3 })
                Text('应援 3 位偶像 · 参与项目 26 个')
                  .fontSize(9)
                  .fontColor('#C4B5FD')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 14 })
            }
            .width('100%')

            Row() {
              Column() {
                Text('3240')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                Text('累计星力')
                  .fontSize(9)
                  .fontColor('#DDD6FE')
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              // ... 更多统计列
            }
            .width('100%')
            .margin({ top: 18 })
          }
          .width('100%')
          .padding(18)
          .linearGradient({
            angle: 135,
            colors: [['#7C3AED', 0], ['#FB7185', 1]]
          })
          .borderRadius(20)
          .margin({ top: 10, left: 12, right: 12 })

我的页顶部是用户资料卡,使用紫到玫红的渐变背景(linearGradient),内含头像、昵称、身份标签和四列统计数据(累计星力、二创收益、应援天数、勋章)。头像背景使用 #FFFFFF33(白色 20% 透明度),在渐变背景上形成半透明圆形容器。

我的页 MePageContent

用户资料卡 渐变背景

打赏收益柱状图

功能列表

头像 + 昵称 + 身份

4列统计: 星力/收益/天数/勋章

ForEach 7日数据柱状图

提现二创收益

我的勋章墙

集资记录

关于

withdrawModal 提现表单

badgeModal 勋章宫格

aboutModal 版本信息

12.5 打赏收益柱状图

          Column() {
            Text('💰 近 7 日打赏收益(¥)')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
              .width('100%')
            Row() {
              ForEach([12, 38, 24, 56, 18, 72, 46], (v: number) => {
                Column() {
                  Column()
                    .width(16)
                    .height((v * 1.4).toFixed(0) + 'vp')
                    .backgroundColor(v >= 60 ? '#FB7185' : '#7C3AED')
                    .borderRadius({ topLeft: 4, topRight: 4 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
                .justifyContent(FlexAlign.End)
                .height(105)
              }, (v: number) => v.toString())
            }
            .width('100%')
            .alignItems(VerticalAlign.Bottom)
            .margin({ top: 8 })
            Row() {
              ForEach(['一', '二', '三', '四', '五', '六', '日'], (d: string) => {
                Text(d)
                  .fontSize(8)
                  .fontColor('#9CA3AF')
                  .layoutWeight(1)
                  .textAlign(TextAlign.Center)
              }, (d: string) => d)
            }
            .width('100%')
            .margin({ top: 4 })
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(18)
          .margin({ top: 12, left: 12, right: 12 })

打赏收益柱状图的结构与打榜页的星力柱状图一致——ForEach 遍历数据数组,为每个值生成柱子。柱子高度通过 (v * 1.4).toFixed(0) + 'vp' 计算,系数 1.4 使数值放大到合适的视觉高度。颜色阈值 v >= 60 区分高收益日和普通日。

12.6 功能列表

          Column() {
            Row() {
              Text('💸')
                .fontSize(16)
              Text('提现二创收益')
                .fontSize(13)
                .fontColor('#1F2937')
                .margin({ left: 12 })
                .layoutWeight(1)
              Text('¥286.40')
                .fontSize(10)
                .fontColor('#F59E0B')
              Text('›')
                .fontSize(13)
                .fontColor('#D1D5DB')
                .margin({ left: 8 })
            }
            .width('100%')
            .padding({ top: 15, bottom: 15 })
            .onClick(() => { this.showWithdrawModal = true })
            Divider().color('#F3F0FA')
            // ... 更多功能行
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
          .backgroundColor('#FFFFFF')
          .borderRadius(18)
          .margin({ top: 12, left: 12, right: 12, bottom: 16 })

功能列表使用 Column 纵向排列多个 Row,每行代表一个功能项。行间使用 Divider 组件添加分隔线——Divider 是 ArkUI 内置的分隔线组件,.color('#F3F0FA') 设置浅紫色。每行结构为"图标 + 标题 + 右侧值/箭头",箭头"›"暗示可点击进入下一级。

Divider 是 ArkUI 的内置分隔线组件,用于在列表项之间添加视觉分隔。它默认占据父容器全宽,高度为 1 像素(可配置)。Divider 不响应用户交互,仅用于视觉分隔。在列表中使用 Divider 而非为每个 Row 设置底部边框,是因为 Divider 作为独立节点更易于管理和移除——例如不希望在最后一项后显示分隔线时,只需省略最后一个 Divider 即可。

十三、弹窗系统架构总览

MePageContent

ClubContent

WorksContent

FundContent

ChartContent

HomeContent

artistModal 艺人详情

thanksModal 应援感谢

checkModal 签到打卡

taskModal 任务详情

supportModal 支持集资

createModal 新建项目

editModal 编辑项目

cancelModal 取消确认

workDetailModal 作品详情

likeModal 点赞成功

reportModal 举报

joinModal 加入应援会

manageModal 管理面板

withdrawModal 提现

badgeModal 勋章墙

aboutModal 关于

modalOverlay 通用遮罩

上图展示了应用全部 16 个弹窗的分布与共享遮罩机制。每个内容组件都定义了一个 modalOverlay 构建器作为弹窗的通用遮罩层,通过 @Builder 的参数化能力接收 onClose 回调。这种"共享遮罩 + 各自内容"的弹窗架构使弹窗代码高度统一,同时保持了各弹窗内容的定制自由度。

弹窗的状态管理遵循统一模式:每个弹窗对应一个 @State boolean 变量,初始为 false(隐藏)。触发交互(如点击按钮)时设为 true,弹窗在 Stack 中条件渲染显示。关闭操作(点击遮罩或关闭按钮)时设为 false,弹窗从 Stack 中卸载。

渲染错误: Mermaid 渲染失败: Parse error on line 2: ...R A[用户点击触发] --> B[@State = true] ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

十四、数据流与状态驱动机制

渲染错误: Mermaid 渲染失败: Parse error on line 3: ...ER[ForEach 渲染层] STATE[@State 状态层] -- ----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

应用的数据流遵循"数据 → 状态 → 渲染 → 交互 → 状态"的闭环。Mock 数据(mockArtistsmockProjects 等)作为静态数据源传入 ForEach,生成初始 UI。用户交互(点击、输入)触发事件处理器,处理器通过修改 @State 变量或传入新的筛选条件,驱动 ForEach 重新渲染。

这种单向数据流确保了状态变更的可追溯性——每次 UI 变化都能追溯到某个 @State 赋值操作,而非散落在各处的直接 DOM 操作。这种可预测性是声明式 UI 相比命令式 UI 的核心优势。

十五、核心技术特性对比表

组件/属性/方法 所属类别 核心功能 典型使用场景 是否可动画 父容器要求 状态响应 备注
Column 布局容器 纵向线性排列子组件 列表/表单/信息流 主轴垂直
Row 布局容器 横向线性排列子组件 工具栏/标签栏/卡片行 主轴水平
Stack 布局容器 堆叠覆盖子组件 弹窗/浮层/角标 后入覆盖先入
Flex 布局容器 弹性排列/换行 网格/瀑布流 支持wrap换行
Scroll 滚动容器 可滚动内容区 长列表/横向卡片 单子组件 需BarState控制
Text 基础组件 文本展示 标题/正文/标签 任意 支持maxLines
TextInput 输入组件 文本输入 表单/搜索框 任意 onChange回调
Divider 装饰组件 分隔线 列表项分隔 任意 默认全宽
ForEach 渲染指令 列表循环渲染 数据驱动列表 - 任意容器 需键值函数
@Entry 装饰器 标记页面入口 应用根组件 - - - 每页仅一个
@Component 装饰器 声明UI组件 所有自定义组件 - - - 需build方法
@State 状态装饰器 本地响应式状态 组件内可变数据 - - 赋值触发重渲染
@Builder 构建装饰器 自定义UI片段 复用UI结构/弹窗 - - 可参数化
layoutWeight 布局属性 权重分配空间 弹性宽度/高度 线性容器 数值比例
linearGradient 样式属性 线性渐变背景 头部/资料卡 任意 angle+colors
shadow 样式属性 阴影投影 导航栏/卡片 任意 radius+color
borderRadius 样式属性 圆角 按钮/卡片/头像 任意 数字或对象
scale 变换属性 缩放变换 选中放大/弹跳 任意 x/y轴独立
rotate 变换属性 旋转变换 庆祝旋转/加载 任意 angle度数
opacity 样式属性 透明度 未选中淡化/渐显 任意 0到1
animation 动画属性 属性过渡动画 微交互反馈 - 任意 - duration+curve
maxLines 文本属性 最大行数限制 标题省略 任意 配合textOverflow
textAlign 文本属性 对齐方式 居中/左对齐 任意 Start/Center/End
fontWeight 文本属性 字重 标题强调 任意 Normal/Medium/Bold
enum FanTab 类型定义 枚举标识 Tab状态管理 - - - 替代魔法数字
interface 类型定义 接口契约 数据建模 - - - implements实现

十六、总结

本文对一款基于鸿蒙 HarmonyOS ArkTS 框架开发的粉丝应援生态应用进行了全方位、逐段落的深度技术剖析。该应用涵盖了应援主页、打榜中心、集资管理、二创社区、应援会组织、个人中心六大功能模块,总计实现了 16 个弹窗交互、5 类数据模型、7 个自定义组件,是一个功能完整、结构清晰的中型 ArkUI 应用样本。

在类型系统层面,应用定义了 ArtistCardSupportProjectTaskItemFanWorkFanClub 五个接口,配合 ArtistCardDataSupportProjectData 等实现类和 mockArtistsmockProjects 等 Mock 数据数组,构建了完整的强类型数据模型。这种"接口定义形状、类实现结构、数组承载数据"的三层建模方式,使数据流在编译期就受到严格约束,从源头消除了类型不匹配引发的运行时错误。enum FanTab 的使用进一步强化了状态管理的类型安全性,将 Tab 标识从魔法数字提升为语义化枚举。

在组件架构层面,应用以 @Entry @Component FanlightApp 为入口,通过一个 @State activeTab 状态变量驱动六个内容组件的条件渲染,形成状态驱动的路由切换机制。每个内容组件(HomeContentChartContentFundContentWorksContentClubContentMePageContent)独立管理各自的弹窗状态和业务数据,组件间通过 StarHeader 公共头部组件实现 UI 复用。@Builder 装饰器在其中扮演了关键角色——modalOverlay 作为通用遮罩片段被所有弹窗共享,contentAreabottomTabItem 实现了主入口的内容区域和导航项复用。

在布局系统层面,应用全面运用了 Column(纵向排列)、Row(横向排列)、Stack(堆叠覆盖)、Flex(弹性换行)四类核心容器。ColumnRowlayoutWeight 属性实现了弹性空间分配,Stack 的堆叠特性支撑了弹窗覆盖层的实现,FlexFlexWrap.Wrap 模式实现了勋章墙的四列网格和二创作品的两列瀑布流。Scroll 容器配合 scrollablescrollBar 属性,实现了纵向长列表和横向卡片带的滚动能力。

在状态管理层面,@State 装饰器是整个响应式系统的核心。每个布尔状态变量控制一个弹窗的显示隐藏,每次状态赋值触发 build() 方法的重新执行和 UI 差分更新。totalPoints 的签到累加、amountPicked 的档位切换、pickedCat 的分类筛选、pickedArtist/pickedTask/pickedWork/pickedClub 的选中数据传递,都展示了 @State 在各种业务场景下的驱动能力。TextInputonChange 回调配合 @State 实现了表单的双向绑定。

在渲染层面,ForEach 作为列表渲染指令,配合键值生成函数实现了高效的数据驱动渲染。键值策略的设计体现了对 diff 机制的深入理解——拼接筛选条件到键值强制重建节点,为不同列表添加前缀避免键值冲突。if 条件渲染在 ForEach 内部实现了数据筛选,使筛选逻辑与渲染逻辑内联。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 星云紫粉风:薰衣草底 + 紫罗兰主色 + 玫红点缀 + 鎏金强调
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface ArtistCard {
  id: number;
  name: string;
  fandom: string;
  color: string;
  light: string;
  birthday: string;
  fans: number;
  todayRank: number;
  trending: string;
}

interface SupportProject {
  id: number;
  title: string;
  artist: string;
  target: number;
  raised: number;
  backers: number;
  deadline: string;
  category: string;
  verified: boolean;
  joined: boolean;
}

interface TaskItem {
  id: number;
  name: string;
  points: number;
  desc: string;
  done: boolean;
  icon: string;
}

interface FanWork {
  id: number;
  title: string;
  author: string;
  type: string;
  likes: number;
  tall: boolean;
  color: string;
}

interface FanClub {
  id: number;
  name: string;
  artist: string;
  members: number;
  level: number;
  icon: string;
  joined: boolean;
}

// ============ 应援色配置 ============
const ARTIST_COLORS: string[] = ['#7C3AED', '#FB7185', '#F59E0B', '#06B6D4', '#10B981', '#F43F5E'];

// ============ 艺人数据(16 位) ============
class ArtistCardData implements ArtistCard {
  id: number = 0;
  name: string = '';
  fandom: string = '';
  color: string = '';
  light: string = '';
  birthday: string = '';
  fans: number = 0;
  todayRank: number = 0;
  trending: string = '';

  constructor(id: number, name: string, fandom: string, color: string, light: string,
    birthday: string, fans: number, todayRank: number, trending: string) {
    this.id = id;
    this.name = name;
    this.fandom = fandom;
    this.color = color;
    this.light = light;
    this.birthday = birthday;
    this.fans = fans;
    this.todayRank = todayRank;
    this.trending = trending;
  }
}

const mockArtists: ArtistCard[] = [
  new ArtistCardData(1, '月见凛', '望月人', '#7C3AED', '#EDE9FE', '04-17', 8200000, 1, '↑ 新专辑预售'),
  new ArtistCardData(2, '苏晚晴', '晴雨伞', '#FB7185', '#FFE4E6', '09-02', 6300000, 2, '↑ 巡演开票'),
  new ArtistCardData(3, '江野', '野蔷薇', '#10B981', '#D1FAE5', '12-25', 5100000, 3, '- 新歌打榜中'),
  new ArtistCardData(4, '林深鹿', '小鹿灯', '#F59E0B', '#FEF3C7', '06-08', 4700000, 4, '↑ 生日应援'),
  new ArtistCardData(5, '星辰海', '拾星人', '#06B6D4', '#CFFAFE', '03-30', 3900000, 5, '↓ 综艺播出'),
  new ArtistCardData(6, '陆燃', '火星子', '#F43F5E', '#FFE4E6', '07-21', 3400000, 6, '↑ 代言官宣'),
  new ArtistCardData(7, '白羽笙', '羽衣客', '#8B5CF6', '#EDE9FE', '11-11', 2900000, 7, '- 杂志开售'),
  new ArtistCardData(8, '叶知秋', '秋声赋', '#84CC16', '#ECFCCB', '10-19', 2600000, 8, '↑ 剧集收官'),
  new ArtistCardData(9, '许风眠', '风铃草', '#0EA5E9', '#E0F2FE', '05-23', 2300000, 9, '- 直播连麦'),
  new ArtistCardData(10, '程曦', '晨光队', '#F97316', '#FFEDD5', '08-14', 2000000, 10, '↑ 音乐节'),
  new ArtistCardData(11, '顾遥', '遥望者', '#A855F7', '#F3E8FF', '02-28', 1800000, 11, '- 演唱会筹备'),
  new ArtistCardData(12, '夏栀', '栀子花', '#EAB308', '#FEF9C3', '06-30', 1500000, 12, '↑ 新歌首唱'),
  new ArtistCardData(13, '陈默', '静音键', '#64748B', '#E2E8F0', '01-09', 1300000, 13, '- 沉浸创作'),
  new ArtistCardData(14, '唐棠', '棠梨煎', '#EC4899', '#FCE7F3', '09-15', 1100000, 14, '↑ 时尚封面'),
  new ArtistCardData(15, '墨白', '留白处', '#334155', '#CBD5E1', '04-04', 980000, 15, '- 专辑混音'),
  new ArtistCardData(16, '阮软', '软糖罐', '#F472B6', '#FCE7F3', '03-03', 860000, 16, '↑ 电台连播')
];

// ============ 集资项目数据(18 个) ============
class SupportProjectData implements SupportProject {
  id: number = 0;
  title: string = '';
  artist: string = '';
  target: number = 0;
  raised: number = 0;
  backers: number = 0;
  deadline: string = '';
  category: string = '';
  verified: boolean = false;
  joined: boolean = false;

  constructor(id: number, title: string, artist: string, target: number, raised: number,
    backers: number, deadline: string, category: string, verified: boolean, joined: boolean) {
    this.id = id;
    this.title = title;
    this.artist = artist;
    this.target = target;
    this.raised = raised;
    this.backers = backers;
    this.deadline = deadline;
    this.category = category;
    this.verified = verified;
    this.joined = joined;
  }
}

const mockProjects: SupportProject[] = [
  new SupportProjectData(1, '月见凛生日地铁应援', '月见凛', 80000, 66420, 2140, '09-12', '生日应援', true, true),
  new SupportProjectData(2, '苏晚晴巡演花墙筹备', '苏晚晴', 120000, 98870, 1866, '09-20', '演出应援', true, false),
  new SupportProjectData(3, '江野新歌打榜集资', '江野', 50000, 47210, 3021, '09-05', '打榜', true, false),
  new SupportProjectData(4, '林深鹿生日公益图书馆', '林深鹿', 60000, 58930, 2754, '09-15', '公益应援', true, true),
  new SupportProjectData(5, '星辰海演唱会灯牌', '星辰海', 45000, 31200, 1088, '09-28', '演出应援', false, false),
  new SupportProjectData(6, '陆燃机场大巴广告', '陆燃', 90000, 42700, 921, '10-01', '线下应援', false, false),
  new SupportProjectData(7, '白羽笙出道五周年纪念册', '白羽笙', 70000, 68940, 1902, '09-08', '纪念应援', true, false),
  new SupportProjectData(8, '叶知秋新剧收视率庆功', '叶知秋', 30000, 21650, 774, '09-30', '作品应援', false, false),
  new SupportProjectData(9, '许风眠首场演唱会', '许风眠', 150000, 88300, 1540, '10-10', '演出应援', true, false),
  new SupportProjectData(10, '程曦音乐节大巴应援', '程曦', 40000, 39840, 812, '09-06', '演出应援', true, true),
  new SupportProjectData(11, '顾遥冬季演唱会手幅', '顾遥', 55000, 20120, 634, '10-20', '演出应援', false, false),
  new SupportProjectData(12, '夏栀新歌MV百万播放庆功', '夏栀', 25000, 24860, 1102, '09-04', '作品应援', true, false),
  new SupportProjectData(13, '陈默专辑制作支持', '陈默', 100000, 71400, 486, '10-25', '音乐支持', false, false),
  new SupportProjectData(14, '唐棠时尚杂志冲量', '唐棠', 35000, 18900, 522, '09-18', '作品应援', false, false),
  new SupportProjectData(15, '墨白巡演纪录片众筹', '墨白', 80000, 43200, 388, '11-01', '音乐支持', true, false),
  new SupportProjectData(16, '阮软电台周年庆礼物', '阮软', 20000, 15630, 445, '09-22', '生日应援', false, false),
  new SupportProjectData(17, '月见凛年度大屏汇总', '月见凛', 200000, 146800, 3620, '12-31', '线下应援', true, false),
  new SupportProjectData(18, '苏晚晴粉丝公益植树', '苏晚晴', 50000, 42100, 1566, '09-25', '公益应援', true, true)
];

// ============ 打榜任务数据(10 条) ============
class TaskItemData implements TaskItem {
  id: number = 0;
  name: string = '';
  points: number = 0;
  desc: string = '';
  done: boolean = false;
  icon: string = '';

  constructor(id: number, name: string, points: number, desc: string, done: boolean, icon: string) {
    this.id = id;
    this.name = name;
    this.points = points;
    this.desc = desc;
    this.done = done;
    this.icon = icon;
  }
}

const mockTasks: TaskItem[] = [
  new TaskItemData(1, '每日签到', 10, '连续7天有额外宝箱', true, '📅'),
  new TaskItemData(2, '播放主打歌30次', 30, '今日已播 22/30', false, '🎵'),
  new TaskItemData(3, '分享歌曲到社交平台', 20, '每日上限3次', true, '↗️'),
  new TaskItemData(4, '发布歌曲评论', 15, '评论满10字有效', false, '💬'),
  new TaskItemData(5, '观看MV完整一遍', 25, '倍速播放不计入', true, '📺'),
  new TaskItemData(6, '购买数字专辑', 100, '每张专辑计100点', false, '💿'),
  new TaskItemData(7, '制作应援二创', 50, '审核通过后发放', false, '🎨'),
  new TaskItemData(8, '邀请好友入会', 80, '好友需完成新手任务', false, '👥'),
  new TaskItemData(9, '参与直播间互动', 15, '发送弹幕满5条', true, '📡'),
  new TaskItemData(10, '完成粉丝问答', 20, '每日题目更新', false, '❓')
];

// ============ 二创数据(16 件) ============
class FanWorkData implements FanWork {
  id: number = 0;
  title: string = '';
  author: string = '';
  type: string = '';
  likes: number = 0;
  tall: boolean = false;
  color: string = '';

  constructor(id: number, title: string, author: string, type: string, likes: number, tall: boolean, color: string) {
    this.id = id;
    this.title = title;
    this.author = author;
    this.type = type;
    this.likes = likes;
    this.tall = tall;
    this.color = color;
  }
}

const mockWorks: FanWork[] = [
  new FanWorkData(1, '月见凛·星轨拟人立绘', '画师小鹿', '插画', 12840, true, '#7C3AED'),
  new FanWorkData(2, '应援色眼妆教程', '美妆栗子', '视频', 8210, false, '#FB7185'),
  new FanWorkData(3, '《望月》歌词手写', '墨条er', '书法', 5320, false, '#F59E0B'),
  new FanWorkData(4, '巡演应援花墙设计图', '花艺阿茶', '设计', 9670, true, '#10B981'),
  new FanWorkData(5, '小凛的一百种表情包', '表情包大户', '表情', 15600, false, '#06B6D4'),
  new FanWorkData(6, '粉丝自制MV剪辑', '剪刀手阿浪', '视频', 11020, true, '#F43F5E'),
  new FanWorkData(7, '生日纪念徽章设计', '铁皮铺子', '设计', 4180, false, '#8B5CF6'),
  new FanWorkData(8, '二周年应援曲《引力》', '音乐组', '音乐', 7450, true, '#84CC16'),
  new FanWorkData(9, ' concert 大幅写真集', '长焦镜头', '摄影', 3890, false, '#0EA5E9'),
  new FanWorkData(10, '应援手幅字体设计', '字匠', '设计', 6210, false, '#F97316'),
  new FanWorkData(11, 'Q版角色周边拼图', '拼图狂魔', '插画', 8730, true, '#A855F7'),
  new FanWorkData(12, '地铁站打卡路线图', '巡礼向导', '攻略', 3420, false, '#EAB308'),
  new FanWorkData(13, '舞台灯光复刻模型', '手工大佬', '手工', 10240, true, '#EC4899'),
  new FanWorkData(14, '歌词本装帧设计', '装帧学徒', '设计', 2960, false, '#64748B'),
  new FanWorkData(15, '应援棒灯光编程', '极客灯牌', '技术', 5810, true, '#F472B6'),
  new FanWorkData(16, '粉丝漫画《追星日记》', '连载中的蟹', '漫画', 13980, false, '#7C3AED')
];

// ============ 应援会数据(12 个) ============
class FanClubData implements FanClub {
  id: number = 0;
  name: string = '';
  artist: string = '';
  members: number = 0;
  level: number = 0;
  icon: string = '';
  joined: boolean = false;

  constructor(id: number, name: string, artist: string, members: number, level: number, icon: string, joined: boolean) {
    this.id = id;
    this.name = name;
    this.artist = artist;
    this.members = members;
    this.level = level;
    this.icon = icon;
    this.joined = joined;
  }
}

const mockClubs: FanClub[] = [
  new FanClubData(1, '望月人总部', '月见凛', 48210, 10, '🌙', true),
  new FanClubData(2, '晴雨伞后援会', '苏晚晴', 39650, 10, '☂️', true),
  new FanClubData(3, '野蔷薇花园', '江野', 31200, 9, '🌹', false),
  new FanClubData(4, '小鹿灯营地', '林深鹿', 28470, 9, '🦌', false),
  new FanClubData(5, '拾星人天文台', '星辰海', 24380, 8, '🔭', false),
  new FanClubData(6, '火星子发射中心', '陆燃', 21960, 8, '🚀', false),
  new FanClubData(7, '羽衣客驿站', '白羽笙', 18740, 7, '🪶', false),
  new FanClubData(8, '秋声赋书局', '叶知秋', 16530, 7, '📖', false),
  new FanClubData(9, '风铃草牧场', '许风眠', 14210, 6, '🎐', false),
  new FanClubData(10, '晨光车站', '程曦', 12680, 6, '🌅', false),
  new FanClubData(11, '遥望者灯塔', '顾遥', 10940, 5, '🗼', false),
  new FanClubData(12, '软糖罐工厂', '阮软', 8320, 5, '🍬', false)
];

// ============ 工具函数 ============
function getTypeBadge(type: string): string {
  if (type === '视频') {
    return '#7C3AED';
  } else if (type === '插画' || type === '漫画') {
    return '#FB7185';
  } else if (type === '音乐') {
    return '#F59E0B';
  } else if (type === '设计') {
    return '#06B6D4';
  } else {
    return '#10B981';
  }
}

function formatWan(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1) + '万';
  }
  return n.toString();
}

// ============ Tab 枚举 ============
enum FanTab {
  HOME,
  CHART,
  FUND,
  WORKS,
  CLUB,
  ME
}

// ============ 主入口 ============
@Entry
@Component
struct FanlightApp {
  @State activeTab: FanTab = FanTab.HOME

  @Builder contentArea() {
    Column() {
      if (this.activeTab === FanTab.HOME) {
        HomeContent()
      } else if (this.activeTab === FanTab.CHART) {
        ChartContent()
      } else if (this.activeTab === FanTab.FUND) {
        FundContent()
      } else if (this.activeTab === FanTab.WORKS) {
        WorksContent()
      } else if (this.activeTab === FanTab.CLUB) {
        ClubContent()
      } else {
        MePageContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder bottomTabItem(icon: string, label: string, tab: FanTab) {
    Column() {
      Text(icon)
        .fontSize(20)
        .opacity(this.activeTab === tab ? 1.0 : 0.4)
        .scale({ x: this.activeTab === tab ? 1.2 : 1.0, y: this.activeTab === tab ? 1.2 : 1.0 })
        .animation({ duration: 180, curve: Curve.EaseOut })
      Text(label)
        .fontSize(9)
        .fontColor(this.activeTab === tab ? '#7C3AED' : '#A1A1B5')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 6, bottom: 6 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('⭐', '应援', FanTab.HOME)
        this.bottomTabItem('📈', '打榜', FanTab.CHART)
        this.bottomTabItem('💰', '集资', FanTab.FUND)
        this.bottomTabItem('🎨', '二创', FanTab.WORKS)
        this.bottomTabItem('🏰', '应援会', FanTab.CLUB)
        this.bottomTabItem('👤', '我的', FanTab.ME)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ top: 5, bottom: 7 })
      .shadow({ radius: 14, color: '#267C3AED', offsetY: -4 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F5FF')
  }
}

// ============ 电商式公共头部(静态,无动画) ============
@Component
struct StarHeader {
  title: string = '星海应援';
  subtitle: string = '';
  badgeText: string = '粉丝团';
  onBadgeTap: () => void = () => {};
  onSearchTap: () => void = () => {};

  build() {
    Column() {
      Row() {
        Column() {
          Text('💜 ' + this.badgeText)
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Medium)
          Text('我的粉丝团')
            .fontSize(8)
            .fontColor('#C4B5FD')
            .margin({ top: 1 })
        }
        .alignItems(HorizontalAlign.Start)
        .onClick(() => { this.onBadgeTap() })

        Row() {
          Text('🔍')
            .fontSize(13)
            .margin({ left: 10 })
          Text('搜索偶像 / 应援项目 / 二创')
            .fontSize(11)
            .fontColor('#9CA3AF')
            .margin({ left: 6 })
        }
        .layoutWeight(1)
        .height(34)
        .backgroundColor('#8B5CF633')
        .borderRadius(17)
        .margin({ left: 12 })
        .onClick(() => { this.onSearchTap() })

        Text('🔔')
          .fontSize(16)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .backgroundColor('#8B5CF633')
          .borderRadius(17)
          .margin({ left: 8 })
          .onClick(() => { this.onBadgeTap() })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10 })

      Row() {
        Column() {
          Text(this.title)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          if (this.subtitle !== '') {
            Text(this.subtitle)
              .fontSize(10)
              .fontColor('#C4B5FD')
              .margin({ top: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 12, bottom: 10 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#7C3AED', 0], ['#4C1D95', 1]]
    })
  }
}

// ============ Tab1:应援主页 ============
@Component
struct HomeContent {
  @State showArtistModal: boolean = false
  @State showThanksModal: boolean = false
  @State pickedArtist: ArtistCard | null = null

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(60,40,100,0.45)')
      .onClick(onClose)
  }

  // 弹框A:艺人应援详情(居中大卡 + 应援色 + 日程)
  @Builder artistModal() {
    Column() {
      this.modalOverlay(() => { this.showArtistModal = false })
      Column() {
        Row() {
          Text('⭐')
            .fontSize(38)
            .width(72)
            .height(72)
            .textAlign(TextAlign.Center)
            .backgroundColor(this.pickedArtist === null ? '#EDE9FE' : this.pickedArtist.light)
            .borderRadius(24)
          Column() {
            Text(this.pickedArtist === null ? '' : this.pickedArtist.name)
              .fontSize(19)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
            Text(this.pickedArtist === null ? '' : this.pickedArtist.fandom + ' · ' +
              formatWan(this.pickedArtist === null ? 0 : this.pickedArtist.fans) + ' 粉丝')
              .fontSize(10)
              .fontColor('#6B7280')
              .margin({ top: 3 })
            Text('应援色 · 生日 ' + (this.pickedArtist === null ? '' : this.pickedArtist.birthday))
              .fontSize(9)
              .fontColor(this.pickedArtist === null ? '#7C3AED' : this.pickedArtist.color)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 14 })
        }
        .width('100%')
        .padding(16)

        Row() {
          Column() {
            Text('#' + (this.pickedArtist === null ? 0 : this.pickedArtist.todayRank))
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#7C3AED')
            Text('今日星力榜')
              .fontSize(9)
              .fontColor('#9CA3AF')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('S级')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F59E0B')
            Text('粉丝团等级')
              .fontSize(9)
              .fontColor('#9CA3AF')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('12')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FB7185')
            Text('进行中项目')
              .fontSize(9)
              .fontColor('#9CA3AF')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 6 })

        Text('近期日程')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
          .width('100%')
          .margin({ top: 14 })

        Column() {
          Row() {
            Text('09-02')
              .fontSize(10)
              .fontColor('#7C3AED')
              .width(44)
            Text('新专辑《引力波》预售开启')
              .fontSize(11)
              .fontColor('#374151')
              .layoutWeight(1)
            Text('预热')
              .fontSize(9)
              .fontColor('#9CA3AF')
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          Row() {
            Text('09-12')
              .fontSize(10)
              .fontColor('#7C3AED')
              .width(44)
            Text('生日直播 · 20:00')
              .fontSize(11)
              .fontColor('#374151')
              .layoutWeight(1)
            Text('预约')
              .fontSize(9)
              .fontColor('#F59E0B')
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          Row() {
            Text('09-28')
              .fontSize(10)
              .fontColor('#7C3AED')
              .width(44)
            Text('巡回演唱会首站 · 上海')
              .fontSize(11)
              .fontColor('#374151')
              .layoutWeight(1)
            Text('抢票')
              .fontSize(9)
              .fontColor('#FB7185')
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })

        Text('去应援')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('90%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .backgroundColor('#7C3AED')
      }
      if (this.showAboutModal) {
        this.aboutModal()
      }
    }
    .width('100%')
    .height('100%')
  }
}


在这里插入图片描述

在交互动画层面,.animation() 声明为 scaleopacityrotate 等可动画属性提供了平滑过渡能力。导航项的选中缩放、签到弹窗的日历图标放大、感谢弹窗的星形旋转、点赞弹窗的爱心弹跳、金额档位的选中放大,这些微交互动画显著提升了应用的精致度和情感化体验。Curve.EaseOut 缓动曲线在这些场景中统一使用,营造了"先快后慢"的自然运动感。

在视觉设计层面,应用采用"星云紫粉风"四色体系——薰衣草底色、紫罗兰主色、玫红点缀、鎏金强调。linearGradient 渐变背景为头部和资料卡赋予视觉深度,shadow 阴影为导航栏营造悬浮层次,borderRadius 圆角贯穿所有交互元素。每位艺人的"主色+浅色"双色配置使数据卡片具备品牌差异化表达。getTypeBadgeformatWangetFundTierName 三个工具函数将类型到颜色、数字到中文、金额到天体的映射逻辑封装为可复用的纯函数,保持了 UI 代码的简洁性。

整体而言,该应用展示了 ArkTS/ArkUI 框架在组件化开发、状态管理、布局系统、列表渲染、动画交互等方面的完整能力图谱。通过 @Component/@State/@Builder 装饰器的组合、四类布局容器的配合、ForEach/if 条件渲染的运用、属性动画的点缀,开发者能够以声明式的方式构建出功能丰富、交互流畅、视觉精致的移动应用界面。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐