鸿蒙(HarmonyOS)作为华为自主研发的分布式操作系统,其应用开发框架 ArkUI 采用声明式 UI 编程范式,以 ArkTS 语言为核心,提供了一套高效、简洁的组件化开发模式。本文将以一个完整的"恐龙纪元·员工化石馆"应用为例,从数据层接口定义、配色体系设计、六大功能页面构建、十三种弹框交互,到主入口状态管理,逐段剖析每一行代码背后的设计思想与鸿蒙技术细节,带领开发者深入理解 ArkUI 的声明式渲染机制、组件化拆分策略、状态驱动视图更新等核心能力。

一、技术背景与架构概述

HarmonyOS 的应用开发语言是 ArkTS,它是在 TypeScript 基础上扩展而来的。ArkTS 不仅保留了 TypeScript 的静态类型检查能力,还针对 UI 声明式编程场景做了深度优化,使得开发者可以用更少的代码量实现更复杂的界面逻辑。

ArkUI 是 HarmonyOS 的 UI 开发框架,其核心理念是"声明式编程"。开发者只需描述界面"看起来是什么样",而不需要手动操作 DOM 或视图树。框架会根据数据状态自动完成界面渲染与更新,这极大地降低了 UI 编程的复杂度。

在本项目中,整个应用围绕"恐龙化石馆"这一主题展开,涵盖了首页概览、恐龙图鉴、化石陈列、挖掘现场、活动日历、商店商城、个人中心共七大功能模块。每个模块都被拆分为独立的自定义组件,通过主入口组件统一调度。

从架构层面看,本项目采用了"数据层 + 组件层 + 交互层"的三层设计。数据层定义了七组接口与对应的数据常量;组件层将每个页面封装为独立的 @Component 结构;交互层则通过 @Builder 方法在主入口中集中管理十三种弹框,实现了页面与弹框的解耦。

这种分层架构的好处非常明显。数据层的接口定义使得类型安全得到了保障,所有组件在接收数据时都有明确的类型约束。组件层的独立拆分使得每个页面可以单独维护和测试,不会因为修改一个页面而影响其他页面。交互层的集中管理则让弹框的展示逻辑一目了然,便于后续扩展新的弹框类型。

从技术栈角度来说,本项目用到了 ArkTS 的接口定义(interface)、枚举类型(enum)、装饰器(@Entry@Component@State@Builder)、容器组件(ColumnRowStackScroll)、循环渲染(ForEach)、样式属性链式调用、事件回调(onClick)、过渡动画(TransitionEffect)等核心能力,几乎覆盖了 ArkUI 日常开发中最常用的技术点。

二、数据接口定义

2.1 恐龙数据接口

interface Dino {
  id: number;
  emoji: string;
  name: string;
  era: string;
  diet: string;
  length: number;
  weight: number;
  rarity: string;
  color: string;
  desc: string;
}

在这里插入图片描述

这是恐龙数据的接口定义。在 ArkTS 中,interface 用于定义对象的结构类型,它描述了一个数据对象应该包含哪些字段以及每个字段的类型。Dino 接口包含十个字段,覆盖了一只恐龙的全部信息维度。

id 字段是唯一标识符,类型为 number,在 ForEach 循环渲染中会作为 key 生成函数的返回值,帮助框架高效追踪列表项的增删改。emoji 字段存储 Emoji 表情符号,用于在卡片中展示恐龙的图标,这种设计避免了引入图片资源带来的体积开销。

era 字段记录恐龙所属的地质年代,如"白垩纪"“侏罗纪"等。diet 字段表示食性,分为"肉食”“植食”"杂食"三类。lengthweight 分别是体长(米)和体重(吨),在详情弹框中以数字形式展示。rarity 字段是稀有度等级,取值为 SSR、SR、R、N 四档,后续会通过辅助函数映射为不同颜色。color 字段是每只恐龙的主题色,用于卡片头部背景等装饰区域。desc 字段是一段描述文案,在列表卡片和详情弹框中均有展示。

2.2 化石与挖掘点接口

interface Fossil {
  id: number;
  emoji: string;
  name: string;
  part: string;
  age: string;
  value: number;
  rarity: string;
  color: string;
  desc: string;
}

interface DigSite {
  id: number;
  emoji: string;
  name: string;
  area: string;
  depth: number;
  finds: number;
  status: string;
  color: string;
  desc: string;
}

Fossil 接口定义了化石的数据结构。part 字段表示化石所属的身体部位,如"齿骨化石"“颅骨化石"等。age 字段是地质年代信息,以文字形式存储,如"6800 万年”。value 字段是化石的估值,以"币"为单位,在列表和详情中展示。

DigSite 接口描述了挖掘现场的数据。area 字段是挖掘点的位置描述,如"园区东侧山坡"。depth 字段是可挖掘深度,以米为单位。finds 字段记录该挖掘点已出土的化石数量。status 字段表示当前状态,取值为"开放中"“预约中”"维护中"等,后续通过 getStatusColor 函数映射为不同颜色。

这两个接口的共同特点是都包含了 emojicolordesc 三个字段。emoji 用于视觉标识,color 用于主题色装饰,desc 用于文字说明。这种统一的字段命名规范使得不同模块的卡片组件可以复用相同的布局结构,只需替换数据源即可。

2.3 活动与商品接口

interface JuraActivity {
  id: number;
  emoji: string;
  name: string;
  type: string;
  time: string;
  quota: number;
  joined: number;
  reward: number;
  status: string;
  color: string;
  desc: string;
}

interface JuraGoods {
  id: number;
  emoji: string;
  name: string;
  price: number;
  oldPrice: number;
  tag: string;
  stock: number;
  color: string;
  desc: string;
}

在这里插入图片描述

JuraActivity 接口定义了活动信息。type 字段是活动类型,如"答题赛"“手工课”"体验赛"等。time 字段是活动时间,以"MM-DD HH:mm"格式存储。quota 字段是名额上限,joined 字段是已报名人数,两者组合显示为"报名 46/50"的形式。reward 字段是参与奖励的琥珀币数量。

JuraGoods 接口定义了商店商品信息。price 字段是当前售价,oldPrice 字段是原价,在商品卡片中通过删除线样式展示原价,形成价格对比效果。tag 字段是商品标签,如"热门"“限定”"图书"等。stock 字段是库存数量。

这两个接口的设计体现了"活动"和"商品"两种业务场景的数据需求差异。活动更关注时间、名额、奖励等参与维度,而商品更关注价格、库存、标签等交易维度。但它们共享了 emojicolordesc 这三个通用字段,保持了数据结构的一致性。

2.4 标本与成就接口

interface Specimen {
  id: number;
  emoji: string;
  name: string;
  dino: string;
  date: string;
  rarity: string;
  status: string;
  color: string;
}

interface Achievement {
  id: number;
  emoji: string;
  name: string;
  progress: number;
  target: number;
  reward: number;
  status: string;
  color: string;
}

在这里插入图片描述

Specimen 接口定义了已收藏的标本数据。dino 字段记录标本来自哪只恐龙。date 字段是收藏日期。status 字段表示标本的当前状态,取值为"已收藏"“展示中”"待鉴定"等。rarity 字段沿用与恐龙相同的稀有度体系。

Achievement 接口定义了成就数据。progress 字段是当前进度,target 字段是目标值,两者组合形成进度展示。reward 字段是达成成就后的琥珀币奖励。status 字段取值为"已达成"或"进行中"。

在 ArkTS 中,接口定义不仅仅是代码规范,更是类型安全的基石。当组件接收外部数据时,接口类型会确保传入的对象必须包含所有声明字段,编译阶段即可发现数据缺失问题,这比 JavaScript 的运行时报错机制提前了整整一个环节。

三、数据常量与配色体系

3.1 恐龙数据集

const DINOS: Dino[] = [
  { id: 1, emoji: '🦖', name: '霸王龙', era: '白垩纪', diet: '肉食', length: 12, weight: 7, rarity: 'SSR', color: '#B71C1C', desc: '化石馆镇馆之宝,完整的头骨化石高 1.6 米,咬合力惊人' },
  { id: 2, emoji: '🦕', name: '腕龙', era: '侏罗纪', diet: '植食', length: 25, weight: 40, rarity: 'SR', color: '#2E7D32', desc: '脖颈长 9 米,馆内 3D 复原模型高达 12 米,抬头仰望震撼' },
  // ...更多恐龙数据
  { id: 12, emoji: '🌟', name: '棘龙', era: '白垩纪', diet: '肉食', length: 15, weight: 8, rarity: 'SSR', color: '#E65100', desc: '背帆高达 2 米,已知最大的肉食恐龙之一' }
];

DINOS 是一个 Dino[] 类型的常量数组,包含 12 条恐龙数据。使用 const 关键字声明,意味着该引用不可重新赋值,但数组内容本身可以在运行时读取。每条数据严格按照 Dino 接口定义的字段结构编写,如果遗漏某个字段,ArkTS 编译器会立即报错。

从配色角度观察,每只恐龙都被赋予了一个十六进制颜色值作为 color 字段。例如霸王龙是深红色 #B71C1C,腕龙是深绿色 #2E7D32,沧龙是蓝色 #1565C0。这些颜色取自 Material Design 色板,色相饱和度高但明度适中,在浅色背景上具有良好的视觉对比度。

稀有度体系借鉴了卡牌游戏的分级概念,SSR 代表超稀有,SR 代表稀有,R 代表普通稀有,N 代表普通。这种分级方式让用户在浏览恐龙列表时可以直观感知到每只恐龙的价值差异,同时也为后续的颜色映射提供了统一的数据基础。

3.2 化石与挖掘点数据集

const FOSSILS: Fossil[] = [
  { id: 1, emoji: '🦷', name: '霸王龙牙齿', part: '齿骨化石', age: '6800 万年', value: 880, rarity: 'SSR', color: '#B71C1C', desc: '长 18 厘米的锯齿状牙齿,边缘布满锯齿棱' },
  // ...更多化石数据
];

const DIG_SITES: DigSite[] = [
  { id: 1, emoji: '⛏️', name: '一号挖掘场', area: '园区东侧山坡', depth: 12, finds: 18, status: '开放中', color: '#2E7D32', desc: '最热门的挖掘点,出土过霸王龙牙齿与腕龙股骨' },
  // ...更多挖掘点数据
];

在这里插入图片描述

FOSSILS 常量包含 10 条化石数据,DIG_SITES 常量包含 8 个挖掘点数据。化石数据中的 value 字段以"币"为单位,从 120 到 880 不等,形成了从普通到珍贵的价值梯度。挖掘点数据中的 depth 字段从 8 米到 22 米不等,finds 字段记录了出土化石数量,为用户选择挖掘点提供了参考依据。

3.3 活动、商品、标本与成就数据集

const JURA_ACTS: JuraActivity[] = [ /* 8 条活动数据 */ ];
const JURA_GOODS: JuraGoods[] = [ /* 12 条商品数据 */ ];
const SPECIMENS: Specimen[] = [ /* 8 条标本数据 */ ];
const ACHIEVEMENTS: Achievement[] = [ /* 6 条成就数据 */ ];

