HarmonyOS作为华为自主研发的分布式操作系统,其应用开发框架ArkTS在API 24版本中提供了更加完善的声明式UI编程范式。ArkTS在TypeScript的基础上进行了深度定制,引入了@Entry、@Component、@State、@Builder、@Observed等装饰器,使开发者能够以更直观、更高效的方式构建复杂的应用界面。在HarmonyOS 6.1.1的开发环境中,ArkTS语言不仅保留了TypeScript的静态类型检查优势,还通过编译时优化和运行时增强,大幅提升了应用的启动速度和渲染性能。本文将深入剖析一个潮玩盲盒社区应用的完整源码架构,该应用采用了小红书风格的社区设计理念,融合了瀑布流布局、多Tab差异化页面、弹框交互系统等丰富的UI模式。

潮玩盲盒作为近年来快速崛起的潮流文化消费品类,其社区化运营需求日益增长。用户不仅需要分享开盒的惊喜瞬间,还需要管理个人收藏、参与盲盒交易、查看排行榜等。在这样的业务背景下,一个功能完善的盲盒社区应用需要同时兼顾内容展示的丰富性、交互的流畅性以及数据的可维护性。ArkTS的声明式UI范式恰好能够满足这一需求,通过状态驱动的渲染机制,开发者可以轻松实现复杂的数据绑定和视图更新逻辑。本应用采用了紫色与粉色的渐变色彩体系,主色调为#7C4DFF(深紫)与#FF4081(玫红),辅以#E040FB(品红)的渐变过渡,营造出活泼而富有潮玩气息的视觉氛围。

在架构设计层面,本应用采用了单文件单组件的工程组织方式,将所有数据模型、配置常量、工具函数和UI构建器统一封装在一个入口组件中。这种设计虽然看似简单,但在HarmonyOS ArkTS的编译模型下具有独特的优势:编译器能够进行全局优化,减少跨文件引用的开销,同时状态管理的上下文更加清晰。应用的核心数据模型采用interface定义结构契约,通过@Observed装饰的class实现可观察的数据包装器,从而在状态变更时自动触发UI重渲染。这一设计模式是ArkTS状态管理的精髓所在,也是构建高性能HarmonyOS应用的关键技巧。

本应用共包含六个内容Tab页面(推荐、拆盒、图鉴、收藏、交易、排行)和四个弹框组件(新增笔记、编辑配方、删除确认、采购清单),涵盖了内容分享、收藏管理、商品交易、社区排行等完整的社区功能链路。每个Tab页面采用了差异化的布局策略:推荐页使用瀑布流双列卡片网格,拆盒页采用横向滚动加纵向列表的混合布局,图鉴页呈现三列网格,收藏页集成统计卡片与柱状图,交易页提供筛选标签与交易列表,排行页展示前三名特殊卡片与排名列表。这种多布局策略的统一管理,充分体现了ArkTS @Builder装饰器在UI复用方面的强大能力。

此外,应用还实现了一套基于定时器的特效动画系统,通过setInterval驱动的tick计数器,结合一系列纯函数计算emoji的浮动位置、透明度、旋转角度和缩放比例,在页面上层叠加了浮动礼盒、包裹、星光等装饰元素。这一动画方案虽然简洁,但通过数学函数的巧妙运用,实现了类似粒子系统的视觉效果,为应用增添了活泼灵动的潮玩氛围。接下来,我们将逐段深入解析源码的核心实现细节。


一、颜色配置体系与ColorPalette接口设计

在任何HarmonyOS应用中,颜色管理都是视觉设计的基础。本应用通过定义ColorPalette接口和COLORS常量,构建了一套完整的色彩配置体系,将所有颜色值集中管理,便于全局调整和主题切换。

interface ColorPalette {
  primary: string;
  primaryDark: string;
  secondary: string;
  accent: string;
  bg: string;
  card: string;
  text: string;
  textSub: string;
  textLight: string;
  border: string;
  grad1: string;
  grad2: string;
  grad3: string;
  gold: string;
  purple: string;
  pink: string;
  orange: string;
  green: string;
  blue: string;
  red: string;
  white: string;
  overlay: string;
  shadow: string;
}

const COLORS: ColorPalette = {
  primary: '#7C4DFF',
  primaryDark: '#651FFF',
  secondary: '#FF4081',
  accent: '#FFD740',
  bg: '#F8F5FF',
  card: '#FFFFFF',
  text: '#2D2D2D',
  textSub: '#888888',
  textLight: '#BBBBBB',
  border: '#E8E0F7',
  grad1: '#7C4DFF',
  grad2: '#E040FB',
  grad3: '#FF4081',
  gold: '#FFD700',
  purple: '#9C27B0',
  pink: '#E91E63',
  orange: '#FF9800',
  green: '#4CAF50',
  blue: '#2196F3',
  red: '#F44336',
  white: '#FFFFFF',
  overlay: '#80000000',
  shadow: '#15000000'
};

ColorPalette接口定义了23个颜色字段,涵盖了主色、渐变色、功能色、文本色、边框色等完整的色彩谱系。其中grad1grad2grad3三个字段专门用于渐变效果,分别对应紫色#7C4DFF、品红#E040FB和玫红#FF4081,这三个颜色构成了应用最具辨识度的三段渐变色系。overlay字段使用#80000000表示半透明黑色遮罩,用于弹框背景。shadow字段使用#15000000表示极淡的阴影色,为卡片提供柔和的立体感。通过interface定义颜色契约,开发者可以在编码阶段获得完整的类型提示和错误检查,避免拼写错误导致的颜色引用失败问题。


二、稀有度配置与静态数据常量

盲盒应用的核心业务逻辑围绕稀有度展开。本应用通过RarityOption接口和RARITY_OPTIONS数组定义了四级稀有度体系,同时配置了热度标签、Tab名称、交易筛选器和图表数据等静态常量。

interface RarityOption {
  name: string;
  color: string;
  bg: string;
}

const RARITY_OPTIONS: RarityOption[] = [
  { name: '普通', color: '#2196F3', bg: '#E3F2FD' },
  { name: '稀有', color: '#9C27B0', bg: '#F3E5F5' },
  { name: '限定', color: '#E91E63', bg: '#FCE4EC' },
  { name: '隐藏', color: '#FF8F00', bg: '#FFF8E1' }
];

const CONDITION_NAMES: string[] = ['全新未拆', '已拆证完', '近全新', '轻微使用'];

const HOT_TAGS: string[] = ['隐藏款概率', '新品速递', '限量发售', '拆盒攻略', '收藏展示', '交易市场', '换盒专区'];

const TAB_NAMES: string[] = ['推荐', '拆盒', '图鉴', '收藏', '交易', '排行'];

const TRADE_FILTERS: string[] = ['全部', '隐藏款', '稀有款', '限定款', '全新未拆', '近全新'];

RARITY_OPTIONS数组将稀有度名称、文字颜色和背景色三元组化,使得在UI渲染时可以直接通过索引获取匹配的配色方案。普通级使用蓝色系,稀有级使用紫色系,限定级使用玫红系,隐藏级使用琥珀色系,这种颜色编码体系不仅视觉上层次分明,而且与用户的直觉认知高度吻合。CONDITION_NAMES定义了商品的四种新旧状态,HOT_TAGS提供了七个热门话题标签,TAB_NAMES确定了六个内容页面的名称,TRADE_FILTERS则为交易市场提供了六种筛选条件。这些静态常量的集中定义,使得应用的配置数据与业务逻辑完全解耦,便于后期维护和功能扩展。

图表配置部分通过ChartBar接口定义了收藏页柱状图的数据结构:

interface ChartBar {
  label: string;
  value: number;
  color: string;
}

const CHART_BARS: ChartBar[] = [
  { label: '普通', value: 45, color: '#2196F3' },
  { label: '稀有', value: 28, color: '#9C27B0' },
  { label: '限定', value: 15, color: '#E91E63' },
  { label: '隐藏', value: 8, color: '#FFD700' }
];

在这里插入图片描述

这组数据直接驱动了收藏页面的稀有度分布柱状图渲染,每个柱子的标签、高度和颜色都由配置项决定。柱状图通过barHeight函数将数值映射为像素高度(value * 2.4),实现了简洁而有效的数据可视化效果。


三、数据模型定义与@Observed可观察类

ArkTS的状态管理系统要求所有需要在UI中响应变化的数据必须通过@State@Observed进行装饰。本应用采用了一种"interface定义结构契约 + @Observed class实现可观察包装器"的双层设计模式。

interface Post {
  id: number;
  title: string;
  author: string;
  avatar: string;
  series: string;
  rarity: string;
  likes: number;
  liked: boolean;
  tags: string;
  content: string;
  pics: string;
  time: string;
}

interface Series {
  id: number;
  name: string;
  brand: string;
  total: number;
  collected: number;
  price: number;
  hot: number;
  color: string;
  emoji: string;
}

interface Trade {
  id: number;
  name: string;
  series: string;
  rarity: string;
  price: number;
  seller: string;
  status: string;
  condition: string;
}

interface Rank {
  id: number;
  name: string;
  score: number;
  avatar: string;
  collection: number;
  badge: string;
}

在这里插入图片描述

四个interface分别定义了帖子、系列、交易和排名的数据结构。Post接口包含了帖子标题、作者、头像emoji、所属系列、稀有度、点赞数、点赞状态、标签、内容、配图emoji和发布时间等12个字段。Series接口定义了盲盒系列的基本信息,包括品牌、总数、已收藏数、价格、热度和主题色。Trade接口描述了交易商品的信息,包括价格、卖家、状态和成色。Rank接口则定义了排行榜用户的积分、收藏数和徽章信息。

接下来是通过@Observed装饰的可观察类实现:

@Observed
class PostItem implements Post {
  id: number = 0;
  title: string = '';
  author: string = '';
  avatar: string = '';
  series: string = '';
  rarity: string = '';
  likes: number = 0;
  liked: boolean = false;
  tags: string = '';
  content: string = '';
  pics: string = '';
  time: string = '';

  constructor(o: Post) {
    this.id = o.id;
    this.title = o.title;
    this.author = o.author;
    this.avatar = o.avatar;
    this.series = o.series;
    this.rarity = o.rarity;
    this.likes = o.likes;
    this.liked = o.liked;
    this.tags = o.tags;
    this.content = o.content;
    this.pics = o.pics;
    this.time = o.time;
  }
}

@Observed装饰器使PostItem类成为可观察对象,当其属性发生变化时,绑定了该对象的UI组件将自动重新渲染。构造函数接收一个Post接口类型的参数,将所有属性逐一拷贝到实例中。这种"接口 + 可观察类"的模式在ArkTS中非常常见,其优势在于:interface可以用于定义静态Mock数据的类型约束,而@Observed class则用于运行时的可变状态管理。SeriesItemTradeItemRankItem均采用相同的模式实现,分别对应SeriesTradeRank接口的可观察包装器。


四、Mock数据与全局纯函数

应用的数据初始化依赖于Mock数据数组和一系列纯函数。这些函数负责将静态interface数组转换为@Observed class实例数组,并提供了丰富的工具函数用于颜色映射、数据格式化和特效动画计算。

const POSTS: Post[] = [
  { id: 1, title: '终于抽到隐藏款了!激动哭', author: '盲盒少女小C', avatar: '🐰', series: '森林精灵', rarity: '隐藏', likes: 2341, liked: false, tags: '#开盒欧皇 #隐藏款', content: '攒了两周零花钱终于入手!拆开看到配色直接尖叫,隐藏款真的太美了,这个渐变紫绝了!', pics: '🎁', time: '2小时前' },
  { id: 2, title: '三连拆盒出货率分析', author: '数据控拆盒党', avatar: '🤓', series: '太空漫游', rarity: '稀有', likes: 892, liked: false, tags: '#拆盒测评 #概率', content: '连续拆了三盒,稀有款概率约12.5%,隐藏款3.1%', pics: '🚀', time: '3小时前' },
  { id: 3, title: '我的收藏墙终于满了', author: '收藏家大白', avatar: '🐼', series: '动物乐园', rarity: '限定', likes: 1567, liked: true, tags: '#收藏展示 #满墙', content: '花了半年收集完整套,看着满墙的盲盒太有成就感了,亚克力柜防尘又美观!推荐大家用展示柜收纳', pics: '🧸', time: '5小时前' },
  // ... 更多帖子数据
];

在这里插入图片描述

POSTS数组包含了12条帖子数据,每条数据都包含了完整的帖子信息。值得注意的是,avatarpics字段使用emoji字符代替了图片URL,这是一种在Mock环境中常用的轻量级视觉占位方案。rarity字段的值与RARITY_OPTIONS中的name字段对应,便于在渲染时查找配色方案。

全局纯函数承担了数据处理和工具计算的核心职责:

function getLeftPosts(): PostItem[] {
  let result: PostItem[] = [];
  for (let i = 0; i < POSTS.length; i = i + 2) {
    result.push(new PostItem(POSTS[i]));
  }
  return result;
}

function getRightPosts(): PostItem[] {
  let result: PostItem[] = [];
  for (let i = 1; i < POSTS.length; i = i + 2) {
    result.push(new PostItem(POSTS[i]));
  }
  return result;
}

