一、技术背景与开发范式概述

HarmonyOS 6.1.1 作为华为全场景分布式操作系统的最新演进版本,其应用开发体系已经从早期的 Java/UI 语法全面收敛至以 ArkTS 为核心的声明式开发范式。ArkTS 在 TypeScript 超集的基础上引入了若干编译期约束,例如不允许 any 类型、强制开启严格模式、限制了动态对象结构等,这些约束的本质目的是为了让编译器能够在编译阶段完成更多的类型推导与静态检查,从而为运行时性能优化提供更充足的腾挪空间。在 HarmonyOS ArkTS API 24 这一版本节点上,开发者可以使用的系统能力已经覆盖了从基础的 UI 声明式绘制到高级的分布式调度、原子化服务卡片、ArkUI 动效系统等完整链路,足以支撑复杂业务场景下的端侧应用构建。

声明式 UI 的核心理念是“状态驱动视图”。开发者只需声明界面在不同状态下应当呈现的形态,框架会自动追踪状态变量的变更,并以最小化的差异更新方式将变更同步到渲染树。这种范式相比传统的命令式 UI 编程,显著降低了状态与视图不一致的风险,同时也让代码的可读性和可维护性大幅提升。在 ArkTS 中,状态变量通过 @State@Prop@Link@Observed@ObjectLink 等装饰器来声明其观测层级与传递方向,开发者需要根据数据流的方向和组件树的层级合理选择装饰器组合,才能构建出既高效又清晰的状态传播网络。

本文将要剖析的案例是一个“潮玩盲盒社区”应用,它在视觉风格上借鉴了小红书式的瀑布流双列卡片布局,同时融合了多 Tab 差异化布局、底部弹窗表单、居中确认弹窗、装饰性浮动动效、自绘柱状图、排行榜颁奖台等多种复杂的 UI 形态。整个应用在一个 ArkTS 源文件内以单一 @Entry 组件为根,通过一系列 @Builder 方法拆分出十余个独立的视图片段,再由 build() 方法根据当前激活的 Tab 索引动态拼装。这种“单文件多 Builder”的组织方式非常适合中小型应用的快速原型开发,也便于在阅读时一次性把握整体架构,是学习 ArkTS 组件化思想非常典型的样本。

从工程价值的角度看,这个案例涵盖了状态管理、列表渲染、条件分支布局、纯函数数据处理、定时器动效、自定义图表绘制、弹窗遮罩层管理等 ArkTS 开发中最高频使用的技术点。通过对它的逐段拆解,读者不仅可以掌握各个 API 的具体用法,更能理解“为什么这样组织代码”——即如何在声明式范式下平衡复用性、可读性与渲染性能。下文将按照代码的自然组织顺序,从配置层、模型层、数据层、函数层到组件层逐段展开,并在关键节点辅以流程图与对比表,力求做到既见树木又见森林。

二、颜色配置层:类型化配色体系的设计思路

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 接口,把应用中可能用到的全部颜色槽位以字段形式枚举出来,包括主色(primary)、深主色(primaryDark)、次色(secondary)、强调色(accent)、背景色(bg)、卡片色(card)、主文本色(text)、次级文本色(textSub)、浅文本色(textLight)、边框色(border)、三段渐变色(grad1/grad2/grad3)以及一系列语义化命名色(gold、purple、pink 等)和叠加色(overlay)、阴影色(shadow)。

采用“接口 + 常量实现”而非直接散落字符串字面量的做法,带来了三个显著好处。第一是类型安全:任何使用 COLORS.xxx 的地方都会得到编译器的字段提示与拼写检查,避免因为手误写错颜色键名而导致运行时取到 undefined。第二是统一收口:当设计稿需要全局调整主色时,只需修改这一处常量,所有引用该槽位的组件会自动同步,而不必在成百上千处 backgroundColor('#7C4DFF') 中逐一查找替换。第三是语义可读:COLORS.textSub 显然比 '#888888' 更能表达“次级文字颜色”的含义,这让后续维护者在阅读布局代码时能够将注意力集中在结构上而非在色值之间反复跳转。