这四组数据常量分别支撑了活动日历、商店商城、个人标本库和成就系统四个功能模块。商品数据中包含了 oldPrice(原价)字段,在 UI 上以删除线样式展示,营造"折扣"的视觉感受。标本数据分为"已收藏"和"展示中"两组,通过两个分页函数分别返回。成就数据中,progresstarget 字段的组合形成了"1/1""8/10"这样的进度展示。

配色设计是应用视觉体验的灵魂。本项目通过 color 字段为每条数据绑定主题色,在卡片头部、标签背景、按钮底色等位置统一使用,形成了"数据驱动配色"的设计模式。这种方式的优势在于新增数据时只需指定颜色值,界面会自动适配,无需修改组件代码。

四、辅助函数与数据分页

4.1 数据分页函数

function getDinoRows(): Dino[] {
  return [DINOS[0], DINOS[1], DINOS[2], DINOS[3], DINOS[4], DINOS[5]];
}

function getDinoRows2(): Dino[] {
  return [DINOS[6], DINOS[7], DINOS[8], DINOS[9], DINOS[10], DINOS[11]];
}

getDinoRowsgetDinoRows2 这两个函数将 12 条恐龙数据分成两组,每组 6 条。第一组在恐龙图鉴页面中以"主推卡片"样式展示,字体更大、描述更详细;第二组以"更多恐龙"样式展示,字体较小、布局更紧凑。这种分页设计不仅解决了列表过长导致的视觉疲劳问题,还通过不同的卡片样式区分了"精选"和"完整列表"两个层次。

类似地,化石数据的 getFossilRowsgetFossilRows2 将 10 条数据分为 5+5 两组;挖掘点的 getDigRowsgetDigRows2 将 8 条数据分为 4+4 两组;活动的 getActRowsgetActRows2 将 8 条数据分为 4+4 两组,分别对应"全部活动"和"我组织的活动"两个区块。

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

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

商品数据的分页方式比较特殊。getGoodsRows 返回的是索引为偶数的商品(第 1、3、5、7、9、11 项),getGoodsRows2 返回的是索引为奇数的商品。这种"交叉分页"的方式确保两组数据的商品种类不重复,避免了用户在不同区块看到相同商品。

4.2 颜色映射函数

function getRarityColor(r: string): string {
  if (r === 'SSR') {
    return '#FFB300';
  }
  if (r === 'SR') {
    return '#7B1FA2';
  }
  if (r === 'R') {
    return '#00838F';
  }
  return '#78909C';
}

function getStatusColor(s: string): string {
  if (s === '开放中' || s === '报名中') {
    return '#2E7D32';
  }
  if (s === '预约中') {
    return '#F57C00';
  }
  return '#BDBDBD';
}

function getSpecStatusColor(s: string): string {
  if (s === '已收藏') {
    return '#2E7D32';
  }
  if (s === '展示中') {
    return '#00838F';
  }
  return '#F57C00';
}

在这里插入图片描述

getRarityColor 函数将稀有度字符串映射为颜色值。SSR 对应琥珀金 #FFB300,SR 对应深紫 #7B1FA2,R 对应深青 #00838F,N 对应蓝灰 #78909C。这套配色从"金→紫→青→灰"形成了从珍贵到普通的视觉梯度,用户一眼就能感知到稀有度等级。

getStatusColor 函数将状态字符串映射为颜色。“开放中"和"报名中"对应绿色 #2E7D32,表示"可以进行”;“预约中"对应橙色 #F57C00,表示"需要等待”;其他状态(如"维护中"“报名截止”)对应灰色 #BDBDBD,表示"不可用"。

getSpecStatusColor 函数专门用于标本状态的颜色映射,逻辑与 getStatusColor 类似但取值不同。这种为不同业务场景设计独立颜色函数的做法,保证了各模块的配色语义清晰且互不干扰。

4.3 工具函数

function getBarHeight(v: number): number {
  return 26 + v * 0.55;
}

function formatPrice(p: number): string {
  if (p >= 1000) {
    return String(p / 1000) + 'k';
  }
  return String(p);
}

getBarHeight 函数用于计算柱状图的高度。输入参数 v 是一个数值,输出高度为基础值 26 加上 v * 0.55 的偏移量。这种线性映射方式将数据值转换为像素高度,用于在挖掘页面的"本周产出"柱状图中展示每天的出土数量。

formatPrice 函数将价格数值格式化为字符串。当价格大于等于 1000 时,用"k"缩写表示千位,例如 1000 显示为"1k"。这种格式化方式在价格较高的场景下可以减少字符长度,使界面更简洁。

function getPayLevels(): number[] {
  return [100, 300, 500, 1000];
}

function getPayGifts(): string[] {
  return ['送 20 币', '送 80 币', '送 150 币', '送 400 币'];
}

function getSlotList(): string[] {
  return ['09:00-10:00', '13:00-14:00', '15:00-16:00', '19:00-20:00'];
}

function getDietTags(): string[] {
  return ['🦖 肉食', '🌿 植食', '🦤 杂食', '⭐ SSR'];
}

这组函数返回的是用于选择器或标签栏的简单数组。getPayLevels 返回充值档位,getPayGifts 返回每个档位的赠送信息,两者通过索引一一对应。getSlotList 返回预约挖掘的时段选项。getDietTags 返回恐龙图鉴页面的筛选标签。这些函数将"静态配置数据"与"组件渲染逻辑"分离,使得后续修改选项内容时只需调整函数返回值,无需触碰组件代码。

五、首页模块深度解析

5.1 组件声明与回调接口

@Component
struct JuraHomeTab {
  onDinoClick: (d: Dino) => void = () => {};
  onDigClick: (s: DigSite) => void = () => {};
  onActClick: (a: JuraActivity) => void = () => {};
  onMoreAct: () => void = () => {};

  build() {
    // ...
  }
}

在这里插入图片描述

@Component 装饰器是 ArkUI 中定义自定义组件的关键。被 @Component 修饰的 struct 结构可以被当作 UI 组件在其他组件的 build() 方法中引用。JuraHomeTab 是首页组件的名称,遵循"模块名 + Tab"的命名约定。

组件内部声明了四个回调函数属性:onDinoClickonDigClickonActClickonMoreAct。每个属性都有默认值(空箭头函数 () => {}),这意味着即使父组件不传入回调,组件也不会因调用未定义函数而崩溃。这是一种防御性编程的设计思路。

@Component 装饰器的核心作用是告诉 ArkUI 编译器:"这个 struct 不是一个普通的数据结构,而是一个可渲染的 UI 组件。"编译器会为它生成组件生命周期管理代码、状态观察者注册代码以及渲染调度代码。没有 @Component 修饰的 struct 无法在 build() 中被引用。

5.2 顶部欢迎卡片

