在 HarmonyOS(鸿蒙操作系统)的生态中,ArkUI 声明式开发范式已经成为构建原生应用界面的核心方式。它采用基于 TypeScript 扩展的 ArkTS 语言,通过装饰器、状态管理、组件化等现代前端工程理念,让开发者能够以极高的效率构建出结构清晰、交互丰富的全场景应用。本文将以一个完整的"次元漫展"ACGN 社区应用为例,从类型定义、数据模型、组件拆分、状态驱动、弹窗体系到布局细节,逐段拆解每一个关键技术点,帮助开发者深入理解鸿蒙声明式 UI 的工程实践。

一、技术背景与整体架构概述

鸿蒙 ArkUI 框架提供了全新的声明式 UI 开发范式。与传统的命令式 UI 不同,声明式范式让开发者只需描述"界面应该是什么样子",而不需要关心"如何一步步去更新界面"。框架内部通过状态管理系统自动追踪数据变化,并在数据变更时高效地重新渲染受影响的组件区域。

在本应用中,整体架构围绕一个主入口组件(@Entry 装饰的 Index)展开。主组件负责管理全局状态、Tab 切换逻辑、弹窗显示控制以及事件分发。七个功能子组件(首页、漫展、手办、谷子、Cos、应援、我的)各自封装独立的界面逻辑,通过回调函数与主组件通信。这种"父子组件 + 回调通信"的模式是鸿蒙声明式开发中最常见、也最稳定的架构选型。

声明式 UI 的核心思想是"数据驱动视图"。开发者只需关注数据状态的变化,UI 的更新由框架自动完成。这种模式大幅减少了手动 DOM 操作带来的复杂性和 bug 隐患。

下面的 Mermaid 流程图展示了整个应用从启动到渲染、再到用户交互的完整数据流转过程:

0

1

2

3

4

5

6

应用启动 @Entry

Index 主组件初始化

初始化全局 @State 状态变量

根据 curTab 渲染对应子组件

当前 Tab 编号

HomeTab 首页

ExpoTab 漫展

FigureTab 手办

GoodsTab 谷子

CosTab Cos区

SupportTab 应援

MineTab 我的

用户点击交互

触发回调函数

更新 @State 变量

框架自动重新渲染

是否需要弹窗?

显示对应 Modal 弹窗

更新当前 Tab 内容

在这里插入图片描述

从流程图可以看出,整个应用的核心运行机制是一个"状态变更 -> 自动渲染 -> 用户交互 -> 状态再变更"的闭环。所有的 UI 变化都由 @State 装饰的状态变量驱动,开发者不需要手动调用任何刷新方法。

二、类型定义层:接口驱动的数据建模

2.1 核心业务接口定义

在任何严肃的应用开发中,类型安全都是第一道防线。ArkTS 继承了 TypeScript 的接口(interface)机制,允许开发者为每一种业务实体定义精确的数据形状。

interface ExpoItem {
  id: number
  title: string
  city: string
  date: string
  venue: string
  price: number
  tag: string
  hot: number
}

interface FigureItem {
  id: number
  name: string
  series: string
  price: number
  oldPrice: number
  stock: number
  tag: string
}

这里定义了 ExpoItem(漫展项目)和 FigureItem(手办项目)两个核心接口。ExpoItem 包含了漫展标题、城市、日期、场馆、票价、标签和热度值等字段,精确描述了一个漫展活动所需的全部信息。FigureItem 则涵盖了手办名称、系列、现价、原价、库存和标签,支持价格对比和库存展示功能。

接口定义的好处是多方面的。首先,它提供了编译期的类型检查,当开发者在赋值时写错了字段名或传入了错误类型的数据,编译器会立即报错。其次,接口本身就是一种文档,其他开发者阅读代码时可以通过接口定义快速理解某个数据结构包含哪些信息。最后,在使用 ForEach 渲染列表时,接口类型确保了模板中访问的每个属性都是安全的,不会出现运行时未定义错误。

interface GoodsItem {
  id: number
  name: string
  type: string
  price: number
  rarity: string
  series: string
}

interface CosWork {
  id: number
  name: string
  coser: string
  series: string
  likes: number
  votes: number
  tag: string
}

继续看 GoodsItemCosWork 两个接口。GoodsItem 描述的是"谷子"(周边商品),其中 rarity 字段表示稀有度(金、银、铜),这在后续的渲染中会通过颜色函数映射为不同的视觉标识。CosWork 描述的是 Cosplay 作品,包含 Coser 名字、点赞数和投票数,支持社交互动场景。

interface SupportEvent {
  id: number
  title: string
  target: number
  done: number
  deadline: string
  reward: string
  icon: string
}

interface TopicItem {
  id: number
  title: string
  hot: string
  tag: string
}

interface FavItem {
  id: number
  name: string
  type: string
  date: string
}

interface OrderItem {
  id: number
  title: string
  date: string
  amount: number
  status: string
  type: string
}

interface MonthSlot {
  month: string
  label: string
  count: number
}

在这里插入图片描述

最后这一组接口覆盖了应援活动(SupportEvent,含目标和已筹金额、截止日期、奖励)、话题(TopicItem)、收藏(FavItem)、订单(OrderItem,含金额和状态)以及月份排期(MonthSlot)。每个接口都严格对应一个业务领域,字段命名清晰且类型明确。

在 ArkTS 中,接口(interface)不仅用于约束对象字面量的形状,更在组件的属性传递、ForEach 的键值生成、以及 @State 变量的类型标注中起到关键作用。良好的接口设计是构建可维护鸿蒙应用的基础。

三、全局静态数据层

3.1 漫展与手办数据

本应用采用了"全局写死数据"的策略,即所有展示数据以常量数组的形式定义在文件顶层。这种做法在原型开发、UI 展示和功能验证阶段非常实用。

const EXPO_LIST: ExpoItem[] = [
  { id: 1, title: '春日次元祭', city: '上海', date: '2026-09-12', venue: '国家会展中心', price: 128, tag: '热门', hot: 98 },
  { id: 2, title: 'ACGN 夏日盛典', city: '广州', date: '2026-08-30', venue: '琶洲展馆', price: 98, tag: '即将开票', hot: 92 },
  { id: 3, title: '国漫之光巡展', city: '北京', date: '2026-10-01', venue: '首钢园', price: 158, tag: '十一档', hot: 96 },
  { id: 4, title: '谷子节 2026', city: '杭州', date: '2026-09-20', venue: '白马湖', price: 88, tag: '新品首发', hot: 89 },
  { id: 5, title: '手办嘉年华', city: '成都', date: '2026-10-24', venue: '世纪城', price: 118, tag: '限定款', hot: 85 },
  { id: 6, title: '虚拟偶像演唱会', city: '深圳', date: '2026-11-07', venue: '春茧体育馆', price: 288, tag: 'VIP', hot: 99 },
  { id: 7, title: '同人创作市集', city: '武汉', date: '2026-09-05', venue: '国博中心', price: 68, tag: '自由行', hot: 80 },
  { id: 8, title: '二次元音乐节', city: '南京', date: '2026-10-17', venue: '奥体中心', price: 198, tag: '嘉宾公布', hot: 88 }
]

EXPO_LIST 是漫展数据的核心数组,包含 8 条漫展记录,每条记录覆盖了全国不同城市的漫展信息。注意到每条数据都严格遵守 ExpoItem 接口定义,字段一一对应。const 关键字确保了这个数组引用不会被重新赋值,虽然数组内部的元素本身在 ArkTS 中仍可修改,但在本应用中它们作为只读展示数据使用。

const FIGURE_LIST: FigureItem[] = [
  { id: 1, name: '星夜少女·初音', series: '虚拟歌姬', price: 1299, oldPrice: 1499, stock: 12, tag: '预售' },
  { id: 2, name: '焰之战士·焰', series: '热血番', price: 899, oldPrice: 999, stock: 8, tag: '热卖' },
  { id: 3, name: '深海姬·琳', series: '幻海物语', price: 759, oldPrice: 899, stock: 5, tag: '限定' },
  { id: 4, name: '圣剑骑士', series: '王国物语', price: 1099, oldPrice: 1299, stock: 3, tag: '热卖' },
  { id: 5, name: '樱色魔法使', series: '魔法学园', price: 649, oldPrice: 799, stock: 15, tag: '现货' },
  { id: 6, name: '机械先锋', series: '星际战甲', price: 1599, oldPrice: 1899, stock: 4, tag: '预售' },
  { id: 7, name: '黑猫侦探', series: '都市奇谭', price: 529, oldPrice: 699, stock: 9, tag: '现货' },
  { id: 8, name: '月下舞姬', series: '和风物语', price: 949, oldPrice: 1099, stock: 6, tag: '热卖' },
  { id: 9, name: '像素勇者', series: '复古游戏', price: 429, oldPrice: 529, stock: 18, tag: '现货' },
  { id: 10, name: '冰霜女王', series: '冰雪纪元', price: 1399, oldPrice: 1699, stock: 2, tag: '限定' },
  { id: 11, name: '街头涂鸦', series: '潮玩街区', price: 499, oldPrice: 599, stock: 10, tag: '热卖' },
  { id: 12, name: '云端少女', series: '幻想天空', price: 699, oldPrice: 849, stock: 7, tag: '现货' }
]

在这里插入图片描述

FIGURE_LIST 包含 12 条手办数据。每条记录都有现价和原价两个字段,用于在卡片中展示划线原价和折扣现价的对比效果。stock 字段表示库存数量,在后续的详情弹窗中会展示。tag 字段(预售、热卖、限定、现货)则在卡片上以彩色标签呈现。

这种将全部业务数据集中定义的方式有明显的工程优势:数据与视图分离,修改数据不需要翻找组件代码;便于统一管理和审查;在迁移到真实接口时,只需替换数据来源,组件代码几乎不用改动。

3.2 谷子、Cos 与应援数据

const GOODS_LIST: GoodsItem[] = [
  { id: 1, name: '星夜徽章·吧唧', type: '吧唧', price: 35, rarity: '银', series: '虚拟歌姬' },
  { id: 2, name: '焰之战士立牌', type: '立牌', price: 59, rarity: '金', series: '热血番' },
  { id: 3, name: '深海姬亚克力挂件', type: '挂件', price: 29, rarity: '银', series: '幻海物语' },
  { id: 4, name: '圣剑骑士色纸', type: '色纸', price: 19, rarity: '铜', series: '王国物语' },
  { id: 5, name: '樱色Q版吧唧套装', type: '吧唧', price: 49, rarity: '金', series: '魔法学园' },
  { id: 6, name: '机械先锋胸章', type: '胸章', price: 25, rarity: '银', series: '星际战甲' },
  { id: 7, name: '黑猫毛绒挂件', type: '挂件', price: 45, rarity: '金', series: '都市奇谭' },
  { id: 8, name: '月下舞姬团扇', type: '周边', price: 39, rarity: '铜', series: '和风物语' },
  { id: 9, name: '像素勇者贴纸包', type: '贴纸', price: 15, rarity: '铜', series: '复古游戏' },
  { id: 10, name: '冰霜女王立牌', type: '立牌', price: 79, rarity: '金', series: '冰雪纪元' },
  { id: 11, name: '街头涂鸦帆布袋', type: '周边', price: 69, rarity: '银', series: '潮玩街区' },
  { id: 12, name: '云端少女明信片', type: '明信片', price: 12, rarity: '铜', series: '幻想天空' }
]

在这里插入图片描述

GOODS_LIST 是谷子(周边商品)数据集,共 12 条。每条记录都有 rarity(稀有度)字段,取值为"金"、“银"或"铜”。这个字段在渲染时会通过 getRarityColor 函数映射为不同的颜色——金色对应橙黄色、银色对应灰蓝色、铜色对应棕色——从而在视觉上直观区分商品的稀有等级。

const COS_LIST: CosWork[] = [
  { id: 1, name: '星夜少女 cosplay', coser: '小夜', series: '虚拟歌姬', likes: 3280, votes: 285, tag: '正片' },
  { id: 2, name: '焰之战士·焰', coser: '阿泽', series: '热血番', likes: 2960, votes: 240, tag: '正片' },
  { id: 3, name: '深海姬·琳', coser: '琳琳', series: '幻海物语', likes: 4120, votes: 356, tag: '正片' },
  { id: 4, name: '圣剑骑士', coser: '白羽', series: '王国物语', likes: 1880, votes: 152, tag: '舞台' },
  { id: 5, name: '樱色魔法使', coser: '小樱酱', series: '魔法学园', likes: 3560, votes: 298, tag: '正片' },
  { id: 6, name: '机械先锋', coser: '老K', series: '星际战甲', likes: 2200, votes: 175, tag: '机甲' },
  { id: 7, name: '黑猫侦探', coser: '墨瞳', series: '都市奇谭', likes: 2680, votes: 210, tag: '正片' },
  { id: 8, name: '月下舞姬', coser: '初雪', series: '和风物语', likes: 3120, votes: 265, tag: '舞蹈' },
  { id: 9, name: '像素勇者', coser: '像素君', series: '复古游戏', likes: 1540, votes: 120, tag: '趣味' },
  { id: 10, name: '冰霜女王', coser: '霜儿', series: '冰雪纪元', likes: 3890, votes: 320, tag: '特效' }
]

COS_LIST 包含 10 条 Cosplay 作品数据。likes 字段的数值较大(千级别),在界面上会通过 formatLikes 函数格式化为"万"为单位的简写形式,例如 4120 会显示为"0.4w"。votes 字段用于投票榜的展示和排序。

const SUPPORT_LIST: SupportEvent[] = [
  { id: 1, title: '星夜生日应援', target: 5000, done: 4200, deadline: '2026-09-30', reward: '限定签名照', icon: '🎂' },
  { id: 2, title: '新专辑打榜计划', target: 3000, done: 2100, deadline: '2026-08-29', reward: '电子徽章', icon: '🎵' },
  { id: 3, title: '商圈大屏投放', target: 8000, done: 5600, deadline: '2026-10-15', reward: '署名名单', icon: '🖥' },
  { id: 4, title: '公益应援捐赠', target: 10000, done: 7800, deadline: '2026-12-01', reward: '公益证书', icon: '💝' },
  { id: 5, title: '地铁灯箱应援', target: 6000, done: 3300, deadline: '2026-11-20', reward: '灯箱合影', icon: '🚇' },
  { id: 6, title: '生日蛋糕众筹', target: 2000, done: 1950, deadline: '2026-09-10', reward: '蛋糕周边', icon: '🎂' },
  { id: 7, title: '演唱会花墙计划', target: 15000, done: 9200, deadline: '2026-11-07', reward: '花墙明信片', icon: '🌸' },
  { id: 8, title: '应援服定制', target: 4000, done: 2800, deadline: '2026-10-30', reward: '同款应援服', icon: '👕' }
]

SUPPORT_LIST 是应援活动数据,每条记录有 target(目标金额)和 done(已筹金额),这两个字段用于计算进度条宽度百分比。icon 字段使用 Emoji,在时间轴列表中作为视觉锚点。reward 字段描述参与应援可获得的奖励,激励用户参与。

值得注意的是,本应用将所有数据定义为全局常量,而非放在组件内部。这种设计使得数据可以被子组件直接引用(通过辅助函数),避免了在组件树中层层传递数据的复杂度。在实际工程中,当数据来源切换为网络接口时,只需将这些常量替换为异步获取的响应数据即可。

3.3 辅助数据与投票趋势

const VOTE_DAYS: number[] = [120, 180, 150, 240, 300, 260, 356]

在这里插入图片描述