从色彩搭配的角度看,这套配色以紫色(#7C4DFF)到粉色(#FF4081)的渐变为主轴,辅以洋红(#E040FB)作为中段过渡,整体呈现出活泼、年轻、偏女性向的视觉气质,这与“潮玩盲盒”这一目标用户群体的审美取向高度契合。背景色采用极浅的紫调白(#F8F5FF)而非纯白,可以让卡片在浮起时产生更柔和的层次感;阴影色使用极低透明度的黑(#15000000,约 8% 不透明度),保证阴影存在感的同时不会显得脏污。这些细节体现了“配色不是随意选色,而是有体系、有层次、有语义”的工程化思维。

三、业务枚举与图表配置:领域知识的常量化表达

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 分类以及交易筛选维度。这段代码把这些业务概念全部以常量数组的形式集中声明,并通过 RarityOption 接口为稀有度建立了“名称 + 主色 + 背景色”的三元组结构。

把稀有度做成带配色信息的对象数组是一个值得注意的设计决策。稀有度不仅是一个分类标签,它在整个应用的多个页面都会以“彩色徽章”的形式出现——瀑布流卡片、拆盒列表、交易卡片、排行榜徽章都需要根据稀有度显示对应的颜色。如果每个使用点都各自写一份 if (rarity === '隐藏') color = '#FF8F00' 的判断逻辑,会导致逻辑重复且极易出现不一致。而通过 RARITY_OPTIONS 这一单一数据源,配合后续的 rarityColor / rarityBg 纯函数,实现了“一处定义、处处引用”的收敛式管理,这正是声明式编程中“数据驱动视图”思想的延伸——连配色本身也是数据。

其余几个数组(CONDITION_NAMESHOT_TAGSTAB_NAMESTRADE_FILTERS)则属于纯展示型的枚举集合。值得注意的是 TAB_NAMES 定义了六个 Tab,这六个 Tab 在后续的主构建方法中通过索引 curTab 来切换,每个 Tab 对应一个独立的页面构建器。将 Tab 名称集中声明而不是在每个 Tab 的布局代码里硬编码字符串,既方便国际化扩展(未来若做多语言只需替换这一处数组),也保证了 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' }
];

在这里插入图片描述

紧接着的图表配置 CHART_BARS 为收藏页面中的自绘柱状图提供了数据源。每根柱子由标签(label)、数值(value)和颜色(color)三个字段构成。这种“数据即图表”的设计意味着柱状图的呈现完全由这份数据驱动:增删一根柱子只需修改数组,无需改动任何绘制逻辑。这是声明式 UI 中“配置优于编码”的典型实践,也使得图表具备了一定的数据可视化通用性。

四、数据模型层:接口契约与可观测类的双轨设计

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;
}

在这里插入图片描述

数据模型层定义了四个核心业务实体:Post(开盒帖子)、Series(盲盒系列)、Trade(交易商品)、Rank(排行榜用户)。这四个接口共同覆盖了应用的全部业务域——内容分享(Post)、图鉴收藏(Series)、二手交易(Trade)、用户排行(Rank)。每个接口的字段都经过精心裁剪,只保留 UI 展示所需的最小集合,例如 Post 中用 pics 字段以 emoji 字符串代替真实图片资源,avatar 同样使用 emoji,这是一种在原型阶段非常高效的占位策略,可以让开发者把精力集中在布局与交互逻辑而非资源准备上。

采用纯 interface 而非 class 来定义数据契约,是为了把它们作为“数据形状的描述”,而非可实例化的对象。接口在 ArkTS 中只参与类型检查,不产生运行时开销,非常适合用来约束 Mock 数据和函数签名的形状。而真正需要被框架观测的对象,则由下一组的 @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;
  }
}

在这里插入图片描述

PostItem 是一个标注了 @Observed 装饰器并 implements Post 的类。@Observed 的作用是让该类的实例被 ArkUI 框架纳入可观测范围:当实例的属性被修改时,框架能够感知到变化并触发依赖该实例的 UI 重新渲染。这一点对于本应用至关重要——例如点赞按钮点击后需要立刻更新心形图标和点赞数,如果 PostItem 不可观测,则修改 liked 属性后视图不会自动刷新。

类中每个字段都给了默认值(如 id: number = 0title: string = ''),这是 ArkTS 的强制要求:可观测类的字段必须初始化,否则编译器会报错。构造函数 constructor(o: Post) 接收一个符合 Post 接口形状的对象,逐字段赋值到当前实例,这实质上是一个“从纯数据对象转换为可观测实例”的适配过程。SeriesItemTradeItemRankItem 三个类采用了完全一致的模式,这种一致的“接口 + 可观测类 + 构造适配”三段式,让四个实体的可观测化方式高度统一,降低了认知负担。

五、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小时前' },
  // ... 其余帖子数据结构一致,省略以节省篇幅
];

POSTS 数组提供了十二条帖子样本,覆盖了隐藏款开箱炫耀、拆盒概率分析、收藏墙展示、新品预告、求购信息、十连拆记录、换盒需求、收纳攻略、欧皇日记、限量对比、交易避坑、年度总结等多种典型的社区内容形态。这种多元化的样本设计并非随意堆砌,而是为了让瀑布流双列布局在视觉上呈现出明显的高度差异(不同帖子的文案长度不同、图片区域 padding 不同),从而真实地模拟小红书式瀑布流的参差感。

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: '🚀' },
  // ... 其余系列数据
];

在这里插入图片描述

SERIES_DATA 提供了十二个盲盒系列,每个系列关联了品牌(POP MART、52TOYS、寻找独角兽、若态、HobbyFun 等真实潮玩品牌)、总数、已收集数、单价、热度、主题色和 emoji。其中 collectedtotal 的比例关系是后续计算收藏进度条、进度颜色、完成率的核心数据,样本中刻意安排了“已集齐”(collected === total)、“接近集齐”、“刚起步”等多种状态,以便图鉴页和收藏页的进度条展示出丰富的视觉层次。