Scroll() {
  Column() {
    Column() {
      Row() {
        Text('🦖')
          .fontSize(36)
        Column() {
          Text('欢迎来到恐龙纪元')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('已收集 8 件标本 · 挖掘 23 次')
            .fontSize(10)
            .fontColor('#C8E6C9')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text('🗿')
          .fontSize(22)
      }
      .width('100%')

在这里插入图片描述

这段代码构建了首页顶部的欢迎卡片。最外层是 Scroll 容器,它使整个页面内容可以纵向滚动。Scroll 内部嵌套一个 Column,所有页面元素按从上到下的顺序垂直排列。

Column 是 ArkUI 中的线性布局容器,其子元素默认沿垂直方向排列。与之对应的是 Row 容器,子元素沿水平方向排列。在欢迎卡片中,Row 容器横向放置了 Emoji 图标、文字信息列和右侧装饰图标三部分。

Text 组件通过链式调用设置样式属性。.fontSize(36) 设置字号,.fontWeight(FontWeight.Bold) 设置粗体,.fontColor('#FFFFFF') 设置白色字体,.margin({ top: 4 }) 设置顶部外边距。这种链式调用风格是 ArkUI 声明式语法的标志性特征。

.alignItems(HorizontalAlign.Start) 设置 Column 内部子元素沿水平方向左对齐。.layoutWeight(1) 是一个关键的布局属性,它让该 Column 占据 Row 中剩余的全部水平空间,从而将右侧的 Emoji 推到最右端。这种"弹性布局 + 固定元素"的组合是 ArkUI 中实现左对齐+右对齐的经典手法。

5.3 倒计时通知条

Row() {
  Text('🌋 本周末大挖掘开放 · 报名倒计时 2 天')
    .fontSize(10)
    .fontColor('#C8E6C9')
  Text('')
    .layoutWeight(1)
  Text('去报名 ›')
    .fontSize(10)
    .fontWeight(FontWeight.Bold)
    .fontColor('#FFD600')
    .onClick(() => {
      this.onMoreAct();
    })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 12, right: 12 })
.backgroundColor('#33FFFFFF')
.borderRadius(10)
.margin({ top: 12 })

在这里插入图片描述

这段代码构建了一个通知条,包含左侧文案、中间弹性占位和右侧操作按钮三部分。Text('') 配合 .layoutWeight(1) 是 ArkUI 中实现"两端对齐"的常用技巧——一个空文本占据中间的弹性空间,将左右两侧的元素分别推向两端。

.backgroundColor('#33FFFFFF') 使用了带透明度的颜色值。#33 是十六进制的透明度,约等于 20% 的不透明度,使得白色背景呈现半透明效果,叠在绿色卡片底色上形成浅绿色质感。.borderRadius(10) 设置圆角半径为 10vp(虚拟像素),让通知条的四角圆润。

.onClick(() => { this.onMoreAct(); }) 为"去报名 ›"文本绑定了点击事件。当用户点击时,调用 this.onMoreAct() 回调函数,该函数在父组件中被定义为切换到活动页签,实现了从首页到活动页的快速跳转。

5.4 成就进度横条

Row() {
  ForEach(getAchieveRows(), (a: Achievement, ai: number) => {
    Column() {
      Text(a.emoji)
        .fontSize(22)
      Text(a.name)
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .maxLines(1)
        .margin({ top: 4 })
      Text(a.status)
        .fontSize(8)
        .fontColor(a.status === '已达成' ? '#2E7D32' : '#9E9E9E')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .padding({ top: 10, bottom: 10 })
    .justifyContent(FlexAlign.Center)
    .backgroundColor(ai === 0 ? '#E8F5E9' : '#FFFFFF')
    .borderRadius(12)
    .margin({ right: 6 })
  }, (a: Achievement) => String(a.id))
}
.width('100%')
.margin({ top: 12 })

这段代码使用 ForEach 渲染成就进度横条。ForEach 是 ArkUI 中用于循环渲染列表的核心组件,它接收三个参数:数据源数组、子项生成函数和键值生成函数。

数据源 getAchieveRows() 返回 6 条成就数据。子项生成函数接收两个参数:a 是当前数据项,ai 是索引值。索引值 ai 被用于条件判断 ai === 0 ? '#E8F5E9' : '#FFFFFF',使得第一个成就卡片(索引为 0)使用浅绿色背景突出显示,其余使用白色背景。

.maxLines(1) 限制成就名称最多显示一行,防止过长的文字撑破布局。.justifyContent(FlexAlign.Center) 设置 Column 内部子元素在主轴(垂直方向)上居中对齐。

ForEach 的第三个参数——键值生成函数——至关重要。它返回一个字符串作为列表项的唯一标识。当数据发生变化时,ArkUI 框架通过这个标识判断哪些项需要新增、删除或更新,从而实现高效的差量渲染。如果省略键值函数或返回不唯一的值,可能导致列表渲染错乱或性能下降。

5.5 馆藏明星横滑列表

Scroll() {
  Row() {
    ForEach(getHomeDinos(), (d: Dino) => {
      Column() {
        Text(d.emoji)
          .fontSize(38)
        Text(d.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ top: 8 })
        Text(d.era + ' · ' + d.diet)
          .fontSize(9)
          .fontColor('#8E8E9E')
          .margin({ top: 3 })
        Text(d.rarity)
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .backgroundColor(getRarityColor(d.rarity))
          .borderRadius(9)
          .margin({ top: 6 })
      }
      .width(118)
      .padding({ top: 14, bottom: 14, left: 8, right: 8 })
      .backgroundColor('#FFFFFF')
      .borderRadius(14)
      .margin({ right: 10 })
      .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
      .onClick(() => {
        this.onDinoClick(d);
      })
    }, (d: Dino) => String(d.id))
  }
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

这段代码实现了一个横向滑动的恐龙卡片列表。关键在于外层的 Scroll 容器配置了 .scrollable(ScrollDirection.Horizontal),将滚动方向从默认的垂直改为水平。.scrollBar(BarState.Off) 隐藏了滚动条,使界面更简洁。

每张恐龙卡片是一个固定宽度(118vp)的 Column,内部从上到下依次展示 Emoji 图标、名称、年代与食性信息、稀有度标签。稀有度标签的背景色通过 getRarityColor(d.rarity) 函数动态计算,实现了数据驱动的配色效果。

.shadow({ radius: 6, color: '#14000000', offsetY: 2 }) 为卡片添加阴影效果。radius 控制模糊半径,color 使用 #14 开头的低透明度黑色,offsetY 使阴影向下偏移 2vp,形成"悬浮"的视觉感受。这种轻微的阴影是 ArkUI 中增强层次感的常用手段。

Scroll 是 ArkUI 的滚动容器组件。它可以包裹任意内容,当内容超出可视区域时自动提供滚动能力。通过 scrollable 属性可以切换水平和垂直滚动方向,通过 scrollBar 属性可以控制滚动条的显示与隐藏。在列表数据量较大或卡片宽度固定时,ScrollList 更灵活。

六、恐龙图鉴模块

6.1 标签筛选栏

Row() {
  ForEach(getDietTags(), (tag: string) => {
    Text(tag)
      .fontSize(11)
      .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      .backgroundColor('#E8F5E9')
      .fontColor('#2E7D32')
      .borderRadius(12)
      .margin({ right: 8 })
  }, (tag: string) => tag)
}
.width('100%')
.margin({ bottom: 12 })

恐龙图鉴页面的顶部有一个标签筛选栏,通过 ForEach 渲染 getDietTags() 返回的四个标签。每个标签是一个 Text 组件,背景色为浅绿色 #E8F5E9,文字为深绿色 #2E7D32,圆角为 12vp。

这种"浅底深字"的配色方案是标签组件的经典样式。背景色和文字色取自同一色系的不同明度层级,保证了视觉协调性。标签之间通过 .margin({ right: 8 }) 设置右间距,形成均匀的间隔。

6.2 主推恐龙卡片

ForEach(getDinoRows(), (d: Dino) => {
  Row() {
    Text(d.emoji)
      .fontSize(36)
    Column() {
      Row() {
        Text(d.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(' ' + d.rarity)
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor(getRarityColor(d.rarity))
          .margin({ left: 4 })
      }
      Text(d.era + ' · ' + d.diet + ' · 体长 ' + String(d.length) + 'm · 体重 ' + String(d.weight) + 't')
        .fontSize(9)
        .fontColor('#8E8E9E')
        .margin({ top: 3 })
      Text(d.desc)
        .fontSize(9)
        .fontColor('#9E9E9E')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onDinoClick(d);
  })
}, (d: Dino) => String(d.id))

这段代码渲染恐龙图鉴的"主推卡片"列表。每张卡片采用 Row + Column 的组合布局:左侧是 36 号字的大 Emoji 图标,右侧是信息列。

信息列内部又嵌套了一个 Row,横向展示恐龙名称和稀有度标签。稀有度的文字颜色通过 getRarityColor(d.rarity) 动态设置,与卡片横滑列表中的标签配色保持一致。

.maxLines(2) 限制描述文案最多显示两行,.textOverflow({ overflow: TextOverflow.Ellipsis }) 设置溢出时以省略号结尾。这两个属性组合使用,确保长文本不会撑破卡片高度,同时通过省略号提示用户"内容被截断"。

.onClick(() => { this.onDinoClick(d); }) 为每张卡片绑定点击事件。用户点击后,恐龙数据 d 通过回调函数传递给父组件,父组件据此弹出恐龙详情弹框。这种"子组件只负责通知、父组件负责响应"的设计模式,是 ArkUI 组件化开发的推荐实践。

6.3 紧凑列表卡片

ForEach(getDinoRows2(), (d: Dino) => {
  Row() {
    Text(d.emoji)
      .fontSize(30)
    Column() {
      Text(d.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text(d.era + ' · ' + d.diet)
        .fontSize(9)
        .fontColor('#8E8E9E')
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Text(d.rarity)
      .fontSize(9)
      .fontWeight(FontWeight.Bold)
      .fontColor(getRarityColor(d.rarity))
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 10, bottom: 10 })
  .backgroundColor('#F9F9FB')
  .borderRadius(12)
  .margin({ bottom: 8 })
  .onClick(() => {
    this.onDinoClick(d);
  })
}, (d: Dino) => String(d.id))

与主推卡片相比,紧凑列表卡片的差异在于:Emoji 字号从 36 降为 30,名称字号从 15 降为 13,描述文案被省略,只保留年代和食性信息。背景色从纯白 #FFFFFF 改为极浅灰 #F9F9FB,阴影效果也被移除,整体视觉更轻量。

这种"主推 + 紧凑"的双层列表设计在移动端应用中非常常见。主推卡片用于展示精选内容,信息丰富、视觉突出;紧凑列表用于展示完整列表,信息精简、扫读高效。两者通过中间的"— 更多恐龙 —"分割线自然过渡。

七、化石陈列模块

7.1 化石主推卡片

ForEach(getFossilRows(), (f: Fossil) => {
  Row() {
    Text(f.emoji)
      .fontSize(34)
    Column() {
      Text(f.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text(f.part + ' · ' + f.age)
        .fontSize(9)
        .fontColor('#8E8E9E')
        .margin({ top: 3 })
      Text(f.desc)
        .fontSize(9)
        .fontColor('#9E9E9E')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Column() {
      Text(f.rarity)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(getRarityColor(f.rarity))
        .borderRadius(9)
      Text(String(f.value) + ' 币')
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2E7D32')
        .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onFossilClick(f);
  })
}, (f: Fossil) => String(f.id))

化石卡片的布局采用了三段式结构:左侧 Emoji、中间信息列、右侧稀有度与估值列。右侧的 Column 使用 .alignItems(HorizontalAlign.End) 使子元素右对齐,让稀有度标签和估值数值在视觉上形成"右贴边"的排列效果。

稀有度标签的背景色通过 getRarityColor(f.rarity) 动态生成,文字为白色,配合圆角和内边距形成"药丸"形状的标签。估值文字使用绿色 #2E7D32,与稀有度标签上下排列,传递出"这件化石值多少钱"的信息。

7.2 化石紧凑列表

ForEach(getFossilRows2(), (f: Fossil) => {
  Row() {
    Text(f.emoji)
      .fontSize(26)
    Column() {
      Text(f.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text(f.part + ' · ' + f.age)
        .fontSize(9)
        .fontColor('#8E8E9E')
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 8 })

    Text(String(f.value) + ' 币')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#2E7D32')
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 10, bottom: 10 })
  .backgroundColor('#FFF8E1')
  .borderRadius(10)
  .margin({ bottom: 8 })
  .onClick(() => {
    this.onFossilClick(f);
  })
}, (f: Fossil) => String(f.id))

化石紧凑列表的背景色使用了 #FFF8E1,这是一种极浅的暖黄色调。与主推卡片的纯白背景相比,暖黄色调传递出"古老""化石"的视觉联想,是一种符合业务场景的语义化配色。描述文案被省略,只保留名称、部位、年代和估值四项核心信息。

八、挖掘现场模块

8.1 柱状图组件

Row() {
  ForEach(getDigDays(), (d: DigDay, di: number) => {
    Column() {
      Column() {
        Column()
          .width(18)
          .height(getBarHeight(d.count * 6))
          .backgroundColor(di === 6 ? '#2E7D32' : '#A5D6A7')
          .borderRadius(4)
      }
      .width('100%')
      .height(70)
      .justifyContent(FlexAlign.End)
      .alignItems(HorizontalAlign.Center)

      Text(d.day)
        .fontSize(9)
        .fontColor('#8E8E9E')
        .margin({ top: 6 })
    }
    .layoutWeight(1)
  }, (d: DigDay) => d.day)
}
.width('100%')
.padding({ top: 12, bottom: 12, left: 8, right: 8 })
.backgroundColor('#FFFFFF')
.borderRadius(12)

这段代码实现了一个简易柱状图,展示一周内每天的出土化石数量。每个柱子是一个 Column 组件,宽度固定为 18vp,高度通过 getBarHeight(d.count * 6) 动态计算。

柱状图的核心技巧在于外层 Column 设置了 .height(70).justifyContent(FlexAlign.End)。固定高度为 70vp 的容器配合底部对齐,使得柱子从底部向上"生长",形成标准的柱状图效果。如果柱子高度小于 70vp,上方会留出空白;如果接近 70vp,柱子几乎顶满容器。

di === 6 判断是否为周日(索引从 0 开始,6 代表第七天即周日),周日的柱子使用深绿色 #2E7D32,其他天使用浅绿色 #A5D6A7,突出"峰值日"的视觉效果。

FlexAlign 是 ArkUI 中控制弹性布局对齐方式的枚举类型。FlexAlign.Center 表示居中对齐,FlexAlign.Start 表示顶部对齐,FlexAlign.End 表示底部对齐。在柱状图场景中,FlexAlign.End 是关键——它让柱子从容器底部开始排列,模拟了真实柱状图的视觉效果。

8.2 挖掘点卡片与预约按钮

ForEach(getDigRows(), (s: DigSite) => {
  Row() {
    Text(s.emoji)
      .fontSize(30)
    Column() {
      Text(s.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
      Text(s.area + ' · 深度 ' + String(s.depth) + 'm')
        .fontSize(9)
        .fontColor('#8E8E9E')
        .margin({ top: 3 })
      Text('已出土 ' + String(s.finds) + ' 件')
        .fontSize(9)
        .fontColor('#2E7D32')
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Text('预约 ›')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      .backgroundColor('#2E7D32')
      .borderRadius(12)
      .onClick(() => {
        this.onBookDig(s);
      })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onDigClick(s);
  })
}, (s: DigSite) => String(s.id))

挖掘点卡片在恐龙卡片的基础上增加了右侧的"预约 ›"按钮。这个按钮是一个 Text 组件,通过 .backgroundColor('#2E7D32') 设置绿色背景、.borderRadius(12) 设置圆角,配合白色文字形成"按钮"的视觉效果。

值得注意的是卡片本身和预约按钮分别绑定了不同的点击事件。卡片整体的 onClick 调用 this.onDigClick(s),弹出挖掘点详情;预约按钮的 onClick 调用 this.onBookDig(s),弹出预约抽屉。这种"卡片点击 + 按钮点击"的双事件设计,让用户可以在同一个卡片上执行两种不同的操作。

九、活动日历模块

9.1 活动主推卡片

ForEach(getActRows(), (a: JuraActivity, ai: number) => {
  Row() {
    Column() {
      Text(String(ai + 1))
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
    }
    .width(30)
    .height(30)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(a.color)
    .borderRadius(15)

    Column() {
      Row() {
        Text(a.emoji)
          .fontSize(16)
        Text(a.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ left: 4 })
        Text('')
          .layoutWeight(1)
        Text(a.status)
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(getStatusColor(a.status))
          .borderRadius(9)
      }
      .width('100%')

      Text(a.type + ' · ' + a.time)
        .fontSize(10)
        .fontColor('#8E8E9E')
        .margin({ top: 4 })

      Row() {
        Text('报名 ' + String(a.joined) + '/' + String(a.quota))
          .fontSize(9)
          .fontColor('#2E7D32')
        Text('')
          .layoutWeight(1)
        Text('奖励 ' + String(a.reward) + ' 币')
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F57C00')
      }
      .width('100%')
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onActClick(a);
  })
}, (a: JuraActivity) => String(a.id))

活动卡片的左侧是一个序号圆圈,宽高均为 30vp,.borderRadius(15) 使其变为圆形(宽高的一半即为半径)。圆圈背景色取自 a.color,即活动数据自带的主题色,内含白色序号文字(ai + 1),形成"排名"或"编号"的视觉效果。

卡片右侧的信息区分为三层:顶部是活动名称和状态标签的横向排列,中间是类型和时间信息,底部是报名人数和奖励信息的两端对齐排列。这种三层结构通过嵌套 RowColumn 实现,展示了 ArkUI 灵活的组合布局能力。

9.2 我组织的活动卡片

ForEach(getActRows2(), (a: JuraActivity) => {
  Column() {
    Row() {
      Text(a.emoji)
        .fontSize(22)
      Column() {
        Text(a.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(a.time + ' · ' + a.type)
          .fontSize(9)
          .fontColor('#8E8E9E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 8 })

      Text(a.status)
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(getStatusColor(a.status))
        .borderRadius(9)
    }
    .width('100%')

    Row() {
      Text('报名 ' + String(a.joined) + '/' + String(a.quota))
        .fontSize(9)
        .fontColor('#2E7D32')
      Text('')
        .layoutWeight(1)
      Text('编辑')
        .fontSize(11)
        .fontColor('#F57C00')
        .onClick(() => {
          this.onEditAct(a);
        })
      Text('删除')
        .fontSize(11)
        .fontColor('#D32F2F')
        .margin({ left: 14 })
        .onClick(() => {
          this.onDeleteAct(a);
        })
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#E8F5E9')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .onClick(() => {
    this.onActClick(a);
  })
}, (a: JuraActivity) => String(a.id))

"我组织的活动"卡片使用了浅绿色背景 #E8F5E9,与"全部活动"的白色背景形成区分。卡片底部增加了"编辑"和"删除"两个操作文字,分别使用橙色 #F57C00 和红色 #D32F2F,通过颜色语义传达操作的性质——编辑是温和的修改,删除是危险的操作。

.margin({ left: 14 }) 为"删除"文字设置左间距,使其与"编辑"文字保持视觉间隔。两个操作文字各自绑定了不同的点击事件,分别调用 this.onEditAct(a)this.onDeleteAct(a),触发编辑弹框和删除确认弹框。

十、商店模块

10.1 商品主推卡片

ForEach(getGoodsRows(), (g: JuraGoods) => {
  Column() {
    Row() {
      Text(g.emoji)
        .fontSize(32)
      Column() {
        Text(g.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(g.tag + ' · 库存 ' + String(g.stock) + ' 件')
          .fontSize(9)
          .fontColor('#8E8E9E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

      Text(g.tag)
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(g.color)
        .borderRadius(9)
    }
    .width('100%')

    Row() {
      Text('¥' + String(g.price))
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#D32F2F')
      Text(' ¥' + String(g.oldPrice))
        .fontSize(10)
        .fontColor('#BDBDBD')
        .decoration({ type: TextDecorationType.LineThrough })
        .margin({ left: 6 })
      Text('')
        .layoutWeight(1)
      Text('兑换 ›')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2E7D32')
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onGoodsClick(g);
  })
}, (g: JuraGoods) => String(g.id))

商品卡片分为上下两层。上层是商品图标、名称、标签信息横向排列;下层是价格信息和兑换操作。价格区域展示了当前售价(红色 #D32F2F)和原价(灰色 #BDBDBD)的对比。

.decoration({ type: TextDecorationType.LineThrough }) 是关键属性,它为原价文字添加删除线效果。这是电商场景中的标准 UI 模式——用删除线划掉原价,旁边展示现价,直观传达"折扣"信息。

TextDecorationType 是 ArkUI 的文字装饰枚举,包含 LineThrough(删除线)、Underline(下划线)、Overline(上划线)和 None(无装饰)四种取值。在商品价格展示场景中,LineThrough 是最常用的取值。

10.2 琥珀币余额展示

Column() {
  Text('860')
    .fontSize(15)
    .fontWeight(FontWeight.Bold)
    .fontColor('#FFD600')
  Text('琥珀币')
    .fontSize(9)
    .fontColor('#C8E6C9')
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#33FFFFFF')
.borderRadius(10)
.onClick(() => {
  this.onRecharge();
})

商店页面顶部展示用户当前的琥珀币余额。余额数字使用琥珀金色 #FFD600,下方标注"琥珀币"单位文字。整个余额区域包裹在半透明白色背景 #33FFFFFF 中,点击后触发充值弹框。

十一、个人中心模块

11.1 用户信息卡与统计数据

Column() {
  Row() {
    Text('🧑‍🔬')
      .fontSize(40)
    Column() {
      Text('考古队长 · 小岩')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text('加入化石馆 3 个月 · 累计挖掘 96m')
        .fontSize(10)
        .fontColor('#C8E6C9')
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })

    Text('🎖️')
      .fontSize(28)
  }
  .width('100%')

  Row() {
    Column() {
      Text('8')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('标本数')
        .fontSize(9)
        .fontColor('#C8E6C9')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    Column() {
      Text('23')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('挖掘次数')
        .fontSize(9)
        .fontColor('#C8E6C9')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    Column() {
      Text('2')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD600')
      Text('SSR 藏品')
        .fontSize(9)
        .fontColor('#C8E6C9')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
  }
  .width('100%')
  .padding({ top: 14, bottom: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 18, bottom: 16 })
.borderRadius(16)
.shadow({ radius: 12, color: '#332E7D32', offsetY: 4 })

个人中心页面的顶部是用户信息卡,包含头像 Emoji、用户名、加入时间和三个统计指标。三个统计指标(标本数、挖掘次数、SSR 藏品)通过三个 Column 横向排列,每个 Column 使用 .layoutWeight(1) 等分宽度,形成"三等分"的布局效果。

每个统计指标由数字和标签两部分组成。数字使用琥珀金色 #FFD600,标签使用浅绿色 #C8E6C9,两者通过 .margin({ top: 2 }) 保持紧凑的垂直间距。这种"大数字 + 小标签"的设计是数据统计展示的经典范式。

11.2 标本收藏卡片

ForEach(getSpecRows(), (s: Specimen) => {
  Column() {
    Row() {
      Text(s.emoji)
        .fontSize(26)
      Column() {
        Text(s.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(s.dino + ' · ' + s.date)
          .fontSize(9)
          .fontColor('#8E8E9E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

      Text(s.status)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(getSpecStatusColor(s.status))
        .borderRadius(10)
    }
    .width('100%')

    Row() {
      Text(s.rarity + ' 稀有度')
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor(getRarityColor(s.rarity))
      Text('')
        .layoutWeight(1)
      Text('删除')
        .fontSize(10)
        .fontColor('#D32F2F')
        .onClick(() => {
          this.onDeleteSpec(s);
        })
    }
    .width('100%')
    .margin({ top: 8 })
    .padding({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onSpecClick(s);
  })
}, (s: Specimen) => String(s.id))

标本卡片采用上下两层结构。上层展示标本图标、名称、来源恐龙和收藏日期,以及右侧的状态标签。下层展示稀有度文字和删除操作。稀有度文字的颜色通过 getRarityColor(s.rarity) 动态设置,与恐龙和化石模块的配色保持一致。

状态标签的背景色通过 getSpecStatusColor(s.status) 动态设置——"已收藏"对应绿色,"展示中"对应青色,"待鉴定"对应橙色。这种基于数据动态计算颜色的方式贯穿了整个应用的所有模块,是本项目配色体系的核心特征。

十二、主入口与状态管理

12.1 枚举定义与状态声明

enum JuraTab {
  Home = 0,
  Dino = 1,
  Fossil = 2,
  Dig = 3,
  Act = 4,
  Shop = 5,
  Mine = 6
}

@Entry
@Component
struct JuraApp {
  @State curTab: number = JuraTab.Home;
  @State showDino: boolean = false;
  @State selDino: Dino | null = null;
  @State showFossil: boolean = false;
  @State selFossil: Fossil | null = null;
  // ...更多状态变量
  @State toast: string = '';
  @State fSlot: string = '09:00-10:00';
  @State fPayIdx: number = 0;
  @State fName: string = '';
  @State fTime: string = '';
  @State fReward: number = 100;

JuraTab 枚举定义了七个页签的索引值。使用枚举而非魔法数字(如直接写 0、1、2…)的好处是代码可读性大幅提升——JuraTab.Home0 更容易理解其含义。枚举值从 0 开始递增,恰好与底部导航栏的 ForEach 索引对应。

@Entry 装饰器标记 JuraApp 为应用的入口组件。一个页面只能有一个 @Entry 组件,它是组件树的根节点。@Component 装饰器使其成为可渲染的自定义组件。

@State 装饰器是 ArkUI 状态管理的核心。被 @State 修饰的变量称为"状态变量",当其值发生变化时,ArkUI 框架会自动重新渲染依赖该变量的 UI 部分。例如,curTab 变化时,contentArea Builder 中的条件判断会重新执行,切换显示的页面组件。

状态变量的类型设计值得关注。selDino 的类型是 Dino | null,表示它可以是 Dino 对象或 null。初始值为 null,当用户点击恐龙卡片时被赋值为对应的 Dino 对象。配合 showDino 布尔变量控制弹框的显示与隐藏,两者组合形成了"选中数据 + 显示标志"的弹框管理模式。

@State 装饰器的工作原理基于"观察者模式"。ArkUI 编译器会在编译阶段分析每个 @State 变量在 build() 方法中的使用位置,自动注册依赖关系。当变量值改变时,框架只重新渲染依赖该变量的 UI 片段,而非整个页面。这种"精确差量更新"机制是声明式 UI 高性能的基础。

12.2 内容区域 Builder

@Builder
contentArea() {
  if (this.curTab === JuraTab.Home) {
    JuraHomeTab({
      onDinoClick: (d: Dino) => {
        this.selDino = d;
        this.showDino = true;
      },
      onDigClick: (s: DigSite) => {
        this.selDig = s;
        this.showDig = true;
      },
      onActClick: (a: JuraActivity) => {
        this.selAct = a;
        this.showAct = true;
      },
      onMoreAct: () => {
        this.curTab = JuraTab.Act;
      }
    })
  } else if (this.curTab === JuraTab.Dino) {
    DinoTab({
      onDinoClick: (d: Dino) => {
        this.selDino = d;
        this.showDino = true;
      }
    })
  } else if (this.curTab === JuraTab.Fossil) {
    FossilTab({
      onFossilClick: (f: Fossil) => {
        this.selFossil = f;
        this.showFossil = true;
      }
    })
  }
  // ...其余页签分支
}

@Builder 装饰器用于定义一个可复用的 UI 片段。与 @Component 不同,@Builder 方法不能拥有独立的状态,它直接引用所在组件的状态变量。contentArea 是一个 @Builder 方法,根据 curTab 的值条件渲染不同的页面组件。

每个页面组件在实例化时,通过参数传入了回调函数。例如 JuraHomeTab 接收了 onDinoClickonDigClickonActClickonMoreAct 四个回调。当子组件内部触发这些回调时,父组件 JuraApp 中的箭头函数会执行状态更新操作,如 this.selDino = d; this.showDino = true;

这种"回调传参 + 状态上提"的模式是 ArkUI 组件间通信的核心方式。子组件不直接管理全局状态,而是通过回调通知父组件;父组件统一管理状态,状态变化后驱动弹框的显示。这种设计保证了数据流的单向性和可追踪性。

@Builder 装饰器的本质是一个"UI 构建函数"。它不产生独立组件实例,而是在调用处内联展开。这意味着 @Builder 方法中引用的 this 指向的是宿主组件(如 JuraApp),而非独立的作用域。这种设计使得 @Builder 可以直接读写宿主组件的状态变量,非常适合用于抽取重复的 UI 片段和实现条件渲染逻辑。

12.3 底部导航栏 Builder

@Builder
bottomTabItem(icon: string, label: string, idx: number) {
  Column() {
    Text(icon)
      .fontSize(19)
      .opacity(this.curTab === idx ? 1 : 0.55)
    Text(label)
      .fontSize(9)
      .fontColor(this.curTab === idx ? '#2E7D32' : '#9E9E9E')
      .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .padding({ top: 5, bottom: 5 })
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.curTab = idx;
  })
}

bottomTabItem 是一个带参数的 @Builder 方法,接收 icon(图标)、label(文字)和 idx(索引)三个参数。它通过 this.curTab === idx 判断当前是否为选中页签,据此设置不同的透明度、颜色和字重。

选中页签的图标不透明(opacity(1))、文字为深绿色加粗;未选中页签的图标半透明(opacity(0.55))、文字为灰色常规字重。这种"选中态 + 默认态"的视觉差异是导航栏的标准交互模式。

点击导航项时执行 this.curTab = idx,修改状态变量 curTab 的值。ArkUI 框架检测到 curTab 变化后,会重新执行 contentArea Builder 的条件判断,切换显示的页面组件,同时重新评估所有 bottomTabItem 的选中态样式。整个过程由框架自动完成,开发者无需手动操作视图。

12.4 主构建方法与 Stack 布局

build() {
  Stack() {
    Column() {
      Column() {
        Text('🦖 恐龙纪元 · 员工化石馆')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Row() {
          Text('⛏️')
            .fontSize(12)
          Text(' 本周挖掘之星:周六出土 6 件')
            .fontSize(9)
            .fontColor('#C8E6C9')
            .margin({ left: 2 })
        }
        .margin({ top: 3 })
      }
      .width('100%')
      .padding({ top: 14, bottom: 12 })
      .justifyContent(FlexAlign.Center)

      this.contentArea()

      Column() {
        Row() {
          ForEach(this.tabs1, (t: string, ti: number) => {
            this.bottomTabItem(this.tabs1Icons[ti], t, ti)
          }, (t: string) => t)
        }
        .width('100%')

        Row() {
          ForEach(this.tabs2, (t: string, ti: number) => {
            this.bottomTabItem(this.tabs2Icons[ti], t, ti + 4)
          }, (t: string) => t)
        }
        .width('100%')
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F8F0')

    if (this.showDino && this.selDino !== null) {
      this.modalOverlay(() => {
        this.showDino = false;
      })
      this.dinoModal()
    }
    // ...其余弹框条件分支

    if (this.toast.length > 0) {
      Column() {
        Text('✅ ' + this.toast)
          .fontSize(13)
          .fontColor('#FFFFFF')
          .padding({ left: 18, right: 18, top: 10, bottom: 10 })
          .backgroundColor('#333333')
          .borderRadius(18)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .position({ x: 0, y: '72%' })
    }
  }
  .width('100%')
  .height('100%')
}

build()JuraApp 组件的主构建方法。最外层使用 Stack 容器,这是 ArkUI 中的堆叠布局容器,其子元素默认沿 Z 轴方向叠放——后声明的子元素覆盖在先声明的子元素之上。

Stack 内部第一个子元素是 Column,包含顶部标题栏、内容区域(this.contentArea())和底部导航栏三部分,构成应用的基础界面。背景色 #F4F8F0 是一种极浅的绿色调,为整个应用奠定了"自然""化石"的视觉基调。

Stack 内部后续的子元素是各种弹框。每个弹框通过 if 条件判断控制是否渲染。例如 if (this.showDino && this.selDino !== null) 同时检查布尔标志和数据非空,两个条件都满足时才渲染恐龙详情弹框。弹框由 modalOverlay(半透明遮罩)和具体的弹框 Builder(如 dinoModal)两部分组成,遮罩在底层、弹框内容在上层,叠放在基础界面之上。

最后是 Toast 提示,通过 if (this.toast.length > 0) 判断是否显示。Toast 使用 .position({ x: 0, y: '72%' }) 绝对定位到屏幕 72% 高度处,以深色背景圆角的形式呈现操作反馈。

Stack 容器在 ArkUI 中扮演着"层叠管理器"的角色。它将基础界面、遮罩层、弹框内容层、Toast 提示层按声明顺序从底到顶堆叠,通过 Z 轴排列实现了"模态弹框覆盖在页面上"的视觉效果。如果改用 Column 布局,弹框会被挤到页面下方而非覆盖在页面上方,无法实现模态效果。

十三、弹框体系详解

13.1 遮罩层 Builder

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

modalOverlay 是所有弹框共用的遮罩层 Builder。它接收一个 onClose 回调参数,当用户点击遮罩区域时触发,用于关闭弹框。

遮罩层是一个充满整个屏幕的 Column,背景色为 rgba(0,0,0,0.55),即 55% 不透明度的黑色。这种半透明遮罩 dim 了底层页面内容,使弹框成为视觉焦点。点击遮罩关闭弹框是移动端应用的标准交互模式,用户无需寻找关闭按钮,点击弹框外的任意区域即可退出。

13.2 恐龙详情弹框

@Builder
dinoModal() {
  Column() {
    Column() {
      Row() {
        Text(this.selDino!.emoji)
          .fontSize(40)
        Column() {
          Text(this.selDino!.name)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text(this.selDino!.era + ' · ' + this.selDino!.diet)
            .fontSize(10)
            .fontColor('#C8E6C9')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })

        Text('✕')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .padding(8)
          .onClick(() => {
            this.showDino = false;
          })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 16 })

    Column() {
      Row() {
        Column() {
          Text(String(this.selDino!.length) + 'm')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2E7D32')
          Text('体长')
            .fontSize(9)
            .fontColor('#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(String(this.selDino!.weight) + 't')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2E7D32')
          Text('体重')
            .fontSize(9)
            .fontColor('#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(this.selDino!.rarity)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(getRarityColor(this.selDino!.rarity))
          Text('稀有度')
            .fontSize(9)
            .fontColor('#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#E8F5E9')
      .borderRadius(12)

      Text(this.selDino!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 12 })

      Text('查看馆藏化石')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#2E7D32')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showDino = false;
          this.toast = '已为你定位 ' + this.selDino!.name + ' 化石展区';
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('82%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

恐龙详情弹框采用上下拼接的卡片结构。上部是恐龙名称和年代信息的头部区域,下部是数据统计区、描述文案和操作按钮。注意 this.selDino! 中的感叹号 !——这是 ArkTS 的"非空断言"操作符,告诉编译器"我确定 selDino 此时不为 null",因为弹框的渲染条件已经包含了 this.selDino !== null 的判断。

弹框的统计区使用了三等分布局,分别展示体长、体重和稀有度。稀有度的文字颜色通过 getRarityColor(this.selDino!.rarity) 动态设置,与列表卡片中的配色一致。

.transition(TransitionEffect.OPACITY.animation({ duration: 200 })) 为弹框添加了 200 毫秒的透明度过渡动画。当弹框出现时从透明渐变为不透明,消失时反向过渡,使弹框的进出有平滑的视觉效果而非生硬的闪现。

13.3 化石详情弹框

@Builder
fossilModal() {
  Column() {
    Column() {
      Text(this.selFossil!.emoji)
        .fontSize(52)
      Text(this.selFossil!.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text(this.selFossil!.part + ' · ' + this.selFossil!.age)
        .fontSize(10)
        .fontColor('#C8E6C9')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 22, bottom: 22 })
    .justifyContent(FlexAlign.Center)

    Column() {
      Row() {
        Text(this.selFossil!.rarity)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .backgroundColor(getRarityColor(this.selFossil!.rarity))
          .borderRadius(10)
        Text('')
          .layoutWeight(1)
        Text('估值 ' + String(this.selFossil!.value) + ' 币')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2E7D32')
      }
      .width('100%')

      Text(this.selFossil!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 12 })

      Text('加入收藏')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#5D4037')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showFossil = false;
          this.toast = '已加入收藏:' + this.selFossil!.name;
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('80%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

化石详情弹框与恐龙弹框结构类似,但头部区域居中展示大号 Emoji(52 号字)和化石名称,视觉风格更加"展览式"。稀有度标签和估值信息在同一行左右排列,通过 Text('').layoutWeight(1) 实现两端对齐。

"加入收藏"按钮的背景色使用了棕色 #5D4037,而非恐龙弹框中的绿色 #2E7D32。不同弹框使用不同的按钮配色,增加了视觉多样性,同时也符合"化石 = 棕色 = 大地"的语义联想。

13.4 挖掘点详情弹框

@Builder
digModal() {
  Column() {
    Row() {
      Text(this.selDig!.emoji + ' ' + this.selDig!.name)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showDig = false;
        })
    }
    .width('100%')

    Text(this.selDig!.desc)
      .fontSize(11)
      .fontColor('#616161')
      .lineHeight(18)
      .margin({ top: 10 })

    Row() {
      Column() {
        Text(String(this.selDig!.depth) + 'm')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2E7D32')
        Text('可挖深度')
          .fontSize(9)
          .fontColor('#8E8E9E')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .padding({ top: 12, bottom: 12 })
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#E8F5E9')
      .borderRadius(12)

      Column() {
        Text(String(this.selDig!.finds))
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F57C00')
        Text('已出土')
          .fontSize(9)
          .fontColor('#8E8E9E')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .padding({ top: 12, bottom: 12 })
      .justifyContent(FlexAlign.Center)
      .backgroundColor('#FFF8E1')
      .borderRadius(12)
      .margin({ left: 10 })
    }
    .width('100%')
    .margin({ top: 12 })

    Row() {
      Text('⛏️ 立即挖掘')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('48%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#2E7D32')
        .borderRadius(24)
        .onClick(() => {
          this.showDig = false;
          this.digResult = getDigGift();
          this.showDigResult = true;
        })

      Text('预约挖掘')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('48%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#5D4037')
        .borderRadius(24)
        .onClick(() => {
          this.bookDig = this.selDig;
          this.fSlot = '09:00-10:00';
          this.showDig = false;
          this.showBookDig = true;
        })
    }
    .width('100%')
    .margin({ top: 16 })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

挖掘点详情弹框与其他弹框有一个重要区别:它使用了 .borderRadius({ topLeft: 18, topRight: 18 }) 只设置上方两个圆角,配合 .width('100%').constraintSize({ maxHeight: '80%' }),形成了一个从底部弹出的"底部抽屉"样式。

弹框底部有两个操作按钮:“立即挖掘"和"预约挖掘”,各占 48% 宽度。点击"立即挖掘"会关闭当前弹框并打开挖掘收获弹框(showDigResult = true);点击"预约挖掘"会关闭当前弹框并打开预约抽屉(showBookDig = true)。这种"弹框链式跳转"的交互方式在复杂表单流程中很常见。

13.5 挖掘预约抽屉

@Builder
bookDigModal() {
  Column() {
    Row() {
      Text('📅 预约挖掘')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showBookDig = false;
        })
    }
    .width('100%')

    Row() {
      Text(this.bookDig!.emoji)
        .fontSize(24)
      Text(this.bookDig!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ left: 8 })
      Text('')
        .layoutWeight(1)
      Text(this.bookDig!.status)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(getStatusColor(this.bookDig!.status))
        .borderRadius(9)
    }
    .width('100%')
    .padding({ top: 12, bottom: 12, left: 12, right: 12 })
    .backgroundColor('#E8F5E9')
    .borderRadius(12)
    .margin({ top: 12 })

    Text('选择时段')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 8 })

    Row() {
      ForEach(getSlotList(), (slot: string) => {
        Text(slot)
          .fontSize(11)
          .padding({ left: 10, right: 10, top: 6, bottom: 6 })
          .backgroundColor(this.fSlot === slot ? '#2E7D32' : '#F5F5F5')
          .fontColor(this.fSlot === slot ? '#FFFFFF' : '#616161')
          .borderRadius(10)
          .margin({ right: 8 })
          .onClick(() => {
            this.fSlot = slot;
          })
      }, (slot: string) => slot)
    }
    .width('100%')
    .margin({ top: 2 })

    Text('确认预约')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#2E7D32')
      .borderRadius(24)
      .margin({ top: 16 })
      .onClick(() => {
        this.showBookDig = false;
        this.toast = '预约成功:' + this.bookDig!.name + ' ' + this.fSlot;
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

预约挖掘弹框是一个底部抽屉式表单,包含标题栏、挖掘点信息卡、时段选择器和确认按钮。时段选择器通过 ForEach 渲染 getSlotList() 返回的四个时段选项,每个选项的背景色和文字色根据 this.fSlot === slot 的判断结果动态切换——选中时为绿底白字,未选中时为灰底深灰字。

点击时段选项时执行 this.fSlot = slot,更新状态变量。由于 fSlot@State 修饰的,ArkUI 会自动重新渲染时段选择器,更新选中态样式。这种"点击 → 状态更新 → 自动重渲染"的循环正是声明式 UI 的核心工作流程。

13.6 挖掘收获弹框

@Builder
digResultModal() {
  Column() {
    Column() {
      Text('🎉')
        .fontSize(44)
      Text('挖掘成功!')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text('本次出土')
        .fontSize(10)
        .fontColor('#C8E6C9')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 22, bottom: 18 })
    .justifyContent(FlexAlign.Center)

    Column() {
      Text(this.digResult)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#5D4037')
        .margin({ top: 18 })

      Text('已自动收入标本库,可在「我的」查看')
        .fontSize(10)
        .fontColor('#8E8E9E')
        .margin({ top: 6 })

      Text('收下它')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#5D4037')
        .borderRadius(24)
        .margin({ top: 16, bottom: 18 })
        .onClick(() => {
          this.showDigResult = false;
          this.toast = '收获 +1 标本';
        })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
  .width('78%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

挖掘收获弹框是一个奖励展示卡,上部居中展示"🎉"庆祝图标和"挖掘成功!"标题,下部展示出土的化石名称(来自 this.digResult 状态变量)和"收下它"按钮。这个弹框的 digResult 值是在挖掘点详情弹框中点击"立即挖掘"时通过 this.digResult = getDigGift() 设置的,体现了弹框间的数据传递。

13.7 活动详情弹框

@Builder
actModal() {
  Column() {
    Column() {
      Row() {
        Text(this.selAct!.emoji)
          .fontSize(34)
        Column() {
          Text(this.selAct!.name)
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text(this.selAct!.type + ' · ' + this.selAct!.status)
            .fontSize(10)
            .fontColor('#C8E6C9')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text('✕')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .padding(8)
          .onClick(() => {
            this.showAct = false;
          })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 16 })

    Column() {
      Row() {
        Column() {
          Text(this.selAct!.time)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text('活动时间')
            .fontSize(9)
            .fontColor('#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(String(this.selAct!.joined) + '/' + String(this.selAct!.quota))
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2E7D32')
          Text('报名人数')
            .fontSize(9)
            .fontColor('#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(String(this.selAct!.reward) + ' 币')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F57C00')
          Text('参与奖励')
            .fontSize(9)
            .fontColor('#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#E8F5E9')
      .borderRadius(12)

      Text(this.selAct!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 12 })

      Text(this.selAct!.status === '报名中' ? '立即报名' : '活动详情')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(this.selAct!.status === '报名中' ? '#2E7D32' : '#9E9E9E')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showAct = false;
          if (this.selAct!.status === '报名中') {
            this.toast = '报名成功:' + this.selAct!.name;
          }
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('82%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

活动详情弹框的底部按钮文案和背景色都通过条件表达式动态决定。当活动状态为"报名中"时,按钮显示"立即报名"且背景为绿色;否则显示"活动详情"且背景为灰色。这种"按钮文案和样式随数据状态变化"的设计,使得同一个弹框可以适配不同的活动状态。

点击按钮时,先关闭弹框(this.showAct = false),再根据状态条件决定是否显示报名成功的 Toast。this.selAct!.status === '报名中' 的判断确保只有可报名的活动才会提示"报名成功"。

13.8 新增活动表单弹框

@Builder
addActModal() {
  Column() {
    Row() {
      Text('+ 发起新活动')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showAddAct = false;
        })
    }
    .width('100%')

    Text('活动名称')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 6 })
    Text(this.fName.length > 0 ? this.fName : '例如:恐龙夜宿体验')
      .fontSize(12)
      .fontColor(this.fName.length > 0 ? '#333333' : '#BDBDBD')
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor('#F5F5F5')
      .borderRadius(10)

    Text('奖励琥珀币')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 12, bottom: 6 })
    Row() {
      ForEach(getRewardList(), (rw: number) => {
        Text(String(rw) + ' 币')
          .fontSize(11)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.fReward === rw ? '#2E7D32' : '#F5F5F5')
          .fontColor(this.fReward === rw ? '#FFFFFF' : '#616161')
          .borderRadius(10)
          .margin({ right: 8 })
          .onClick(() => {
            this.fReward = rw;
          })
      }, (rw: number) => String(rw))
    }
    .width('100%')
    .margin({ top: 2 })

    Text('确认发起')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#2E7D32')
      .borderRadius(24)
      .margin({ top: 16 })
      .onClick(() => {
        this.showAddAct = false;
        this.toast = '活动发起成功,等待审核';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

新增活动弹框是一个底部抽屉式表单,包含活动名称、活动时间和奖励琥珀币三个表单字段。活动名称字段使用了"占位符"技巧——当 this.fName.length > 0 时显示输入内容(深色文字),否则显示提示文案"例如:恐龙夜宿体验"(浅灰文字)。这种"有值显示值,无值显示提示"的模式模拟了输入框的 placeholder 效果。

奖励琥珀币字段通过 ForEach 渲染 getRewardList() 返回的四个选项(100、200、300、500),用户点击选项后更新 this.fReward 状态变量,选中项以绿底白字高亮显示。这种"标签选择器"的交互方式比下拉菜单更直观,是移动端表单的推荐实践。

13.9 删除确认弹框

@Builder
delActModal() {
  Column() {
    Column() {
      Text('🗑️')
        .fontSize(40)
      Text('删除活动')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text('删除后不可恢复,已报名员工将收到通知')
        .fontSize(10)
        .fontColor('#C8E6C9')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 20, bottom: 20 })
    .justifyContent(FlexAlign.Center)

    Column() {
      Text(this.delAct!.emoji + ' ' + this.delAct!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14 })

      Row() {
        Text('取消')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#616161')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#F5F5F5')
          .borderRadius(22)
          .onClick(() => {
            this.showDelAct = false;
          })

        Text('确认删除')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#D32F2F')
          .borderRadius(22)
          .onClick(() => {
            this.showDelAct = false;
            this.toast = '活动已删除';
          })
      }
      .width('100%')
      .margin({ top: 14, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
  .width('80%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

删除确认弹框是一个居中卡片,上部为红色头部区域展示"🗑️"图标和警告文案,下部为白色内容区展示被删除的活动名称和两个操作按钮。

"取消"按钮使用浅灰背景 #F5F5F5 和灰色文字 #616161,"确认删除"按钮使用红色背景 #D32F2F 和白色文字。这种"灰取消 + 红确认"的配色对比是危险操作确认弹框的标准模式——红色提醒用户操作的不可逆性,灰色降低取消操作的视觉权重,引导用户谨慎操作。

13.10 充值弹框

@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('选择充值档位')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 10 })

    Row() {
      ForEach(getPayLevels(), (lv: number, li: number) => {
        Column() {
          Text(String(lv))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.fPayIdx === li ? '#2E7D32' : '#333333')
          Text(getPayGifts()[li])
            .fontSize(9)
            .fontColor(this.fPayIdx === li ? '#2E7D32' : '#8E8E9E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .justifyContent(FlexAlign.Center)
        .backgroundColor(this.fPayIdx === li ? '#E8F5E9' : '#F5F5F5')
        .borderRadius(12)
        .margin({ right: 8 })
        .onClick(() => {
          this.fPayIdx = li;
        })
      }, (lv: number) => String(lv))
    }
    .width('100%')

    Text('充值即享双倍琥珀币福利')
      .fontSize(11)
      .fontColor('#F57C00')
      .margin({ top: 12 })
      .alignSelf(ItemAlign.Start)

    Text('确认充值')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#2E7D32')
      .borderRadius(24)
      .margin({ top: 14 })
      .onClick(() => {
        this.showPay = false;
        this.toast = '充值成功:+' + String(getPayLevels()[this.fPayIdx] * 2) + ' 琥珀币';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

充值弹框通过 ForEach 渲染 getPayLevels() 返回的四个充值档位(100、300、500、1000),同时通过 getPayGifts()[li] 获取对应索引的赠送信息。liForEach 的索引参数,用于与 this.fPayIdx 比较判断选中态。

选中档位的数字和赠送文字都变为绿色 #2E7D32,背景变为浅绿色 #E8F5E9;未选中档位为深灰文字和浅灰背景。.alignSelf(ItemAlign.Start) 使"充值即享双倍琥珀币福利"提示文字左对齐,覆盖了父容器的默认居中对齐行为。

确认充值时,Toast 提示的琥珀币数量为 getPayLevels()[this.fPayIdx] * 2,即所选档位金额的两倍,呼应了"双倍福利"的文案承诺。

十四、鸿蒙核心技术解析

14.1 Column 容器

Column 是 ArkUI 中最基本的线性布局容器之一。它的作用是将子组件沿垂直方向从上到下排列。在本项目中,几乎每个页面和卡片的根容器都是 Column,因为移动端页面的内容通常是从上到下滚动的。

Column 提供了 justifyContentalignItems 两个核心对齐属性。justifyContent 控制子组件在主轴(垂直方向)上的对齐方式,可选值包括 FlexAlign.Start(顶部对齐)、FlexAlign.Center(居中对齐)、FlexAlign.End(底部对齐)等。alignItems 控制子组件在交叉轴(水平方向)上的对齐方式,可选值包括 HorizontalAlign.Start(左对齐)、HorizontalAlign.Center(居中对齐)、HorizontalAlign.End(右对齐)。

14.2 Row 容器

Row 是与 Column 对应的横向线性布局容器。它的作用是将子组件沿水平方向从左到右排列。在本项目中,Row 广泛用于卡片内部的信息横向排列、导航栏的图标排列、标签栏的标签排列等场景。

RowjustifyContent 控制水平方向对齐,alignItems 控制垂直方向对齐。项目中大量使用的".layoutWeight(1) 弹性占位"技巧,本质上是利用了 Row 的弹性布局能力——当一个子组件设置了 layoutWeight 后,它会占据 Row 中剩余的水平空间。

14.3 Stack 容器

Stack 是堆叠布局容器,其子组件沿 Z 轴方向叠放。后声明的子组件覆盖在先声明的子组件之上。在本项目中,主入口的 build() 方法使用 Stack 作为根容器,将基础页面、弹框遮罩层、弹框内容层和 Toast 提示层按顺序堆叠,实现了模态弹框覆盖在页面之上的效果。

Stack 还支持 alignContent 属性控制子组件的对齐方式,默认为 Alignment.Center(居中)。这也是为什么所有居中弹框(如恐龙详情、化石详情)会自动居中显示的原因——它们是 Stack 的子组件,默认居中对齐。

14.4 @Builder 装饰器

@Builder 装饰器用于定义可复用的 UI 构建方法。与 @Component 不同,@Builder 方法不创建独立的组件实例,而是在调用处内联展开。这意味着 @Builder 方法内部引用的 this 指向宿主组件,可以直接读写宿主组件的状态变量。

在本项目中,@Builder 被用于三类场景:一是抽取复用的 UI 片段(如 modalOverlay 遮罩层、bottomTabItem 导航项);二是实现条件渲染逻辑(如 contentArea 根据 curTab 切换页面);三是封装弹框 UI(如 dinoModalfossilModal 等 13 个弹框 Builder)。

14.5 @Component 装饰器

@Component 装饰器将一个 struct 声明为自定义组件。被 @Component 修饰的组件可以拥有自己的状态变量(通过 @State 等装饰器声明),可以在其他组件的 build() 方法中被引用。在本项目中,JuraHomeTabDinoTabFossilTabDigTabJuraActTabJuraShopTabJuraMineTab 都是 @Component 修饰的独立页面组件。

14.6 @State 状态管理

@State 是最常用的状态装饰器。被 @State 修饰的变量值变化时,框架会自动重新渲染引用了该变量的 UI 片段。在本项目中,主入口 JuraApp 声明了 26 个 @State 变量,管理着页签切换、弹框显示、选中数据、表单输入等全部交互状态。

14.7 ForEach 循环渲染

ForEach 是 ArkUI 中用于列表渲染的核心组件。它接收三个参数:数据源数组、子项生成函数和键值生成函数。键值生成函数返回的字符串作为列表项的唯一标识,框架通过它实现高效的差量渲染。在本项目中,几乎所有列表(恐龙列表、化石列表、挖掘点列表、活动列表、商品列表、标本列表、成就列表、导航栏、标签栏)都使用 ForEach 渲染。

14.8 Scroll 滚动容器

Scroll 是 ArkUI 的滚动容器组件。当内容超出可视区域时,Scroll 自动提供滚动能力。通过 scrollable 属性可以设置滚动方向(ScrollDirection.VerticalScrollDirection.Horizontal),通过 scrollBar 属性可以控制滚动条显示(BarState.OnBarState.OffBarState.Auto)。在本项目中,每个页面组件的最外层都是 Scroll,首页的馆藏明星区域还使用了横向 Scroll

14.8 TransitionEffect 过渡动画

TransitionEffect 是 ArkUI 的过渡效果枚举。TransitionEffect.OPACITY 表示透明度过渡,配合 .animation({ duration: 200 }) 设置 200 毫秒的动画时长。在本项目中,所有 13 个弹框都使用了 .transition(TransitionEffect.OPACITY.animation({ duration: 200 })),使弹框出现时从透明渐变为不透明,消失时反向过渡。

十五、流程图解

15.1 组件架构图

@Entry JuraApp(主入口)

@Builder contentArea 内容区

@Builder bottomTabItem 底部导航项

@Builder modalOverlay 遮罩层

13个 @Builder 弹框方法

JuraHomeTab 首页

DinoTab 恐龙图鉴

FossilTab 化石陈列

DigTab 挖掘现场

JuraActTab 活动日历

JuraShopTab 商店

JuraMineTab 个人中心

dinoModal 恐龙详情

fossilModal 化石详情

digModal 挖掘点详情

bookDigModal 预约挖掘

digResultModal 挖掘收获

actModal 活动详情

addActModal 新增活动

editActModal 编辑活动

delActModal 删除活动

goodsModal 商品详情

payModal 充值

specModal 标本详情

delSpecModal 删除标本

15.2 页签切换数据流

Home

Dino

Fossil

Dig

Act

Shop

Mine

用户点击底部导航项

bottomTabItem onClick

this.curTab = idx

@State curTab 变化

ArkUI 框架检测到状态变化

重新执行 contentArea Builder

curTab === ?

渲染 JuraHomeTab

渲染 DinoTab

渲染 FossilTab

渲染 DigTab

渲染 JuraActTab

渲染 JuraShopTab

渲染 JuraMineTab

导航项选中态样式更新

15.3 弹框触发流程

Stack容器 JuraApp 页面组件 用户 Stack容器 JuraApp 页面组件 用户 点击列表卡片 调用回调函数 onXxxClick(data) 设置 selXxx = data 设置 showXxx = true @State 变化触发重渲染 if(showXxx && selXxx !== null) 渲染 modalOverlay 遮罩层 渲染 xxxModal 弹框内容 弹框显示(带200ms过渡动画) 点击遮罩或关闭按钮 showXxx = false @State 变化触发重渲染 弹框消失

15.4 挖掘操作完整链路

立即挖掘

预约挖掘

用户在挖掘页点击挖掘点卡片

onDigClick 回调

selDig = 挖掘点数据

showDig = true

digModal 弹框显示

用户选择操作

digResult = getDigGift()

showDigResult = true

digResultModal 收获弹框显示

点击收下它

toast = 收获+1标本

bookDig = selDig

showBookDig = true

bookDigModal 预约抽屉显示

选择时段 fSlot

点击确认预约

toast = 预约成功

十六、总结与对比

16.1 核心装饰器对比

装饰器 作用 适用对象 能否拥有独立状态 典型使用场景
@Entry 标记入口组件 struct 继承 @Component 特性 页面根组件
@Component 声明自定义组件 struct 可以拥有 @State 等状态 页面组件、独立功能组件
@State 声明状态变量 成员变量 自身即状态 页签索引、弹框开关、选中数据
@Builder 声明 UI 构建方法 成员方法 不能拥有独立状态 弹框、复用 UI 片段、条件渲染

16.2 容器组件对比

容器 排列方向 典型用途 核心对齐属性 项目中的应用场景
Column 垂直 页面骨架、卡片内容 justifyContent / alignItems 页面根容器、卡片信息列
Row 水平 信息行、标签栏、导航栏 justifyContent / alignItems 卡片信息行、导航栏、标签栏
Stack Z轴堆叠 模态层、覆盖层 alignContent 弹框层叠、Toast 定位
Scroll 可滚动 长内容滚动 scrollable / scrollBar 页面滚动、横滑列表

16.3 弹框类型对比

弹框名称 展示位置 宽度 圆角样式 核心交互
恐龙详情 居中 82% 四角圆 查看馆藏化石
化石详情 居中 80% 四角圆 加入收藏
挖掘点详情 底部 100% 上圆角 立即挖掘/预约挖掘
预约挖掘 底部 100% 上圆角 选择时段并确认
挖掘收获 居中 78% 四角圆 收下标本
活动详情 居中 82% 四角圆 立即报名
新增活动 底部 100% 上圆角 填写表单并提交
编辑活动 底部 100% 上圆角 修改信息并保存
删除活动 居中 80% 四角圆 取消/确认删除
商品详情 居中 80% 四角圆 立即兑换
充值 底部 100% 上圆角 选择档位并充值
标本详情 居中 82% 四角圆 查看鉴定报告
删除标本 居中 80% 四角圆 取消/确认移出

16.4 数据接口对比

接口名 字段数 核心特有字段 数据条数 对应页面
Dino 10 era/diet/length/weight 12 恐龙图鉴
Fossil 9 part/age/value 10 化石陈列
DigSite 9 area/depth/finds/status 8 挖掘现场
JuraActivity 10 type/time/quota/joined/reward 8 活动日历
JuraGoods 8 price/oldPrice/tag/stock 12 商店
Specimen 8 dino/date/status 8 个人中心
Achievement 8 progress/target/reward 6 首页成就栏

16.5 颜色映射函数对比

函数名 输入类型 输出维度 取值映射 使用场景
getRarityColor string 4档 SSR→金/SR→紫/R→青/N→灰 稀有度标签
getStatusColor string 3档 开放报名→绿/预约→橙/其他→灰 挖掘点/活动状态
getSpecStatusColor string 3档 已收藏→绿/展示→青/待鉴定→橙 标本状态
getBarHeight number 线性 26 + v * 0.55 柱状图高度
formatPrice number 二分 >=1000→k缩写/<1000→原值 价格格式化

16.6 详细总结

本文以一个完整的"恐龙纪元·员工化石馆"应用为案例,深入剖析了 HarmonyOS ArkUI 声明式 UI 开发的核心技术实践。从数据层到视图层,从组件拆分到状态管理,从页面渲染到弹框交互,每一个环节都体现了声明式编程的设计哲学。

在数据层设计方面,项目定义了七个数据接口,覆盖了恐龙、化石、挖掘点、活动、商品、标本和成就七种业务实体。每个接口的字段设计既包含了通用字段(如 emoji、color、desc),又包含了业务特有字段(如恐龙的 era、化石的 part、商品的 oldPrice),在保证数据结构一致性的同时兼顾了各模块的差异化需求。七组数据常量共包含 64 条数据,通过 14 个分页函数和 7 个工具函数进行加工和分发,实现了"数据定义一次、多处复用"的设计目标。

在组件层设计方面,项目将七大功能页面拆分为独立的 @Component 组件,每个组件通过回调函数属性与父组件通信。这种"子组件通知、父组件响应"的单向数据流模式,保证了组件间的解耦和可测试性。每个页面组件内部采用了"Scroll 包裹 Column"的骨架结构,通过 ForEach 循环渲染列表数据,通过 Row + Column 的嵌套组合实现复杂的卡片布局。主推卡片和紧凑列表的双层设计,在视觉层次和信息密度之间取得了平衡。

在交互层设计方面,项目在主入口 JuraApp 中集中管理了 26 个 @State 状态变量和 13 个 @Builder 弹框方法。状态变量分为四类:页签索引(curTab)、弹框开关(showXxx 系列)、选中数据(selXxx 系列)和表单输入(fSlot、fPayIdx、fName 等)。13 个弹框分为三种类型:居中卡片(恐龙详情、化石详情、商品详情等)、底部抽屉(挖掘点详情、预约挖掘、新增活动等)和居中确认框(删除活动、删除标本等)。所有弹框统一使用 modalOverlay 遮罩层和 TransitionEffect.OPACITY 过渡动画,保证了视觉和交互的一致性。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface Dino {
  id: number;
  emoji: string;
  name: string;
  era: string;
  diet: string;
  length: number;
  weight: number;
  rarity: string;
  color: string;
  desc: string;
}

interface Fossil {
  id: number;
  emoji: string;
  name: string;
  part: string;
  age: string;
  value: number;
  rarity: string;
  color: string;
  desc: string;
}

interface DigSite {
  id: number;
  emoji: string;
  name: string;
  area: string;
  depth: number;
  finds: number;
  status: string;
  color: string;
  desc: string;
}

interface JuraActivity {
  id: number;
  emoji: string;
  name: string;
  type: string;
  time: string;
  quota: number;
  joined: number;
  reward: number;
  status: string;
  color: string;
  desc: string;
}

interface JuraGoods {
  id: number;
  emoji: string;
  name: string;
  price: number;
  oldPrice: number;
  tag: string;
  stock: number;
  color: string;
  desc: string;
}

interface Specimen {
  id: number;
  emoji: string;
  name: string;
  dino: string;
  date: string;
  rarity: string;
  status: string;
  color: string;
}

interface Achievement {
  id: number;
  emoji: string;
  name: string;
  progress: number;
  target: number;
  reward: number;
  status: string;
  color: string;
}

const DINOS: Dino[] = [
  { id: 1, emoji: '🦖', name: '霸王龙', era: '白垩纪', diet: '肉食', length: 12, weight: 7, rarity: 'SSR', color: '#B71C1C', desc: '化石馆镇馆之宝,完整的头骨化石高 1.6 米,咬合力惊人' },
  { id: 2, emoji: '🦕', name: '腕龙', era: '侏罗纪', diet: '植食', length: 25, weight: 40, rarity: 'SR', color: '#2E7D32', desc: '脖颈长 9 米,馆内 3D 复原模型高达 12 米,抬头仰望震撼' },
  { id: 3, emoji: '🦴', name: '三角龙', era: '白垩纪', diet: '植食', length: 9, weight: 6, rarity: 'SR', color: '#5D4037', desc: '标志性三只角化石保存完好,颅骨宽达 2 米' },
  { id: 4, emoji: '🦎', name: '剑龙', era: '侏罗纪', diet: '植食', length: 9, weight: 3, rarity: 'R', color: '#00695C', desc: '背部 17 块骨板化石整齐排列,尾刺可作防御武器' },
  { id: 5, emoji: '🦈', name: '沧龙', era: '白垩纪', diet: '肉食', length: 17, weight: 12, rarity: 'SSR', color: '#1565C0', desc: '海洋霸主,下颌化石展开近 2 米,牙齿如利刃' },
  { id: 6, emoji: '🐉', name: '翼龙', era: '白垩纪', diet: '肉食', length: 6, weight: 0.1, rarity: 'R', color: '#6A1B9A', desc: '翼展 11 米,头冠化石完整,翱翔天空的远古巨兽' },
  { id: 7, emoji: '🦌', name: '雷龙', era: '侏罗纪', diet: '植食', length: 21, weight: 25, rarity: 'SR', color: '#558B2F', desc: '尾巴长达 12 米,甩尾时可产生音爆般的声响' },
  { id: 8, emoji: '🦔', name: '甲龙', era: '白垩纪', diet: '植食', length: 7, weight: 4, rarity: 'R', color: '#455A64', desc: '全身骨甲覆盖,尾锤重达 30 公斤,一击可击碎岩石' },
  { id: 9, emoji: '🐊', name: '迅猛龙', era: '白垩纪', diet: '肉食', length: 2, weight: 0.09, rarity: 'N', color: '#C62828', desc: '体形小巧但极为敏捷,镰刀状爪子是致命武器' },
  { id: 10, emoji: '🦤', name: '似鸟龙', era: '白垩纪', diet: '杂食', length: 3, weight: 0.15, rarity: 'N', color: '#F9A825', desc: '外形酷似鸵鸟,奔跑时速可达 60 公里' },
  { id: 11, emoji: '🦛', name: '鸭嘴龙', era: '白垩纪', diet: '植食', length: 10, weight: 4, rarity: 'N', color: '#00838F', desc: '喙部扁平成鸭嘴状,头冠中空可发出共鸣声' },
  { id: 12, emoji: '🌟', name: '棘龙', era: '白垩纪', diet: '肉食', length: 15, weight: 8, rarity: 'SSR', color: '#E65100', desc: '背帆高达 2 米,已知最大的肉食恐龙之一' }
];

const FOSSILS: Fossil[] = [
  { id: 1, emoji: '🦷', name: '霸王龙牙齿', part: '齿骨化石', age: '6800 万年', value: 880, rarity: 'SSR', color: '#B71C1C', desc: '长 18 厘米的锯齿状牙齿,边缘布满锯齿棱' },
  { id: 2, emoji: '🪨', name: '三角龙头骨', part: '颅骨化石', age: '6600 万年', value: 660, rarity: 'SR', color: '#5D4037', desc: '保存完整的头骨,三只角清晰可辨' },
  { id: 3, emoji: '🦴', name: '腕龙股骨', part: '肢骨化石', age: '1.5 亿年', value: 520, rarity: 'SR', color: '#2E7D32', desc: '长达 2.4 米的巨型股骨,人类在其面前显得渺小' },
  { id: 4, emoji: '🪸', name: '沧龙脊椎', part: '脊椎化石', age: '7000 万年', value: 450, rarity: 'R', color: '#1565C0', desc: '一节节圆润的脊椎骨,来自海洋霸主沧龙' },
  { id: 5, emoji: '🦩', name: '翼龙翼骨', part: '翼骨化石', age: '7200 万年', value: 380, rarity: 'R', color: '#6A1B9A', desc: '细长的翼骨化石,撑起 11 米翼展的翅膀' },
  { id: 6, emoji: '🐚', name: '菊石化石', part: '软体动物', age: '2 亿年', value: 120, rarity: 'N', color: '#00838F', desc: '螺旋状外壳精美绝伦,侏罗纪海洋的常客' },
  { id: 7, emoji: '🦅', name: '始祖鸟羽毛', part: '羽毛印痕', age: '1.5 亿年', value: 200, rarity: 'R', color: '#F9A825', desc: '珍贵羽毛印痕化石,证明恐龙与鸟类的亲缘关系' },
  { id: 8, emoji: '🪨', name: '恐龙蛋化石', part: '蛋化石', age: '8000 万年', value: 300, rarity: 'R', color: '#558B2F', desc: '一窝 6 枚恐龙蛋化石,排列整齐仿佛还在孵化' },
  { id: 9, emoji: '🐾', name: '足迹化石', part: '足迹印痕', age: '1.3 亿年', value: 160, rarity: 'N', color: '#455A64', desc: '清晰的巨型脚印印痕,重现恐龙行走的姿态' },
  { id: 10, emoji: '🦴', name: '甲龙尾锤', part: '尾锤化石', age: '7000 万年', value: 260, rarity: 'R', color: '#00695C', desc: '重达 30 公斤的骨锤,防御力满分的武器' }
];

const DIG_SITES: DigSite[] = [
  { id: 1, emoji: '⛏️', name: '一号挖掘场', area: '园区东侧山坡', depth: 12, finds: 18, status: '开放中', color: '#2E7D32', desc: '最热门的挖掘点,出土过霸王龙牙齿与腕龙股骨' },
  { id: 2, emoji: '🏜️', name: '砂岩挖掘带', area: '西区红砂岩层', depth: 9, finds: 12, status: '开放中', color: '#E65100', desc: '红砂岩地层松软好挖,适合新手体验' },
  { id: 3, emoji: '🌊', name: '远古河床', area: '南区干涸河床', depth: 15, finds: 9, status: '开放中', color: '#1565C0', desc: '河床沉积层厚,曾出土菊石与
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .position({ x: 0, y: '72%' })
      }
    }
    .width('100%')
    .height('100%')
  }
}


在这里插入图片描述

在配色体系方面,项目通过 color 字段为每条数据绑定主题色,通过 getRarityColorgetStatusColorgetSpecStatusColor 三个映射函数实现状态到颜色的动态转换。这种"数据驱动配色"的设计使得新增数据时只需指定颜色值或状态字符串,界面会自动适配,无需修改组件代码。整个应用的配色以深绿色 #2E7D32 为主色调,搭配浅绿色 #E8F5E9 作为辅助色,棕色 #5D4037 和琥珀金 #FFD600 作为点缀色,形成了"自然"“化石”"考古"的视觉主题。

在核心技术运用方面,项目全面覆盖了 ArkUI 的关键能力。ColumnRow 的线性布局构成了所有页面的骨架;Stack 的堆叠布局实现了弹框的模态覆盖效果;Scroll 的滚动容器使长内容页面和横滑列表成为可能;ForEach 的循环渲染处理了所有列表场景;@Component@Builder 的组合实现了组件化和 UI 复用;@State 的状态管理驱动了所有交互响应;TransitionEffect 的过渡动画为弹框进出增添了流畅感。这些技术的协同配合,构成了一个完整的声明式 UI 应用开发范式。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