VOTE_DAYS 是一周七天的投票数据数组,纯数字类型。在 CosTab 中,这些数据被渲染为一个简易的柱状图,每个值通过 getBarHeight 函数转换为柱状高度。最后一个值(356,对应周日)用高亮色标记,突出周末的投票高峰。

四、全局辅助函数层

4.1 数据分列函数群

本应用的一个显著设计特征是:大量列表采用了"双列瀑布流"布局。为实现这一效果,作者为每种数据类型都编写了配对的"取奇数行"和"取偶数行"两个函数。

function getExpoRows(): ExpoItem[] {
  return [EXPO_LIST[0], EXPO_LIST[2], EXPO_LIST[4], EXPO_LIST[6]];
}

function getExpoRows2(): ExpoItem[] {
  return [EXPO_LIST[1], EXPO_LIST[3], EXPO_LIST[5], EXPO_LIST[7]];
}

getExpoRows 返回索引为 0、2、4、6 的元素(即第一、三、五、七条),getExpoRows2 返回索引为 1、3、5、7 的元素。在 ExpoTab 中,这两组数据分别渲染到左右两列 Column 中,形成交错排列的双列卡片效果。

这种分列策略虽然简单,但效果显著——它让列表在竖向滚动时呈现"瀑布流"的视觉感受,而不是单调的线性堆叠。在手办、谷子、Cos、应援和收藏等多个 Tab 中,都采用了相同的分列模式。

function getFigureRows(): FigureItem[] {
  return [FIGURE_LIST[0], FIGURE_LIST[2], FIGURE_LIST[4], FIGURE_LIST[6], FIGURE_LIST[8], FIGURE_LIST[10]];
}

function getFigureRows2(): FigureItem[] {
  return [FIGURE_LIST[1], FIGURE_LIST[3], FIGURE_LIST[5], FIGURE_LIST[7], FIGURE_LIST[9], FIGURE_LIST[11]];
}

手办的分列函数与漫展类似,但因为手办数据有 12 条,所以每列各分到 6 条。这体现了分列函数的灵活性——它们根据数据的实际数量灵活分配,不需要关心具体的渲染逻辑。

4.2 颜色与格式映射函数

function getRarityColor(r: string): string {
  if (r === '金') {
    return '#F9A825';
  }
  if (r === '银') {
    return '#90A4AE';
  }
  return '#A1887F';
}

function getStatusColor(s: string): string {
  if (s === '已完成') {
    return '#43A047';
  }
  if (s === '已发货') {
    return '#1E88E5';
  }
  if (s === '已取消') {
    return '#BDBDBD';
  }
  return '#F57C00';
}

getRarityColor 将稀有度文字映射为十六进制颜色值:金色为暖橙黄、银色为冷灰蓝、铜色为深棕。getStatusColor 将订单状态映射为语义化颜色:已完成用绿色、已发货用蓝色、已取消用灰色、其他状态用橙色。

这种"文字到颜色"的映射函数是 UI 开发中的常见模式。它将视觉决策集中在一个函数中,当需要调整配色方案时,只需修改这一处即可全局生效,而不需要翻遍所有组件代码寻找散落的颜色值。

function getBarHeight(v: number): number {
  return 24 + v * 0.12;
}

function formatLikes(n: number): string {
  if (n >= 10000) {
    return String(Math.floor(n / 1000) / 10) + 'w';
  }
  return String(n);
}

function getProgressWidth(ev: SupportEvent): string {
  let p: number = ev.done * 100 / ev.target;
  if (p > 100) {
    p = 100;
  }
  return String(Math.floor(p)) + '%';
}

getBarHeight 是柱状图的高度计算函数,基数为 24,加上投票值乘以 0.12 的系数,确保柱状高度与数据值成正比且不会过小或过大。formatLikes 将大数字格式化为"万"为单位的简写,如 4120 变为"0.4w",这在社交场景中是常见的数字简化策略。getProgressWidth 计算应援进度百分比,特别处理了超过 100% 的情况(截断为 100%),返回带百分号的字符串用于设置进度条宽度。

在鸿蒙的声明式 UI 中,函数可以直接在 build() 方法内被调用来计算样式值。例如 .height(getBarHeight(v)).width(getProgressWidth(ev)),框架会在每次渲染时自动调用这些函数获取最新的计算结果。这种"函数式样式"的能力让开发者可以将复杂的计算逻辑抽取为独立函数,保持模板代码的简洁性。

4.3 选项配置函数群

function getTypeTags(): string[] {
  return ['吧唧', '立牌', '挂件', '色纸', '贴纸', '周边'];
}

function getSeriesOptions(): string[] {
  return ['虚拟歌姬', '热血番', '幻海物语', '王国物语', '魔法学园', '星际战甲', '冰雪纪元'];
}

function getPriceOptions(): number[] {
  return [499, 699, 899, 1299, 1599];
}

function getStockOptions(): number[] {
  return [3, 5, 8, 12, 20];
}

function getNameOptions(): string[] {
  return ['新作·黎明', '幻影·夜羽', '星尘·洛', '雷鸣·赤焰', '花语·铃兰'];
}

这组函数返回各种表单选项数据,用于新增手办和编辑手办弹窗中的选择器。getTypeTags 返回谷子分类标签,getSeriesOptions 返回系列选项,getPriceOptionsgetStockOptions 返回预设的价格和库存档位,getNameOptions 返回候选手办名称。

将这些选项封装为函数而非直接内联在组件中,有两个好处:一是复用性(同一组选项可能在多个弹窗中使用),二是可测试性(函数可以被独立调用和验证)。在实际工程中,这些选项通常来自后端接口的字典数据,使用函数封装也方便未来替换数据来源。

五、首页 Tab 组件:HomeTab 深度解析

5.1 组件声明与回调接口