const TRADES: Trade[] = [
  { id: 1, name: '森林精灵-隐藏款', series: '森林精灵', rarity: '隐藏', price: 580, seller: '盲盒少女小C', status: '在售', condition: '全新未拆' },
  // ... 其余交易数据
];

const RANKS: Rank[] = [
  { id: 1, name: '欧皇本皇', score: 9876, avatar: '🦄', collection: 234, badge: '🏆收藏之王' },
  // ... 其余排行数据
];

在这里插入图片描述

TRADESRANKS 分别为交易市场和排行榜提供数据。交易数据刻意包含了“在售/预订/已售”三种状态以及四种成色,以便交易页面的筛选标签和状态徽章能够完整展示;排行榜数据按分数降序排列,前三名分别对应金、银、铜颁奖台,第四名及以后为列表项,这种数据结构直接决定了排行榜页面的“颁奖台 + 列表”两段式布局。

六、全局纯函数层:数据处理与视觉映射的工具集

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;
}

getLeftPostsgetRightPosts 两个函数把 POSTS 数组按奇偶索引拆分成左右两列,这是实现瀑布流双列布局的关键预处理。每个被取出的 Post 纯对象都通过 new PostItem(...) 转换为可观测实例,保证后续点赞操作能够被框架观测到。getSeriesListgetTradeListgetRankList 三个函数同样把对应的 Mock 数组整体转换为可观测实例列表,这一组转换函数构成了“原始数据 → 可观测数据”的统一入口。

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';
}

这一组函数承担了“业务语义 → 视觉属性”的映射职责。rarityColorrarityBg 把稀有度名称映射为主色和背景色,与前面声明的 RARITY_OPTIONS 数据形成呼应;withAlpha 则是一个通用的颜色透明度工具,它把一个 #RRGGBB 格式的颜色加上两位十六进制透明度前缀变成 #AARRGGBB 格式,这是 ArkUI 渐变色和半透明背景常用的颜色格式。把这类纯函数提取到全局,既避免了在布局代码中嵌入大量条件判断,也保证了同一映射规则在所有调用点的一致性。

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 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';
}

在这里插入图片描述

progressPercent 计算收藏完成百分比,做了除零保护和上限封顶;seriesProgressColor 根据进度区间返回不同的颜色——满额绿色、过半紫色、四分之一以上橙色、不足则红色。这种“进度区间 → 语义颜色”的映射让进度条本身具备了信息密度:用户只需扫一眼颜色就能判断收集进展,无需仔细阅读百分比数字,这是数据可视化中“颜色编码”原则的典型应用。

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

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;
}

这一组以 fx 为前缀的函数是装饰性浮动动效的位移与透明度计算器。它们都接收一个 tick 参数(由定时器递增的时间刻度),通过取模运算让数值在固定区间内周期性循环,从而让浮动的 emoji 产生不重复但又有节奏的位置、透明度、缩放变化。这种“纯函数 + 定时器 tick”的动效实现方式非常轻量——它不依赖 ArkUI 的 animateTo 或属性动画接口,而是通过定时刷新状态变量触发重新渲染,再由纯函数计算出每一帧的位置参数。虽然性能上不如系统级动画高效,但胜在实现简单、可控性强,适合用于不要求物理仿真的装饰性元素。

七、入口组件的状态声明与生命周期

@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;

这是整个应用的根组件 BlindBoxApp,标注了 @Entry@Component@Entry 表示它是页面入口组件,会被框架作为渲染树的根挂载;@Component 表示它是一个自定义组件,拥有独立的 build() 方法。组件内部声明了大量的 @State 变量,可以按职责分为四组:导航状态(curTab、mainTab)、弹窗开关(addOpen、editOpen、delOpen、bizOpen)、表单字段(add*、edit*、del*、biz*)、数据列表(leftPosts 等)以及动效刻度(tick)。

把所有状态都集中在根组件是一种典型的“单一状态树”模式。它的好处是状态来源唯一、调试方便、跨 Builder 共享无需层层传递;代价是根组件承担了较多的职责,当应用规模继续增长时可能需要拆分为多个子组件并通过 @Prop/@Link 下发状态。但在当前规模下,这种集中式管理是合理且高效的。注意 timer 使用 private 而非 @State,因为它不需要触发视图刷新,只是一个定时器句柄的存储。

  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 是组件生命周期回调,在组件创建后、build() 执行前调用。这里完成了两件事:一是把 Mock 数据转换为可观测实例并赋值给对应的状态变量,触发首次渲染;二是启动一个 120ms 间隔的定时器,持续递增 tick 以驱动浮动动效。aboutToDisappear 则在组件销毁前清理定时器,防止内存泄漏。这种“成对的生命周期资源管理”是 ArkTS 开发的基本素养——任何在 aboutToAppear 中申请的资源(定时器、监听器、订阅等)都应当在 aboutToDisappear 中释放。

下面用流程图展示组件从创建到销毁的完整生命周期与状态流转。

切换 Tab

点击点赞

打开弹窗

定时器触发

关闭弹窗

组件创建

aboutToAppear 生命周期

加载 Mock 数据为可观测实例

赋值给 @State 列表变量

启动 120ms 定时器递增 tick