function rarityColor(name: string): string {
  if (name === '隐藏') return '#FF8F00';
  if (name === '稀有') return '#9C27B0';
  if (name === '限定') return '#E91E63';
  return '#2196F3';
}

function withAlpha(color: string, alpha: string): string {
  if (color.length === 7) {
    return '#' + alpha + color.slice(1);
  }
  return color;
}

function formatLikes(n: number): string {
  if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w';
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
  return n.toString();
}

在这里插入图片描述

getLeftPostsgetRightPosts两个函数分别从POSTS数组中提取偶数索引和奇数索引的元素,将其转换为PostItem实例,从而实现了瀑布流的双列数据分割。rarityColorrarityBg函数根据稀有度名称返回对应的文字色和背景色。withAlpha函数是一个颜色处理工具,它将7位十六进制颜色值(如#7C4DFF)转换为带有透明度的9位格式(如#307C4DFF),用于卡片渐变背景的半透明叠加效果。formatLikes函数将数字格式化为带"k"或"w"后缀的简写形式,如2341显示为"2.3k",8901显示为"8.9k",提升了界面的信息密度和可读性。


五、特效动画函数与浮动emoji系统

应用最富特色的视觉元素之一是覆盖在页面上的浮动emoji特效层。这套系统通过一系列基于tick计数器的纯函数,计算每个emoji的位置、透明度、旋转角度和缩放比例,实现了类似粒子动画的效果。

function fxOpacity1(tick: number): number {
  return 0.2 + (tick % 8) * 0.06;
}

function fxOpacity2(tick: number): number {
  return 0.15 + (tick % 6) * 0.08;
}

function fxOpacity3(tick: number): number {
  return 0.1 + (tick % 10) * 0.05;
}

function fxScale(tick: number): number {
  return 0.7 + (tick % 5) * 0.12;
}

function fxX1(tick: number): number {
  return 30 + (tick * 3) % 300;
}

function fxY1(tick: number): number {
  return 120 + (tick * 4) % 500;
}

在这里插入图片描述

这些函数的核心设计思路是利用取模运算(%)创建循环动画效果。fxOpacity1通过tick % 8将透明度限制在0.2到0.62之间循环变化,fxScale通过tick % 5使缩放比例在0.7到1.18之间波动。位置函数fxX1fxY1分别使用tick * 3tick * 4的乘法因子,使水平和垂直方向的运动速度不同,从而产生不规则的飘动轨迹。四个emoji元素分别使用不同的位置函数(fxX1-fxX4, fxY1-fxY4),确保它们的运动路径互不相同,避免了视觉上的单调感。

这套动画系统的驱动机制在组件的aboutToAppear生命周期中初始化:

aboutToAppear(): void {
  this.leftPosts = getLeftPosts();
  this.rightPosts = getRightPosts();
  this.seriesList = getSeriesList();
  this.tradeList = getTradeList();
  this.rankList = getRankList();
  this.timer = setInterval(() => {
    this.tick = this.tick + 1;
  }, 120);
}

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

aboutToAppear在组件创建时被调用,负责初始化所有数据列表和启动定时器。定时器每120毫秒将tick值递增1,由于tick@State装饰的状态变量,每次变化都会触发UI重渲染,从而使所有依赖tick的特效函数重新计算,驱动emoji位置的更新。aboutToDisappear在组件销毁时清除定时器,防止内存泄漏。这种基于定时器的动画方案虽然不如ArkTS的animateTo动画系统流畅,但胜在实现简单、可控性强,适合Mock原型的快速验证。


六、入口组件状态管理与生命周期

BlindBoxApp是应用的入口组件,通过@Entry@Component装饰器标记。该组件管理着应用的所有状态变量,包括当前Tab索引、弹框开关、表单数据、列表数据和动画状态。

@Entry
@Component
struct BlindBoxApp {
  @State curTab: number = 0;
  @State mainTab: number = 0;
  @State addOpen: boolean = false;
  @State editOpen: boolean = false;
  @State delOpen: boolean = false;
  @State bizOpen: boolean = false;

  // 新增开盒记录表单
  @State addTitle: string = '';
  @State addSeriesIdx: number = 0;
  @State addRarityIdx: number = 0;
  @State addContent: string = '';
  @State addTags: string = '';

  // 编辑收藏表单
  @State editName: string = '';
  @State editCollected: number = 0;
  @State editNote: string = '';

  // 删除确认
  @State delName: string = '森林精灵';

  // 交易上架表单
  @State bizName: string = '';
  @State bizSeriesIdx: number = 0;
  @State bizRarityIdx: number = 0;
  @State bizPrice: string = '';
  @State bizConditionIdx: number = 0;
  @State bizContact: string = '';

  // 数据
  @State leftPosts: PostItem[] = [];
  @State rightPosts: PostItem[] = [];
  @State seriesList: SeriesItem[] = [];
  @State tradeList: TradeItem[] = [];
  @State rankList: RankItem[] = [];

  // 交易筛选
  @State tradeFilterIdx: number = 0;

  // 特效动画
  @State tick: number = 0;
  private timer: number = -1;

在这里插入图片描述

状态变量分为五组:页面导航状态(curTabmainTab)、弹框开关状态(四个布尔值)、表单数据状态(各弹框对应的输入字段)、列表数据状态(五个数组)和动画状态(ticktimer)。timer使用private关键字声明,表示它不需要参与UI渲染,仅用于定时器ID的内部管理。这种分组式的状态管理方式使得组件的状态结构清晰可辨,便于开发者快速定位和修改特定的状态变量。

弹框管理方法提供了统一的开闭接口:

closeAll(): void {
  this.addOpen = false;
  this.editOpen = false;
  this.delOpen = false;
  this.bizOpen = false;
}

openAdd(): void {
  this.addTitle = '';
  this.addSeriesIdx = 0;
  this.addRarityIdx = 0;
  this.addContent = '';
  this.addTags = '';
  this.addOpen = true;
}

openEdit(name: string, collected: number): void {
  this.editName = name;
  this.editCollected = collected;
  this.editNote = '';
  this.editOpen = true;
}

openDel(name: string): void {
  this.delName = name;
  this.delOpen = true;
}

openBiz(): void {
  this.bizName = '';
  this.bizSeriesIdx = 0;
  this.bizRarityIdx = 0;
  this.bizPrice = '';
  this.bizConditionIdx = 0;
  this.bizContact = '';
  this.bizOpen = true;
}

在这里插入图片描述

closeAll方法一次性关闭所有弹框,简化了弹框间的切换逻辑。每个open方法在打开弹框前先重置对应的表单字段,确保每次打开时表单都是干净的初始状态。openEditopenDel方法接收参数,将目标数据注入表单,实现了数据传递与弹框打开的原子性操作。点赞交互的toggleLike方法通过修改PostItem实例的likedlikes属性,并使用slice()方法创建数组的新引用,从而触发ArkTS的数组变化检测机制。


七、特效层fxLayer的@Builder实现

fxLayer是一个使用@Builder装饰的UI构建器,它负责渲染覆盖在整个页面之上的浮动emoji特效层。

@Builder
fxLayer() {
  Stack() {
    Text('🎁')
      .fontSize(38)
      .position({ x: fxX1(this.tick), y: fxY1(this.tick) })
      .opacity(fxOpacity1(this.tick))
      .rotate({ angle: this.tick * 8 })
      .hitTestBehavior(HitTestMode.None)
    Text('📦')
      .fontSize(30)
      .position({ x: fxX2(this.tick), y: fxY2(this.tick) })
      .opacity(fxOpacity2(this.tick))
      .rotate({ angle: -this.tick * 6 })
      .hitTestBehavior(HitTestMode.None)
    Text('✨')
      .fontSize(26)
      .position({ x: fxX3(this.tick), y: fxY3(this.tick) })
      .opacity(fxOpacity3(this.tick))
      .scale({ x: fxScale(this.tick), y: fxScale(this.tick) })
      .hitTestBehavior(HitTestMode.None)
    Text('🎊')
      .fontSize(34)
      .position({ x: fxX4(this.tick), y: fxY4(this.tick) })
      .opacity(fxOpacity2(this.tick))
      .rotate({ angle: this.tick * 12 })
      .hitTestBehavior(HitTestMode.None)
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

四个Text组件分别渲染了礼盒、包裹、星光和庆祝彩带emoji,每个emoji通过position属性绝对定位,其x/y坐标由对应的特效函数实时计算。opacity属性控制透明度的循环变化,rotate属性使emoji产生旋转效果(角度可以是负值,实现反向旋转),scale属性实现了星光emoji的呼吸缩放效果。最关键的是hitTestBehavior(HitTestMode.None)属性,它使整个特效层及其子元素不响应任何触摸事件,确保浮动emoji不会干扰底层页面的正常交互。外层Stack容器同样设置了HitTestMode.None,形成了一个完全透明的事件穿透层。


八、头部header组件与渐变背景

header构建器渲染了应用的顶部区域,包含品牌标识、搜索栏和热门标签滚动条,整体覆盖在紫色到粉色的三段渐变背景之上。

@Builder
header() {
  Column() {
    Row() {
      Text('🎁')
        .fontSize(24)
      Text('盲盒星球')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .margin({ left: 8 })
      Row()
        .layoutWeight(1)
      Text('🔔')
        .fontSize(22)
        .onClick(() => {
          this.switchTab(0);
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 8 })
    .alignItems(VerticalAlign.Center)

    Row() {
      Text('🔍')
        .fontSize(16)
        .margin({ right: 8 })
      Text('搜索盲盒 / 系列 / 玩家')
        .fontSize(13)
        .fontColor(COLORS.textLight)
        .layoutWeight(1)
      Text('📷')
        .fontSize(18)
        .onClick(() => {
          this.openAdd();
        })
    }
    .width('90%')
    .height(38)
    .backgroundColor('#FFFFFF')
    .borderRadius(19)
    .padding({ left: 14, right: 14 })
    .alignItems(VerticalAlign.Center)
    .margin({ bottom: 10 })
  }
  .width('100%')
  .linearGradient({
    angle: 135,
    colors: [[COLORS.grad1, 0], [COLORS.grad2, 0.5], [COLORS.grad3, 1]]
  })
  .padding({ bottom: 10 })
}

在这里插入图片描述

头部的渐变背景通过linearGradient属性实现,angle: 135指定了从左上到右下的渐变方向,colors数组定义了三个渐变色锚点:起始为#7C4DFF(深紫),中间为#E040FB(品红),终点为#FF4081(玫红),三个锚点分别在0%、50%和100%位置。搜索栏使用白色圆角背景(borderRadius: 19),在紫色渐变背景上形成了显著的视觉对比。搜索栏右侧的相机图标绑定了openAdd方法,点击即可打新增开盒记录弹框。热门标签区域使用Scroll容器实现横向滚动,每个标签是半透明白色背景(#35000000)的圆角胶囊按钮。


九、tabBar内容切换栏与tab0推荐页瀑布流

tabBar构建器渲染了六个内容Tab的切换栏,当前选中的Tab使用主色高亮并显示下划线指示器。

@Builder
tabBar() {
  Row() {
    ForEach(TAB_NAMES, (name: string, i: number) => {
      Column() {
        Text(name)
          .fontSize(this.curTab === i ? 15 : 13)
          .fontColor(this.curTab === i ? COLORS.primary : COLORS.textSub)
          .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
        if (this.curTab === i) {
          Column()
            .width(20)
            .height(3)
            .backgroundColor(COLORS.primary)
            .borderRadius(2)
            .margin({ top: 3 })
        }
      }
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      .padding({ top: 8, bottom: 6 })
      .layoutWeight(1)
      .onClick(() => {
        this.switchTab(i);
      })
    }, (name: string) => name)
  }
  .width('100%')
  .backgroundColor(COLORS.card)
}

Tab栏使用ForEach遍历TAB_NAMES数组生成六个Tab项,每个项的字体大小、颜色和粗细根据curTab === i条件进行差异化设置。选中状态下还会渲染一个20px宽、3px高的圆角下划线指示器。layoutWeight(1)确保六个Tab均匀分布。推荐页的瀑布流布局通过feedCardpageFeed两个构建器实现:

@Builder
feedCard(p: PostItem) {
  Column() {
    Column() {
      Text(p.pics)
        .fontSize(p.id % 2 === 0 ? 48 : 36)
    }
    .width('100%')
    .padding({ top: p.id % 2 === 0 ? 24 : 16, bottom: p.id % 2 === 0 ? 24 : 16 })
    .linearGradient({
      angle: 160,
      colors: [[rarityBg(p.rarity), 0], [withAlpha(rarityColor(p.rarity), '30'), 1]]
    })
    .alignItems(HorizontalAlign.Center)

    Column() {
      Text(p.title)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ bottom: 4 })

      Text(p.content)
        .fontSize(12)
        .fontColor(COLORS.textSub)
        .maxLines(p.id % 3 === 0 ? 3 : 1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ bottom: 6 })

      Row() {
        Text(p.avatar)
          .fontSize(16)
          .margin({ right: 4 })
        Text(p.author)
          .fontSize(11)
          .fontColor(COLORS.textSub)
          .layoutWeight(1)
        Text(p.liked ? '❤️' : '🤍')
          .fontSize(14)
          .onClick(() => {
            this.toggleLike(p);
          })
        Text(formatLikes(p.likes))
          .fontSize(11)
          .fontColor(COLORS.textSub)
          .margin({ left: 3 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .padding(10)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .shadow({ radius: 4, color: COLORS.shadow })
  .margin({ bottom: 10 })
}

feedCard是单个帖子卡片的构建器,卡片顶部是根据稀有度配色的渐变区域,中间是标题、内容和标签,底部是作者信息和点赞按钮。卡片通过p.id % 2 === 0条件判断实现了偶数ID卡片使用更大的emoji字号(48 vs 36)和更大的内边距,模拟了真实瀑布流中卡片高度不一致的效果。内容区域的maxLines也根据p.id % 3 === 0条件动态设置为3行或1行,进一步增加了瀑布流的参差错落感。点赞按钮通过p.liked状态切换心形emoji,并调用toggleLike方法更新点赞数。

pageFeed构建器使用双列布局组织卡片:

@Builder
pageFeed() {
  Column() {
    Row() {
      Text('🔥 今日热门开盒')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .layoutWeight(1)
      Text('新增记录')
        .fontSize(12)
        .fontColor(COLORS.primary)
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .borderRadius(12)
        .backgroundColor(rarityBg('普通'))
        .onClick(() => {
          this.openAdd();
        })
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 10, bottom: 8 })

    Row() {
      Column() {
        ForEach(this.leftPosts, (p: PostItem) => {
          this.feedCard(p)
        }, (p: PostItem) => p.id.toString())
      }
      .layoutWeight(1)
      .padding({ left: 6, right: 5 })

      Column() {
        ForEach(this.rightPosts, (p: PostItem) => {
          this.feedCard(p)
        }, (p: PostItem) => p.id.toString())
      }
      .layoutWeight(1)
      .padding({ left: 5, right: 6 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)
  }
  .width('100%')
}

两列各自使用layoutWeight(1)平分宽度,左列内边距为left:6, right:5,右列为left:5, right:6,使两列之间的间距为10px(5+5),与两侧边距6px接近,视觉上保持均匀。alignItems(VerticalAlign.Top)确保两列从顶部对齐,模拟了瀑布流从上到下流动的视觉效果。


十、tab2图鉴页三列网格与收藏进度条

图鉴页使用三列网格布局展示所有盲盒系列,每个系列卡片包含emoji图标、名称、品牌、收藏进度和操作按钮。

@Builder
atlasCard(s: SeriesItem) {
  Column() {
    Column() {
      Text(s.emoji)
        .fontSize(32)
    }
    .width('100%')
    .padding({ top: 14, bottom: 14 })
    .linearGradient({
      angle: 140,
      colors: [[withAlpha(s.color, '20'), 0], [withAlpha(s.color, '60'), 1]]
    })
    .alignItems(HorizontalAlign.Center)

    Column() {
      Text(s.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(s.brand)
        .fontSize(10)
        .fontColor(COLORS.textSub)
        .margin({ top: 2 })
      Text(s.collected + '/' + s.total)
        .fontSize(11)
        .fontColor(seriesProgressColor(s.collected, s.total))
        .fontWeight(FontWeight.Bold)
        .margin({ top: 4 })

      Row() {
        Column()
          .width(progressPercent(s.collected, s.total) + '%')
          .height(5)
          .backgroundColor(seriesProgressColor(s.collected, s.total))
          .borderRadius(3)
      }
      .width('100%')
      .height(5)
      .backgroundColor(COLORS.border)
      .borderRadius(3)
      .margin({ top: 4, bottom: 6 })

      Text(s.collected >= s.total ? '✅ 已集齐' : '继续收集')
        .fontSize(10)
        .fontColor(s.collected >= s.total ? COLORS.green : COLORS.primary)
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        .borderRadius(10)
        .backgroundColor(s.collected >= s.total ? '#E8F5E9' : rarityBg('普通'))
        .onClick(() => {
          this.openEdit(s.name, s.collected);
        })
    }
    .padding(8)
    .alignItems(HorizontalAlign.Start)
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .shadow({ radius: 3, color: COLORS.shadow })
  .margin({ bottom: 10 })
}

卡片的顶部区域使用系列主题色(s.color)生成半透明渐变背景,通过withAlpha函数将颜色转换为20%和60%透明度的变体。进度条使用progressPercent函数计算百分比,将其作为Column的宽度(如"75%"),放置在灰色背景的Row容器中,形成进度条效果。seriesProgressColor函数根据完成度返回不同颜色:100%为绿色、50%以上为紫色、25%以上为橙色、不足25%为红色,直观地传达了收藏进度状态。

三列网格通过i % 3取模判断将系列分配到三列:

Row() {
  Column() {
    ForEach(this.seriesList, (s: SeriesItem, i: number) => {
      if (i % 3 === 0) {
        this.atlasCard(s)
      }
    }, (s: SeriesItem) => 'col0_' + s.id.toString())
  }
  .layoutWeight(1)

  Column() {
    ForEach(this.seriesList, (s: SeriesItem, i: number) => {
      if (i % 3 === 1) {
        this.atlasCard(s)
      }
    }, (s: SeriesItem) => 'col1_' + s.id.toString())
  }
  .layoutWeight(1)

  Column() {
    ForEach(this.seriesList, (s: SeriesItem, i: number) => {
      if (i % 3 === 2) {
        this.atlasCard(s)
      }
    }, (s: SeriesItem) => 'col2_' + s.id.toString())
  }
  .layoutWeight(1)
}

这种通过取模实现多列网格的方式是ArkTS中常见的布局技巧,因为ArkTS的Grid组件在复杂场景下的灵活性有限,使用多个Column配合条件渲染可以更精确地控制每列的内容和样式。


十一、tab3收藏页统计卡片与柱状图

收藏页集成了统计卡片、柱状图和收藏列表三个区域,是应用中信息密度最高的页面。

@Builder
collectStatCard(label: string, value: string, color: string) {
  Column() {
    Text(value)
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor(color)
    Text(label)
      .fontSize(11)
      .fontColor(COLORS.textSub)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 14, bottom: 14 })
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .margin({ left: 4, right: 4 })
  .shadow({ radius: 3, color: COLORS.shadow })
}

collectStatCard是一个可复用的统计卡片构建器,接收标签、数值和颜色三个参数。在收藏页中使用三次调用,分别展示总系列数、已收藏数和完成率。柱状图区域使用CHART_BARS配置数据驱动渲染:

Row() {
  ForEach(CHART_BARS, (bar: ChartBar) => {
    Column() {
      Text(bar.value.toString())
        .fontSize(11)
        .fontColor(bar.color)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 4 })
      Column()
        .width(26)
        .height(barHeight(bar.value))
        .backgroundColor(bar.color)
        .borderRadius(4)
      Text(bar.label)
        .fontSize(11)
        .fontColor(COLORS.textSub)
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.End)
  }, (bar: ChartBar) => bar.label)
}
.width('100%')
.height(160)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.End)

柱状图的每个柱子是一个Column组件,其高度通过barHeight(bar.value)函数计算(value * 2.4),数值标签在柱子上方,类别标签在柱子下方。外层Row设置了height(160)alignItems(VerticalAlign.Bottom),使所有柱子从底部对齐,形成标准的柱状图视觉效果。


十二、tab4交易页筛选标签与交易列表

交易页提供了横向滚动的筛选标签栏和纵向排列的交易卡片列表,是应用中交互逻辑最丰富的页面之一。

Scroll() {
  Row() {
    ForEach(TRADE_FILTERS, (f: string, i: number) => {
      Text(f)
        .fontSize(12)
        .fontColor(this.tradeFilterIdx === i ? COLORS.white : COLORS.textSub)
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(this.tradeFilterIdx === i ? COLORS.primary : COLORS.border)
        .margin({ right: 8 })
        .onClick(() => {
          this.tradeFilterIdx = i;
        })
    }, (f: string) => f)
  }
  .padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

筛选标签通过tradeFilterIdx状态变量跟踪当前选中的筛选条件,选中标签使用主色背景和白色文字,未选中标签使用边框色背景和灰色文字。交易卡片中使用了statusColorstatusBg函数根据交易状态(在售/预订/已售)动态设置颜色:

function statusColor(status: string): string {
  if (status === '在售') return '#4CAF50';
  if (status === '预订') return '#FF9800';
  if (status === '已售') return '#9E9E9E';
  return '#2196F3';
}

在售状态使用绿色表示可交易,预订状态使用橙色表示待确认,已售状态使用灰色表示不可用。已售商品的价格标签还通过TextDecorationType.LineThrough添加了删除线效果,进一步强化了不可购买的视觉信号。


十三、tab5排行榜页与前三名特殊卡片

排行榜页对前三名用户使用特殊的大卡片展示,第四名及以后使用紧凑的列表行布局。

@Builder
rankTopCard(r: RankItem) {
  Column() {
    Text(medalEmoji(r.id))
      .fontSize(32)
    Text(r.avatar)
      .fontSize(36)
      .margin({ top: 4 })
    Text(r.name)
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text)
      .margin({ top: 4 })
    Text(r.score.toString() + '分')
      .fontSize(12)
      .fontColor(r.id === 1 ? '#FF8F00' : COLORS.primary)
      .fontWeight(FontWeight.Bold)
      .margin({ top: 2 })
    Text(r.collection.toString() + '件收藏')
      .fontSize(11)
      .fontColor(COLORS.textSub)
      .margin({ top: 2 })
    Text(r.badge)
      .fontSize(10)
      .fontColor(COLORS.white)
      .padding({ left: 10, right: 10, top: 3, bottom: 3 })
      .borderRadius(10)
      .backgroundColor(r.id === 1 ? '#FF8F00' : r.id === 2 ? '#757575' : '#FF7043')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 14, bottom: 14, left: 6, right: 6 })
  .backgroundColor(rankBgColor(r.id))
  .borderRadius(14)
  .alignItems(HorizontalAlign.Center)
  .shadow({ radius: 4, color: COLORS.shadow })
}

前三名卡片的排列顺序是第二名、第一名、第三名(2-1-3),使第一名居中且最高,形成经典的领奖台视觉效果。第一名使用金色(#FF8F00)文字和金色徽章背景,第二名使用灰色,第三名使用橙色。medalEmoji函数根据排名返回对应的奖牌emoji(金、银、铜),rankBgColor函数为前三名分别设置不同的卡片背景色。


十四、弹框系统与modalOverlay遮罩层

应用的四个弹框通过统一的modalOverlay构建器管理,使用Stack容器叠加遮罩层和弹框内容。

@Builder
modalOverlay() {
  Stack() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.overlay)
      .onClick(() => {
        this.closeAll();
      })

    if (this.addOpen) {
      this.modalBodyAdd()
    }
    if (this.editOpen) {
      this.modalBodyEdit()
    }
    if (this.delOpen) {
      this.modalBodyDel()
    }
    if (this.bizOpen) {
      this.modalBodyBiz()
    }
  }
  .width('100%')
  .height('100%')
}

遮罩层使用COLORS.overlay#80000000,50%透明黑色)作为背景色,点击遮罩层调用closeAll关闭所有弹框。四个弹框通过条件渲染(if (this.xxxOpen))决定是否显示,由于同一时刻只有一个弹框处于打开状态,这种设计确保了不会有多个弹框叠加。新增开盒记录弹框(modalBodyAdd)从底部弹出,包含标题输入、系列选择、稀有度选择、内容编辑和标签输入五个表单项。编辑收藏弹框(modalBodyEdit)居中显示,提供系列名称编辑和已收集数量的加减按钮。删除确认弹框(modalBodyDel)是一个简洁的居中小弹框,使用红色警告色。交易上架弹框(modalBodyBiz)从底部弹出,包含商品名称、系列选择、稀有度、新旧程度、价格和联系方式等完整的交易信息表单。


十五、底部导航栏bottomBar与主构建build方法

底部导航栏包含五个入口:首页、发现、发布(中央凸起按钮)、消息和我的。主构建方法build将所有组件组装为最终的应用界面。

@Builder
bottomBar() {
  Row() {
    Column() {
      Text('🏠')
        .fontSize(22)
        .opacity(this.mainTab === 0 ? 1 : 0.4)
      Text('首页')
        .fontSize(10)
        .fontColor(this.mainTab === 0 ? COLORS.primary : COLORS.textSub)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.switchMain(0);
    })

    Column() {
      Text('➕')
        .fontSize(26)
        .fontColor(COLORS.white)
        .textAlign(TextAlign.Center)
        .width(48)
        .height(48)
        .lineHeight(48)
        .linearGradient({
          angle: 135,
          colors: [[COLORS.grad1, 0], [COLORS.grad3, 1]]
        })
        .borderRadius(24)
        .shadow({ radius: 8, color: '#407C4DFF' })
        .margin({ top: -14 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.openAdd();
    })
  }
  .width('100%')
  .height(64)
  .backgroundColor(COLORS.card)
}

中央发布按钮使用紫粉渐变背景(grad1grad3),通过margin({ top: -14 })向上凸出,配合shadow产生悬浮效果,是整个底部栏的视觉焦点。未选中状态的Tab使用opacity: 0.4降低不透明度,与选中状态形成对比。

build() {
  Stack() {
    Column() {
      this.header()
      this.tabBar()
      Scroll() {
        Column() {
          if (this.curTab === 0) {
            this.pageFeed()
          }
          if (this.curTab === 1) {
            this.pageUnbox()
          }
          if (this.curTab === 2) {
            this.pageAtlas()
          }
          if (this.curTab === 3) {
            this.pageCollect()
          }
          if (this.curTab === 4) {
            this.pageTrade()
          }
          if (this.curTab === 5) {
            this.pageRank()
          }
        }
        .width('100%')
        .padding({ bottom: 20 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      this.bottomBar()
    }
    .width('100%')
    .height('100%')

    this.fxLayer()

    if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
      this.modalOverlay()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build方法使用三层Stack结构:底层是Column容器(包含header、tabBar、Scroll内容区和bottomBar),中间层是fxLayer特效层(事件穿透),最上层是条件渲染的modalOverlay弹框层。内容区使用Scroll容器包裹,设置了scrollBar(BarState.Off)隐藏滚动条和edgeEffect(EdgeEffect.Spring)启用弹性回弹效果,使滚动体验更加自然流畅。


组件架构与数据流Mermaid流程图

BlindBoxApp 入口组件

header 头部区域

tabBar 内容Tab栏

Scroll 内容滚动区

bottomBar 底部导航栏

fxLayer 特效层

modalOverlay 弹框层

curTab = 0 推荐

curTab = 1 拆盒

curTab = 2 图鉴

curTab = 3 收藏

curTab = 4 交易

curTab = 5 排行

pageFeed 瀑布流双列

leftPosts 左列

rightPosts 右列

pageUnbox 横向滚动+列表

pageAtlas 三列网格

pageCollect 统计+柱状图

pageTrade 筛选+交易列表

pageRank 排行榜

modalBodyAdd 新增弹框

modalBodyEdit 编辑弹框

modalBodyDel 删除确认

modalBodyBiz 交易弹框

setInterval 定时器

tick 状态递增

fxX/fxY 位置计算

fxOpacity 透明度

fxScale 缩放

fxRotate 旋转

aboutToAppear 初始化数据

getLeftPosts 左列数据

getRightPosts 右列数据

getSeriesList 系列数据

getTradeList 交易数据

getRankList 排行数据


数据模型与组件对比表

维度 PostItem SeriesItem TradeItem RankItem
对应接口 Post Series Trade Rank
核心字段 title, author, rarity, likes name, brand, collected, total name, price, seller, status name, score, collection, badge
使用页面 推荐(瀑布流)、拆盒(列表) 拆盒(横滑)、图鉴(网格)、收藏(列表) 交易(列表) 排行(前3卡片+列表)
颜色映射函数 rarityColor, rarityBg seriesProgressColor statusColor, statusBg rankBgColor, medalEmoji
格式化函数 formatLikes progressPercent
弹框交互 新增开盒记录 编辑收藏信息 交易上架
布局方式 双列瀑布流卡片 三列网格/横向滚动/纵向列表 纵向列表 前三名卡片+列表行
数据来源 POSTS常量(12条) SERIES_DATA常量(12条) TRADES常量(10条) RANKS常量(10条)
Mock数据特征 emoji头像+配图 emoji图标+主题色 价格+状态+成色 积分+收藏数+徽章
弹框组件 弹出方式 宽度占比 最大高度 核心表单字段 触发来源
modalBodyAdd 底部弹出(FlexAlign.End) 90% 75% 标题/系列/稀有度/内容/标签 header相机/推荐页新增/底部发布
modalBodyEdit 居中弹出 85% 无限制 名称/已收集数量/备注 图鉴页/收藏页编辑按钮
modalBodyDel 居中小弹框 70% 无限制 确认/取消 收藏页删除按钮
modalBodyBiz 底部弹出(FlexAlign.End) 95% 80% 商品名/系列/稀有度/新旧/价格/联系方式 交易页上架按钮/查看详情

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

/**
 * 场景:潮玩盲盒社区(小红书风格)
 * 布局风格:瀑布流双列卡片网格 + 多tab差异化布局
 * 主题色:紫色/粉色渐变系,活泼鲜艳
 * 功能:开盒记录分享、收藏管理、盲盒交易、排行榜
 */

// ============================================================
// 颜色配置
// ============================================================

interface ColorPalette {
  primary: string;
  primaryDark: string;
  secondary: string;
  accent: string;
  bg: string;
  card: string;
  text: string;
  textSub: string;
  textLight: string;
  border: string;
  grad1: string;
  grad2: string;
  grad3: string;
  gold: string;
  purple: string;
  pink: string;
  orange: string;
  green: string;
  blue: string;
  red: string;
  white: string;
  overlay: string;
  shadow: string;
}

const COLORS: ColorPalette = {
  primary: '#7C4DFF',
  primaryDark: '#651FFF',
  secondary: '#FF4081',
  accent: '#FFD740',
  bg: '#F8F5FF',
  card: '#FFFFFF',
  text: '#2D2D2D',
  textSub: '#888888',
  textLight: '#BBBBBB',
  border: '#E8E0F7',
  grad1: '#7C4DFF',
  grad2: '#E040FB',
  grad3: '#FF4081',
  gold: '#FFD700',
  purple: '#9C27B0',
  pink: '#E91E63',
  orange: '#FF9800',
  green: '#4CAF50',
  blue: '#2196F3',
  red: '#F44336',
  white: '#FFFFFF',
  overlay: '#80000000',
  shadow: '#15000000'
};

// ============================================================
// 稀有度配置
// ============================================================

interface RarityOption {
  name: string;
  color: string;
  bg: string;
}

const RARITY_OPTIONS: RarityOption[] = [
  { name: '普通', color: '#2196F3', bg: '#E3F2FD' },
  { name: '稀有', color: '#9C27B0', bg: '#F3E5F5' },
  { name: '限定', color: '#E91E63', bg: '#FCE4EC' },
  { name: '隐藏', color: '#FF8F00', bg: '#FFF8E1' }
];

const CONDITION_NAMES: string[] = ['全新未拆', '已拆证完', '近全新', '轻微使用'];

const HOT_TAGS: string[] = ['隐藏款概率', '新品速递', '限量发售', '拆盒攻略', '收藏展示', '交易市场', '换盒专区'];

const TAB_NAMES: string[] = ['推荐', '拆盒', '图鉴', '收藏', '交易', '排行'];

const TRADE_FILTERS: string[] = ['全部', '隐藏款', '稀有款', '限定款', '全新未拆', '近全新'];

// ============================================================
// 图表配置(收藏tab柱状图)
// ============================================================

interface ChartBar {
  label: string;
  value: number;
  color: string;
}

const CHART_BARS: ChartBar[] = [
  { label: '普通', value: 45, color: '#2196F3' },
  { label: '稀有', value: 28, color: '#9C27B0' },
  { label: '限定', value: 15, color: '#E91E63' },
  { label: '隐藏', value: 8, color: '#FFD700' }
];

// ============================================================
// 数据模型
// ============================================================

interface Post {
  id: number;
  title: string;
  author: string;
  avatar: string;
  series: string;
  rarity: string;
  likes: number;
  liked: boolean;
  tags: string;
  content: string;
  pics: string;
  time: string;
}

interface Series {
  id: number;
  name: string;
  brand: string;
  total: number;
  collected: number;
  price: number;
  hot: number;
  color: string;
  emoji: string;
}

interface Trade {
  id: number;
  name: string;
  series: string;
  rarity: string;
  price: number;
  seller: string;
  status: string;
  condition: string;
}

interface Rank {
  id: number;
  name: string;
  score: number;
  avatar: string;
  collection: number;
  badge: string;
}

@Observed
class PostItem implements Post {
  id: number = 0;
  title: string = '';
  author: string = '';
  avatar: string = '';
  series: string = '';
  rarity: string = '';
  likes: number = 0;
  liked: boolean = false;
  tags: string = '';
  content: string = '';
  pics: string = '';
  time: string = '';

  constructor(o: Post) {
    this.id = o.id;
    this.title = o.title;
    this.author = o.author;
    this.avatar = o.avatar;
    this.series = o.series;
    this.rarity = o.rarity;
    this.likes = o.likes;
    this.liked = o.liked;
    this.tags = o.tags;
    this.content = o.content;
    this.pics = o.pics;
    this.time = o.time;
  }
}

@Observed
class SeriesItem implements Series {
  id: number = 0;
  name: string = '';
  brand: string = '';
  total: number = 0;
  collected: number = 0;
  price: number = 0;
  hot: number = 0;
  color: string = '';
  emoji: string = '';

  constructor(o: Series) {
    this.id = o.id;
    this.name = o.name;
    this.brand = o.brand;
    this.total = o.total;
    this.collected = o.collected;
    this.price = o.price;
    this.hot = o.hot;
    this.color = o.color;
    this.emoji = o.emoji;
  }
}

@Observed
class TradeItem implements Trade {
  id: number = 0;
  name: string = '';
  series: string = '';
  rarity: string = '';
  price: number = 0;
  seller: string = '';
  status: string = '';
  condition: string = '';

  constructor(o: Trade) {
    this.id = o.id;
    this.name = o.name;
    this.series = o.series;
    this.rarity = o.rarity;
    this.price = o.price;
    this.seller = o.seller;
    this.status = o.status;
    this.condition = o.condition;
  }
}

@Observed
class RankItem implements Rank {
  id: number = 0;
  name: string = '';
  score: number = 0;
  avatar: string = '';
  collection: number = 0;
  badge: string = '';

  constructor(o: Rank) {
    this.id = o.id;
    this.name = o.name;
    this.score = o.score;
    this.avatar = o.avatar;
    this.collection = o.collection;
    this.badge = o.badge;
  }
}

// ============================================================
// Mock 数据
// ============================================================

const POSTS: Post[] = [
  { id: 1, title: '终于抽到隐藏款了!激动哭', author: '盲盒少女小C', avatar: '🐰', series: '森林精灵', rarity: '隐藏', likes: 2341, liked: false, tags: '#开盒欧皇 #隐藏款', content: '攒了两周零花钱终于入手!拆开看到配色直接尖叫,隐藏款真的太美了,这个渐变紫绝了!', pics: '🎁', time: '2小时前' },
  { id: 2, title: '三连拆盒出货率分析', author: '数据控拆盒党', avatar: '🤓', series: '太空漫游', rarity: '稀有', likes: 892, liked: false, tags: '#拆盒测评 #概率', content: '连续拆了三盒,稀有款概率约12.5%,隐藏款3.1%', pics: '🚀', time: '3小时前' },
  { id: 3, title: '我的收藏墙终于满了', author: '收藏家大白', avatar: '🐼', series: '动物乐园', rarity: '限定', likes: 1567, liked: true, tags: '#收藏展示 #满墙', content: '花了半年收集完整套,看着满墙的盲盒太有成就感了,亚克力柜防尘又美观!推荐大家用展示柜收纳', pics: '🧸', time: '5小时前' },
  { id: 4, title: '新品预告!下周发售', author: '潮玩情报站', avatar: '🦊', series: '赛博朋克', rarity: '普通', likes: 3201, liked: false, tags: '#新品速递 #预告', content: '下周六正式发售,预售价59元/盒', pics: '⚡', time: '6小时前' },
  { id: 5, title: '求购稀有款,价格好商量', author: '求盒小能手', avatar: '🐨', series: '海洋之心', rarity: '稀有', likes: 234, liked: false, tags: '#求购 #稀有款', content: '差一只就齐了,有的姐妹私信我', pics: '🌊', time: '8小时前' },
  { id: 6, title: '十连拆全程高能', author: '拆盒快乐多', avatar: '🦁', series: '甜品派对', rarity: '限定', likes: 4521, liked: false, tags: '#十连拆 #视频', content: '一口气拆了十盒,出了两个限定一个隐藏,运气爆炸!每一盒都拍了下来,最后那个隐藏款拆出来的瞬间我直接跳起来了!', pics: '🍰', time: '12小时前' },
  { id: 7, title: '交换!我有重复隐藏款', author: '换盒达人', avatar: '🐯', series: '森林精灵', rarity: '隐藏', likes: 678, liked: false, tags: '#交换 #隐藏款', content: '抽到重复隐藏款,想换其他系列的隐藏', pics: '🎁', time: '1天前' },
  { id: 8, title: '盲盒收纳攻略分享', author: '收纳小能手', avatar: '🐸', series: '星空梦境', rarity: '普通', likes: 1234, liked: false, tags: '#收纳 #攻略', content: '用亚克力展示柜收纳,防尘又美观', pics: '✨', time: '1天前' },
  { id: 9, title: '今天也是欧皇的一天', author: '欧皇本皇', avatar: '🦄', series: '神话传说', rarity: '隐藏', likes: 5678, liked: true, tags: '#欧皇 #隐藏款', content: '第一次买就出隐藏!这就是欧皇的快乐吗?朋友们都羡慕哭了,图上这个独角兽造型简直精致到发光!', pics: '🦄', time: '1天前' },
  { id: 10, title: '限量版盲盒开箱对比', author: '测评君', avatar: '🐙', series: '复古怀旧', rarity: '限定', likes: 2345, liked: false, tags: '#限量 #开箱对比', content: '普通版和限量版对比,包装和涂装差别还挺大的', pics: '🎀', time: '2天前' },
  { id: 11, title: '交易安全避坑指南', author: '安全小卫士', avatar: '🐱', series: '通用', rarity: '普通', likes: 3456, liked: false, tags: '#交易安全 #避坑', content: '近期交易诈骗增多,分享几个避坑技巧:1.务必走平台担保交易 2.查看卖家历史评价 3.警惕远低于市价的商品 4.收货及时验货拍照', pics: '🛡️', time: '2天前' },
  { id: 12, title: '我的年度盲盒总结', author: '年度总结君', avatar: '🐵', series: '年度合集', rarity: '稀有', likes: 8901, liked: false, tags: '#年度总结 #合集', content: '今年买了234盒,花了约1.2w', pics: '📊', time: '3天前' }
];

const SERIES_DATA: Series[] = [
  { id: 1, name: '森林精灵', brand: 'POP MART', total: 12, collected: 10, price: 59, hot: 9800, color: '#7C4DFF', emoji: '🌲' },
  { id: 2, name: '太空漫游', brand: '52TOYS', total: 10, collected: 7, price: 69, hot: 7500, color: '#2196F3', emoji: '🚀' },
  { id: 3, name: '动物乐园', brand: '泡泡玛特', total: 14, collected: 14, price: 49, hot: 12000, color: '#4CAF50', emoji: '🐼' },
  { id: 4, name: '赛博朋克', brand: '寻找独角兽', total: 10, collected: 3, price: 79, hot: 15000, color: '#9C27B0', emoji: '⚡' },
  { id: 5, name: '海洋之心', brand: '若态', total: 12, collected: 5, price: 59, hot: 5600, color: '#00BCD4', emoji: '🌊' },
  { id: 6, name: '甜品派对', brand: 'HobbyFun', total: 10, collected: 8, price: 55, hot: 8900, color: '#FF9800', emoji: '🍰' },
  { id: 7, name: '星空梦境', brand: 'POP MART', total: 12, collected: 12, price: 65, hot: 11000, color: '#3F51B5', emoji: '✨' },
  { id: 8, name: '神话传说', brand: '寻找独角兽', total: 10, collected: 4, price: 89, hot: 18000, color: '#E91E63', emoji: '🦄' },
  { id: 9, name: '复古怀旧', brand: '52TOYS', total: 12, collected: 6, price: 62, hot: 4300, color: '#795548', emoji: '🎀' },
  { id: 10, name: '萌宠日记', brand: '若态', total: 10, collected: 9, price: 52, hot: 6700, color: '#FF5722', emoji: '🐱' },
  { id: 11, name: '节日限定', brand: 'POP MART', total: 8, collected: 2, price: 99, hot: 21000, color: '#FFD700', emoji: '🎃' },
  { id: 12, name: '校园时光', brand: 'HobbyFun', total: 12, collected: 7, price: 58, hot: 3400, color: '#009688', emoji: '📚' }
];

const TRADES: Trade[] = [
  { id: 1, name: '森林精灵-隐藏款', series: '森林精灵', rarity: '隐藏', price: 580, seller: '盲盒少女小C', status: '在售', condition: '全新未拆' },
  { id: 2, name: '太空漫游-稀有款', series: '太空漫游', rarity: '稀有', price: 120, seller: '拆盒快乐多', status: '在售', condition: '已拆证完好' },
  { id: 3, name: '甜品派对-限定款', series: '甜品派对', rarity: '限定', price: 280, seller: '收藏家大白', status: '预订', condition: '全新未拆' },
  { id: 4, name: '神话传说-隐藏款', series: '神话传说', rarity: '隐藏', price: 880, seller: '欧皇本皇', status: '在售', condition: '已拆证完好' },
  { id: 5, name: '星空梦境-全套12只', series: '星空梦境', rarity: '限定', price: 780, seller: '换盒达人', status: '已售', condition: '已拆证完好' },
  { id: 6, name: '海洋之心-稀有款', series: '海洋之心', rarity: '稀有', price: 150, seller: '求盒小能手', status: '在售', condition: '轻微使用' },
  { id: 7, name: '复古怀旧-限定款', series: '复古怀旧', rarity: '限定', price: 320, seller: '测评君', status: '在售', condition: '全新未拆' },
  { id: 8, name: '萌宠日记-稀有款', series: '萌宠日记', rarity: '稀有', price: 95, seller: '收纳小能手', status: '预订', condition: '近全新' },
  { id: 9, name: '节日限定-隐藏款', series: '节日限定', rarity: '隐藏', price: 1280, seller: '年度总结君', status: '在售', condition: '全新未拆' },
  { id: 10, name: '校园时光-全套12只', series: '校园时光', rarity: '限定', price: 680, seller: '安全小卫士', status: '已售', condition: '已拆证完好' }
];

const RANKS: Rank[] = [
  { id: 1, name: '欧皇本皇', score: 9876, avatar: '🦄', collection: 234, badge: '🏆收藏之王' },
  { id: 2, name: '年度总结君', score: 8765, avatar: '🐵', collection: 198, badge: '💎盲盒达人' },
  { id: 3, name: '拆盒快乐多', score: 7654, avatar: '🦁', collection: 156, badge: '🌟拆盒先锋' },
  { id: 4, name: '收藏家大白', score: 6543, avatar: '🐼', collection: 143, badge: '🥇金牌收藏' },
  { id: 5, name: '盲盒少女小C', score: 5432, avatar: '🐰', collection: 121, badge: '🥈银牌收藏' },
  { id: 6, name: '潮玩情报站', score: 4321, avatar: '🦊', collection: 98, badge: '🥉铜牌收藏' },
  { id: 7, name: '测评君', score: 3987, avatar: '🐙', collection: 87, badge: '🎖️资深玩家' },
  { id: 8, name: '数据控拆盒党', score: 3210, avatar: '🤓', collection: 76, badge: '🎖️资深玩家' },
  { id: 9, name: '换盒达人', score: 2876, avatar: '🐯', collection: 65, badge: '🎖️资深玩家' },
  { id: 10, name: '收纳小能手', score: 2543, avatar: '🐸', collection: 54, badge: '🎖️资深玩家' }
];

// ============================================================
// 全局纯函数
// ============================================================

function getLeftPosts(): PostItem[] {
  let result: PostItem[] = [];
  for (let i = 0; i < POSTS.length; i = i + 2) {
    result.push(new PostItem(POSTS[i]));
  }
  return result;
}

function getRightPosts(): PostItem[] {
  let result: PostItem[] = [];
  for (let i = 1; i < POSTS.length; i = i + 2) {
    result.push(new PostItem(POSTS[i]));
  }
  return result;
}

function getSeriesList(): SeriesItem[] {
  let result: SeriesItem[] = [];
  for (let i = 0; i < SERIES_DATA.length; i++) {
    result.push(new SeriesItem(SERIES_DATA[i]));
  }
  return result;
}

function getTradeList(): TradeItem[] {
  let result: TradeItem[] = [];
  for (let i = 0; i < TRADES.length; i++) {
    result.push(new TradeItem(TRADES[i]));
  }
  return result;
}

function getRankList(): RankItem[] {
  let result: RankItem[] = [];
  for (let i = 0; i < RANKS.length; i++) {
    result.push(new RankItem(RANKS[i]));
  }
  return result;
}

function rarityColor(name: string): string {
  if (name === '隐藏') return '#FF8F00';
  if (name === '稀有') return '#9C27B0';
  if (name === '限定') return '#E91E63';
  return '#2196F3';
}

function withAlpha(color: string, alpha: string): string {
  if (color.length === 7) {
    return '#' + alpha + color.slice(1);
  }
  return color;
}

function rarityBg(name: string): string {
  if (name === '隐藏') return '#FFF8E1';
  if (name === '稀有') return '#F3E5F5';
  if (name === '限定') return '#FCE4EC';
  return '#E3F2FD';
}

function statusColor(status: string): string {
  if (status === '在售') return '#4CAF50';
  if (status === '预订') return '#FF9800';
  if (status === '已售') return '#9E9E9E';
  return '#2196F3';
}

function statusBg(status: string): string {
  if (status === '在售') return '#E8F5E9';
  if (status === '预订') return '#FFF3E0';
  if (status === '已售') return '#F5F5F5';
  return '#E3F2FD';
}

function progressPercent(collected: number, total: number): number {
  if (total <= 0) return 0;
  let p: number = Math.floor(collected / total * 100);
  if (p > 100) p = 100;
  return p;
}

function barHeight(value: number): number {
  return Math.floor(value * 2.4);
}

function formatLikes(n: number): string {
  if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w';
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
  return n.toString();
}

function totalSeriesCount(): number {
  return SERIES_DATA.length;
}

function totalCollectedCount(): number {
  let sum: number = 0;
  for (let i = 0; i < SERIES_DATA.length; i++) {
    sum = sum + SERIES_DATA[i].collected;
  }
  return sum;
}

function totalBoxCount(): number {
  let sum: number = 0;
  for (let i = 0; i < SERIES_DATA.length; i++) {
    sum = sum + SERIES_DATA[i].total;
  }
  return sum;
}

function collectionRate(): number {
  return progressPercent(totalCollectedCount(), totalBoxCount());
}

function seriesProgressColor(collected: number, total: number): string {
  let p: number = progressPercent(collected, total);
  if (p >= 100) return '#4CAF50';
  if (p >= 50) return '#7C4DFF';
  if (p >= 25) return '#FF9800';
  return '#F44336';
}

function medalEmoji(rank: number): string {
  if (rank === 1) return '🥇';
  if (rank === 2) return '🥈';
  if (rank === 3) return '🥉';
  return '';
}

function rankBgColor(rank: number): string {
  if (rank === 1) return '#FFF8E1';
  if (rank === 2) return '#FAFAFA';
  if (rank === 3) return '#FBE9E7';
  return '#FFFFFF';
}

function fxOpacity1(tick: number): number {
  return 0.2 + (tick % 8) * 0.06;
}

function fxOpacity2(tick: number): number {
  return 0.15 + (tick % 6) * 0.08;
}

function fxOpacity3(tick: number): number {
  return 0.1 + (tick % 10) * 0.05;
}

function fxScale(tick: number): number {
  return 0.7 + (tick % 5) * 0.12;
}

function fxX1(tick: number): number {
  return 30 + (tick * 3) % 300;
}

function fxY1(tick: number): number {
  return 120 + (tick * 4) % 500;
}

function fxX2(tick: number): number {
  return 340 - (tick * 4) % 310;
}

function fxY2(tick: number): number {
  return 200 + (tick * 3) % 450;
}

function fxX3(tick: number): number {
  return 60 + (tick * 5) % 280;
}

function fxY3(tick: number): number {
  return 150 + (tick * 5) % 400;
}

function fxX4(tick: number): number {
  return 90 + (tick * 6) % 260;
}

function fxY4(tick: number): number {
  return 100 + (tick * 7) % 520;
}

// ============================================================
// 入口组件
// ============================================================

@Entry
@Component
struct BlindBoxApp {
  @State curTab: number = 0;
  @State mainTab: number = 0;
  @State addOpen: boolean = false;
  @State editOpen: boolean = false;
  @State delOpen: boolean = false;
  @State bizOpen: boolean = false;

  // 新增开盒记录表单
  @State addTitle: string = '';
  @State addSeriesIdx: number = 0;
  @State addRarityIdx: number = 0;
  @State addContent: string = '';
  @State addTags: string = '';

  // 编辑收藏表单
  @State editName: string = '';
  @State editCollected: number = 0;
  @State editNote: string = '';

  // 删除确认
  @State delName: string = '森林精灵';

  // 交易上架表单
  @State bizName: string = '';
  @State bizSeriesIdx: number = 0;
  @State bizRarityIdx: number = 0;
  @State bizPrice: string = '';
  @State bizConditionIdx: number = 0;
  @State bizContact: string = '';

  // 数据
  @State leftPosts: PostItem[] = [];
  @State rightPosts: PostItem[] = [];
  @State seriesList: SeriesItem[] = [];
  @State tradeList: TradeItem[] = [];
  @State rankList: RankItem[] = [];

  // 交易筛选
  @State tradeFilterIdx: number = 0;

  // 特效动画
  @State tick: number = 0;
  private timer: number = -1;

  aboutToAppear(): void {
    this.leftPosts = getLeftPosts();
    this.rightPosts = getRightPosts();
    this.seriesList = getSeriesList();
    this.tradeList = getTradeList();
    this.rankList = getRankList();
    this.timer = setInterval(() => {
      this.tick = this.tick + 1;
    }, 120);
  }

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

  switchTab(i: number): void {
    this.curTab = i;
  }

  switchMain(i: number): void {
    this.mainTab = i;
  }

  closeAll(): void {
    this.addOpen = false;
    this.editOpen = false;
    this.delOpen = false;
    this.bizOpen = false;
  }

  openAdd(): void {
    this.addTitle = '';
    this.addSeriesIdx = 0;
    this.addRarityIdx = 0;
    this.addContent = '';
    this.addTags = '';
    this.addOpen = true;
  }

  openEdit(name: string, collected: number): void {
    this.editName = name;
    this.editCollected = collected;
    this.editNote = '';
    this.editOpen = true;
  }

  openDel(name: string): void {
    this.delName = name;
    this.delOpen = true;
  }

  openBiz(): void {
    this.bizName = '';
    this.bizSeriesIdx = 0;
    this.bizRarityIdx = 0;
    this.bizPrice = '';
    this.bizConditionIdx = 0;
    this.bizContact = '';
    this.bizOpen = true;
  }

  toggleLike(p: PostItem): void {
    p.liked = !p.liked;
    if (p.liked) {
      p.likes = p.likes + 1;
    } else {
      p.likes = p.likes - 1;
    }
    this.leftPosts = this.leftPosts.slice();
    this.rightPosts = this.rightPosts.slice();
  }

  decCollected(): void {
    if (this.editCollected > 0) {
      this.editCollected = this.editCollected - 1;
    }
  }

  incCollected(): void {
    this.editCollected = this.editCollected + 1;
  }

  // ============================================================
  // 特效层:浮动emoji
  // ============================================================

  @Builder
  fxLayer() {
    Stack() {
      Text('🎁')
        .fontSize(38)
        .position({ x: fxX1(this.tick), y: fxY1(this.tick) })
        .opacity(fxOpacity1(this.tick))
        .rotate({ angle: this.tick * 8 })
        .hitTestBehavior(HitTestMode.None)
      Text('📦')
        .fontSize(30)
        .position({ x: fxX2(this.tick), y: fxY2(this.tick) })
        .opacity(fxOpacity2(this.tick))
        .rotate({ angle: -this.tick * 6 })
        .hitTestBehavior(HitTestMode.None)
      Text('✨')
        .fontSize(26)
        .position({ x: fxX3(this.tick), y: fxY3(this.tick) })
        .opacity(fxOpacity3(this.tick))
        .scale({ x: fxScale(this.tick), y: fxScale(this.tick) })
        .hitTestBehavior(HitTestMode.None)
      Text('🎊')
        .fontSize(34)
        .position({ x: fxX4(this.tick), y: fxY4(this.tick) })
        .opacity(fxOpacity2(this.tick))
        .rotate({ angle: this.tick * 12 })
        .hitTestBehavior(HitTestMode.None)
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  // ============================================================
  // 头部:静态渐变背景 + 搜索栏 + 热门标签
  // ============================================================

  @Builder
  header() {
    Column() {
      Row() {
        Text('🎁')
          .fontSize(24)
        Text('盲盒星球')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: 8 })
        Row()
          .layoutWeight(1)
        Text('🔔')
          .fontSize(22)
          .onClick(() => {
            this.switchTab(0);
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 8 })
      .alignItems(VerticalAlign.Center)

      Row() {
        Text('🔍')
          .fontSize(16)
          .margin({ right: 8 })
        Text('搜索盲盒 / 系列 / 玩家')
          .fontSize(13)
          .fontColor(COLORS.textLight)
          .layoutWeight(1)
        Text('📷')
          .fontSize(18)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('90%')
      .height(38)
      .backgroundColor('#FFFFFF')
      .borderRadius(19)
      .padding({ left: 14, right: 14 })
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 10 })

      Scroll() {
        Row() {
          ForEach(HOT_TAGS, (tag: string) => {
            Text(tag)
              .fontSize(12)
              .fontColor(COLORS.white)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(14)
              .backgroundColor('#35000000')
              .margin({ right: 8 })
              .onClick(() => {
                this.switchTab(0);
              })
          }, (tag: string) => tag)
        }
        .padding({ left: 16, right: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .constraintSize({ maxHeight: 40 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [[COLORS.grad1, 0], [COLORS.grad2, 0.5], [COLORS.grad3, 1]]
    })
    .padding({ bottom: 10 })
  }

  // ============================================================
  // 内容tab栏(6个tab)
  // ============================================================

  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_NAMES, (name: string, i: number) => {
        Column() {
          Text(name)
            .fontSize(this.curTab === i ? 15 : 13)
            .fontColor(this.curTab === i ? COLORS.primary : COLORS.textSub)
            .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
          if (this.curTab === i) {
            Column()
              .width(20)
              .height(3)
              .backgroundColor(COLORS.primary)
              .borderRadius(2)
              .margin({ top: 3 })
          }
        }
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)
        .padding({ top: 8, bottom: 6 })
        .layoutWeight(1)
        .onClick(() => {
          this.switchTab(i);
        })
      }, (name: string) => name)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
  }

  // ============================================================
  // tab0 推荐:瀑布流双列卡片
  // ============================================================

  @Builder
  feedCard(p: PostItem) {
    Column() {
      Column() {
        Text(p.pics)
          .fontSize(p.id % 2 === 0 ? 48 : 36)
      }
      .width('100%')
      .padding({ top: p.id % 2 === 0 ? 24 : 16, bottom: p.id % 2 === 0 ? 24 : 16 })
      .linearGradient({
        angle: 160,
        colors: [[rarityBg(p.rarity), 0], [withAlpha(rarityColor(p.rarity), '30'), 1]]
      })
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text(p.title)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ bottom: 4 })

        Text(p.content)
          .fontSize(12)
          .fontColor(COLORS.textSub)
          .maxLines(p.id % 3 === 0 ? 3 : 1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ bottom: 6 })

        Text(p.tags)
          .fontSize(11)
          .fontColor(COLORS.primary)
          .margin({ bottom: 6 })

        Row() {
          Text(p.avatar)
            .fontSize(16)
            .margin({ right: 4 })
          Text(p.author)
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .layoutWeight(1)
          Text(p.liked ? '❤️' : '🤍')
            .fontSize(14)
            .onClick(() => {
              this.toggleLike(p);
            })
          Text(formatLikes(p.likes))
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .margin({ left: 3 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .padding(10)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .borderRadius(12)
    .shadow({ radius: 4, color: COLORS.shadow })
    .margin({ bottom: 10 })
  }

  @Builder
  pageFeed() {
    Column() {
      Row() {
        Text('🔥 今日热门开盒')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .layoutWeight(1)
        Text('新增记录')
          .fontSize(12)
          .fontColor(COLORS.primary)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .borderRadius(12)
          .backgroundColor(rarityBg('普通'))
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 8 })
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          ForEach(this.leftPosts, (p: PostItem) => {
            this.feedCard(p)
          }, (p: PostItem) => p.id.toString())
        }
        .layoutWeight(1)
        .padding({ left: 6, right: 5 })

        Column() {
          ForEach(this.rightPosts, (p: PostItem) => {
            this.feedCard(p)
          }, (p: PostItem) => p.id.toString())
        }
        .layoutWeight(1)
        .padding({ left: 5, right: 6 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
    }
    .width('100%')
  }

  // ============================================================
  // tab1 拆盒:横向滚动卡片 + 下方列表
  // ============================================================

  @Builder
  unboxSeriesCard(s: SeriesItem) {
    Column() {
      Text(s.emoji)
        .fontSize(40)
      Text(s.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .margin({ top: 6 })
      Text(s.brand)
        .fontSize(11)
        .fontColor(COLORS.textSub)
        .margin({ top: 2 })
      Text('¥' + s.price + '/盒')
        .fontSize(12)
        .fontColor(COLORS.primary)
        .margin({ top: 4 })
      Text('🔥 ' + formatLikes(s.hot))
        .fontSize(11)
        .fontColor(COLORS.orange)
        .margin({ top: 2 })
    }
    .width(120)
    .padding({ top: 16, bottom: 12, left: 10, right: 10 })
    .backgroundColor(COLORS.card)
    .borderRadius(14)
    .alignItems(HorizontalAlign.Center)
    .margin({ right: 10 })
    .shadow({ radius: 4, color: COLORS.shadow })
    .onClick(() => {
      this.switchTab(2);
    })
  }

  @Builder
  pageUnbox() {
    Column() {
      Text('🚀 热门系列速拆')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')
        .padding({ left: 12, top: 10, bottom: 8 })

      Scroll() {
        Row() {
          ForEach(this.seriesList, (s: SeriesItem) => {
            this.unboxSeriesCard(s)
          }, (s: SeriesItem) => s.id.toString())
        }
        .padding({ left: 12, right: 12 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ bottom: 8 })

      Divider()
        .strokeWidth(1)
        .color(COLORS.border)
        .margin({ left: 12, right: 12 })

      Text('📋 最新拆盒记录')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')
        .padding({ left: 12, top: 10, bottom: 8 })

      Column() {
        ForEach(this.leftPosts.concat(this.rightPosts), (p: PostItem) => {
          Row() {
            Text(p.pics)
              .fontSize(28)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor(rarityBg(p.rarity))
              .borderRadius(12)
              .margin({ right: 10 })

            Column() {
              Text(p.title)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.text)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Text(p.series)
                  .fontSize(11)
                  .fontColor(COLORS.textSub)
                Text(p.rarity)
                  .fontSize(10)
                  .fontColor(rarityColor(p.rarity))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor(rarityBg(p.rarity))
                  .margin({ left: 6 })
              }
              .margin({ top: 4 })
              Row() {
                Text(p.avatar + ' ' + p.author)
                  .fontSize(11)
                  .fontColor(COLORS.textLight)
                  .layoutWeight(1)
                Text(p.time)
                  .fontSize(10)
                  .fontColor(COLORS.textLight)
              }
              .width('100%')
              .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ left: 12, right: 12, bottom: 8 })
          .alignItems(VerticalAlign.Center)
          .shadow({ radius: 3, color: COLORS.shadow })
          .onClick(() => {
            this.toggleLike(p);
          })
        }, (p: PostItem) => 'unbox_' + p.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  // ============================================================
  // tab2 图鉴:3列网格
  // ============================================================

  @Builder
  atlasCard(s: SeriesItem) {
    Column() {
      Column() {
        Text(s.emoji)
          .fontSize(32)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .linearGradient({
        angle: 140,
        colors: [[withAlpha(s.color, '20'), 0], [withAlpha(s.color, '60'), 1]]
      })
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text(s.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(s.brand)
          .fontSize(10)
          .fontColor(COLORS.textSub)
          .margin({ top: 2 })
        Text(s.collected + '/' + s.total)
          .fontSize(11)
          .fontColor(seriesProgressColor(s.collected, s.total))
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4 })

        Row() {
          Column()
            .width(progressPercent(s.collected, s.total) + '%')
            .height(5)
            .backgroundColor(seriesProgressColor(s.collected, s.total))
            .borderRadius(3)
        }
        .width('100%')
        .height(5)
        .backgroundColor(COLORS.border)
        .borderRadius(3)
        .margin({ top: 4, bottom: 6 })

        Text(s.collected >= s.total ? '✅ 已集齐' : '继续收集')
          .fontSize(10)
          .fontColor(s.collected >= s.total ? COLORS.green : COLORS.primary)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(10)
          .backgroundColor(s.collected >= s.total ? '#E8F5E9' : rarityBg('普通'))
          .onClick(() => {
            this.openEdit(s.name, s.collected);
          })
      }
      .padding(8)
      .alignItems(HorizontalAlign.Start)
      .width('100%')
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .borderRadius(12)
    .shadow({ radius: 3, color: COLORS.shadow })
    .margin({ bottom: 10 })
  }

  @Builder
  pageAtlas() {
    Column() {
      Row() {
        Text('📖 盲盒图鉴')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .layoutWeight(1)
        Text('共' + totalSeriesCount() + '个系列')
          .fontSize(12)
          .fontColor(COLORS.textSub)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 8 })
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          ForEach(this.seriesList, (s: SeriesItem, i: number) => {
            if (i % 3 === 0) {
              this.atlasCard(s)
            }
          }, (s: SeriesItem) => 'col0_' + s.id.toString())
        }
        .layoutWeight(1)
        .padding({ left: 8, right: 4 })

        Column() {
          ForEach(this.seriesList, (s: SeriesItem, i: number) => {
            if (i % 3 === 1) {
              this.atlasCard(s)
            }
          }, (s: SeriesItem) => 'col1_' + s.id.toString())
        }
        .layoutWeight(1)
        .padding({ left: 4, right: 4 })

        Column() {
          ForEach(this.seriesList, (s: SeriesItem, i: number) => {
            if (i % 3 === 2) {
              this.atlasCard(s)
            }
          }, (s: SeriesItem) => 'col2_' + s.id.toString())
        }
        .layoutWeight(1)
        .padding({ left: 4, right: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
    }
    .width('100%')
  }

  // ============================================================
  // tab3 收藏:统计 + 柱状图 + 收藏列表
  // ============================================================

  @Builder
  collectStatCard(label: string, value: string, color: string) {
    Column() {
      Text(value)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor(color)
      Text(label)
        .fontSize(11)
        .fontColor(COLORS.textSub)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 14, bottom: 14 })
    .backgroundColor(COLORS.card)
    .borderRadius(12)
    .margin({ left: 4, right: 4 })
    .shadow({ radius: 3, color: COLORS.shadow })
  }

  @Builder
  pageCollect() {
    Column() {
      Text('💎 我的收藏')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')
        .padding({ left: 12, top: 10, bottom: 8 })

      Row() {
        this.collectStatCard('总系列', totalSeriesCount().toString(), COLORS.primary)
        this.collectStatCard('已收藏', totalCollectedCount().toString(), COLORS.secondary)
        this.collectStatCard('完成率', collectionRate().toString() + '%', COLORS.green)
      }
      .width('100%')
      .padding({ left: 8, right: 8, bottom: 10 })
      .alignItems(VerticalAlign.Center)

      Column() {
        Text('📊 各稀有度收藏分布')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .width('100%')
          .margin({ bottom: 8 })

        Row() {
          ForEach(CHART_BARS, (bar: ChartBar) => {
            Column() {
              Text(bar.value.toString())
                .fontSize(11)
                .fontColor(bar.color)
                .fontWeight(FontWeight.Bold)
                .margin({ bottom: 4 })
              Column()
                .width(26)
                .height(barHeight(bar.value))
                .backgroundColor(bar.color)
                .borderRadius(4)
              Text(bar.label)
                .fontSize(11)
                .fontColor(COLORS.textSub)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .justifyContent(FlexAlign.End)
          }, (bar: ChartBar) => bar.label)
        }
        .width('100%')
        .height(160)
        .alignItems(VerticalAlign.Bottom)
        .justifyContent(FlexAlign.End)
        .padding({ bottom: 4 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.card)
      .borderRadius(14)
      .margin({ left: 12, right: 12, bottom: 12 })
      .shadow({ radius: 3, color: COLORS.shadow })
      .alignItems(HorizontalAlign.Start)

      Text('🗂️ 收藏系列列表')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')
        .padding({ left: 12, bottom: 8 })

      Column() {
        ForEach(this.seriesList, (s: SeriesItem) => {
          Row() {
            Text(s.emoji)
              .fontSize(24)
              .width(44)
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor(withAlpha(s.color, '20'))
              .borderRadius(10)
              .margin({ right: 10 })

            Column() {
              Text(s.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.text)
              Row() {
                Text(s.brand)
                  .fontSize(11)
                  .fontColor(COLORS.textSub)
                Text(s.collected + '/' + s.total)
                  .fontSize(11)
                  .fontColor(seriesProgressColor(s.collected, s.total))
                  .margin({ left: 8 })
              }
              .margin({ top: 3 })

              Row() {
                Column()
                  .width(progressPercent(s.collected, s.total) + '%')
                  .height(4)
                  .backgroundColor(seriesProgressColor(s.collected, s.total))
                  .borderRadius(2)
              }
              .width('100%')
              .height(4)
              .backgroundColor(COLORS.border)
              .borderRadius(2)
              .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text('编辑')
                .fontSize(11)
                .fontColor(COLORS.primary)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .borderRadius(10)
                .backgroundColor(rarityBg('普通'))
                .margin({ bottom: 4 })
                .onClick(() => {
                  this.openEdit(s.name, s.collected);
                })
              Text('删除')
                .fontSize(11)
                .fontColor(COLORS.red)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .borderRadius(10)
                .backgroundColor('#FFEBEE')
                .onClick(() => {
                  this.openDel(s.name);
                })
            }
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ left: 12, right: 12, bottom: 8 })
          .alignItems(VerticalAlign.Center)
          .shadow({ radius: 3, color: COLORS.shadow })
        }, (s: SeriesItem) => 'collect_' + s.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  // ============================================================
  // tab4 交易:筛选标签 + 买卖列表
  // ============================================================

  @Builder
  pageTrade() {
    Column() {
      Row() {
        Text('🛒 盲盒交易市场')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .layoutWeight(1)
        Text('上架商品')
          .fontSize(12)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(COLORS.primary)
          .onClick(() => {
            this.openBiz();
          })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 8 })
      .alignItems(VerticalAlign.Center)

      Scroll() {
        Row() {
          ForEach(TRADE_FILTERS, (f: string, i: number) => {
            Text(f)
              .fontSize(12)
              .fontColor(this.tradeFilterIdx === i ? COLORS.white : COLORS.textSub)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(16)
              .backgroundColor(this.tradeFilterIdx === i ? COLORS.primary : COLORS.border)
              .margin({ right: 8 })
              .onClick(() => {
                this.tradeFilterIdx = i;
              })
          }, (f: string) => f)
        }
        .padding({ left: 12, right: 12 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ bottom: 10 })

      Column() {
        ForEach(this.tradeList, (t: TradeItem) => {
          Column() {
            Row() {
              Text(t.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.text)
                .layoutWeight(1)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text('¥' + t.price.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.secondary)
                .decoration({ type: t.status === '已售' ? TextDecorationType.LineThrough : TextDecorationType.None })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Row() {
              Text(t.series)
                .fontSize(11)
                .fontColor(COLORS.textSub)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(COLORS.bg)
              Text(t.rarity)
                .fontSize(11)
                .fontColor(rarityColor(t.rarity))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(rarityBg(t.rarity))
                .margin({ left: 6 })
              Text(t.condition)
                .fontSize(11)
                .fontColor(COLORS.blue)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor('#E3F2FD')
                .margin({ left: 6 })
              Row()
                .layoutWeight(1)
              Text(t.status)
                .fontSize(11)
                .fontColor(statusColor(t.status))
                .padding({ left: 10, right: 10, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(statusBg(t.status))
            }
            .width('100%')
            .margin({ top: 8 })
            .alignItems(VerticalAlign.Center)

            Divider()
              .strokeWidth(1)
              .color(COLORS.border)
              .margin({ top: 8, bottom: 8 })

            Row() {
              Text('卖家: ' + t.seller)
                .fontSize(11)
                .fontColor(COLORS.textSub)
                .layoutWeight(1)
              Text('查看详情')
                .fontSize(11)
                .fontColor(COLORS.primary)
                .onClick(() => {
                  this.openBiz();
                })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ left: 12, right: 12, bottom: 8 })
          .shadow({ radius: 3, color: COLORS.shadow })
          .alignItems(HorizontalAlign.Start)
        }, (t: TradeItem) => t.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  // ============================================================
  // tab5 排行:排行榜
  // ============================================================

  @Builder
  rankTopCard(r: RankItem) {
    Column() {
      Text(medalEmoji(r.id))
        .fontSize(32)
      Text(r.avatar)
        .fontSize(36)
        .margin({ top: 4 })
      Text(r.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .margin({ top: 4 })
      Text(r.score.toString() + '分')
        .fontSize(12)
        .fontColor(r.id === 1 ? '#FF8F00' : COLORS.primary)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 2 })
      Text(r.collection.toString() + '件收藏')
        .fontSize(11)
        .fontColor(COLORS.textSub)
        .margin({ top: 2 })
      Text(r.badge)
        .fontSize(10)
        .fontColor(COLORS.white)
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        .borderRadius(10)
        .backgroundColor(r.id === 1 ? '#FF8F00' : r.id === 2 ? '#757575' : '#FF7043')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .padding({ top: 14, bottom: 14, left: 6, right: 6 })
    .backgroundColor(rankBgColor(r.id))
    .borderRadius(14)
    .alignItems(HorizontalAlign.Center)
    .shadow({ radius: 4, color: COLORS.shadow })
    .margin({ left: 4, right: 4 })
    .onClick(() => {
      this.switchTab(0);
    })
  }

  @Builder
  pageRank() {
    Column() {
      Text('🏆 收藏排行榜')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .width('100%')
        .padding({ left: 12, top: 10, bottom: 10 })

      if (this.rankList.length >= 3) {
        Row() {
          this.rankTopCard(this.rankList[1])
          this.rankTopCard(this.rankList[0])
          this.rankTopCard(this.rankList[2])
        }
        .width('100%')
        .padding({ left: 8, right: 8, bottom: 12 })
        .alignItems(VerticalAlign.Bottom)
      }

      Column() {
        ForEach(this.rankList, (r: RankItem, i: number) => {
          if (i >= 3) {
            Row() {
              Text((i + 1).toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(i === 3 ? '#FF8F00' : COLORS.textSub)
                .width(32)
                .textAlign(TextAlign.Center)

              Text(r.avatar)
                .fontSize(22)
                .width(40)
                .height(40)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.bg)
                .borderRadius(20)
                .margin({ right: 10 })

              Column() {
                Text(r.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.text)
                Text(r.badge + ' · ' + r.collection.toString() + '件收藏')
                  .fontSize(11)
                  .fontColor(COLORS.textSub)
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)

              Text(r.score.toString() + '分')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primary)
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS.card)
            .borderRadius(12)
            .margin({ left: 12, right: 12, bottom: 8 })
            .alignItems(VerticalAlign.Center)
            .shadow({ radius: 3, color: COLORS.shadow })
            .onClick(() => {
              this.switchTab(0);
            })
          }
        }, (r: RankItem) => 'rank_' + r.id.toString())
      }
      .width('100%')

      Text('💡 积分规则:开盒+1、晒图+3、隐藏款+10、交易成功+5')
        .fontSize(11)
        .fontColor(COLORS.textLight)
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 12 })
    }
    .width('100%')
  }

  // ============================================================
  // 弹框遮罩层
  // ============================================================

  @Builder
  modalOverlay() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(COLORS.overlay)
        .onClick(() => {
          this.closeAll();
        })

      if (this.addOpen) {
        this.modalBodyAdd()
      }
      if (this.editOpen) {
        this.modalBodyEdit()
      }
      if (this.delOpen) {
        this.modalBodyDel()
      }
      if (this.bizOpen) {
        this.modalBodyBiz()
      }
    }
    .width('100%')
    .height('100%')
  }

  // ============================================================
  // 弹框1:新增开盒记录(底部弹出)
  // ============================================================

  @Builder
  modalBodyAdd() {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          this.closeAll();
        })

      Column() {
        Row() {
          Text('记录开盒时刻')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text)
            .layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor(COLORS.textSub)
            .padding(8)
            .onClick(() => {
              this.closeAll();
            })
        }
        .width('100%')
        .margin({ bottom: 14 })
        .alignItems(VerticalAlign.Center)

        Text('帖子标题')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        TextInput({ placeholder: '给你的开盒起个响亮的标题' })
          .height(42)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .fontSize(13)
          .margin({ bottom: 12 })
          .onChange((v: string) => {
            this.addTitle = v;
          })

        Text('选择系列')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        Scroll() {
          Row() {
            ForEach(SERIES_DATA, (s: Series, i: number) => {
              Text(s.name)
                .fontSize(12)
                .fontColor(this.addSeriesIdx === i ? COLORS.white : COLORS.textSub)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(14)
                .backgroundColor(this.addSeriesIdx === i ? COLORS.primary : COLORS.border)
                .margin({ right: 8 })
                .onClick(() => {
                  this.addSeriesIdx = i;
                })
            }, (s: Series) => 'add_' + s.id.toString())
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ bottom: 12 })

        Text('稀有度')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        Row() {
          ForEach(RARITY_OPTIONS, (r: RarityOption, i: number) => {
            Text(r.name)
              .fontSize(12)
              .fontColor(this.addRarityIdx === i ? COLORS.white : r.color)
              .padding({ left: 16, right: 16, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor(this.addRarityIdx === i ? r.color : r.bg)
              .margin({ right: 8 })
              .onClick(() => {
                this.addRarityIdx = i;
              })
          }, (r: RarityOption) => r.name)
        }
        .width('100%')
        .margin({ bottom: 12 })

        Text('内容')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        TextArea({ placeholder: '分享你的开盒心情和体验...' })
          .height(80)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .fontSize(13)
          .margin({ bottom: 12 })
          .onChange((v: string) => {
            this.addContent = v;
          })

        Text('标签')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        TextInput({ placeholder: '如: #开盒欧皇 #隐藏款' })
          .height(42)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .fontSize(13)
          .margin({ bottom: 16 })
          .onChange((v: string) => {
            this.addTags = v;
          })

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.textSub)
            .layoutWeight(1)
            .height(44)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.border)
            .borderRadius(22)
            .margin({ right: 8 })
            .onClick(() => {
              this.closeAll();
            })
          Text('发布记录')
            .fontSize(14)
            .fontColor(COLORS.white)
            .fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .height(44)
            .textAlign(TextAlign.Center)
            .linearGradient({
              angle: 90,
              colors: [[COLORS.grad1, 0], [COLORS.grad3, 1]]
            })
            .borderRadius(22)
            .margin({ left: 8 })
            .onClick(() => {
              this.addOpen = false;
              this.switchTab(0);
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .width('90%')
      .padding(20)
      .backgroundColor(COLORS.card)
      .borderRadius(22)
      .constraintSize({ maxHeight: '75%' })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .alignItems(HorizontalAlign.Center)
  }

  // ============================================================
  // 弹框2:编辑收藏信息(居中弹出)
  // ============================================================

  @Builder
  modalBodyEdit() {
    Column() {
      Text('✏️')
        .fontSize(36)
        .margin({ bottom: 10 })

      Text('编辑收藏信息')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text)
        .margin({ bottom: 16 })

      Text('系列名称')
        .fontSize(13)
        .fontColor(COLORS.textSub)
        .width('100%')
        .margin({ bottom: 6 })
      TextInput({ text: this.editName, placeholder: '输入系列名称' })
        .height(42)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .fontSize(13)
        .margin({ bottom: 14 })
        .onChange((v: string) => {
          this.editName = v;
        })

      Text('已收集数量')
        .fontSize(13)
        .fontColor(COLORS.textSub)
        .width('100%')
        .margin({ bottom: 6 })
      Row() {
        Text('−')
          .fontSize(22)
          .fontColor(COLORS.primary)
          .width(44)
          .height(44)
          .textAlign(TextAlign.Center)
          .backgroundColor(rarityBg('普通'))
          .borderRadius(22)
          .onClick(() => {
            this.decCollected();
          })

        Text(this.editCollected.toString())
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Text('+')
          .fontSize(22)
          .fontColor(COLORS.primary)
          .width(44)
          .height(44)
          .textAlign(TextAlign.Center)
          .backgroundColor(rarityBg('普通'))
          .borderRadius(22)
          .onClick(() => {
            this.incCollected();
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 14 })

      Text('备注')
        .fontSize(13)
        .fontColor(COLORS.textSub)
        .width('100%')
        .margin({ bottom: 6 })
      TextInput({ placeholder: '收藏备注信息(可留空)' })
        .height(42)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .fontSize(13)
        .margin({ bottom: 18 })
        .onChange((v: string) => {
          this.editNote = v;
        })

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS.textSub)
          .layoutWeight(1)
          .height(42)
          .textAlign(TextAlign.Center)
          .borderRadius(21)
          .border({ width: 1, color: COLORS.border })
          .margin({ right: 6 })
          .onClick(() => {
            this.closeAll();
          })
        Text('保存修改')
          .fontSize(14)
          .fontColor(COLORS.white)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .height(42)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.primary)
          .borderRadius(21)
          .margin({ left: 6 })
          .onClick(() => {
            this.editOpen = false;
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .width('85%')
    .padding(22)
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .alignItems(HorizontalAlign.Start)
    .shadow({ radius: 12, color: '#30000000' })
  }

  // ============================================================
  // 弹框3:删除确认(居中小弹框)
  // ============================================================

  @Builder
  modalBodyDel() {
    Column() {
      Text('⚠️')
        .fontSize(44)
        .margin({ bottom: 12 })

      Text('确认删除?')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.red)
        .margin({ bottom: 8 })

      Text('即将删除「' + this.delName + '」的收藏记录,删除后不可恢复,请谨慎操作!')
        .fontSize(13)
        .fontColor(COLORS.textSub)
        .textAlign(TextAlign.Center)
        .margin({ bottom: 20 })

      Column() {
        Text('取消')
          .fontSize(15)
          .fontColor(COLORS.textSub)
          .width('100%')
          .height(44)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.closeAll();
          })
        Divider()
          .strokeWidth(1)
          .color(COLORS.border)
        Text('确认删除')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.red)
          .width('100%')
          .height(44)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.delOpen = false;
            this.switchTab(2);
          })
      }
      .width('100%')
      .backgroundColor(COLORS.bg)
      .borderRadius(14)
      .clip(true)
    }
    .width('70%')
    .padding(24)
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .shadow({ radius: 12, color: '#30000000' })
  }

  // ============================================================
  // 弹框4:交易上架(底部弹出)
  // ============================================================

  @Builder
  modalBodyBiz() {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          this.closeAll();
        })

      Column() {
        Row() {
          Column() {
            Text('🏪')
              .fontSize(22)
            Text('商品上架')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.text)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Center)
          .layoutWeight(1)

          Text('✕')
            .fontSize(18)
            .fontColor(COLORS.textSub)
            .padding(8)
            .onClick(() => {
              this.closeAll();
            })
        }
        .width('100%')
        .margin({ bottom: 14 })
        .alignItems(VerticalAlign.Center)

        Text('商品名称')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        TextInput({ placeholder: '如: 森林精灵-隐藏款' })
          .height(42)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .fontSize(13)
          .margin({ bottom: 12 })
          .onChange((v: string) => {
            this.bizName = v;
          })

        Text('所属系列')
          .fontSize(13)
          .fontColor(COLORS.textSub)
          .width('100%')
          .margin({ bottom: 6 })
        Scroll() {
          Row() {
            ForEach(SERIES_DATA, (s: Series, i: number) => {
              Text(s.name)
                .fontSize(12)
                .fontColor(this.bizSeriesIdx === i ? COLORS.white : COLORS.textSub)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(14)
                .backgroundColor(this.bizSeriesIdx === i ? COLORS.secondary : COLORS.border)
                .margin({ right: 8 })
                .onClick(() => {
                  this.bizSeriesIdx = i;
                })
            }, (s: Series) => 'biz_s_' + s.id.toString())
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ bottom: 12 })

        Row() {
          Column() {
            Text('稀有度')
              .fontSize(13)
              .fontColor(COLORS.textSub)
              .width('100%')
              .margin({ bottom: 6 })
            Column() {
              ForEach(RARITY_OPTIONS, (r: RarityOption, i: number) => {
                Text(r.name)
                  .fontSize(12)
                  .fontColor(this.bizRarityIdx === i ? COLORS.white : r.color)
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .backgroundColor(this.bizRarityIdx === i ? r.color : r.bg)
                  .margin({ bottom: 6 })
                  .onClick(() => {
                    this.bizRarityIdx = i;
                  })
              }, (r: RarityOption) => 'biz_r_' + r.name)
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Column() {
            Text('新旧程度')
              .fontSize(13)
              .fontColor(COLORS.textSub)
              .width('100%')
              .margin({ bottom: 6 })
            Column() {
              ForEach(CONDITION_NAMES, (c: string, i: number) => {
                Text(c)
                  .fontSize(12)
                  .fontColor(this.bizConditionIdx === i ? COLORS.white : COLORS.textSub)
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .backgroundColor(this.bizConditionIdx === i ? COLORS.blue : '#E3F2FD')
                  .margin({ bottom: 6 })
                  .onClick(() => {
                    this.bizConditionIdx = i;
                  })
              }, (c: string) => 'biz_c_' + c)
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })
        .alignItems(VerticalAlign.Top)

        Row() {
          Column() {
            Text('价格(元)')
              .fontSize(13)
              .fontColor(COLORS.textSub)
              .margin({ bottom: 6 })
            TextInput({ placeholder: '0' })
              .height(42)
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              .fontSize(13)
              .onChange((v: string) => {
                this.bizPrice = v;
              })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Column() {
            Text('联系方式')
              .fontSize(13)
              .fontColor(COLORS.textSub)
              .margin({ bottom: 6 })
            TextInput({ placeholder: '微信/手机号' })
              .height(42)
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              .fontSize(13)
              .onChange((v: string) => {
                this.bizContact = v;
              })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 16 })
        .alignItems(VerticalAlign.Top)

        Row() {
          Text('存草稿')
            .fontSize(14)
            .fontColor(COLORS.textSub)
            .layoutWeight(1)
            .height(44)
            .textAlign(TextAlign.Center)
            .border({ width: 1, color: COLORS.border })
            .borderRadius(22)
            .margin({ right: 8 })
            .onClick(() => {
              this.bizOpen = false;
            })
          Text('立即上架')
            .fontSize(14)
            .fontColor(COLORS.white)
            .fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .height(44)
            .textAlign(TextAlign.Center)
            .linearGradient({
              angle: 90,
              colors: [[COLORS.secondary, 0], [COLORS.grad3, 1]]
            })
            .borderRadius(22)
            .margin({ left: 8 })
            .onClick(() => {
              this.bizOpen = false;
              this.switchTab(4);
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
      }
      .width('95%')
      .padding(20)
      .backgroundColor(COLORS.card)
      .borderRadius(22)
      .constraintSize({ maxHeight: '80%' })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .alignItems(HorizontalAlign.Center)
  }

  // ============================================================
  // 底部tab栏
  // ============================================================

  @Builder
  bottomBar() {
    Row() {
      Column() {
        Text('🏠')
          .fontSize(22)
          .opacity(this.mainTab === 0 ? 1 : 0.4)
        Text('首页')
          .fontSize(10)
          .fontColor(this.mainTab === 0 ? COLORS.primary : COLORS.textSub)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(0);
      })

      Column() {
        Text('🔍')
          .fontSize(22)
          .opacity(this.mainTab === 1 ? 1 : 0.4)
        Text('发现')
          .fontSize(10)
          .fontColor(this.mainTab === 1 ? COLORS.primary : COLORS.textSub)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(1);
        this.switchTab(0);
      })

      Column() {
        Text('➕')
          .fontSize(26)
          .fontColor(COLORS.white)
          .textAlign(TextAlign.Center)
          .width(48)
          .height(48)
          .lineHeight(48)
          .linearGradient({
            angle: 135,
            colors: [[COLORS.grad1, 0], [COLORS.grad3, 1]]
          })
          .borderRadius(24)
          .shadow({ radius: 8, color: '#407C4DFF' })
          .margin({ top: -14 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.openAdd();
      })

      Column() {
        Text('💬')
          .fontSize(22)
          .opacity(this.mainTab === 3 ? 1 : 0.4)
        Text('消息')
          .fontSize(10)
          .fontColor(this.mainTab === 3 ? COLORS.primary : COLORS.textSub)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(3);
      })

      Column() {
        Text('👤')
          .fontSize(22)
          .opacity(this.mainTab === 4 ? 1 : 0.4)
        Text('我的')
          .fontSize(10)
          .fontColor(this.mainTab === 4 ? COLORS.primary : COLORS.textSub)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(4);
        this.switchTab(3);
      })
    }
    .width('100%')
    .height(64)
    .backgroundColor(COLORS.card)
    .alignItems(VerticalAlign.Center)
  }

  // ============================================================
  // 主构建
  // ============================================================

  build() {
    Stack() {
      Column() {
        this.header()
        this.tabBar()
        Scroll() {
          Column() {
            if (this.curTab === 0) {
              this.pageFeed()
            }
            if (this.curTab === 1) {
              this.pageUnbox()
            }
            if (this.curTab === 2) {
              this.pageAtlas()
            }
            if (this.curTab === 3) {
              this.pageCollect()
            }
            if (this.curTab === 4) {
              this.pageTrade()
            }
            if (this.curTab === 5) {
              this.pageRank()
            }
          }
          .width('100%')
          .padding({ bottom: 20 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .edgeEffect(EdgeEffect.Spring)
        this.bottomBar()
      }
      .width('100%')
      .height('100%')

      this.fxLayer()

      if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
        this.modalOverlay()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


在这里插入图片描述

总结

通过对这款基于HarmonyOS 6.1.1 ArkTS API 24开发的潮玩盲盒社区应用的完整源码解析,我们可以清晰地看到ArkTS声明式UI编程范式在复杂社区应用中的强大表现力。应用采用了单文件单组件的工程组织方式,通过interface定义数据契约、@Observed class实现可观察状态、@Builder实现UI复用、@State驱动响应式渲染,构建了一套完整的从数据模型到视图呈现的技术链路。紫色与粉色渐变色彩体系的统一应用,配合emoji图标的大量使用,营造出符合潮玩文化定位的视觉风格。

应用的核心技术亮点在于多Tab差异化布局的统一管理。六个内容页面分别采用瀑布流双列、横向滚动+纵向列表、三列网格、统计+柱状图+列表、筛选+列表、前三名卡片+排名列表等不同的布局策略,但所有页面共享同一套颜色配置、数据模型和工具函数。这种"统一基础设施、差异化布局策略"的设计模式,使得应用在保持视觉一致性的同时,能够为不同功能场景提供最优的信息展示方式。特别是瀑布流双列卡片通过p.id % 2p.id % 3取模判断实现了卡片高度差异化,模拟了真实瀑布流的参差错落效果,展现了ArkTS在细节控制方面的灵活性。

弹框系统是应用交互设计的另一个亮点。四个弹框通过统一的modalOverlay遮罩层管理,使用条件渲染决定显示哪个弹框,避免了多弹框叠加的复杂状态管理。底部弹框使用justifyContent(FlexAlign.End)实现从底部滑入的效果,居中弹框使用默认的居中对齐。表单状态与弹框开关状态分离管理,每个open方法在打开弹框前重置表单字段,确保了状态的一致性。这种"遮罩层统一管理 + 弹框独立渲染"的架构模式,在HarmonyOS ArkTS应用中具有广泛的适用性。

特效动画系统虽然实现简洁,但充分展示了基于定时器的状态驱动动画方案。通过setInterval每120毫秒递增tick计数器,结合一系列取模运算的纯函数,实现了四个emoji的浮动、旋转、缩放和透明度变化的复合动画效果。hitTestBehavior(HitTestMode.None)的运用确保了特效层不干扰底层页面的正常交互,这是ArkTS中实现装饰性覆盖层的标准做法。该方案的局限在于动画帧率受限于定时器间隔(120ms约等于8fps),在真实项目中可考虑替换为ArkTS的animateToAnimatorAPI以获得更流畅的60fps动画效果。

Logo

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

更多推荐