@Component
struct HomeTab {
  onOpenExpo: (id: number) => void = () => {
  }
  onOpenFigure: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {

HomeTab 使用 @Component 装饰器声明为一个自定义组件。@Component 是 ArkUI 中最基本的组件装饰器,它告诉编译器这是一个可复用的 UI 单元。组件内部定义了三个回调属性:onOpenExpoonOpenFigureonToast,它们的默认值都是空函数。

这种"回调属性"模式是鸿蒙子组件向父组件通信的标准方式。子组件不直接修改父组件的状态(它无法访问),而是在用户交互发生时调用传入的回调函数,由父组件决定如何响应。这保证了数据流的单向性——状态从父流向子,事件从子回传给父。

@Component 装饰器不仅标记了一个结构体为 UI 组件,还使其具备了状态管理、生命周期回调和 build() 方法渲染能力。每个 @Component 组件都是独立的可复用单元,可以在多个父组件中重复使用。

5.2 横幅轮播区

      Scroll() {
        Row() {
          ForEach(getHotExpos(), (ex: ExpoItem) => {
            Column() {
              Text('🎪')
                .fontSize(40)
              Text(ex.title)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .margin({ top: 6 })
              Text(ex.city + ' · ' + ex.date)
                .fontSize(11)
                .fontColor('#F3E5F5')
                .margin({ top: 4 })
              Text('¥' + String(ex.price) + ' 起')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD54F')
                .margin({ top: 6 })
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .backgroundColor('rgba(0,0,0,0.25)')
                .borderRadius(10)
            }
            .width(240)
            .height(150)
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
            .linearGradient({ angle: 135, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
            .borderRadius(16)
            .margin({ right: 10 })
            .shadow({ radius: 8, color: 'rgba(142,36,170,0.25)', offsetY: 4 })
            .onClick(() => {
              this.onOpenExpo(ex.id);
            })
          }, (ex: ExpoItem) => String(ex.id))
        }
        .padding({ left: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .margin({ top: 12 })

这是首页横幅区域的完整代码。外层是一个横向滚动的 Scroll 组件,设置 scrollable(ScrollDirection.Horizontal) 使其支持水平滑动,scrollBar(BarState.Off) 隐藏滚动条以保持视觉干净。

内层的 Row 容器通过 ForEach 遍历 getHotExpos() 返回的热门漫展数据。ForEach 是鸿蒙声明式 UI 中的核心列表渲染指令,它接受三个参数:数据源、子项生成函数和键值生成函数。键值函数 (ex: ExpoItem) => String(ex.id) 使用漫展 ID 作为唯一标识,确保列表在更新时能正确进行 diff 操作。

每个横幅卡片是一个 Column 容器,使用 linearGradient 设置从紫色到玫红色的 135 度线性渐变背景。FlexAlign.Center 使子元素在主轴(纵向)上居中,HorizontalAlign.Center 使子元素在交叉轴(横向)上居中。shadow 属性添加了带透明度的紫色阴影,增强了卡片的立体浮起感。

在鸿蒙的 ForEach 中,键值生成函数(第三个参数)至关重要。它帮助框架识别哪些列表项是新增的、哪些是移除的、哪些是位置变化的,从而实现高效的增量更新。使用业务 ID 作为键值是最可靠的做法,避免使用数组索引(index)作为键值,否则在列表顺序变化时可能导致渲染异常。

5.3 宫格快捷入口

          Row() {
            ForEach(getQuickEntries(), (mi: string, miIdx: number) => {
              Column() {
                Text(getQuickIcon(miIdx))
                  .fontSize(24)
                Text(mi)
                  .fontSize(11)
                  .fontColor('#616161')
                  .margin({ top: 6 })
              }
              .layoutWeight(1)
              .padding({ top: 12, bottom: 12 })
              .onClick(() => {
                this.onToast(mi + ' 功能开发中');
              })
            }, (mi: string) => mi)
          }
          .width('100%')
          .padding({ left: 8, right: 8, top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ top: 12, left: 16, right: 16 })

宫格入口区域使用 Row 作为水平容器,内部 8 个快捷入口通过 ForEach 渲染。每个入口是一个 Column,包含一个 Emoji 图标和一行文字标签。关键点是 .layoutWeight(1) 的使用——它让每个入口在 Row 中等分宽度,8 个入口各占八分之一,形成均匀的宫格效果。

layoutWeight 是鸿蒙布局中非常重要的属性。在 RowColumn 等线性容器中,子元素可以通过 layoutWeight 声明自己应占多少份的剩余空间。所有设置了 layoutWeight 的子元素会按权重比例瓜分容器扣除固定尺寸后的剩余空间。这比使用百分比宽度更灵活,因为可以混合固定宽度和弹性宽度的子元素。

点击事件调用 this.onToast(mi + ' 功能开发中'),将"功能开发中"的消息通过回调传给父组件,由父组件显示 Toast 提示。这种做法保持了子组件的"无状态"特性——它不负责显示 Toast,只负责通知父组件发生了什么。

5.4 热门手办横滑区与话题列表

          Row() {
            Text('🔥 热门手办')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
            Text('')
              .layoutWeight(1)
            Text('更多 >')
              .fontSize(11)
              .fontColor('#9E9E9E')
              .onClick(() => {
                this.onToast('前往手办专区');
              })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 18 })

这是一个典型的"区域标题栏"布局模式:左侧标题文字、中间用 Text('').layoutWeight(1) 作为弹性占位将右侧的"更多"文字推到最右端。这个模式在本应用中被大量复用——几乎所有带"更多"入口的区域都采用了这种结构。

中间的 Text('') 是一个空文本,它本身不显示任何内容,但通过 layoutWeight(1) 占据了 Row 中的所有剩余空间,从而将左右两部分分隔到两端。这是鸿蒙布局中实现"两端对齐"最简洁的方式。

          Column() {
            ForEach(getHomeTopics(), (tp: TopicItem) => {
              Row() {
                Text('·')
                  .fontSize(16)
                  .fontColor('#D81B60')
                Column() {
                  Text(tp.title)
                    .fontSize(13)
                    .fontColor('#424242')
                    .maxLines(1)
                  Text(tp.hot + ' 热度 · ' + tp.tag)
                    .fontSize(11)
                    .fontColor('#BDBDBD')
                    .margin({ top: 3 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 8 })
                Text('讨论')
                  .fontSize(10)
                  .fontColor('#D81B60')
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor('#FCE4EC')
                  .borderRadius(10)
                  .onClick(() => {
                    this.onToast('进入话题「' + tp.title + '」');
                  })
              }
              .width('100%')
              .padding({ top: 10, bottom: 10 })
              .borderRadius(10)
            }, (tp: TopicItem) => String(tp.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 10, bottom: 16 })

话题列表使用 Column 作为纵向容器,每个话题项是一个 RowmaxLines(1) 限制标题文字最多显示一行,防止长标题撑破布局。右侧的"讨论"标签使用浅粉色背景和玫红色文字,是一个可点击的标签按钮。

六、漫展 Tab 组件:ExpoTab 深度解析

6.1 月份排期横滑条

@Component
struct ExpoTab {
  onOpenExpo: (id: number) => void = () => {
  }
  onBook: (id: number) => void = () => {
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          Scroll() {
            Row() {
              ForEach(getMonthList(), (ms: MonthSlot) => {
                Column() {
                  Text(ms.month)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFFFFF')
                  Text(ms.label)
                    .fontSize(10)
                    .fontColor('#FFD54F')
                    .margin({ top: 3 })
                  Text(String(ms.count) + ' 场')
                    .fontSize(10)
                    .fontColor('#F3E5F5')
                    .margin({ top: 2 })
                }
                .width(80)
                .padding({ top: 12, bottom: 12 })
                .linearGradient({ angle: 180, colors: [['#6A1B9A', 0], ['#AD1457', 1]] })
                .borderRadius(12)
                .margin({ right: 10 })
              }, (ms: MonthSlot) => ms.month)
            }
            .padding({ left: 16 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .margin({ top: 12 })

ExpoTab 的顶部是月份排期横滑条。ForEach 遍历 getMonthList() 返回的 6 个月份数据。每个月份卡片是一个固定宽度 80 的 Column,使用 180 度的纵向线性渐变(从深紫到深玫红)。

Column 内部从上到下依次显示月份(如"8月")、标签(如"夏日盛典")和场次数(如"2 场")。三行文字通过不同的颜色区分层次:月份用白色加粗突出、标签用金色点缀、场次数用浅紫色弱化。这种颜色层次设计让用户一眼就能扫到关键信息。

鸿蒙的 Scroll 组件既可以纵向滚动也可以横向滚动,通过 scrollable(ScrollDirection.Horizontal)scrollable(ScrollDirection.Vertical) 控制。当需要嵌套滚动时(如外层纵向、内层横向),需要确保滚动方向不冲突,否则可能导致手势冲突。本应用中,外层 Scroll 是纵向的,内层月份条和横幅是横向的,这种嵌套方式在实践中工作良好。

6.2 双列卡片布局与 @Builder 方法

          Row() {
            Column() {
              ForEach(getExpoRows(), (ex: ExpoItem) => {
                this.expoCard(ex)
              }, (ex: ExpoItem) => String(ex.id))
            }
            .layoutWeight(1)
            Column() {
              ForEach(getExpoRows2(), (ex: ExpoItem) => {
                this.expoCard(ex)
              }, (ex: ExpoItem) => String(ex.id))
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
          .alignItems(VerticalAlign.Top)

双列卡片布局的核心结构是一个 Row 包含两个 Column,每个 Column 设置 layoutWeight(1) 平分宽度。左列渲染 getExpoRows() 返回的奇数索引数据,右列渲染 getExpoRows2() 返回的偶数索引数据。

alignItems(VerticalAlign.Top) 确保两列从顶部对齐。这是瀑布流布局的关键设置——如果没有这个属性,两列默认会在中线对齐,导致短的一列下方留出大量空白。

  @Builder
  expoCard(ex: ExpoItem) {
    Column() {
      Text('🎪')
        .fontSize(34)
        .width('100%')
        .height(84)
        .textAlign(TextAlign.Center)
        .backgroundColor('#F3E5F5')
        .borderRadius({ topLeft: 12, topRight: 12 })
      Text(ex.title)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .margin({ top: 8 })
        .maxLines(1)
      Text(ex.city + ' · ' + ex.venue)
        .fontSize(11)
        .fontColor('#9E9E9E')
        .width('100%')
        .margin({ top: 4 })
        .maxLines(1)
      Text(ex.date)
        .fontSize(11)
        .fontColor('#757575')
        .width('100%')
        .margin({ top: 4 })
      Row() {
        Text(ex.tag)
          .fontSize(10)
          .fontColor('#D81B60')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor('#FCE4EC')
          .borderRadius(8)
        Text('')
          .layoutWeight(1)
        Text('¥' + String(ex.price))
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
      }
      .width('100%')
      .margin({ top: 8 })
      Text('立即预约')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 })
        .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
        .borderRadius(8)
        .margin({ top: 10 })
        .onClick(() => {
          this.onBook(ex.id);
        })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .margin({ bottom: 12 })
    .shadow({ radius: 6, color: 'rgba(142,36,170,0.12)', offsetY: 3 })
    .onClick(() => {
      this.onOpenExpo(ex.id);
    })
  }

@Builder 是鸿蒙声明式 UI 中用于定义可复用 UI 片段的装饰器。expoCard 方法接受一个 ExpoItem 参数,返回一段完整的卡片 UI 结构。通过 @Builder,开发者可以将复杂的 UI 片段抽取为独立方法,在 ForEach 中通过 this.expoCard(ex) 调用,避免在模板中内联大量重复代码。

卡片结构从上到下依次为:顶部 Emoji 图标区(浅紫色背景,顶部圆角)、标题(加粗、单行)、城市和场馆(灰色、单行)、日期(中灰)、标签和价格(左右分布)、底部"立即预约"按钮(渐变背景)。

@Builder 方法与普通方法的关键区别在于:@Builder 方法返回的是 UI 描述而非普通值。框架在编译期会将其转换为高效的渲染指令。在 ForEach 中调用 @Builder 方法是鸿蒙中实现列表项复用的推荐做法,它既保持了代码的可读性,又不牺牲渲染性能。

七、手办 Tab 组件:FigureTab 深度解析

7.1 顶部标题栏与新增按钮

@Component
struct FigureTab {
  onOpenFigure: (id: number) => void = () => {
  }
  onAdd: () => void = () => {
  }
  onEdit: (id: number) => void = () => {
  }
  onDel: (id: number) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🧸 手办图鉴')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('+ 新增')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
          .borderRadius(16)
          .onClick(() => {
            this.onAdd();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

FigureTab 提供了四个回调接口:打开详情、新增、编辑和删除。这比 HomeTab 的三个回调更多,反映了手办管理场景的 CRUD(增删改查)需求。顶部标题栏右侧的"新增"按钮使用了渐变背景和胶囊圆角,视觉上像一个行动召唤按钮(Call to Action)。

7.2 手办卡片与操作按钮

  @Builder
  figureCard(fg: FigureItem) {
    Column() {
      Text(getFigureEmoji(fg.series))
        .fontSize(36)
        .width('100%')
        .height(96)
        .textAlign(TextAlign.Center)
        .backgroundColor('#FBE9E7')
        .borderRadius(12)
      Text(fg.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .margin({ top: 8 })
        .maxLines(1)
      Text(fg.series)
        .fontSize(10)
        .fontColor('#9E9E9E')
        .width('100%')
        .margin({ top: 3 })
      Row() {
        Text('¥' + String(fg.price))
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
        Text('¥' + String(fg.oldPrice))
          .fontSize(10)
          .fontColor('#BDBDBD')
          .decoration({ type: TextDecorationType.LineThrough })
          .margin({ left: 6 })
      }
      .width('100%')
      .margin({ top: 6 })

      Row() {
        Text(fg.tag)
          .fontSize(10)
          .fontColor('#8E24AA')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor('#F3E5F5')
          .borderRadius(8)
        Text('')
          .layoutWeight(1)
        Text('✎')
          .fontSize(13)
          .fontColor('#8E24AA')
          .padding(4)
          .onClick(() => {
            this.onEdit(fg.id);
          })
        Text('🗑')
          .fontSize(12)
          .fontColor('#E53935')
          .padding(4)
          .onClick(() => {
            this.onDel(fg.id);
          })
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .margin({ bottom: 12 })
    .shadow({ radius: 6, color: 'rgba(142,36,170,0.10)', offsetY: 3 })
    .onClick(() => {
      this.onOpenFigure(fg.id);
    })
  }

手办卡片比漫展卡片更复杂,包含了一个额外的操作行。价格区域展示了现价和原价的对比,原价使用 TextDecorationType.LineThrough 添加删除线效果。底部操作行包含标签、编辑按钮(✎)和删除按钮(🗑),两个操作按钮通过 onClick 分别触发 onEditonDel 回调。

值得注意的设计细节是:卡片本身的 onClick 触发 onOpenFigure(打开详情),而内部的操作按钮各自有独立的 onClick 事件。鸿蒙的事件传播机制会先触发内层元素的点击事件,如果内层事件没有被消费,才会传播到外层。这确保了点击编辑按钮时不会同时触发打开详情。

八、谷子 Tab 组件:GoodsTab 深度解析

8.1 分类标签条与行式列表

      Row() {
        ForEach(getTypeTags(), (tg: string, ti: number) => {
          Text(tg)
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(ti === 0 ? '#8E24AA' : '#FFFFFF')
            .fontColor(ti === 0 ? '#FFFFFF' : '#616161')
            .borderRadius(12)
            .margin({ right: 8 })
            .onClick(() => {
              this.onToast('查看' + tg + '分类');
            })
        }, (tg: string) => tg)
      }
      .width('100%')
      .padding({ left: 16, top: 10 })

分类标签条使用 ForEach 的第二个参数(索引 ti)来判断当前是否为第一个标签。第一个标签用深紫色背景和白色文字表示"选中状态",其余标签用白色背景和灰色文字表示"未选中状态"。这是一个简化版的单选标签条——实际上它不会真正切换选中状态(点击只是显示 Toast),但视觉上模拟了选中效果。

      Scroll() {
        Column() {
          ForEach(getGoodsRows(), (gd: GoodsItem) => {
            this.goodsRow(gd)
          }, (gd: GoodsItem) => String(gd.id))
          Text('— 已加载 ' + String(GOODS_LIST.length) + ' 件谷子 —')
            .fontSize(10)
            .fontColor('#BDBDBD')
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 4, bottom: 12 })
          ForEach(getGoodsRows2(), (gd: GoodsItem) => {
            this.goodsRow(gd)
          }, (gd: GoodsItem) => String(gd.id))
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 16 })
      }

与漫展和手办的双列布局不同,谷子列表采用了单列行式布局。两组数据(奇数索引和偶数索引)在同一个 Column 中纵向排列,中间插入了一条"已加载 N 件谷子"的分隔提示文字。这种设计既保持了数据分组,又通过分隔文字提供了数据量反馈。

8.2 谷子行卡片

  @Builder
  goodsRow(gd: GoodsItem) {
    Row() {
      Text(getGoodsEmoji(gd.type))
        .fontSize(22)
        .width(44)
        .height(44)
        .textAlign(TextAlign.Center)
        .backgroundColor('#F3E5F5')
        .borderRadius(12)
      Column() {
        Text(gd.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .maxLines(1)
        Text(gd.series + ' · ' + gd.type)
          .fontSize(11)
          .fontColor('#9E9E9E')
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 10 })

      Column() {
        Text(gd.rarity)
          .fontSize(10)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          .backgroundColor(getRarityColor(gd.rarity))
          .borderRadius(8)
        Text('¥' + String(gd.price))
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
          .margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.End)
      .margin({ left: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
    .onClick(() => {
      this.onOpenGoods(gd.id);
    })
  }

谷子行卡片采用了经典的"左图中文右价"三段式布局。左侧是 Emoji 图标(固定 44x44 尺寸),中间是商品名称和系列信息(layoutWeight(1) 弹性占位),右侧是稀有度标签和价格。

稀有度标签的背景颜色通过 getRarityColor(gd.rarity) 动态获取,金色商品显示橙黄色标签、银色商品显示灰蓝色标签。这种动态颜色映射让用户可以在不阅读文字的情况下通过颜色快速识别商品等级。

鸿蒙的 Row 组件默认在主轴(水平方向)上从左到右排列子元素。当需要左侧固定宽度、中间弹性占位、右侧固定宽度的布局时,只需让中间元素设置 layoutWeight(1) 即可。这种三段式布局在列表项设计中极为常见,掌握它可以应对大多数列表场景。

九、Cos Tab 组件:CosTab 深度解析

9.1 柱状图投票榜

          Row() {
            ForEach(getVoteDays(), (v: number, vi: number) => {
              Column() {
                Text(String(v))
                  .fontSize(9)
                  .fontColor('#8E24AA')
                Column() {
                }
                .width(18)
                .height(getBarHeight(v))
                .backgroundColor(vi === 6 ? '#D81B60' : '#BA68C8')
                .borderRadius({ topLeft: 4, topRight: 4 })
                .margin({ top: 2 })
                Text('周' + getWeekName(vi))
                  .fontSize(9)
                  .fontColor('#9E9E9E')
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.End)
              .height(110)
            }, (v: number, vi: number) => String(vi))
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 10 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 10 })

这是本应用中最具特色的可视化元素——一个用纯 ArkUI 组件构建的简易柱状图。ForEach 遍历 getVoteDays() 返回的 7 天投票数据。每个柱子是一个空的 Column() {},其高度通过 getBarHeight(v) 动态计算。

每个柱子外层包裹一个 Column 容器,高度固定为 110,使用 justifyContent(FlexAlign.End) 让柱子在底部对齐——这是柱状图的关键,因为柱状图的柱子需要从底部向上"生长"。FlexAlign.End 让子元素在主轴的末端(即底部)对齐,空柱子就会从底部开始向上占据 getBarHeight(v) 计算出的高度。

第 7 根柱子(vi === 6,即周日)使用高亮的玫红色 #D81B60,其余柱子用浅紫色 #BA68C8。柱子顶部圆角 borderRadius({ topLeft: 4, topRight: 4 }) 让柱子看起来更柔和。

鸿蒙中没有内置的图表组件库,但通过组合 ColumnRowheight 属性,可以轻松构建柱状图、进度条等数据可视化元素。justifyContent(FlexAlign.End) 是实现"底部对齐"的关键属性——FlexAlign 枚举提供了 StartCenterEndSpaceBetweenSpaceAroundSpaceEvenly 等多种主轴对齐方式,是控制线性容器子元素分布的核心机制。

9.2 Cos 作品卡片

  @Builder
  cosCard(cw: CosWork) {
    Row() {
      Text(getCosEmoji(cw.series))
        .fontSize(26)
        .width(64)
        .height(64)
        .textAlign(TextAlign.Center)
        .linearGradient({ angle: 135, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
        .borderRadius(12)
      Column() {
        Text(cw.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .maxLines(1)
        Text('Coser:' + cw.coser + ' · ' + cw.tag)
          .fontSize(11)
          .fontColor('#9E9E9E')
          .margin({ top: 4 })
        Row() {
          Text('❤ ' + formatLikes(cw.likes))
            .fontSize(11)
            .fontColor('#E91E63')
          Text('')
            .layoutWeight(1)
          Text('🏆 ' + String(cw.votes) + ' 票')
            .fontSize(11)
            .fontColor('#8E24AA')
          Text('投票')
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#D81B60')
            .borderRadius(10)
            .margin({ left: 8 })
            .onClick(() => {
              this.onVote(cw.id);
            })
        }
        .width('100%')
        .margin({ top: 6 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
    .onClick(() => {
      this.onOpenCos(cw.id);
    })
  }

Cos 卡片采用了"左图右文"的横向布局。左侧 Emoji 图标区域使用了渐变背景,比谷子卡片的纯色背景更有视觉冲击力。右侧信息区包含作品名、Coser 信息、点赞数和投票数,以及一个独立的投票按钮。

点赞数通过 formatLikes(cw.likes) 格式化,将千级别数字转换为"万"简写。投票按钮设置了 margin({ left: 8 }) 与左侧的票数信息保持间距,同时使用玫红色背景和白色文字形成视觉对比,吸引用户点击。

十、应援 Tab 组件:SupportTab 深度解析

10.1 汇总卡与时间轴布局

          Row() {
            Column() {
              Text('累计应援')
                .fontSize(11)
                .fontColor('#F3E5F5')
              Text('¥1,680')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column() {
              Text('参与活动')
                .fontSize(11)
                .fontColor('#F3E5F5')
              Text('6 场')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column() {
              Text('应援等级')
                .fontSize(11)
                .fontColor('#F3E5F5')
              Text('Lv.3')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD54F')
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ top: 16, bottom: 16 })
          .linearGradient({ angle: 90, colors: [['#6A1B9A', 0], ['#AD1457', 1]] })
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 12 })

汇总卡是一个三等分的统计区域,使用渐变背景。三个统计指标(累计金额、参与活动数、应援等级)各占三分之一宽度。前两个指标用白色文字,第三个指标(等级)用金色文字突出,形成层次差异。

10.2 时间轴行卡片与进度条

  @Builder
  supportRow(ev: SupportEvent) {
    Row() {
      Column() {
        Text(ev.icon)
          .fontSize(16)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .backgroundColor('#F3E5F5')
          .borderRadius(17)
        Text('')
          .width(2)
          .layoutWeight(1)
          .backgroundColor('#E1BEE7')
          .margin({ top: 2 })
      }
      .width(40)
      .alignItems(HorizontalAlign.Center)
      .height(120)

      Column() {
        Row() {
          Text(ev.title)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text('')
            .layoutWeight(1)
          Text(ev.deadline + ' 截止')
            .fontSize(10)
            .fontColor('#9E9E9E')
        }
        .width('100%')

        Column() {
          Column() {
          }
          .width(getProgressWidth(ev))
          .height('100%')
          .backgroundColor('#D81B60')
          .borderRadius(3)
        }
        .width('100%')
        .height(6)
        .backgroundColor('#F5E9EC')
        .borderRadius(3)
        .margin({ top: 8 })

应援行卡片采用了时间轴(Timeline)布局风格。左侧是一个固定宽度 40 的 Column,包含一个圆形图标(borderRadius(17) 形成圆形)和一条连接线(宽度 2 的 Text,通过 layoutWeight(1) 占据剩余高度)。连接线的紫色 #E1BEE7 模拟了时间轴的轴线效果。

进度条是一个嵌套的 Column 结构:外层 Column 是灰色背景的容器(高度 6),内层 Column 是玫红色的填充条,宽度通过 getProgressWidth(ev) 动态计算。这种"容器 + 填充"的双层结构是进度条的经典实现方式。

        Row() {
          Text('已筹 ¥' + String(ev.done) + ' / ¥' + String(ev.target))
            .fontSize(11)
            .fontColor('#8E24AA')
          Text('')
            .layoutWeight(1)
          Text('奖励:' + ev.reward)
            .fontSize(10)
            .fontColor('#9E9E9E')
            .maxLines(1)
        }
        .width('100%')
        .margin({ top: 6 })

        Row() {
          Text('查看详情')
            .fontSize(11)
            .fontColor('#8E24AA')
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .backgroundColor('#F3E5F5')
            .borderRadius(10)
            .onClick(() => {
              this.onOpenSupport(ev.id);
            })
          Text('')
            .layoutWeight(1)
          Text('参与应援')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#D81B60')
            .borderRadius(12)
            .onClick(() => {
              this.onJoin(ev.id);
            })
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding(12)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .margin({ left: 8, bottom: 12 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)
  }

进度条下方是已筹金额和目标金额的对比文字,以及奖励信息。最底部是两个操作按钮:“查看详情”(浅色风格)和"参与应援"(深色高亮风格)。两个按钮通过中间的 Text('').layoutWeight(1) 分隔到两端。

时间轴布局在鸿蒙中的实现要点是:左侧固定宽度的图标列需要设置固定高度(这里是 120),让内部的连接线通过 layoutWeight(1) 自动填充剩余空间。如果左侧列不设置固定高度,layoutWeight 将无法正确计算剩余空间,连接线就不会显示。

十一、我的 Tab 组件:MineTab 深度解析

11.1 个人信息卡

          Row() {
            Text('👾')
              .fontSize(36)
              .width(64)
              .height(64)
              .textAlign(TextAlign.Center)
              .backgroundColor('rgba(255,255,255,0.25)')
              .borderRadius(32)
            Column() {
              Text('次元收藏家·阿离')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('ID:2091 · 漫展 12 场')
                .fontSize(11)
                .fontColor('#F3E5F5')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
            Text('')
              .layoutWeight(1)
            Text('设置')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('rgba(255,255,255,0.2)')
              .borderRadius(10)
              .onClick(() => {
                this.onToast('设置中心');
              })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 18, bottom: 18 })
          .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#C2185B', 1]] })
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 12 })

个人信息卡使用了半透明的白色叠加层来在渐变背景上创造层次。头像区域的 backgroundColor('rgba(255,255,255,0.25)') 是 25% 透明度的白色,设置圆角 32 形成圆形头像效果。设置按钮同样使用半透明白色背景(20% 透明度),在渐变背景上形成"毛玻璃"般的视觉感受。

11.2 统计栏与收藏/订单列表

          Row() {
            Column() {
              Text('8')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#D81B60')
              Text('收藏')
                .fontSize(11)
                .fontColor('#9E9E9E')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column() {
              Text('26')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#D81B60')
              Text('订单')
                .fontSize(11)
                .fontColor('#9E9E9E')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column() {
              Text('16')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#D81B60')
              Text('应援')
                .fontSize(11)
                .fontColor('#9E9E9E')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column() {
              Text('280')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#D81B60')
              Text('积分')
                .fontSize(11)
                .fontColor('#9E9E9E')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }

统计栏采用四等分布局,每个统计项包含一个加粗的数字和一行标签文字。与应援汇总卡不同,这里使用白色背景和玫红色数字,风格更偏简洁。四个指标分别是收藏数、订单数、应援次数和积分,覆盖了用户的核心数据维度。

收藏列表和订单列表都使用了 @Builder 方法抽取行卡片。favRoworderRow 的结构类似——都是"左图标 + 中信息 + 右操作"的三段式布局。订单行卡片右侧额外显示金额和状态,状态颜色通过 getStatusColor 动态映射。

在个人信息卡中,rgba 颜色值的运用非常精妙。rgba(255,255,255,0.25) 表示 25% 不透明度的白色,在深色渐变背景上呈现出柔和的半透明效果。鸿蒙支持所有标准 CSS 颜色格式,包括十六进制(#FFFFFF)、rgbrgba 和命名颜色。善用 rgba 的透明度通道可以创造出丰富的层次感。

十二、Emoji 映射工具函数群

12.1 系列 Emoji 映射

function getFigureEmoji(s: string): string {
  if (s === '虚拟歌姬') {
    return '🎤';
  }
  if (s === '热血番') {
    return '🔥';
  }
  if (s === '幻海物语') {
    return '🌊';
  }
  if (s === '王国物语') {
    return '⚔️';
  }
  if (s === '魔法学园') {
    return '🌸';
  }
  if (s === '星际战甲') {
    return '🤖';
  }
  if (s === '都市奇谭') {
    return '🐱';
  }
  if (s === '和风物语') {
    return '🌙';
  }
  if (s === '复古游戏') {
    return '👾';
  }
  if (s === '冰雪纪元') {
    return '❄️';
  }
  if (s === '潮玩街区') {
    return '🎨';
  }
  return '☁️';
}

getFigureEmoji 是一个纯粹的映射函数,将系列名称映射为对应的 Emoji 图标。每个系列都有视觉上高度契合的 Emoji:虚拟歌姬用麦克风、热血番用火焰、幻海物语用波浪、魔法学园用樱花等。最后的 return '☁️' 是默认值,处理未匹配的情况。

这种"文字到 Emoji"的映射策略在本应用中大量使用,替代了传统的图片资源。使用 Emoji 的优势在于:零网络加载、零存储成本、跨平台一致性、系统级渲染性能优秀。对于原型开发和功能验证阶段,这是极为高效的方案。

12.2 其他 Emoji 映射函数

function getGoodsEmoji(t: string): string {
  if (t === '吧唧') {
    return '📛';
  }
  if (t === '立牌') {
    return '🖼';
  }
  if (t === '挂件') {
    return '🔖';
  }
  if (t === '色纸') {
    return '📄';
  }
  if (t === '贴纸') {
    return '✨';
  }
  return '👜';
}

function getCosEmoji(s: string): string {
  return getFigureEmoji(s);
}

getGoodsEmoji 将谷子品类映射为 Emoji,getCosEmoji 直接复用了 getFigureEmoji,因为 Cos 作品的系列与手办的系列完全一致。这种函数复用体现了 DRY(Don’t Repeat Yourself)原则——当两个函数逻辑完全相同时,直接让一个函数调用另一个,而不是复制粘贴代码。

function formatLikes(n: number): string {
  if (n >= 10000) {
    return String(Math.floor(n / 1000) / 10) + 'w';
  }
  return String(n);
}

function getWeekName(i: number): string {
  if (i === 0) {
    return '一';
  }
  if (i === 1) {
    return '二';
  }
  if (i === 2) {
    return '三';
  }
  if (i === 3) {
    return '四';
  }
  if (i === 4) {
    return '五';
  }
  if (i === 5) {
    return '六';
  }
  return '日';
}

formatLikes 的逻辑值得仔细分析:当点赞数大于等于 10000 时,先除以 1000 再除以 10,等价于除以 10000,然后取整。例如 4120 不满足条件(小于 10000),直接返回"4120";而 10000 则返回"1w"。getWeekName 将索引 0-6 映射为中文"一到日",用于柱状图的 X 轴标签。

在鸿蒙中,全局函数(不依附于任何组件的函数)可以被任何组件的 build() 方法直接调用。这为工具函数、格式化函数和映射函数提供了天然的复用机制。合理地将逻辑抽取为全局函数,可以大幅减少组件内部的代码量,提高可维护性。

十三、主组件 Index:全局状态管理中枢

13.1 状态变量体系

@Entry
@Component
struct Index {
  @State curTab: number = 0
  private tabs1: string[] = ['首页', '漫展', '手办', '谷子']
  private tabs2: string[] = ['cos', '应援', '我的']

  @State showExpoDetail: boolean = false
  @State selExpo: ExpoItem | null = null
  @State showBook: boolean = false
  @State selBookExpo: ExpoItem | null = null
  @State showFigureDetail: boolean = false
  @State selFigure: FigureItem | null = null
  @State showAddFigure: boolean = false
  @State showEditFigure: boolean = false
  @State showDelFigure: boolean = false
  @State showGoodsDetail: boolean = false
  @State selGoods: GoodsItem | null = null
  @State showCosDetail: boolean = false
  @State selCos: CosWork | null = null
  @State showVote: boolean = false
  @State showSupportDetail: boolean = false
  @State selSupport: SupportEvent | null = null
  @State showJoin: boolean = false
  @State showPay: boolean = false
  @State showDelFav: boolean = false
  @State selFav: FavItem | null = null
  @State showOrder: boolean = false
  @State selOrder: OrderItem | null = null

@Entry 装饰器标记 Index 为应用的入口组件,每个页面只能有一个 @Entry 组件。@State 装饰器声明了响应式状态变量——当这些变量的值发生变化时,框架会自动重新渲染依赖这些变量的 UI 部分。

状态变量分为三类。第一类是 Tab 切换控制:curTab 决定当前显示哪个子组件。第二类是弹窗显示控制:showExpoDetailshowBookshowFigureDetail 等布尔值控制各类弹窗的显示和隐藏。第三类是选中数据:selExposelFigureselGoods 等,存储当前弹窗需要展示的选中数据,类型为接口或 null

这种"布尔开关 + 选中数据"的弹窗管理范式在鸿蒙开发中极为常见。每个弹窗都有一对状态变量——一个布尔值控制是否显示,一个数据变量存储弹窗内容。当用户点击某个项目时,先设置选中数据,再将布尔值设为 true,框架检测到状态变化后自动渲染弹窗。

  // 表单状态
  @State fName: string = ''
  @State fSeries: string = '魔法学园'
  @State fPrice: number = 699
  @State fStock: number = 8
  @State bookSlot: string = '09:00-11:00'
  @State bookQuota: number = 1
  @State voteStar: number = 5
  @State joinLevel: number = 50
  @State payLevel: number = 100
  @State toast: string = ''

表单状态变量管理各类弹窗中的表单选择值。fNamefSeriesfPricefStock 是新增/编辑手办表单的字段;bookSlotbookQuota 是门票预约表单的选择项;voteStar 是投票星级;joinLevelpayLevel 是应援和充值的档位选择。toast 变量存储 Toast 提示文字,当非空时显示 Toast 组件。

@State 是鸿蒙状态管理的基础装饰器。它的核心机制是:当被装饰的变量被赋新值时,框架会自动触发该组件的重新渲染。但只有真正读取了该变量的 UI 部分才会被更新,其他部分保持不变。这种"细粒度更新"机制确保了状态变化时只有必要的 UI 区域被重新渲染,性能开销极小。

13.2 build 方法:布局骨架与条件渲染

  build() {
    Column() {
      // 头部
      Row() {
        Column() {
          Text('次元漫展')
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('ACGN 员工文化中心')
            .fontSize(10)
            .fontColor('#F3E5F5')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Text('')
          .layoutWeight(1)
        Text('🔍')
          .fontSize(18)
          .padding(8)
          .onClick(() => {
            this.toast = '搜索功能';
          })
        Text('🛒')
          .fontSize(18)
          .padding(8)
          .onClick(() => {
            this.toast = '购物车(3 件)';
          })
      }
      .width('100%')
      .padding({ left: 16, right: 12, top: 10, bottom: 10 })
      .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#C2185B', 1]] })

build() 方法是每个 @Component 的核心,它返回组件的 UI 描述。Indexbuild() 方法首先渲染一个渐变头部栏,包含应用名称、副标题、搜索图标和购物车图标。头部使用 135 度线性渐变,从深紫色过渡到玫红色。

      // 内容区
      if (this.curTab === 0) {
        HomeTab({
          onOpenExpo: (id: number) => {
            this.openExpo(id);
          },
          onOpenFigure: (id: number) => {
            this.openFigure(id);
          },
          onToast: (msg: string) => {
            this.toast = msg;
          }
        })
      } else if (this.curTab === 1) {
        ExpoTab({
          onOpenExpo: (id: number) => {
            this.openExpo(id);
          },
          onBook: (id: number) => {
            this.selBookExpo = this.findExpo(id);
            this.bookSlot = '09:00-11:00';
            this.bookQuota = 1;
            this.showBook = true;
          }
        })
      } else if (this.curTab === 2) {

内容区使用 if/else if 条件渲染链,根据 curTab 的值渲染对应的子组件。每个子组件通过构造参数传入回调函数,这些回调函数内部修改 Index@State 变量。

在 ArkTS 的条件渲染中,if/else 语句会在条件变化时销毁旧分支的组件并创建新分支的组件。这意味着当用户切换 Tab 时,旧 Tab 的组件会被销毁(释放资源),新 Tab 的组件会被创建。这与某些框架的"隐藏/显示"模式不同——鸿蒙的条件渲染是真正的"创建/销毁"。

      // 底栏两排
      Column() {
        Row() {
          ForEach(this.tabs1, (tb: string, ti: number) => {
            this.bottomTabItem(getTabIcon(tb), tb, ti)
          }, (tb: string, ti: number) => tb + String(ti))
        }
        .width('100%')
        .padding({ top: 6, bottom: 2 })

        Row() {
          ForEach(this.tabs2, (tb: string, ti: number) => {
            this.bottomTabItem(getTabIcon(tb), tb, ti + 4)
          }, (tb: string, ti: number) => tb + String(ti))
        }
        .width('100%')
        .padding({ top: 2, bottom: 6 })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: -2 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F3FA')

底部导航栏分为两排,第一排 4 个 Tab(首页、漫展、手办、谷子),第二排 3 个 Tab(cos、应援、我的)。两排都通过 ForEach 渲染,调用 bottomTabItem 这个 @Builder 方法。第二排传入 ti + 4 作为索引,确保 7 个 Tab 的索引从 0 到 6 连续。底部栏使用了向上偏移的阴影 offsetY: -2,模拟从底部投射的光影效果。

13.3 弹窗条件渲染群

    // ========== 弹框区 ==========
    if (this.showExpoDetail && this.selExpo !== null) {
      this.modalOverlay(() => {
        this.showExpoDetail = false;
      })
      this.expoDetailModal()
    }
    if (this.showBook && this.selBookExpo !== null) {
      this.modalOverlay(() => {
        this.showBook = false;
      })
      this.bookModal()
    }

弹窗区的渲染采用了一个统一模式:每个弹窗由一个 if 条件守护,条件同时检查"显示布尔值"和"选中数据非空"。当条件为真时,先渲染 modalOverlay(半透明遮罩层),再渲染具体的弹窗内容。modalOverlay 接收一个关闭回调,点击遮罩层时将布尔值设为 false 关闭弹窗。

这种"遮罩 + 弹窗"的渲染方式不使用任何鸿蒙的弹窗 API(如 DialogbindSheet),而是纯粹通过条件渲染在页面内绘制。这种方式的优点是弹窗内容可以使用所有 ArkUI 布局能力,不受弹窗组件的布局约束限制。

showXxx && selData !== null

点击遮罩/关闭按钮

提交表单

用户点击卡片

设置 selData

设置 showXxx = true

条件渲染检查

渲染遮罩层

渲染弹窗内容

用户交互

操作类型

设置 showXxx = false

执行业务逻辑

弹窗消失

13.4 Toast 组件

    // Toast
    if (this.toast.length > 0) {
      Column() {
        Text(this.toast)
          .fontSize(12)
          .fontColor('#FFFFFF')
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
      }
      .backgroundColor('rgba(33,33,33,0.85)')
      .borderRadius(18)
      .position({ x: 0, y: '72%' })
      .onAppear(() => {
        setTimeout(() => {
          this.toast = '';
        }, 1600);
      })
    }

Toast 组件的实现非常巧妙。当 this.toast 非空时,渲染一个半透明深灰色的圆角文本框,通过 position({ x: 0, y: '72%' }) 绝对定位到屏幕 72% 高度处。onAppear 生命周期回调在组件出现时触发,通过 setTimeout 在 1600 毫秒后将 toast 清空,Toast 自动消失。

onAppear 是鸿蒙组件的生命周期回调之一,在组件被挂载到组件树后立即调用。利用它来启动定时器是一种常见模式——确保定时器只在组件实际显示后才开始计时。清空 toast 变量后,条件 this.toast.length > 0 变为 false,Toast 组件被自动销毁,定时器也随之失效。

十四、遮罩与底部导航 Builder

14.1 遮罩层

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

modalOverlay 是一个极简的 @Builder 方法。它创建一个全屏的空 Column,背景色为 55% 透明度的黑色,点击时调用传入的 onClose 回调。这个遮罩层覆盖在页面内容之上、弹窗内容之下,阻挡用户与底层页面的交互。

值得注意的是 @Builder 方法可以接收函数类型的参数(onClose: () => void)。这使得遮罩层可以被复用——每次调用时传入不同的关闭逻辑,但 UI 结构完全一致。

14.2 底部 Tab 项

  @Builder
  bottomTabItem(icon: string, label: string, idx: number) {
    Column() {
      Text(icon)
        .fontSize(17)
        .fontColor(this.curTab === idx ? '#C2185B' : '#9E9E9E')
      Text(label)
        .fontSize(10)
        .fontColor(this.curTab === idx ? '#C2185B' : '#9E9E9E')
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .padding({ top: 4, bottom: 2 })
    .onClick(() => {
      this.curTab = idx;
      this.toast = '';
    })
  }

bottomTabItem 接收三个参数:图标、标签和索引。颜色通过 this.curTab === idx 判断——如果当前 Tab 等于该项索引,图标和文字使用高亮玫红色,否则使用灰色。点击时设置 curTab 为该项索引,并清空 Toast。

这个 @Builder 方法中直接访问了 this.curTab,这之所以能工作,是因为 @Builder 方法定义在 Index 组件内部,this 指向 Index 实例。当 curTab 变化时,框架会自动重新调用 bottomTabItem,更新所有 Tab 项的高亮状态。

十五、查找函数群

  findExpo(id: number): ExpoItem | null {
    for (let i = 0; i < EXPO_LIST.length; i++) {
      if (EXPO_LIST[i].id === id) {
        return EXPO_LIST[i];
      }
    }
    return null;
  }

  findFigure(id: number): FigureItem | null {
    for (let i = 0; i < FIGURE_LIST.length; i++) {
      if (FIGURE_LIST[i].id === id) {
        return FIGURE_LIST[i];
      }
    }
    return null;
  }

findExpofindFigurefindGoodsfindCosfindSupportfindFavfindOrder 是一组结构完全一致的查找函数。它们遍历对应的常量数组,通过 ID 匹配返回找到的元素,找不到则返回 null。返回类型使用了联合类型 ExpoItem | null,这在 ArkTS 中表示值可以是 ExpoItem 类型或 null

  openExpo(id: number) {
    this.selExpo = this.findExpo(id);
    this.showExpoDetail = true;
  }

  openFigure(id: number) {
    this.selFigure = this.findFigure(id);
    this.showFigureDetail = true;
  }

openExpoopenFigure 是便捷方法,封装了"查找 + 设置选中数据 + 打开弹窗"的三步操作。子组件通过回调传入 ID,Index 调用这些便捷方法完成弹窗的准备工作。

在实际工程中,查找函数通常会被替换为数据库查询或网络请求。但由于本应用将函数调用与 UI 渲染解耦(查找发生在事件回调中,而非 build() 方法中),切换数据来源时只需修改查找函数的实现,不影响 UI 代码。这正是关注点分离原则的体现。

十六、弹窗组件群:详情类弹窗

16.1 漫展详情弹窗(居中卡片)

  @Builder
  expoDetailModal() {
    Column() {
      Text('🎪')
        .fontSize(44)
        .margin({ top: 18 })
      Text(this.selExpo!.title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 8 })
      Text(this.selExpo!.city + ' · ' + this.selExpo!.venue)
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 4 })
      Row() {
        Text('📅 ' + this.selExpo!.date)
          .fontSize(12)
          .fontColor('#616161')
        Text('')
          .layoutWeight(1)
        Text(this.selExpo!.tag)
          .fontSize(11)
          .fontColor('#D81B60')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('#FCE4EC')
          .borderRadius(10)
      }
      .width('100%')
      .padding({ left: 4, right: 4, top: 10 })

漫展详情弹窗使用居中卡片样式,宽度 82%。内部通过 this.selExpo! 访问选中数据——! 是 ArkTS 的非空断言操作符,告诉编译器"我确定这个值不为 null"。因为在条件渲染中已经检查了 this.selExpo !== null,所以这里使用 ! 是安全的。

弹窗从上到下依次展示:大号 Emoji 图标、漫展标题、城市和场馆、日期和标签、人气热度、票价、描述文字,以及底部的"知道了"和"去预约"两个按钮。信息层级从粗到细,引导用户逐步了解详情。

      Row() {
        Text('知道了')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8E24AA')
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor('#F3E5F5')
          .borderRadius(20)
          .onClick(() => {
            this.showExpoDetail = false;
          })
        Text('')
          .layoutWeight(1)
        Text('去预约')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
          .borderRadius(20)
          .onClick(() => {
            this.selBookExpo = this.selExpo;
            this.bookSlot = '09:00-11:00';
            this.bookQuota = 1;
            this.showExpoDetail = false;
            this.showBook = true;
          })
      }
      .width('100%')
      .margin({ top: 14 })
    }
    .width('82%')
    .padding({ left: 20, right: 20, top: 16, bottom: 20 })
    .backgroundColor('#FFFFFF')
    .borderRadius(18)
    .clip(true)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

底部按钮区有两个操作:“知道了”(关闭弹窗)和"去预约"(关闭当前弹窗并打开预约弹窗)。"去预约"按钮的点击事件展示了弹窗串联的模式:先将 selExpo 赋值给 selBookExpo,初始化表单默认值,关闭当前弹窗(showExpoDetail = false),然后打开预约弹窗(showBook = true)。

transition(TransitionEffect.OPACITY.animation({ duration: 200 })) 为弹窗添加了 200 毫秒的透明度过渡动画。当弹窗通过条件渲染出现或消失时,框架会自动播放这个动画,使弹窗淡入淡出而非突兀地出现/消失。clip(true) 确保弹窗圆角内部的内容不溢出圆角边界。

16.2 门票预约弹窗(底部抽屉)

  @Builder
  bookModal() {
    Column() {
      Row() {
        Text('🎫 预约门票')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showBook = false;
          })
      }
      .width('100%')

      Text(this.selBookExpo!.title + ' · ' + this.selBookExpo!.date)
        .fontSize(13)
        .fontColor('#616161')
        .width('100%')
        .padding({ top: 10, bottom: 10, left: 12, right: 12 })
        .backgroundColor('#F9F5FF')
        .borderRadius(10)
        .margin({ top: 12 })

      Text('选择场次')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getSlotList(), (sl: string) => {
          Text(sl)
            .fontSize(11)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.bookSlot === sl ? '#8E24AA' : '#F5F5F5')
            .fontColor(this.bookSlot === sl ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.bookSlot = sl;
            })
        }, (sl: string) => sl)
      }
      .width('100%')

门票预约弹窗采用了底部抽屉样式(borderRadius({ topLeft: 18, topRight: 18 }) 只设置顶部圆角,constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%)。这种样式模拟了从屏幕底部滑出的抽屉面板,是移动端表单交互的常见模式。

场次选择器是一个使用 ForEach 渲染的水平标签组。通过 this.bookSlot === sl 判断当前选中项,选中的标签使用深紫色背景和白色文字,未选中的使用浅灰色背景。点击标签更新 bookSlot 状态,框架自动重新渲染更新选中样式。

      Row() {
        Column() {
          Text('合计')
            .fontSize(11)
            .fontColor('#9E9E9E')
          Text('¥' + String(this.selBookExpo!.price * this.bookQuota))
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#D81B60')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Text('')
          .layoutWeight(1)
        Text('确认支付')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 26, right: 26, top: 11, bottom: 11 })
          .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
          .borderRadius(22)
          .onClick(() => {
            this.showBook = false;
            this.toast = '预约成功,出票码 2091' + String(this.selBookExpo!.id * 7);
          })
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 24 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .constraintSize({ maxHeight: '80%' })
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

合计金额通过 this.selBookExpo!.price * this.bookQuota 实时计算——当用户改变购票数量时,合计金额会自动更新。这是因为 selBookExpo!.pricebookQuota 都是 @State 变量(或其属性),框架会追踪它们的变化并重新渲染显示金额的 Text 组件。

鸿蒙声明式 UI 的"计算属性"不需要额外的装饰器——任何在 build() 方法中对状态变量的引用都会自动建立依赖关系。当状态变化时,引用该状态的所有 UI 表达式都会被重新计算。这就是为什么 this.selBookExpo!.price * this.bookQuota 能够实时反映最新金额。

十七、弹窗组件群:表单类弹窗

17.1 新增手办弹窗

  @Builder
  addFigureModal() {
    Column() {
      Row() {
        Text('🧸 新增手办')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showAddFigure = false;
          })
      }
      .width('100%')

      Text('手办名称')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14, bottom: 6 })
      Row() {
        ForEach(getNameOptions(), (nm: string) => {
          Text(nm)
            .fontSize(11)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.fName === nm ? '#8E24AA' : '#F5F5F5')
            .fontColor(this.fName === nm ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.fName = nm;
            })
        }, (nm: string) => nm)
      }
      .width('100%')

新增手办弹窗是一个完整的表单,包含四个选择字段:手办名称、所属系列、售价和库存。每个字段都使用 ForEach 渲染一组可选标签,通过状态变量追踪当前选中值。选中项使用深紫色背景,未选中项使用浅灰色背景。

这种"标签选择器"表单模式替代了传统的下拉菜单或输入框,用户通过点击标签直接选择预设值。在移动端,这种交互方式比下拉菜单更直观——所有选项都直接可见,不需要额外的展开操作。

      Text('确认上架')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
        .borderRadius(24)
        .margin({ top: 16 })
        .onClick(() => {
          this.showAddFigure = false;
          this.toast = '「' + this.fName + '」已上架,等待审核';
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 24 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .constraintSize({ maxHeight: '80%' })
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

"确认上架"按钮使用渐变背景和较大的圆角(24),视觉上比弹窗内其他元素更突出。点击后关闭弹窗并显示成功 Toast。注意到 Toast 文字中使用了 this.fName,这确保了 Toast 文字反映用户实际的表单选择。

17.2 编辑手办弹窗

  @Builder
  editFigureModal() {
    Column() {
      Row() {
        Text('✏️ 编辑手办')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showEditFigure = false;
          })
      }
      .width('100%')

      Text(getFigureEmoji(this.selFigure!.series) + ' ' + this.selFigure!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .padding({ top: 10, bottom: 10, left: 12, right: 12 })
        .backgroundColor('#FBF3FF')
        .borderRadius(10)
        .margin({ top: 12 })

编辑弹窗与新增弹窗的结构高度相似,但顶部多了一个信息展示区,显示当前编辑的手办 Emoji 和名称。这个区域使用淡紫色背景 #FBF3FF,与白色卡片背景形成轻微的层次区分。

编辑弹窗中的选中标签使用玫红色 #D81B60 而非新增弹窗的深紫色 #8E24AA,这是一个细微但有意的设计差异——通过颜色区分"新增"和"编辑"两种操作模式。

17.3 删除手办弹窗(深色警示卡)

  @Builder
  delFigureModal() {
    Column() {
      Text('🗑')
        .fontSize(40)
      Text('确认下架?')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 10 })
      Text('「' + this.selFigure!.name + '」下架后将从图鉴移除,不可恢复。')
        .fontSize(12)
        .fontColor('#F3E5F5')
        .textAlign(TextAlign.Center)
        .margin({ top: 8 })
        .lineHeight(18)
      Row() {
        Text('再想想')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#BDBDBD')
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .backgroundColor('rgba(255,255,255,0.12)')
          .borderRadius(20)
          .onClick(() => {
            this.showDelFigure = false;
          })
        Text('')
          .layoutWeight(1)
        Text('确认下架')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .backgroundColor('#E53935')
          .borderRadius(20)
          .onClick(() => {
            this.showDelFigure = false;
            this.toast = '「' + this.selFigure!.name + '」已下架';
          })
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('78%')
    .padding({ top: 24, bottom: 22, left: 20, right: 20 })
    .linearGradient({ angle: 135, colors: [['#37474F', 0], ['#263238', 1]] })
    .borderRadius(18)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

删除弹窗是唯一使用深色背景的弹窗——深灰蓝色渐变 #37474F#263238,传达警示和严肃感。文字使用白色和浅紫色,"确认下架"按钮使用红色 #E53935 背景,与深色卡片形成强烈对比。

这种通过视觉风格传达操作严重性的设计原则很重要:危险操作(删除、下架)应该使用深色或红色基调,让用户在视觉上感受到"这里需要谨慎"。

鸿蒙的 linearGradient 属性支持任意角度的线性渐变。angle: 135 表示从左上角到右下角的对角线渐变。通过组合不同的起始色和终止色,可以创造出从温暖到冷酷、从柔和到强烈的各种视觉情绪。本应用中,紫色系渐变用于常规场景,深灰渐变用于警示场景,形成了完整的视觉情绪体系。

十八、弹窗组件群:票根与交互类弹窗

18.1 谷子详情弹窗(票根样式)

  @Builder
  goodsDetailModal() {
    Column() {
      Row() {
        Column() {
          Text('GACHA')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#8E24AA')
            .letterSpacing(2)
          Text('谷 子 票 根')
            .fontSize(10)
            .fontColor('#BDBDBD')
            .margin({ top: 4 })
            .letterSpacing(1)
        }
        .alignItems(HorizontalAlign.Start)
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(15)
          .fontColor('#9E9E9E')
          .padding(6)
          .onClick(() => {
            this.showGoodsDetail = false;
          })
      }
      .width('100%')

谷子详情弹窗采用了"票根"视觉风格。顶部的"GACHA"和"谷 子 票 根"文字使用了 letterSpacing 属性增加字符间距,模拟票据上的印刷字体效果。letterSpacing(2) 表示每个字符之间额外增加 2vp 的间距,营造出正式、复古的票据感。

弹窗的信息区域使用了表格式的信息展示——“品名”、“品类”、"出货状态"每行一组,左侧标签灰色、右侧值深色,形成清晰的对照关系。这种"键值对"式信息展示在票据和凭证类 UI 中非常常见。

18.2 Cos 详情弹窗(上下拼接卡)

  @Builder
  cosDetailModal() {
    Column() {
      Column() {
        Text(getCosEmoji(this.selCos!.series))
          .fontSize(46)
        Text(this.selCos!.name)
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ top: 8 })
        Text(this.selCos!.series + ' · ' + this.selCos!.tag)
          .fontSize(12)
          .fontColor('#F3E5F5')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 22, bottom: 22 })
      .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#D81B60', 1]] })
      .borderRadius({ topLeft: 18, topRight: 18 })

      Column() {
        Row() {
          Column() {
            Text(formatLikes(this.selCos!.likes))
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E91E63')
            Text('点赞')
              .fontSize(10)
              .fontColor('#9E9E9E')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text(String(this.selCos!.votes))
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E24AA')
            Text('票数')
              .fontSize(10)
              .fontColor('#9E9E9E')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('NO.2')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F57C00')
            Text('本周排名')
              .fontSize(10)
              .fontColor('#9E9E9E')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 14, bottom: 14 })
        .backgroundColor('#F9F5FF')
        .borderRadius(12)

Cos 详情弹窗采用了"上下拼接"的结构——上半部分是渐变背景的头部区域(含 Emoji、名称、系列标签),下半部分是白色背景的信息区。两部分通过 clip(true) 裁剪为一个整体圆角卡片。上半部分设置了顶部圆角 borderRadius({ topLeft: 18, topRight: 18 }),下半部分设置了底部圆角 borderRadius({ bottomLeft: 18, bottomRight: 18 })

这种"拼接卡"设计在鸿蒙中需要特别注意圆角的分配——如果两部分都设置完整圆角,拼接处的内圆角会不匹配。正确的做法是上半部分只设置顶部圆角,下半部分只设置底部圆角,外层容器设置 clip(true) 裁剪溢出。

18.3 投票弹窗(星级选择)

  @Builder
  voteModal() {
    Column() {
      Text('⭐ 为你支持的作品投票')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text(this.selCos!.name + ' · ' + this.selCos!.coser)
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 6 })

      Row() {
        ForEach(getVoteStars(), (st: number) => {
          Text(st <= this.voteStar ? '★' : '☆')
            .fontSize(34)
            .fontColor(st <= this.voteStar ? '#F9A825' : '#E0E0E0')
            .padding({ left: 4, right: 4 })
            .onClick(() => {
              this.voteStar = st;
            })
        }, (st: number) => String(st))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 12 })

      Text(this.voteStar <= 2 ? '不太满意,继续加油' : (this.voteStar <= 4 ? '不错哦,继续支持' : '太棒了,强烈推荐!'))
        .fontSize(12)
        .fontColor('#8E24AA')
        .margin({ top: 6 })

投票弹窗的星级选择器是一个有趣的交互设计。ForEach 遍历 [1, 2, 3, 4, 5] 五个数字,每个数字渲染一个星形字符。通过 st <= this.voteStar 判断当前星级是否小于等于选中值——如果是,显示实心星 和金色 #F9A825;如果不是,显示空心星 和浅灰色 #E0E0E0

点击任意星级设置 this.voteStar 为该值,框架自动重新渲染所有星级的外观。下方还根据 voteStar 的值显示不同的评语文本——1-2 星显示"不太满意"、3-4 星显示"不错哦"、5 星显示"太棒了"。这种"选择即时反馈"的交互模式让用户在投票过程中就能看到评价结果。

justifyContent(FlexAlign.Center) 在星级选择器的 Row 中起到了关键作用——它让 5 颗星在水平方向居中排列。如果没有这个属性,星星会从左侧开始排列,视觉上不够平衡。FlexAlign.CenterFlexAlign 枚举中常用的对齐方式之一,表示子元素在主轴上居中分布。

十九、弹窗组件群:应援与充值类弹窗

19.1 应援详情弹窗

  @Builder
  supportDetailModal() {
    Column() {
      Text(this.selSupport!.icon)
        .fontSize(40)
        .margin({ top: 16 })
      Text(this.selSupport!.title)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 8 })
      Text(this.selSupport!.deadline + ' 截止')
        .fontSize(11)
        .fontColor('#9E9E9E')
        .margin({ top: 4 })

      Column() {
        Column() {
        }
        .width(getProgressWidth(this.selSupport!))
        .height('100%')
        .backgroundColor('#D81B60')
        .borderRadius(3)
      }
      .width('100%')
      .height(8)
      .backgroundColor('#F5E9EC')
      .borderRadius(4)
      .margin({ top: 14 })

      Row() {
        Text('已筹 ¥' + String(this.selSupport!.done))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
        Text('')
          .layoutWeight(1)
        Text('目标 ¥' + String(this.selSupport!.target))
          .fontSize(11)
          .fontColor('#9E9E9E')
      }
      .width('100%')
      .margin({ top: 8 })

应援详情弹窗内嵌了一个进度条,与 SupportTab 中的进度条结构完全一致——外层灰色容器、内层玫红填充。getProgressWidth 函数被复用,确保弹窗和列表中的进度条展示一致。

19.2 参与应援弹窗(奖励结果卡)

  @Builder
  joinModal() {
    Column() {
      Text('🎉')
        .fontSize(42)
        .margin({ top: 16 })
      Text('应援成功!')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 8 })
      Text('感谢你为「' + this.selSupport!.title + '」贡献力量')
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 6 })

      Row() {
        Text('档位')
          .fontSize(12)
          .fontColor('#9E9E9E')
        Text('')
          .layoutWeight(1)
        Text('¥' + String(this.joinLevel))
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
      }
      .width('100%')
      .padding({ top: 10, bottom: 10 })
      Row() {
        Text('解锁奖励')
          .fontSize(12)
          .fontColor('#9E9E9E')
        Text('')
          .layoutWeight(1)
        Text(getJoinReward(this.joinLevel))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8E24AA')
      }
      .width('100%')
      .padding({ top: 10, bottom: 10 })
      .margin({ top: 10 })
      .backgroundColor('#F9F5FF')
      .borderRadius(10)

      Row() {
        ForEach(getSupportLevels(), (lv: number) => {
          Text('¥' + String(lv))
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.joinLevel === lv ? '#D81B60' : '#F5F5F5')
            .fontColor(this.joinLevel === lv ? '#FFFFFF' : '#616161')
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => {
              this.joinLevel = lv;
            })
        }, (lv: number) => String(lv))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 14 })

参与应援弹窗展示了"应援成功"的反馈,同时允许用户选择应援档位。getJoinReward(this.joinLevel) 根据档位返回对应的奖励描述。用户切换档位后,奖励文字和档位金额都会自动更新——这是 @State 驱动 UI 的又一体现。

19.3 充值弹窗

  @Builder
  payModal() {
    Column() {
      Row() {
        Text('💰 充值币')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')
        Text('')
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#9E9E9E')
          .padding(8)
          .onClick(() => {
            this.showPay = false;
          })
      }
      .width('100%')

      Text('当前余额:280 币')
        .fontSize(12)
        .fontColor('#8E24AA')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#F9F5FF')
        .borderRadius(10)
        .margin({ top: 10 })

      Text('选择充值档位')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 12, bottom: 6 })
      Row() {
        ForEach(getPayLevels(), (pl: number, pi: number) => {
          Column() {
            Text('¥' + String(pl))
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.payLevel === pl ? '#FFFFFF' : '#8E24AA')
            Text(getPayGifts()[pi])
              .fontSize(9)
              .fontColor(this.payLevel === pl ? '#F3E5F5' : '#9E9E9E')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(this.payLevel === pl ? '#8E24AA' : '#F5F5F5')
          .borderRadius(12)
          .margin({ right: 8 })
          .alignItems(HorizontalAlign.Center)
          .onClick(() => {
            this.payLevel = pl;
          })
        }, (pl: number, pi: number) => String(pl) + String(pi))
      }
      .width('100%')

      Row() {
        Text('到账')
          .fontSize(12)
          .fontColor('#616161')
        Text('')
          .layoutWeight(1)
        Text(String(this.payLevel * 10) + ' 币 + 赠礼')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
      }
      .width('100%')
      .padding({ top: 12 })

充值弹窗的档位选择器使用了 Column 嵌套 Column 的结构——每个档位是一个 Column,内部显示金额和赠礼文字,选中时整个档位区域变为深紫色背景。到账金额 this.payLevel * 10 实时计算——充值 50 元到账 500 币,充值 500 元到账 5000 币。

ForEach 的键值函数使用了 String(pl) + String(pi),将金额和索引拼接为键值。这是因为金额数组中可能有重复值(虽然当前没有),加上索引可以确保键值的唯一性。

19.4 收藏移出弹窗与订单票根弹窗

  @Builder
  delFavModal() {
    Column() {
      Text('💔')
        .fontSize(34)
      Text('移出收藏?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 8 })
      Text('「' + this.selFav!.name + '」将从收藏夹移除')
        .fontSize(12)
        .fontColor('#9E9E9E')
        .margin({ top: 6 })
      Row() {
        Text('取消')
          .fontSize(12)
          .fontColor('#616161')
          .padding({ left: 18, right: 18, top: 8, bottom: 8 })
          .backgroundColor('#F5F5F5')
          .borderRadius(16)
          .onClick(() => {
            this.showDelFav = false;
          })
        Text('')
          .layoutWeight(1)
        Text('确认移出')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 18, right: 18, top: 8, bottom: 8 })
          .backgroundColor('#E53935')
          .borderRadius(16)
          .onClick(() => {
            this.showDelFav = false;
            this.toast = '已移出收藏';
          })
      }
      .width('100%')
      .margin({ top: 14 })
    }
    .width('74%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
  }

收藏移出弹窗是一个简洁的确认对话框,宽度 74%(比其他弹窗更窄),传达"这是一个简单操作"的视觉暗示。与删除手办弹窗的深色警示不同,收藏移出使用白色背景——因为移出收藏不如删除手办那样"危险",不需要过度的警示。

  @Builder
  orderModal() {
    Column() {
      Column() {
        Text(getOrderEmoji(this.selOrder!.type))
          .fontSize(36)
        Text(this.selOrder!.title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ top: 8 })
        Text('次元漫展 · 订单凭证')
          .fontSize(11)
          .fontColor('#F3E5F5')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 20, bottom: 20 })
      .linearGradient({ angle: 135, colors: [['#6A1B9A', 0], ['#C2185B', 1]] })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Column() {
        Row() {
          Text('订单号')
            .fontSize(11)
            .fontColor('#9E9E9E')
          Text('')
            .layoutWeight(1)
          Text('NO.20910' + String(this.selOrder!.id))
            .fontSize(11)
            .fontColor('#424242')
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })

订单弹窗采用了"竖向票根"样式,与谷子详情的"横向票根"形成呼应。上半部分是渐变背景的头部(含 Emoji、标题、凭证标识),下半部分是白底信息区(含订单号、日期、金额、状态)。每一行信息都使用"左标签右值"的对照格式,状态文字的颜色通过 getStatusColor 动态映射。

二十、全局尾部函数

function getTabIcon(label: string): string {
  if (label === '首页') {
    return '🏠';
  }
  if (label === '漫展') {
    return '🎪';
  }
  if (label === '手办') {
    return '🧸';
  }
  if (label === '谷子') {
    return '🎁';
  }
  if (label === 'cos') {
    return '🎭';
  }
  if (label === '应援') {
    return '📣';
  }
  return '👤';
}

function getJoinReward(lv: number): string {
  if (lv === 10) {
    return '电子应援棒';
  }
  if (lv === 50) {
    return '限定小卡';
  }
  if (lv === 100) {
    return '签名明信片';
  }
  return '线下 VIP 名额';
}

getTabIcon 将 Tab 标签文字映射为 Emoji 图标,getJoinReward 将应援档位映射为奖励描述。这两个函数定义在文件尾部(组件外部),作为全局函数被组件内部调用。

getJoinReward 的映射逻辑与 getSupportRewards() 返回的数组保持一致——10 元对应电子应援棒、50 元对应限定小卡、100 元对应签名明信片、500 元对应线下 VIP 名额。函数式映射比数组索引访问更安全——当传入的值不在预设列表中时,会返回默认值而非 undefined

二十一、核心技术点对比总结

下表对本应用中涉及的核心鸿蒙技术点进行了系统性的对比和归纳:

技术点 装饰器/关键字 核心作用 本应用中的使用场景
入口组件 @Entry 标记应用入口页面,每页仅一个 Index 主组件,管理全局状态和路由
自定义组件 @Component 声明可复用的 UI 组件单元 七个 Tab 子组件 + Index 主组件
响应式状态 @State 变量变化时自动触发 UI 重渲染 Tab 切换、弹窗开关、表单选择、Toast
构建器方法 @Builder 抽取可复用的 UI 片段 所有卡片、行项、弹窗、遮罩、Tab 项
纵向容器 Column 子元素从上到下垂直排列 页面骨架、卡片主体、信息区
横向容器 Row 子元素从左到右水平排列 标题栏、按钮组、标签条、行项
弹性权重 layoutWeight 按比例分配剩余空间 三段式布局、等分布局、占位符
列表渲染 ForEach 遍历数组生成重复 UI 所有列表、标签组、星级、柱状图
滚动容器 Scroll 内容超出屏幕时支持滚动 页面纵向滚动、横幅/月份横向滚动
主轴对齐 justifyContent / FlexAlign 控制子元素在主轴上的分布方式 柱状图底部对齐、星级居中
交叉轴对齐 alignItems / HorizontalAlign 控制子元素在交叉轴上的对齐 文本左对齐/右对齐/居中
线性渐变 linearGradient 创建双色或多色渐变背景 头部、卡片、按钮、弹窗头部
过渡动画 transition / TransitionEffect 组件出现/消失时的动画效果 所有弹窗的淡入淡出动画
条件渲染 if / else if / else 根据条件动态创建或销毁组件 Tab 切换、弹窗显示控制、Toast
文本装饰 decoration / TextDecorationType 文本的删除线、下划线等效果 原价删除线
字间距 letterSpacing 控制字符间的额外间距 票根印刷字体效果
绝对定位 position 将组件定位到指定坐标 Toast 定位到屏幕 72% 处
裁剪 clip 裁剪超出容器边界的内容 弹窗圆角裁剪
阴影 shadow 添加投影效果 卡片立体感、底部栏投影
非空断言 ! 告诉编译器值不为 null 弹窗中访问选中数据
生命周期 onAppear 组件挂载后的回调 Toast 自动消失定时器
最大高度 constraintSize 限制组件的最大尺寸 底部抽屉弹窗高度限制

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============ 类型定义 ============
interface ExpoItem {
  id: number
  title: string
  city: string
  date: string
  venue: string
  price: number
  tag: string
  hot: number
}

interface FigureItem {
  id: number
  name: string
  series: string
  price: number
  oldPrice: number
  stock: number
  tag: string
}

interface GoodsItem {
  id: number
  name: string
  type: string
  price: number
  rarity: string
  series: string
}

interface CosWork {
  id: number
  name: string
  coser: string
  series: string
  likes: number
  votes: number
  tag: string
}

interface SupportEvent {
  id: number
  title: string
  target: number
  done: number
  deadline: string
  reward: string
  icon: string
}

interface TopicItem {
  id: number
  title: string
  hot: string
  tag: string
}

interface FavItem {
  id: number
  name: string
  type: string
  date: string
}

interface OrderItem {
  id: number
  title: string
  date: string
  amount: number
  status: string
  type: string
}

interface MonthSlot {
  month: string
  label: string
  count: number
}

// ============ 全局写死数据 ============
const EXPO_LIST: ExpoItem[] = [
  { id: 1, title: '春日次元祭', city: '上海', date: '2026-09-12', venue: '国家会展中心', price: 128, tag: '热门', hot: 98 },
  { id: 2, title: 'ACGN 夏日盛典', city: '广州', date: '2026-08-30', venue: '琶洲展馆', price: 98, tag: '即将开票', hot: 92 },
  { id: 3, title: '国漫之光巡展', city: '北京', date: '2026-10-01', venue: '首钢园', price: 158, tag: '十一档', hot: 96 },
  { id: 4, title: '谷子节 2026', city: '杭州', date: '2026-09-20', venue: '白马湖', price: 88, tag: '新品首发', hot: 89 },
  { id: 5, title: '手办嘉年华', city: '成都', date: '2026-10-24', venue: '世纪城', price: 118, tag: '限定款', hot: 85 },
  { id: 6, title: '虚拟偶像演唱会', city: '深圳', date: '2026-11-07', venue: '春茧体育馆', price: 288, tag: 'VIP', hot: 99 },
  { id: 7, title: '同人创作市集', city: '武汉', date: '2026-09-05', venue: '国博中心', price: 68, tag: '自由行', hot: 80 },
  { id: 8, title: '二次元音乐节', city: '南京', date: '2026-10-17', venue: '奥体中心', price: 198, tag: '嘉宾公布', hot: 88 }
]

const FIGURE_LIST: FigureItem[] = [
  { id: 1, name: '星夜少女·初音', series: '虚拟歌姬', price: 1299, oldPrice: 1499, stock: 12, tag: '预售' },
  { id: 2, name: '焰之战士·焰', series: '热血番', price: 899, oldPrice: 999, stock: 8, tag: '热卖' },
  { id: 3, name: '深海姬·琳', series: '幻海物语', price: 759, oldPrice: 899, stock: 5, tag: '限定' },
  { id: 4, name: '圣剑骑士', series: '王国物语', price: 1099, oldPrice: 1299, stock: 3, tag: '热卖' },
  { id: 5, name: '樱色魔法使', series: '魔法学园', price: 649, oldPrice: 799, stock: 15, tag: '现货' },
  { id: 6, name: '机械先锋', series: '星际战甲', price: 1599, oldPrice: 1899, stock: 4, tag: '预售' },
  { id: 7, name: '黑猫侦探', series: '都市奇谭', price: 529, oldPrice: 699, stock: 9, tag: '现货' },
  { id: 8, name: '月下舞姬', series: '和风物语', price: 949, oldPrice: 1099, stock: 6, tag: '热卖' },
  { id: 9, name: '像素勇者', series: '复古游戏', price: 429, oldPrice: 529, stock: 18, tag: '现货' },
  { id: 10, name: '冰霜女王', series: '冰雪纪元', price: 1399, oldPrice: 1699, stock: 2, tag: '限定' },
  { id: 11, name: '街头涂鸦', series: '潮玩街区', price: 499, oldPrice: 599, stock: 10, tag: '热卖' },
  { id: 12, name: '云端少女', series: '幻想天空', price: 699, oldPrice: 849, stock: 7, tag: '现货' }
]

const GOODS_LIST: GoodsItem[] = [
  { id: 1, name: '星夜徽章·吧唧', type: '吧唧', price: 35, rarity: '银', series: '虚拟歌姬' },
  { id: 2, name: '焰之战士立牌', type: '立牌', price: 59, rarity: '金', series: '热血番' },
  { id: 3, name: '深海姬亚克力挂件', type: '挂件', price: 29, rarity: '银', series: '幻海物语' },
  { id: 4, name: '圣剑骑士色纸', type: '色纸', price: 19, rarity: '铜', series: '王国物语' },
  { id: 5, name: '樱色Q版吧唧套装', type: '吧唧', price: 49, rarity: '金', series: '魔法学园' },
  { id: 6, name: '机械先锋胸章', type: '胸章', price: 25, rarity: '银', series: '星际战甲' },
  { id: 7, name: '黑猫毛绒挂件', type: '挂件', price: 45, rarity: '金', series: '都市奇谭' },
  { id: 8, name: '月下舞姬团扇', type: '周边', price: 39, rarity: '铜', series: '和风物语' },
  { id: 9, name: '像素勇者贴纸包', type: '贴纸', price: 15, rarity: '铜', series: '复古游戏' },
  { id: 10, name: '冰霜女王立牌', type: '立牌', price: 79, rarity: '金', series: '冰雪纪元' },
  { id: 11, name: '街头涂鸦帆布袋', type: '周边', price: 69, rarity: '银', series: '潮玩街区' },
  { id: 12, name: '云端少女明信片', type: '明信片', price: 12, rarity: '铜', series: '幻想天空' }
]

const COS_LIST: CosWork[] = [
  { id: 1, name: '星夜少女 cosplay', coser: '小夜', series: '虚拟歌姬', likes: 3280, votes: 285, tag: '正片' },
  { id: 2, name: '焰之战士·焰', coser: '阿泽', series: '热血番', likes: 2960, votes: 240, tag: '正片' },
  { id: 3, name: '深海姬·琳', coser: '琳琳', series: '幻海物语', likes: 4120, votes: 356, tag: '正片' },
  { id: 4, name: '圣剑骑士', coser: '白羽', series: '王国物语', likes: 1880, votes: 152, tag: '舞台' },
  { id: 5, name: '樱色魔法使', coser: '小樱酱', series: '魔法学园', likes: 3560, votes: 298, tag: '正片' },
  { id: 6, name: '机械先锋', coser: '老K', series: '星际战甲', likes: 2200, votes: 175, tag: '机甲' },
  { id: 7, name: '黑猫侦探', coser: '墨瞳', series: '都市奇谭', likes: 2680, votes: 210, tag: '正片' },
  { id: 8, name: '月下舞姬', coser: '初雪', series: '和风物语', likes: 3120, votes: 265, tag: '舞蹈' },
  { id: 9, name: '像素勇者', coser: '像素君', series: '复古游戏', likes: 1540, votes: 120, tag: '趣味' },
  { id: 10, name: '冰霜女王', coser: '霜儿', series: '冰雪纪元', likes: 3890, votes: 320, tag: '特效' }
]

const SUPPORT_LIST: SupportEvent[] = [
  { id: 1, title: '星夜生日应援', target: 5000, done: 4200, deadline: '2026-09-30', reward: '限定签名照', icon: '🎂' },
  { id: 2, title: '新专辑打榜计划', target: 3000, done: 2100, deadline: '2026-08-29', reward: '电子徽章', icon: '🎵' },
  { id: 3, title: '商圈大屏投放', target: 8000, done: 5600, deadline: '2026-10-15', reward: '署名名单', icon: '🖥' },
  { id: 4, title: '公益应援捐赠', target: 10000, done: 7800, deadline: '2026-12-01', reward: '公益证书', icon: '💝' },
  { id: 5, title: '地铁灯箱应援', target: 6000, done: 3300, deadline: '2026-11-20', reward: '灯箱合影', icon: '🚇' },
  { id: 6, title: '生日蛋糕众筹', target: 2000, done: 1950, deadline: '2026-09-10', reward: '蛋糕周边', icon: '🎂' },
  { id: 7, title: '演唱会花墙计划', target: 15000, done: 9200, deadline: '2026-11-07', reward: '花墙明信片', icon: '🌸' },
  { id: 8, title: '应援服定制', target: 4000, done: 2800, deadline: '2026-10-30', reward: '同款应援服', icon: '👕' }
]

const TOPIC_LIST: TopicItem[] = [
  { id: 1, title: '你心中的年度最佳 cos 是谁?', hot: '2.4w', tag: '热议' },
  { id: 2, title: '手办柜子放不下了怎么办', hot: '1.8w', tag: '晒图' },
  { id: 3, title: '本周谷子上新大盘点', hot: '1.2w', tag: '资讯' },
  { id: 4, title: '漫展搭子招募帖', hot: '9800', tag: '组队' },
  { id: 5, title: '那些惊艳全场的舞台表演', hot: '8600', tag: '视频' },
  { id: 6, title: '冷门神作安利大会', hot: '7300', tag: '讨论' },
  { id: 7, title: '应援的正确打开方式', hot: '6500', tag: '科普' },
  { id: 8, title: '二手手办交换避坑指南', hot: '5200', tag: '攻略' }
]

const FAV_LIST: FavItem[] = [
  { id: 1, name: '星夜少女·初音', type: '手办', date: '2026-08-20' },
  { id: 2, name: '深海姬·琳', type: '手办', date: '2026-08-15' },
  { id: 3, name: '焰之战士立牌', type: '谷子', date: '2026-08-10' },
  { id: 4, name: '春日次元祭', type: '漫展', date: '2026-07-30' },
  { id: 5, name: '冰霜女王', type: '手办', date: '2026-07-25' },
  { id: 6, name: '樱色Q版吧唧套装', type: '谷子', date: '2026-07-18' },
  { id: 7, name: 'ACGN 夏日盛典', type: '漫展', date: '2026-07-12' },
  { id: 8, name: '黑猫毛绒挂件', type: '谷子', date: '2026-07-05' }
]

const ORDER_LIST: OrderItem[] = [
  { id: 1, title: '春日次元祭门票×2', date: '2026-08-01', amount: 256, status: '已完成', type: '门票' },
  { id: 2, title: '星夜少女·初音', date: '2026-07-28', amount: 1299, status: '已发货', type: '手办' },
  { id: 3, title: '焰之战士立牌', date: '2026-07-20', amount: 59, status: '已完成', type: '谷子' },
  { id: 4, title: '星夜生日应援参与', date: '2026-07-15', amount: 100, status: '已完成', type: '应援' },
  { id: 5, title: 'ACGN 门票×1', date: '2026-07-10', amount: 98, status: '已取消', type: '门票' },
  { id: 6, title: '深海姬亚克力挂件', date: '2026-07-02', amount: 29, status: '已完成', type: '谷子' },
  { id: 7, title: '新专辑打榜计划', date: '2026-06-28', amount: 50, status: '已完成', type: '应援' },
  { id: 8, title: '月下舞姬团扇', date: '2026-06-20', amount: 39, status: '已完成', type: '谷子' }
]

const MONTH_LIST: MonthSlot[] = [
  { month: '8月', label: '夏日盛典', count: 2 },
  { month: '9月', label: '次元祭典', count: 3 },
  { month: '10月', label: '国庆巡展', count: 4 },
  { month: '11月', label: '偶像演唱会', count: 2 },
  { month: '12月', label: '年终祭典', count: 5 },
  { month: '1月', label: '新春漫展', count: 3 }
]

const VOTE_DAYS: number[] = [120, 180, 150, 240, 300, 260, 356]

// ============ 全局辅助函数 ============
function getExpoRows(): ExpoItem[] {
  return [EXPO_LIST[0], EXPO_LIST[2], EXPO_LIST[4], EXPO_LIST[6]];
}

function getExpoRows2(): ExpoItem[] {
  return [EXPO_LIST[1], EXPO_LIST[3], EXPO_LIST[5], EXPO_LIST[7]];
}

function getFigureRows(): FigureItem[] {
  return [FIGURE_LIST[0], FIGURE_LIST[2], FIGURE_LIST[4], FIGURE_LIST[6], FIGURE_LIST[8], FIGURE_LIST[10]];
}

function getFigureRows2(): FigureItem[] {
  return [FIGURE_LIST[1], FIGURE_LIST[3], FIGURE_LIST[5], FIGURE_LIST[7], FIGURE_LIST[9], FIGURE_LIST[11]];
}

function getGoodsRows(): GoodsItem[] {
  return [GOODS_LIST[0], GOODS_LIST[2], GOODS_LIST[4], GOODS_LIST[6], GOODS_LIST[8], GOODS_LIST[10]];
}

function getGoodsRows2(): GoodsItem[] {
  return [GOODS_LIST[1], GOODS_LIST[3], GOODS_LIST[5], GOODS_LIST[7], GOODS_LIST[9], GOODS_LIST[11]];
}

function getCosRows(): CosWork[] {
  return [COS_LIST[0], COS_LIST[2], COS_LIST[4], COS_LIST[6], COS_LIST[8]];
}

function getCosRows2(): CosWork[] {
  return [COS_LIST[1], COS_LIST[3], COS_LIST[5], COS_LIST[7], COS_LIST[9]];
}

function getSupportRows(): SupportEvent[] {
  return [SUPPORT_LIST[0], SUPPORT_LIST[2], SUPPORT_LIST[4], SUPPORT_LIST[6]];
}

function getSupportRows2(): SupportEvent[] {
  return [SUPPORT_LIST[1], SUPPORT_LIST[3], SUPPORT_LIST[5], SUPPORT_LIST[7]];
}

function getFavRows(): FavItem[] {
  return [FAV_LIST[0], FAV_LIST[2], FAV_LIST[4], FAV_LIST[6]];
}

function getFavRows2(): FavItem[] {
  return [FAV_LIST[1], FAV_LIST[3], FAV_LIST[5], FAV_LIST[7]];
}

function getHomeTopics(): TopicItem[] {
  return [TOPIC_LIST[0], TOPIC_LIST[1], TOPIC_LIST[2], TOPIC_LIST[3]];
}

function getHotExpos(): ExpoItem[] {
  return [EXPO_LIST[0], EXPO_LIST[5], EXPO_LIST[2]];
}

function getTopFigures(): FigureItem[] {
  return [FIGURE_LIST[0], FIGURE_LIST[3], FIGURE_LIST[9], FIGURE_LIST[1]];
}

function getMonthList(): MonthSlot[] {
  return [MONTH_LIST[0], MONTH_LIST[1], MONTH_LIST[2], MONTH_LIST[3], MONTH_LIST[4], MONTH_LIST[5]];
}

function getVoteDays(): number[] {
  return [VOTE_DAYS[0], VOTE_DAYS[1], VOTE_DAYS[2], VOTE_DAYS[3], VOTE_DAYS[4], VOTE_DAYS[5], VOTE_DAYS[6]];
}

function getBarHeight(v: number): number {
  return 24 + v * 0.12;
}

function getRarityColor(r: string): string {
  if (r === '金') {
    return '#F9A825';
  }
  if (r === '银') {
    return '#90A4AE';
  }
  return '#A1887F';
}

function getStatusColor(s: string): string {
  if (s === '已完成') {
    return '#43A047';
  }
  if (s === '已发货') {
    return '#1E88E5';
  }
  if (s === '已取消') {
    return '#BDBDBD';
  }
  return '#F57C00';
}

function getTypeTags(): string[] {
  return ['吧唧', '立牌', '挂件', '色纸', '贴纸', '周边'];
}

function getSeriesOptions(): string[] {
  return ['虚拟歌姬', '热血番', '幻海物语', '王国物语', '魔法学园', '星际战甲', '冰雪纪元'];
}

function getPriceOptions(): number[] {
  return [499, 699, 899, 1299, 1599];
}

function getStockOptions(): number[] {
  return [3, 5, 8, 12, 20];
}

function getNameOptions(): string[] {
  return ['新作·黎明', '幻影·夜羽', '星尘·洛', '雷鸣·赤焰', '花语·铃兰'];
}

function getSlotList(): string[] {
  return ['09:00-11:00', '12:00-14:00', '15:00-17:00', '18:00-20:00'];
}

function getQuotaList(): number[] {
  return [1, 2, 3, 4];
}

function getPayLevels(): number[] {
  return [50, 100, 200, 500];
}

function getPayGifts(): string[] {
  return ['送 10 币', '送 30 币', '送 80 币', '送 300 币'];
}

function getSupportLevels(): number[] {
  return [10, 50, 100, 500];
}

function getSupportRewards(): string[] {
  return ['电子应援棒', '限定小卡', '签名明信片', '线下 VIP 名额'];
}

function getVoteStars(): number[] {
  return [1, 2, 3, 4, 5];
}

// ============ 首页 Tab ============
@Component
struct HomeTab {
  onOpenExpo: (id: number) => void = () => {
  }
  onOpenFigure: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 横幅
          Scroll() {
            Row() {
              ForEach(getHotExpos(), (ex: ExpoItem) => {
                Column() {
                  Text('🎪')
                    .fontSize(40)
                  Text(ex.title)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFFFFF')
                    .margin({ top: 6 })
                  Text(ex.city + ' · ' + ex.date)
                    .fontSize(11)
                    .fontColor('#F3E5F5')
                    .margin({ top: 4 })
                  Text('¥' + String(ex.price) + ' 起')
                    .fontSize(12)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFD54F')
                    .margin({ top: 6 })
                    .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                    .backgroundColor('rgba(0,0,0,0.25)')
                    .borderRadius(10)
                }
                .width(240)
                .height(150)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .linearGradient({ angle: 135, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
                .borderRadius(16)
                .margin({ right: 10 })
                .shadow({ radius: 8, color: 'rgba(142,36,170,0.25)', offsetY: 4 })
                .onClick(() => {
                  this.onOpenExpo(ex.id);
                })
              }, (ex: ExpoItem) => String(ex.id))
            }
            .padding({ left: 16 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .margin({ top: 12 })

          // 宫格入口
          Row() {
            ForEach(getQuickEntries(), (mi: string, miIdx: number) => {
              Column() {
                Text(getQuickIcon(miIdx))
                  .fontSize(24)
                Text(mi)
                  .fontSize(11)
                  .fontColor('#616161')
                  .margin({ top: 6 })
              }
              .layoutWeight(1)
              .padding({ top: 12, bottom: 12 })
              .onClick(() => {
                this.onToast(mi + ' 功能开发中');
              })
            }, (mi: string) => mi)
          }
          .width('100%')
          .padding({ left: 8, right: 8, top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ top: 12, left: 16, right: 16 })

          // 横滑热门手办
          Row() {
            Text('🔥 热门手办')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
            Text('')
              .layoutWeight(1)
            Text('更多 >')
              .fontSize(11)
              .fontColor('#9E9E9E')
              .onClick(() => {
                this.onToast('前往手办专区');
              })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 18 })

          Scroll() {
            Row() {
              ForEach(getTopFigures(), (ft: FigureItem) => {
                Column() {
                  Text(getFigureEmoji(ft.series))
                    .fontSize(30)
                    .width(56)
                    .height(56)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#FBE9E7')
                    .borderRadius(14)
                  Text(ft.name)
                    .fontSize(12)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#424242')
                    .margin({ top: 8 })
                    .maxLines(1)
                  Text('¥' + String(ft.price))
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#D81B60')
                    .margin({ top: 4 })
                }
                .width(96)
                .padding({ top: 12, bottom: 12 })
                .backgroundColor('#FFFFFF')
                .borderRadius(12)
                .margin({ right: 10 })
                .onClick(() => {
                  this.onOpenFigure(ft.id);
                })
              }, (ft: FigureItem) => String(ft.id))
            }
            .padding({ left: 16 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .margin({ top: 10 })

          // 话题列表
          Row() {
            Text('💬 圈子热议')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
            Text('')
              .layoutWeight(1)
            Text('全部话题 >')
              .fontSize(11)
              .fontColor('#9E9E9E')
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 18 })

          Column() {
            ForEach(getHomeTopics(), (tp: TopicItem) => {
              Row() {
                Text('·')
                  .fontSize(16)
                  .fontColor('#D81B60')
                Column() {
                  Text(tp.title)
                    .fontSize(13)
                    .fontColor('#424242')
                    .maxLines(1)
                  Text(tp.hot + ' 热度 · ' + tp.tag)
                    .fontSize(11)
                    .fontColor('#BDBDBD')
                    .margin({ top: 3 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 8 })
                Text('讨论')
                  .fontSize(10)
                  .fontColor('#D81B60')
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor('#FCE4EC')
                  .borderRadius(10)
                  .onClick(() => {
                    this.onToast('进入话题「' + tp.title + '」');
                  })
              }
              .width('100%')
              .padding({ top: 10, bottom: 10 })
              .borderRadius(10)
            }, (tp: TopicItem) => String(tp.id))
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .margin({ left: 16, right: 16, top: 10, bottom: 16 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F3FA')
  }
}

// ============ 漫展 Tab ============
@Component
struct ExpoTab {
  onOpenExpo: (id: number) => void = () => {
  }
  onBook: (id: number) => void = () => {
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 月份排期横滑
          Scroll() {
            Row() {
              ForEach(getMonthList(), (ms: MonthSlot) => {
                Column() {
                  Text(ms.month)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFFFFF')
                  Text(ms.label)
                    .fontSize(10)
                    .fontColor('#FFD54F')
                    .margin({ top: 3 })
                  Text(String(ms.count) + ' 场')
                    .fontSize(10)
                    .fontColor('#F3E5F5')
                    .margin({ top: 2 })
                }
                .width(80)
                .padding({ top: 12, bottom: 12 })
                .linearGradient({ angle: 180, colors: [['#6A1B9A', 0], ['#AD1457', 1]] })
                .borderRadius(12)
                .margin({ right: 10 })
              }, (ms: MonthSlot) => ms.month)
            }
            .padding({ left: 16 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .margin({ top: 12 })

          // 大图卡片列表(两列)
          Row() {
            Column() {
              ForEach(getExpoRows(), (ex: ExpoItem) => {
                this.expoCard(ex)
              }, (ex: ExpoItem) => String(ex.id))
            }
            .layoutWeight(1)
            Column() {
              ForEach(getExpoRows2(), (ex: ExpoItem) => {
                this.expoCard(ex)
              }, (ex: ExpoItem) => String(ex.id))
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
          .alignItems(VerticalAlign.Top)
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F3FA')
  }

  @Builder
  expoCard(ex: ExpoItem) {
    Column() {
      Text('🎪')
        .fontSize(34)
        .width('100%')
        .height(84)
        .textAlign(TextAlign.Center)
        .backgroundColor('#F3E5F5')
        .borderRadius({ topLeft: 12, topRight: 12 })
      Text(ex.title)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .margin({ top: 8 })
        .maxLines(1)
      Text(ex.city + ' · ' + ex.venue)
        .fontSize(11)
        .fontColor('#9E9E9E')
        .width('100%')
        .margin({ top: 4 })
        .maxLines(1)
      Text(ex.date)
        .fontSize(11)
        .fontColor('#757575')
        .width('100%')
        .margin({ top: 4 })
      Row() {
        Text(ex.tag)
          .fontSize(10)
          .fontColor('#D81B60')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor('#FCE4EC')
          .borderRadius(8)
        Text('')
          .layoutWeight(1)
        Text('¥' + String(ex.price))
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
      }
      .width('100%')
      .margin({ top: 8 })
      Text('立即预约')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 })
        .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
        .borderRadius(8)
        .margin({ top: 10 })
        .onClick(() => {
          this.onBook(ex.id);
        })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .margin({ bottom: 12 })
    .shadow({ radius: 6, color: 'rgba(142,36,170,0.12)', offsetY: 3 })
    .onClick(() => {
      this.onOpenExpo(ex.id);
    })
  }
}

// ============ 手办 Tab(双列卡片) ============
@Component
struct FigureTab {
  onOpenFigure: (id: number) => void = () => {
  }
  onAdd: () => void = () => {
  }
  onEdit: (id: number) => void = () => {
  }
  onDel: (id: number) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🧸 手办图鉴')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('+ 新增')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .linearGradient({ angle: 90, colors: [['#8E24AA', 0], ['#D81B60', 1]] })
          .borderRadius(16)
          .onClick(() => {
            this.onAdd();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column() {
          Row() {
            Column() {
              ForEach(getFigureRows(), (fg: FigureItem) => {
                this.figureCard(fg)
              }, (fg: FigureItem) => String(fg.id))
            }
            .layoutWeight(1)
            Column() {
              ForEach(getFigureRows2(), (fg: FigureItem) => {
                this.figureCard(fg)
              }, (fg: FigureItem) => String(fg.id))
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 16 })
          .alignItems(VerticalAlign.Top)
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F3FA')
  }

  @Builder
  figureCard(fg: FigureItem) {
    Column() {
      Text(getFigureEmoji(fg.series))
        .fontSize(36)
        .width('100%')
        .height(96)
        .textAlign(TextAlign.Center)
        .backgroundColor('#FBE9E7')
        .borderRadius(12)
      Text(fg.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .margin({ top: 8 })
        .maxLines(1)
      Text(fg.series)
        .fontSize(10)
        .fontColor('#9E9E9E')
        .width('100%')
        .margin({ top: 3 })
      Row() {
        Text('¥' + String(fg.price))
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
        Text('¥' + String(fg.oldPrice))
          .fontSize(10)
          .fontColor('#BDBDBD')
          .decoration({ type: TextDecorationType.LineThrough })
          .margin({ left: 6 })
      }
      .width('100%')
      .margin({ top: 6 })

      Row() {
        Text(fg.tag)
          .fontSize(10)
          .fontColor('#8E24AA')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor('#F3E5F5')
          .borderRadius(8)
        Text('')
          .layoutWeight(1)
        Text('✎')
          .fontSize(13)
          .fontColor('#8E24AA')
          .padding(4)
          .onClick(() => {
            this.onEdit(fg.id);
          })
        Text('🗑')
          .fontSize(12)
          .fontColor('#E53935')
          .padding(4)
          .onClick(() => {
            this.onDel(fg.id);
          })
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .margin({ bottom: 12 })
    .shadow({ radius: 6, color: 'rgba(142,36,170,0.10)', offsetY: 3 })
    .onClick(() => {
      this.onOpenFigure(fg.id);
    })
  }
}

// ============ 谷子 Tab(行式列表) ============
@Component
struct GoodsTab {
  onOpenGoods: (id: number) => void = () => {
  }
  onToast: (msg: string) => void = () => {
  }

  build() {
    Column() {
      Row() {
        Text('🎁 谷子商店')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('')
          .layoutWeight(1)
        Text('按系列 >')
          .fontSize(11)
          .fontColor('#9E9E9E')
          .onClick(() => {
            this.onToast('系列筛选');
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      // 分类 chips
      Row() {
        ForEach(getTypeTags(), (tg: string, ti: number) => {
          Text(tg)
            .fontSize(11)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(ti === 0 ? '#8E24AA' : '#FFFFFF')
            .fontColor(ti === 0 ? '#FFFFFF' : '#616161')
            .borderRadius(12)
            .margin({ right: 8 })
            .onClick(() => {
              this.onToast('查看' + tg + '分类');
            })
        }, (tg: string) => tg)
      }
      .width('100%')
      .padding({ left: 16, top: 10 })

      Scroll() {
        Column() {
          ForEach(getGoodsRows(), (gd: GoodsItem) => {
            this.goodsRow(gd)
          }, (gd: GoodsItem) => String(gd.id))
          Text('— 已加载 ' + String(GOODS_LIST.length) + ' 件谷子 —')
            .fontSize(10)
            .fontColor('#BDBDBD')
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 4, bottom: 12 })
          ForEach(getGoodsRows2(), (gd: GoodsItem) => {
            this.goodsRow(gd)
          }, (gd: GoodsItem) => String(gd.id))
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F3FA')
  }

  @Builder
  goodsRow(gd: GoodsItem) {
    Row() {
      Text(getGoodsEmoji(gd.type))
        .fontSize(22)
        .width(44)
        .height(44)
        .textAlign(TextAlign.Center)
        .backgroundColor('#F3E5F5')
        .borderRadius(12)
      Column() {
        Text(gd.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .maxLines(1)
        Text(gd.series + ' · ' + gd.type)
          .fontSize(11)
          .fontColor('#9E9E9E')
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 10 })

      Column() {
        Text(gd.rarity)
          .fontSize(10)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
          .backgroundColor(getRarityColor(gd.rarity))
          .borderRadius(8)
        Text('¥' + String(gd.price))
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D81B60')
          .margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.End)
      .margin({ left: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 10 })
    .onClick(() => {
      this.onOpenGoods(gd.id);
    })
  }
}

// ============ cos Tab(投票榜 + 卡片) ============
@Component
struct CosTab {
  onOpenCos: (id: number) => void = () => {
  }
  onVote: (id: number) => void = () => {
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 投票柱状图
          Row() {
            Text('📊 本周人气榜')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
            Text('')
              .layoutWeight(1)
            Text('每日更新')
              .fontSize(10)
              .fontColor('#BDBDBD')
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12 })

          Row() {
            ForEach(getVoteDays(), (v: number, vi: number) => {
              Column() {
                Text(String(v))
                  .fontSize(9)
                  .fontColor('#8E24AA')
                Column() {
                }
                .width(18)
                .height(getBarHeight(v))
                .backgroundColor(vi === 6 ? '#D81B60' : '#BA68C8')
                .borderRadius({ topLeft: 4, topRight: 4 })
                .margin({ top: 2 })
                Text('周' + getWeekName(vi))
                  .fontSize(9)
                  .fontColor('#9E9E9E')
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.End)
              .height(110)
            }, (v: number, vi: number) => String(vi))
          }
          .width('100%')
  }
  if (lv === 100) {
    return '签名明信片';
  }
  return '线下 VIP 名额';
}


在这里插入图片描述

二十二、总结

本文完整剖析了一个基于鸿蒙 ArkUI 声明式范式构建的 ACGN 次元漫展社区应用。从类型定义到数据模型,从子组件拆分到主组件状态管理,从布局细节到弹窗体系,该应用展示了一套完整的、可操作的鸿蒙应用工程实践。

在架构层面,该应用采用了"主组件统一管理状态 + 子组件通过回调通信"的经典模式。主组件 Index 持有全部 @State 变量,包括 Tab 切换索引、弹窗显示控制、选中数据存储和表单状态。七个 Tab 子组件是"半无状态"的——它们不维护跨会话状态,但通过回调属性将用户交互事件回传给主组件,由主组件决定后续操作。这种模式确保了数据流的单向性和可预测性,是鸿蒙声明式开发中最推荐的组件通信方式。

在布局层面,该应用大量运用了 ColumnRow 的嵌套组合来构建复杂的 UI 结构。三段式布局(固定 + 弹性 + 固定)通过 layoutWeight 实现,双列瀑布流通过两个 layoutWeight(1)Column 并排实现,柱状图通过空 Column 的动态高度实现。FlexAlign 枚举在控制子元素对齐方式上发挥了核心作用——Center 用于居中、End 用于底部对齐、SpaceBetween 用于两端分布。

在弹窗体系层面,该应用没有使用任何鸿蒙原生的弹窗 API,而是通过条件渲染 + 遮罩层 + 内容卡片三层结构自行构建了一套完整的弹窗系统。14 个弹窗覆盖了详情展示、表单编辑、操作确认和结果反馈四种交互类型,每种类型都有独特的视觉风格——居中卡片用于详情、底部抽屉用于表单、深色卡片用于警示、票根样式用于凭证。所有弹窗都使用了 transition(TransitionEffect.OPACITY.animation({ duration: 200 })) 实现统一的淡入淡出动画效果。

在数据层面,该应用将所有展示数据集中定义为全局常量数组,通过辅助函数进行分列和筛选。Emoji 映射函数替代了图片资源,颜色映射函数将业务语义(稀有度、状态)转换为视觉颜色。这种"数据与视图分离 + 函数式映射"的设计使得未来切换到真实数据源时,只需替换数据获取函数,组件代码无需改动。

在状态管理层面,@State 装饰器是整个响应式系统的核心。无论是 Tab 切换、弹窗显示、表单选择还是 Toast 提示,所有 UI 变化都由状态变量的赋值驱动。开发者不需要手动调用任何刷新方法,框架自动追踪状态变化并精确地重新渲染受影响的 UI 区域。配合 if/else 条件渲染和 ForEach 列表渲染,该应用以极少的代码量实现了丰富的交互功能。

鸿蒙 ArkUI 声明式范式的核心价值在于:让开发者以声明的方式描述界面与数据的关系,将"如何更新 UI"的复杂逻辑交给框架处理,从而专注于业务逻辑本身。本应用的实践证明,即使不使用高级 API(如 Dialog、Navigation、Tabs 组件),仅凭借基础的 ColumnRowForEachif/else@State@Builder,也能构建出结构完整、交互丰富、视觉精美的全场景应用。这正是鸿蒙声明式 UI 范式的设计初衷——以简洁的语法表达复杂的界面,以自动的更新替代手动的操作。

Logo

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

更多推荐