首次 build 渲染

用户交互

switchTab 更新 curTab

toggleLike 修改 liked/likes

openAdd/openEdit/openDel/openBiz

tick+1 触发 fxLayer 重绘

closeAll 复位四个弹窗开关

组件销毁

aboutToDisappear

clearInterval 清理定时器

资源释放完成

八、交互行为方法:导航、弹窗与点赞的逻辑实现

  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;
  }

switchTabswitchMain 分别切换内容 Tab 和底部主导航 Tab,实现极简——直接赋值即可,框架会自动观测到 curTab/mainTab 的变化并重新渲染对应区域。closeAll 一次性关闭所有弹窗,保证同一时刻只有一个弹窗处于打开状态,避免弹窗层叠造成的视觉混乱。

  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;
  }

四个 open* 方法各自负责打开一种弹窗,并在打开前重置该弹窗的表单字段为初始值。这种“打开即重置”的策略保证了每次打开弹窗时用户看到的都是干净的表单,而非上一次操作遗留的脏数据。openEditopenDel 接收参数(系列名称、已收集数),把上下文信息注入弹窗,使弹窗能够展示当前操作的具体对象——例如删除确认弹窗会显示“即将删除「森林精灵」的收藏记录”。

  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;
  }

toggleLike 是点赞逻辑的核心。由于 PostItem@Observed 类,直接修改 p.likedp.likes 属性本身已经能触发观测,但这里额外调用了 this.leftPosts.slice()this.rightPosts.slice()——这是为了强制触发 @State 数组本身的变更通知。因为 @State 对数组的观测是基于引用变化的,直接修改数组内部元素不一定能被 @State 这一层级感知,通过 slice() 生成一个新数组引用可以确保整条观测链路被激活。decCollected 做了下限保护(不能小于 0),incCollected 直接递增,两者配合编辑弹窗中的加减按钮实现数量调节。

九、特效层:浮动 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)
  }

fxLayer 是装饰性浮动 emoji 的渲染层,被声明为一个 @Builder 方法。它使用 Stack 作为容器,在其内部叠放四个 Text 组件,分别渲染 🎁、📦、✨、🎊 四个 emoji。每个 emoji 的位置(position)、透明度(opacity)、旋转角度(rotate)、缩放(scale)都由前述的 fx* 纯函数根据当前 tick 值计算得出。由于 tick@State 变量且每 120ms 递增一次,每次递增都会触发 fxLayer 重新执行,从而让四个 emoji 产生连续的浮动、闪烁、旋转效果。

这里有一个关键设计:整个 Stack 和所有子 Text 都设置了 hitTestBehavior(HitTestMode.None)HitTestMode.None 表示该节点及其子节点都不参与点击命中测试,这意味着这层覆盖在整个页面上方的特效层不会拦截任何触摸事件,用户的所有点击都会穿透到下方的实际内容。这是实现“纯装饰覆盖层”的正确做法——既保证了视觉效果,又不影响交互。如果不设置这一属性,浮动的 emoji 会像一个透明遮罩一样挡住下方按钮的点击,造成严重的体验问题。

十、头部区域:渐变背景、搜索栏与热门标签横滑

  @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 })
  }

header 构建了应用顶部区域,整体是一个 Column,其背景使用 linearGradient 设置了 135 度方向的三段渐变——从紫色(#7C4DFF)到洋红(#E040FB)再到粉色(#FF4081),这正是 COLORS.grad1/grad2/grad3 的用途所在。渐变背景为头部区域奠定了整个应用的视觉基调。

头部内部纵向排布了三段内容。第一段是顶栏 Row,左侧是 Logo emoji 和应用名“盲盒星球”,中间用一个 layoutWeight(1) 的空 Row 占位把右侧的铃铛按钮推到最右——这是 ArkUI 中实现“两端对齐”的常用技巧。第二段是搜索栏,一个圆角胶囊形的白色 Row,内部用 emoji 代替搜索图标和相机图标,点击相机会触发 openAdd() 打开新增记录弹窗。第三段是热门标签横向滚动区,使用 Scroll + 横向 scrollable 方向,通过 ForEach 渲染 HOT_TAGS 数组中的每个标签为半透明黑色背景的胶囊。scrollBar(BarState.Off) 隐藏了滚动条,constraintSize({ maxHeight: 40 }) 限制了滚动区高度,防止标签换行。

值得注意的是每个标签的点击都调用了 switchTab(0),即点击标签会跳转到推荐 Tab。这种“标签即快捷入口”的设计在内容社区中非常常见,它让热门话题成为导航的一部分,提升了内容发现效率。

十一、内容 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)
  }

tabBar 使用 ForEach 遍历 TAB_NAMES 数组渲染六个 Tab 项,每个 Tab 是一个 Column,内部包含名称文字和可选的下划线指示条。这里通过三元表达式 this.curTab === i ? ... : ... 实现了选中态与未选中态的差异化样式:选中时字号 15、主色、粗体,并在下方渲染一个 20×3 的主色圆角下划线;未选中时字号 13、次级色、常规字重、无下划线。这种“条件渲染 + 条件样式”的组合是声明式 UI 实现 Tab 高亮的标准范式。

每个 Tab 的 onClick 调用 switchTab(i),由于 curTab@State,赋值后会自动触发 tabBar 重新执行,所有 Tab 的样式会根据新的 curTab 重新计算。layoutWeight(1) 让六个 Tab 平均分配宽度,保证横向铺满。

十二、推荐页(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 })
  }

feedCard 是瀑布流中的单个帖子卡片构建器。它采用“图片区 + 内容区”的两段式结构。图片区是一个 Column,内部用 Text 渲染 emoji 占位图,其 fontSize 和 padding 都根据 p.id % 2 取奇偶来差异化——偶数 id 的卡片图片更大(字号 48、padding 24),奇数 id 的卡片图片更小(字号 36、padding 16)。这个细节是制造瀑布流高度差的关键手段之一,让左右两列的卡片自然形成参差不齐的视觉效果。图片区还叠加了基于稀有度的渐变背景,使用 rarityBgwithAlpha(rarityColor(...), '30') 生成从稀有度背景色到半透明稀有度主色的渐变,让每张卡片的色调与其稀有度语义呼应。

内容区包含标题(最多两行、溢出省略)、正文(根据 id % 3 决定显示 1 行还是 3 行,进一步制造高度差异)、标签、作者信息和点赞按钮。点赞按钮根据 liked 状态切换 ❤️/🤍 表情,点击调用 toggleLike(p)formatLikes 函数把点赞数格式化为带 k/w 后缀的简写形式(如 2341 → 2.3k,8901 → 8.9k),这是社交内容中常见的数字简化展示,既节省空间又保持可读性。

  @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%')
  }

pageFeed 是推荐页的主体。顶部是一个带“新增记录”按钮的标题行,下方是瀑布流双列布局的核心实现:一个 Row 内含两个 Column,左列通过 ForEach 渲染 this.leftPosts,右列渲染 this.rightPosts。两列各自 layoutWeight(1) 平均分配宽度,并通过细微的 padding 差异(左列 left:6 right:5,右列 left:5 right:6)形成中间 10px 的间距。alignItems(VerticalAlign.Top) 保证两列从顶部对齐。ForEach 的第三个参数是键值生成器(p.id.toString()),用于框架的列表差量更新——当数据变化时,框架通过 key 判断哪些项需要新增、删除或重排,从而实现高效渲染。

十三、拆盒页(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);
    })
  }

unboxSeriesCard 是拆盒页中横向滚动的系列速览卡片。每张卡片固定宽度 120,纵向展示 emoji、系列名、品牌、单价、热度五个信息层级。点击卡片会跳转到图鉴 Tab(switchTab(2)),形成“速览 → 详情”的导航流。卡片使用圆角和阴影实现浮起感,margin({ right: 10 }) 在卡片之间留出横向间距。

  @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%')
  }

pageUnbox 是拆盒页的主体,采用“横向滚动卡片区 + 纵向列表区”的组合布局。横向区使用 Scroll + 横向 Row 渲染所有系列卡片;中间用 Divider 分隔;纵向区使用 ForEach 遍历 this.leftPosts.concat(this.rightPosts)(把左右列合并为完整列表)渲染每条拆盒记录。每条记录是一个 Row:左侧是 56×56 的稀有度背景色方块(内含 emoji),右侧是纵向排布的标题、系列+稀有度徽章行、作者+时间行。点击记录触发 toggleLike,把列表项也接入了点赞交互。

注意 ForEach 的键值使用了 'unbox_' + p.id.toString() 前缀,而推荐页中用的是 p.id.toString()。这种键值前缀化是为了避免同一 PostItem 在不同页面的 ForEach 中产生 key 冲突——因为这两个页面渲染的是同一批数据实例,如果不加前缀,框架的差量更新可能会误判。这是 ArkUI 列表渲染中一个容易被忽视但很重要的细节。

十四、图鉴页(Tab2):三列网格与进度条可视化

  @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 })
  }

atlasCard 是图鉴页的单个系列卡片。它同样采用两段式:顶部是带渐变背景的 emoji 展示区,渐变色基于该系列自身的 color 属性生成(withAlpha(s.color, '20')withAlpha(s.color, '60')),让每张卡片拥有与系列主题色一致的色调;底部是信息区,展示系列名、品牌、已收集/总数、进度条和状态按钮。

进度条的实现是一个值得学习的细节:外层 Row 固定宽度 100%、高度 5、背景为边框色,作为进度槽;内层 Column 宽度为 progressPercent(...) + '%'、高度 5、背景为 seriesProgressColor(...),作为进度填充。这种“外槽内填”的双层结构是纯 ArkUI 实现进度条的标准做法,无需引入任何图表组件即可完成。状态按钮根据是否集齐显示“✅ 已集齐”(绿色)或“继续收集”(主色),点击触发 openEdit 打开编辑弹窗。

  @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%')
  }

pageAtlas 用三个 Column 并排实现三列网格。每个 Column 内部通过 ForEach 遍历完整的 seriesList,但用 if (i % 3 === 0/1/2) 条件判断只渲染对应余数的项——第 0 列渲染索引为 0、3、6…的系列,第 1 列渲染 1、4、7…,第 2 列渲染 2、5、8…。这种“三次遍历 + 取模分流”的方式虽然遍历了三次数组,但在数据量不大时性能影响可忽略,而实现上非常直观。三列的键值分别加了 col0_col1_col2_ 前缀以避免冲突。

十五、收藏页(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 是一个可复用的统计卡片构建器,接收标签、数值、颜色三个参数。这种参数化的 Builder 体现了 ArkUI 组件化的复用思想——同一个 Builder 可以被传入不同参数渲染出多个相似但不同的卡片。在收藏页中它被调用三次,分别展示“总系列数”“已收藏数”“完成率”。

      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)

三个统计卡片的数据由纯函数计算:totalSeriesCount 返回系列总数,totalCollectedCount 累加所有系列的 collected 字段,collectionRate 计算总完成率。这种“纯函数取数 + Builder 展示”的分离让数据来源单一、可测试、可替换——未来接入真实接口时只需替换函数实现,展示层完全不变。

      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 })
      }

这段是自绘柱状图的实现。整个图表是一个 Row,内部通过 ForEach 渲染 CHART_BARS 数组的每一根柱子。每根柱子是一个 Column,从上到下依次是数值标签、柱体(一个固定宽度 26、高度由 barHeight(bar.value) 计算的 Column)、类别标签。外层 Row 设置 height(160)alignItems(VerticalAlign.Bottom),让所有柱子底部对齐,形成标准的柱状图视觉。

barHeight 函数把数值乘以 2.4 转换为像素高度(如 45 → 108px、8 → 19px),这是一种简单的线性映射。这种纯 ArkUI 绘制的柱状图虽然不如专业图表库功能丰富,但胜在零依赖、完全可控、与整体视觉风格高度一致,非常适合简单数据分布的展示。

统计卡数据流

totalSeriesCount

collectStatCard 总系列

totalCollectedCount

collectStatCard 已收藏

collectionRate

collectStatCard 完成率

收藏页数据流

CHART_BARS 配置

barHeight 计算柱高

ForEach 渲染每根柱子

Column: 数值标签

Column: 柱体 26x barHeight

Text: 类别标签

Row 底部对齐 160px

完整柱状图

      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())
      }

收藏列表是收藏页的第三段内容。每行是一个 Row:左侧是带系列主题色半透明背景的 emoji 方块,中间是系列名、品牌、收集进度(含进度条),右侧是“编辑”和“删除”两个操作按钮。编辑按钮调用 openEdit,删除按钮调用 openDel,分别打开对应的弹窗。这里的进度条比图鉴页更细(高度 4),体现了同一组件在不同场景下的尺寸适配。键值使用 'collect_' 前缀,与图鉴页的 'col0_' 等区分开。

十六、交易页(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 })

交易页顶部是标题行和“上架商品”按钮(点击打开交易上架弹窗),下方是横向滚动的筛选标签栏。筛选标签通过 tradeFilterIdx 状态控制高亮:选中项白字主色背景,未选中项灰字边框色背景。点击切换 tradeFilterIdx 即可实现筛选切换——虽然当前实现并未真正根据筛选条件过滤列表数据(这是原型阶段的合理简化),但交互状态已经完整具备。

      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())
      }

每张交易卡片是一个 Column,内含三行内容。第一行是商品名和价格,其中价格使用 decoration 属性——当状态为“已售”时添加删除线(TextDecorationType.LineThrough),这是电商场景中标识已售商品的标准视觉手段。第二行是四个标签的横向排列:系列、稀有度、成色、状态,每个标签用对应的颜色映射函数(rarityColor/rarityBg/statusColor/statusBg)着色,中间用一个 layoutWeight(1) 的空 Row 把状态标签推到最右。第三行是卖家信息和“查看详情”链接。

这个卡片的设计充分体现了“数据驱动视觉”的理念:同一套 Builder 模板,根据不同的 statusraritycondition 数据,自动渲染出不同颜色和装饰的标签,无需为每种状态写独立的布局分支。

十七、排行榜页(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);
    })
  }

rankTopCard 是排行榜前三名的颁奖台卡片。它纵向展示奖牌 emoji(medalEmoji 函数根据排名返回 🥇🥈🥉)、头像、用户名、积分、收藏数、徽章。徽章的背景色根据排名差异化:冠军金(#FF8F00)、亚军灰(#757575)、季军橙红(#FF7043)。卡片整体背景也由 rankBgColor 根据排名返回不同色调。点击颁奖台卡片会跳回推荐 Tab。

  @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%')
  }

pageRank 是排行榜页主体。它的核心设计是“颁奖台 + 列表”两段式:先用一个 Row 渲染前三名颁奖台,注意三个卡片的传入顺序是 [1][0][2]——即亚军在左、冠军在中、季军在右,配合 alignItems(VerticalAlign.Bottom) 让三张卡片底部对齐,冠军卡片因为内容最多会最高,形成经典的“中间高、两边低”的颁奖台轮廓。第二段用 ForEach 遍历 rankList,通过 if (i >= 3) 只渲染第四名及以后的列表项。每项是一个 Row,包含排名数字、头像、用户名+徽章、积分。底部还有一条积分规则说明文字,让用户理解积分体系。

十八、弹窗遮罩层:多弹窗统一管理的实现

  @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%')
  }

modalOverlay 是弹窗层的统一容器。它使用 Stack 叠放一个全屏半透明遮罩 Column(背景色 COLORS.overlay 即 #80000000,50% 黑色)和当前打开的弹窗体。遮罩的 onClick 调用 closeAll(),实现“点击遮罩空白处关闭弹窗”的常见交互。四个弹窗体通过四个独立的 if 判断条件渲染——由于 closeAll 保证同一时刻只有一个开关为 true,因此同一时刻最多只有一个弹窗体被渲染。

这种“统一遮罩 + 条件分发”的弹窗管理模式比每个弹窗各自管理遮罩要优雅得多:遮罩的样式、动画、关闭逻辑只需维护一处,新增弹窗只需增加一个 if 分支和一个对应的 modalBody* Builder。这是声明式 UI 中“组合优于重复”的典型体现。

下面用流程图展示弹窗的触发与关闭流程。

点击新增记录

点击编辑

点击删除

点击上架商品

addOpen

editOpen

delOpen

bizOpen

点击遮罩

点击取消

点击确认

用户操作

触发类型

openAdd 重置表单并 addOpen=true

openEdit 注入参数并 editOpen=true

openDel 注入名称并 delOpen=true

openBiz 重置表单并 bizOpen=true

modalOverlay 被渲染

弹窗开关判断

渲染 modalBodyAdd 底部弹出

渲染 modalBodyEdit 居中弹出

渲染 modalBodyDel 居中小框

渲染 modalBodyBiz 底部弹出

关闭触发

closeAll 全部置 false

业务回调 + 关闭

modalOverlay 不再渲染

十九、新增记录弹窗:底部弹出的多字段表单

  @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;
          })

modalBodyAdd 是新增开盒记录的弹窗体。它采用“上方空白点击区 + 下方表单卡片”的结构:顶部一个 layoutWeight(1) 的空白 Column 占据剩余空间并绑定 onClick 关闭弹窗(扩大了遮罩点击关闭的命中区域),底部是实际的表单卡片。表单卡片顶部是标题行(“记录开盒时刻” + 关闭按钮 ✕)。

表单包含五个字段:帖子标题(TextInput)、系列选择(横向滚动标签)、稀有度选择(标签组)、内容(TextArea 多行文本)、标签(TextInput)。每个输入控件的 onChange 回调把输入值同步到对应的 @State 变量(addTitleaddContentaddTags),系列和稀有度通过点击标签切换 addSeriesIdxaddRarityIdx 索引。这种“每个字段一个 @State 变量”的方式虽然显式但非常清晰,便于后续提交时统一收集。

        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)
  }

表单底部的按钮行包含“取消”(边框色背景)和“发布记录”(渐变背景)两个按钮。发布按钮使用了 linearGradient 渐变背景,从主色到次色横向渐变,视觉上比纯色按钮更有吸引力。点击发布后关闭弹窗并切换到推荐 Tab。整个表单卡片设置了 constraintSize({ maxHeight: '75%' }),防止内容过多时超出屏幕,外层 ColumnjustifyContent(FlexAlign.End) 让卡片贴底显示,形成“从底部滑入”的视觉语义。

二十、编辑弹窗与删除确认弹窗:居中弹出的两种形态

  @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 })

modalBodyEdit 是编辑收藏信息的弹窗,采用居中弹出(区别于新增弹窗的底部弹出)。它包含系列名称输入框和一个数量调节器。数量调节器由“减号按钮 + 数字显示 + 加号按钮”三段组成,点击加减按钮分别调用 decCollected(有下限保护)和 incCollected,修改的是 @State editCollected,由于 Text 绑定了 this.editCollected.toString(),数值变化会实时反映到显示上。这种步进器(Stepper)交互比直接输入数字更符合移动端操作习惯。

  @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' })
  }

modalBodyDel 是删除确认弹窗,一个居中的小尺寸警示框。顶部是 ⚠️ 警示图标,中间是红色加粗的“确认删除?”标题和包含具体对象名称的说明文字(this.delName 注入了上下文),底部是“取消/确认删除”的纵向按钮组。按钮组使用 clip(true) 配合圆角实现顶部和底部的圆角裁剪,中间用 Divider 分隔,形成类似 iOS ActionSheet 的列表式按钮外观。确认删除后关闭弹窗并跳转到图鉴 Tab。

二十一、交易上架弹窗:双列表单与多选择器组合

  @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 })

modalBodyBiz 是交易上架弹窗,结构与新增弹窗类似(底部弹出),但表单字段更丰富。它包含商品名称输入框、所属系列横向滚动选择器。注意这里系列选中态使用的是 COLORS.secondary(粉色)而非 COLORS.primary(紫色),这是一个细微的视觉区分——新增记录弹窗用主色,交易上架弹窗用次色,让两种弹窗在视觉上有可辨识的差异。

        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)

这段是交易上架弹窗中的“稀有度”和“新旧程度”双列选择器。两个 Column 各自 layoutWeight(1) 并排,左列纵向排列四个稀有度选项,右列纵向排列四个成色选项。稀有度选项的配色复用了 RARITY_OPTIONS 数据中的 colorbg,成色选项统一使用蓝色系。每个选项的选中态通过 bizRarityIdx/bizConditionIdx 索引判断,选中时反色显示(白字主色背景)。这种纵向标签列表比横向滚动更适合选项数量固定且需要同时可见的场景。

        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(switchTab(4))。整个弹窗的 constraintSize({ maxHeight: '80%' }) 比新增弹窗的 75% 略高,因为表单字段更多,需要更多垂直空间。

二十二、底部导航栏:五项导航与中央悬浮按钮

  @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)
  }

bottomBar 是底部主导航栏,包含五个项:首页、发现、中央发布按钮、消息、我的。前两个和后两个使用 opacity 区分选中态(1.0)与未选中态(0.4),配合文字颜色变化实现高亮。中央的发布按钮是视觉焦点:一个 48×48 的渐变圆形按钮,使用紫粉渐变(grad1grad3),margin({ top: -14 }) 让它向上凸出于导航栏,shadow 带主色阴影(#407C4DFF)形成悬浮发光感。点击发布按钮调用 openAdd() 打开新增记录弹窗。

“发现”和“我的”两项在切换 mainTab 的同时还会联动切换 curTab(分别切到 0 和 3),实现底部主导航与内容 Tab 的联动——这是一种“主导航跳转到特定内容页”的常见交互模式,让用户从底部导航能够直达深层内容。

二十三、主构建方法:整体页面的最终拼装

  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()
            }
        


在这里插入图片描述

二十五、总结

纵观整个案例,我们可以清晰地看到 ArkTS 声明式开发范式的几个核心特征。第一是“状态即真相”——所有的视图变化都由 @State 变量的变更驱动,无论是 Tab 切换、点赞、弹窗开关还是动效,开发者只需关心“状态是什么”,框架负责“视图怎么变”。这种范式大幅降低了命令式 UI 中“状态与视图不一致”的风险,也让代码的可推理性显著提升——读代码时,只要追踪状态变量的赋值点就能理解全部交互行为。第二是“Builder 即组件”——十余个 @Builder 方法把一个本来会极其庞大的 build() 方法拆分成了职责清晰的视图片段,每个 Builder 专注于一小块 UI 的渲染,这种拆分虽然不如独立 @Component 子组件那样彻底,但在单文件原型阶段是复用性与可读性之间极佳的平衡点。

从数据流的角度看,整个应用采用了“纯函数数据层 + 可观测实例层 + Builder 展示层”的三段式架构。Mock 数据作为不可变的原始事实源,经过 getXxxList 等转换函数变为可观测实例列表,再由各 @Builder 根据 @State 状态和纯函数计算结果进行渲染。这种分层让数据来源单一、转换逻辑集中、展示逻辑无副作用,非常符合“单向数据流”的架构理念。虽然当前实现中数据流是单向的(没有从视图回写到数据的完整闭环),但点赞 toggleLike 已经展示了“视图交互 → 修改可观测实例 → 触发重渲染”的闭环雏形,为未来接入真实数据持久化预留了清晰的扩展路径。

从视觉工程的角度看,这个案例展示了“配色体系化、布局差异化、动效装饰化”三大原则的落地。配色通过 ColorPalette 接口和 COLORS 常量实现全局收口;布局通过奇偶取模、条件 padding、横向与纵向滚动组合实现六个 Tab 的差异化呈现,避免了“千篇一律的列表”的视觉疲劳;动效通过纯函数 tick 驱动的浮动 emoji 层为静态界面注入了生命力,同时通过 hitTestBehavior(None) 保证了装饰性不干扰功能性。这些细节共同构成了一个“看起来精致、用起来流畅”的应用原型。

最后需要指出的是,作为一个原型级的单文件实现,它在工程化方面仍有提升空间。例如,随着业务增长,把所有 @State 集中在根组件会导致状态管理负担过重,此时应考虑使用 @Provide/@Consume 或 AppStorage 进行跨层级状态分发;@Builder 方法可以进一步抽取为独立的 @Component 子组件以获得独立的状态管理能力;纯函数可以组织为独立的工具模块;Mock 数据可以替换为基于 @State + 网络请求的异步数据加载。但作为一个学习 ArkTS 声明式开发范式的完整样本,它已经覆盖了从配色、建模、数据、函数到布局、动效、弹窗、导航的全部高频技术点,是理解 HarmonyOS ArkTS API 24 开发实践非常值得研读的案例。掌握这些技术点,开发者就具备了在 HarmonyOS 6.1.1 平台上构建复杂业务应用的基础能力,能够在声明式范式下高效地组织代码、管理状态、构建视觉、实现交互。

Logo

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

更多推荐