引言:鸿蒙开发背景与 ArkTS 语言演进

鸿蒙操作系统(HarmonyOS)作为华为推出的面向万物互联时代的分布式操作系统,其应用开发框架经历了从早期的 Java UI 到如今 ArkUI 声明式范式的重大演进。在鸿蒙的整个技术体系中,应用开发层是最贴近开发者的一环,也是决定生态繁荣度的关键所在。传统的命令式 UI 开发模式要求开发者手动操控视图树的创建、更新与销毁,这种模式在页面复杂度较低时尚可应对,但当应用规模增长到数十个页面、数百个交互状态时,状态与视图的同步问题便会成为开发者的沉重负担。声明式 UI 的核心思想是"状态驱动视图"——开发者只需描述界面在不同状态下的呈现方式,框架负责在状态变化时自动完成视图的差分更新,从而将开发者从繁琐的 DOM 操作中解放出来。

ArkTS 是鸿蒙生态中专为应用开发设计的编程语言,它在 TypeScript 的基础上进行了扩展与约束。TypeScript 本身已经通过静态类型系统为 JavaScript 带来了类型安全,而 ArkTS 进一步收紧了动态特性,禁止了运行时修改对象结构等容易引发不可预期行为的操作,同时引入了一批面向 UI 开发的装饰器语法。这些装饰器包括 @Entry@Component@State@Link@Builder@Provide@Consume 等,它们构成了 ArkTS 声明式 UI 的状态管理与组件组织基石。通过这些装饰器,开发者可以用接近自然语言的方式描述"这个变量是组件的内部状态"“这个组件接收父组件的引用”“这是一个可复用的 UI 片段构建器”,而框架的编译器会据此生成高效的状态追踪与视图更新代码。

声明式 UI 范式在鸿蒙中的落地形态是 ArkUI 组件体系。ArkUI 提供了从基础容器(Column、Row、Stack、Flex)到交互组件(TextInput、Toggle、Button、Slider)再到滚动容器(Scroll、List、Grid)的完整组件库。这些组件采用统一的链式调用风格进行属性配置,例如 .fontSize(14).fontColor('#22D3EE').borderRadius(8),使得界面描述具有极强的可读性。更重要的是,ArkUI 的组件树是声明式的——组件之间的关系在代码中一目了然,框架会在状态变化时自动执行最小化的 DOM 更新。本篇将围绕一个赛博实验室主题的合成器工坊应用,逐段剖析 ArkTS 代码的实现细节,深入讲解声明式 UI 的核心机制与最佳实践。

一、数据建模:interface 接口与写死数据的设计哲学

1.1 数据接口定义:类型安全的基石

在任何应用开发中,数据建模都是第一步工作。ArkTS 保留了 TypeScript 的 interface 语法用于定义对象的结构类型,这在大型应用中尤为重要——它让编译器能够在编译期捕获类型不匹配的错误,而非等到运行时才暴露问题。

interface FeedItem {
  id: number
  title: string
  time: string
  tag: string
  text: string
}

interface ModuleItem {
  id: number
  name: string
  kind: string
  level: number
  state: string
  cover: string
  note: string
}

interface VoiceItem {
  id: number
  name: string
  osc: string
  wave: string
  hot: number
  state: string
  cover: string
  note: string
}

这段代码定义了三个核心数据接口。FeedItem 描述了实验室动态流中的单条消息,包含标识 id、标题 title、发布时间 time、分类标签 tag 与正文 text,这五个字段构成了动态展示所需的最小信息集合。ModuleItem 描述了合成器模块这一核心业务对象,它比 FeedItem 更为复杂——除了名称与标识,还包含 kind(模块类型,如振荡、滤波)、level(等级,数值类型)、state(状态码,用字符串短码表示,如 ok/cal/tune)、cover(封面,这里使用 Emoji 字符充当图标)、note(说明文字)。

VoiceItem 则描述了音色预设,它引入了 osc(振荡器配置描述)、wave(主波形类型)、hot(热度值,用于排序与展示)等音乐领域专有字段。值得注意的是,这三个接口都没有使用可选字段(? 标记),这意味着每条数据在创建时都必须完整提供所有字段。这种"全字段必填"的设计选择反映了一个重要原则:在写死数据驱动的原型阶段,强制完整字段可以避免后续渲染时出现 undefined 导致的空白或异常,同时也让数据结构本身成为一份清晰的契约文档。

技术要点:interface 与 type 的选择
在 ArkTS 中,interfacetype 都可以用来定义对象类型,但 interface 支持声明合并(同名接口会自动合并字段),更适合用于描述可扩展的数据模型;而 type 更适合定义联合类型、交叉类型等复杂类型别名。本应用统一使用 interface,体现了对可维护性的考量。

1.2 完整数据接口族:乐手、赛事与我的收藏

继续看其余三个接口的定义,它们分别服务于不同的业务场景:

interface PlayerItem {
  id: number
  name: string
  role: string
  hot: number
  state: string
  note: string
}

interface MatchItem {
  id: number
  name: string
  date: string
  prize: number
  quota: number
  state: string
  note: string
}

interface MyItem {
  id: number
  name: string
  kind: string
  date: string
  note: string
}

在这里插入图片描述

PlayerItem 描述了乐手这一用户角色对象,其中 role 字段记录乐手的职能身份(模块工程师、现场演奏家、音色设计师等),state 字段用 online/tour 区分在线与巡演中两种状态。MatchItem 描述了赛事这一业务对象,它比其他接口多了两个数值字段:prize(奖金金额)与 quota(剩余名额),这两个字段在赛事报名场景中是核心业务数据——quota 会随着用户报名而递减,当降至零时报名入口应当自动关闭。

MyItem 是一个有趣的"聚合型"数据结构,它用于表示用户在"我的"页面中的收藏记录。注意它的 kind 字段可以是"音色"“模块”“乐手”"赛事"中的任意一个,这意味着 MyItem 实际上是对其他四种业务对象的统一引用。这种设计的好处是收藏列表可以用单一数据结构渲染,而无需为每种收藏类型分别维护一个列表;代价是丢失了被收藏对象的部分细节字段(例如音色的热度值、模块的等级),因为 MyItem 只保留了 namedatenote 三个通用字段。这是典型的"展示优先"数据建模思路——在原型阶段,简化数据结构能让 UI 开发更顺畅。

技术要点:状态码的字符串短码设计
注意所有接口中的 state 字段都使用了短字符串(okcaltuneonlinetourclassicexpopenclosed)而非完整的中文或枚举值。这是一种"分层"设计:数据层只存机器友好的短码,展示层通过工具函数将短码映射为中文文案与颜色。这样做的优势是数据层与展示层解耦,接入后端 API 时可直接传输短码而无需调整展示层逻辑。

1.3 写死数据:原型驱动的数据填充

定义完接口后,代码紧接着用 const 声明了一批写死数据数组。这些数组是应用运行的"血液"——在没有后端服务的原型阶段,它们模拟了真实的数据来源:

const FEEDS: FeedItem[] = [
  { id: 1, title: '新模块 OSC-9 振荡器上线', time: '今天 09:00', tag: '上新', text: '实验室推出全新 OSC-9 振荡器模块,支持 8 种波形,音色上限再次突破。' },
  { id: 2, title: '周末音色调制公开课', time: '昨天 23:00', tag: '活动', text: '本周六下午三点,资深工程师带你一小时调出属于自己的招牌音色。' },
  { id: 3, title: '合成器拼装赛报名开启', time: '昨天 18:00', tag: '赛事', text: '第八届合成器拼装大赛开始报名,冠军将获得实验室首席工程师称号。' },
  { id: 4, title: '复古模拟模块复刻计划', time: '前天 21:00', tag: '企划', text: '复刻经典 70 年代模拟滤波模块,第一批样机已完成焊接调试。' },
  { id: 5, title: '新手拼装指南 V2 发布', time: '前天 15:00', tag: '教程', text: '从零开始认识合成器模块,图文教程全面更新,小白也能上手。' }
]

const MODULES: ModuleItem[] = [
  { id: 1, name: 'OSC-9 振荡器', kind: '振荡', level: 5, state: 'ok', cover: '🎛️', note: '8 种波形输出,支持 FM/AM 调制,实验室旗舰级振荡模块。' },
  { id: 2, name: 'FILT-2 滤波模块', kind: '滤波', level: 4, state: 'ok', cover: '🔊', note: '经典低通滤波,带共鸣扫频,复刻 70 年代模拟声。' },
  { id: 3, name: 'ENV-3 包络发生器', kind: '包络', level: 3, state: 'cal', cover: '⏱️', note: '三段可调包络曲线,ADSR 全参数开放,正在校准中。' },
  { id: 4, name: 'LFO-1 低频振荡', kind: '调制', level: 3, state: 'ok', cover: '🌀', note: '0.01Hz 至 100Hz 超宽范围,适合颤音与扫频调制。' }
]

FEEDS 数组是首页动态流的数据源,每条动态都是一个完整的 FeedItem 对象字面量。注意这里的数据编写颇具匠心:标题使用了"新模块"“公开课”"拼装赛"等具有话题性的表述,时间字段则模拟了真实的相对时间格式(“今天 09:00"“昨天 23:00”),标签字段覆盖了"上新”“活动”“赛事”“企划”“教程”“数据”"招募"等多种类别,正文文字则围绕合成器实验室这一主题展开。这些细节让原型在视觉上非常接近一个真实运行的应用。

MODULES 数组展示了合成器模块这一核心业务对象。每条数据都包含一个 Emoji 字符作为 cover 封面——这是一种在原型阶段非常聪明的做法,避免了引入图片资源的复杂性,同时 Emoji 在不同设备上都能正常渲染。level 字段从 1 到 5 表示模块等级,state 字段则覆盖了 ok(可用)、cal(校准中)、tune(调音中)三种状态,为后续状态展示与颜色映射提供了完整的测试数据。

技术要点:const 数组与可变性
使用 const 声明的数组,其引用本身不可变(不能重新赋值为另一个数组),但数组内部的元素是可变的——可以通过 pushunshiftsplice 等方法修改内容。这正是后续组件中"新增动态""移除收藏"等操作能够成立的前提。

1.4 热度数据与工具函数

除了对象数组,代码还定义了一个简单数值数组与一批工具函数:

const HEAT: number[] = [72, 85, 64, 90, 78, 95, 82, 88]

function heatBar(v: number): number {
  return Math.floor(28 + v * 0.85)
}

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

function moduleStateText(s: string): string {
  if (s === 'ok') {
    return '可用'
  }
  if (s === 'cal') {
    return '校准中'
  }
  return '调音中'
}

function moduleStateColor(s: string): string {
  if (s === 'ok') {
    return '#22D3EE'
  }
  if (s === 'cal') {
    return '#FBBF24'
  }
  return '#FB923C'
}

在这里插入图片描述
HEAT 数组是首页周热度柱状图的数据源,包含 8 个数值,代表近 8 期的拼装热度。heatBar 函数将一个 0-100 的热度值映射为柱状图高度像素值(28 到 113 之间),这种"原始值到视觉值"的映射是数据可视化中常见的预处理步骤。playsText 函数处理播放量的格式化,当数值超过一万时自动转换为"万"为单位并保留一位小数,这是国内应用展示大数据的通用做法。

moduleStateTextmoduleStateColor 是一对典型的"状态映射工具函数"。前者将状态短码(ok/cal/tune)转换为中文文案(“可用”/“校准中”/“调音中”),后者将同样的状态短码映射为十六进制颜色值(电光青 #22D3EE、警示黄 #FBBF24、警示橙 #FB923C)。这种"文案函数 + 颜色函数"成对出现的模式贯穿了整个应用——音色状态、乐手状态、赛事状态、趋势方向都有各自的映射函数对。将映射逻辑提取为独立函数的好处是显而易见的:调用处只需一行 moduleStateText(item.state),而所有状态文案与颜色的调整都集中在一处,便于统一维护。

技术要点:工具函数与组件解耦
将状态映射逻辑放在组件之外的独立函数中,是 ArkTS 应用中值得推崇的实践。组件应当专注于"如何渲染",而"渲染什么内容"的决策逻辑应当下沉到工具函数或数据层。这种分离让组件代码更简洁,也让业务逻辑可被单独测试。

1.5 工厂函数:数据构建的统一入口

代码中还定义了一批 build* 工厂函数,它们用于在运行时构造新的数据对象:

function buildFeed(id: number, title: string): FeedItem {
  return { id: id, title: title, time: '刚刚', tag: '动态', text: '这是一条刚刚发布的实验室动态,欢迎各位调音师围观互动。' }
}

function buildMy(id: number, name: string, kind: string): MyItem {
  return { id: id, name: name, kind: kind, date: '08-28', note: '刚刚收藏' }
}

function buildModule(id: number, name: string): ModuleItem {
  return { id: id, name: name, kind: '振荡', level: 1, state: 'ok', cover: '🧩', note: '这是一块刚刚入库的合成器模块,欢迎申请测试体验。' }
}

在这里插入图片描述

buildFeed 函数接收 idtitle 两个参数,返回一个完整的 FeedItem 对象,其余字段使用默认值填充(时间为"刚刚"、标签为"动态"、正文为一段欢迎文案)。buildMy 函数类似,接收 idnamekind 三个参数构造收藏记录。buildModule 函数则用于构造新入库的模块对象,默认类型为"振荡"、等级为 1、状态为 ok、封面为拼图 Emoji。

这些工厂函数的存在意义在于"统一数据构造入口"。如果直接在组件的事件回调中用对象字面量构造数据,会导致默认值散落在各处,难以维护。而通过工厂函数,所有新增数据都遵循统一的模板,默认字段集中管理,且构造逻辑可被复用。例如"发布动态"与"新增模块入库"两个场景虽然业务不同,但都调用了各自的 build* 函数,保证了数据结构的完整性。

技术要点:工厂函数与类型安全
工厂函数的返回值类型被显式标注为 FeedItemMyItem 等,这意味着如果函数内部遗漏了某个字段,编译器会立即报错。这是 ArkTS 类型系统在数据构造场景下提供的安全保障——它让"忘记填字段"这类低级错误在编译期就被拦截。

二、主入口 Index:六 Tab 架构与赛博仪表条头部

2.1 @Entry 与 @Component:应用入口的标识

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State modules: ModuleItem[] = MODULES
  @State voices: VoiceItem[] = VOICES
  @State players: PlayerItem[] = PLAYERS
  @State matches: MatchItem[] = MATCHES
  @State mys: MyItem[] = MYS

在这里插入图片描述

这段代码是整个应用的入口结构体 Index 的声明部分。@Entry 装饰器标记这个组件为页面的入口组件——在一个页面中只能有一个被 @Entry 标记的组件,框架会以此为起点构建组件树。@Component 装饰器则声明这是一个自定义组件,可以被其他组件引用或在组件树中渲染。这两个装饰器通常成对出现在入口组件上,是 ArkTS 声明式 UI 的基础约定。

组件内部通过 @State 装饰器声明了一批状态变量。@State 是 ArkTS 中最基础的状态装饰器,它标记的变量会被框架追踪——当变量值发生变化时,所有依赖该变量的 UI 部分会自动重新渲染。这里声明了七个状态变量:currentTab 记录当前选中的 Tab 索引(初始为 0,即首页),其余六个变量分别持有六大数据数组(动态、模块、音色、乐手、赛事、收藏)。

值得注意的是,这六个数组变量虽然用 @State 声明并初始化为写死的常量数组,但它们的作用是作为"全局数据源"——后续各个 Tab 内容组件会通过 @Link 装饰器与这些数组建立双向绑定,从而实现跨 Tab 的数据共享。例如在"模块"Tab 中新增一个模块后,切换到"我的"Tab 中能看到对应的收藏记录,正是因为两个 Tab 共享了同一个 mys 数组的引用。

技术要点:@State 的核心机制
@State 装饰的变量具备三个特性:一是值的改变会触发依赖该变量的组件重新渲染;二是对于数组与对象类型,框架会递归追踪其内部变化(前提是使用 ArkTS 推荐的赋值方式,如整体替换或调用 push/splice 等方法);三是 @State 变量是组件私有的,不能直接从外部传入初始值。若需要从父组件接收并保持同步,应使用 @Link@Prop

2.2 @Builder:可复用 UI 片段的构建器

@Builder
tabItem(icon: string, label: string, tab: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(22)
      .opacity(this.currentTab === tab ? 1 : 0.55)
      .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
    Text(label)
      .fontSize(11)
      .fontColor(this.currentTab === tab ? '#22D3EE' : '#8A8A96')
      .fontWeight(this.currentTab === tab ? FontWeight.Bold : FontWeight.Normal)
  }
  .width('16.6%')
  .height(58)
  .justifyContent(FlexAlign.Center)
  .animation({ duration: 200, curve: Curve.EaseOut })
  .onClick(() => {
    this.currentTab = tab
  })
}

在这里插入图片描述

@Builder 装饰器用于定义一个可复用的 UI 构建方法。与普通方法不同,被 @Builder 标记的方法内部使用声明式 UI 语法(Column、Text 等组件)来描述界面结构,而不是返回值。这里的 tabItem 构建器接收三个参数:icon(Emoji 图标)、label(文字标签)、tab(Tab 索引),用于渲染底部导航栏中的单个 Tab 项。

构建器内部的结构是一个 Column 容器(纵向排列子组件),通过 { space: 3 } 参数指定子组件之间的间距为 3 像素。Column 内部包含两个 Text 组件:上方的图标与下方的文字标签。每个 Text 都通过链式调用配置了字体大小、颜色、字重等属性。这里最值得关注的是大量使用的三元表达式——例如 .opacity(this.currentTab === tab ? 1 : 0.55),它根据当前 Tab 是否被选中来决定图标的透明度。这种"状态决定样式"的写法是声明式 UI 的精髓:开发者描述的是"在选中状态下应如何呈现"“在未选中状态下应如何呈现”,而框架负责在状态切换时应用对应的样式。

.scale 属性通过 { x, y } 对象配置 X 与 Y 方向的缩放比例,选中时缩放 1.12 倍(轻微放大以突出选中状态),未选中时保持 1 倍。.animation 属性为这些样式变化添加了过渡动画——持续 200 毫秒,缓动曲线为 Curve.EaseOut(先快后慢的减速曲线),这让 Tab 切换时的图标放大与颜色变化变得平滑而非生硬跳变。

.onClick 事件回调中执行 this.currentTab = tab,将当前选中 Tab 索引更新为被点击项的索引。由于 currentTab@State 变量,这一赋值会触发所有依赖它的 UI(包括六个 Tab 项的样式与内容区的显示)自动更新。这就是声明式 UI 的工作方式:开发者只需在事件中改变状态,框架自动完成视图更新。

技术要点:@Builder 与 @Component 的区别
@Builder 用于定义"轻量级"的可复用 UI 片段,它没有独立的状态与生命周期,适合用于在同一个组件内复用的简单 UI 块(如本例的 Tab 项)。@Component 则用于定义完整的自定义组件,拥有独立的状态、属性与生命周期,适合跨页面复用。选择哪种取决于复用粒度与状态独立性。

2.3 build 方法与赛博仪表条头部

build() {
  Column() {
    // 赛博仪表条头部(无动画)
    Column() {
      Row() {
        Text('▲▲▲')
          .fontSize(12)
          .fontColor('#22D3EE')
          .letterSpacing(3)
        Text('SYNTH LAB')
          .fontSize(17)
          .fontColor('#E5E7EB')
          .fontWeight(FontWeight.Bold)
          .letterSpacing(2)
          .backgroundColor('#1C1C26')
          .border({ width: 1, color: '#22D3EE' })
          .borderRadius(4)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        Text('▼▼▼')
          .fontSize(12)
          .fontColor('#A78BFA')
          .letterSpacing(3)
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      Row() {
        ForEach([30, 45, 22, 55, 38], (w: number, index: number) => {
          Text('▊')
            .fontSize(10)
            .fontColor(index % 2 === 0 ? '#22D3EE' : '#A78BFA')
        }, (w: number, index: number) => 'sig' + index.toString())
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 6 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 10 })
    .backgroundColor('#14141C')
    .border({ width: { bottom: 1 }, color: '#22D3EE' })

在这里插入图片描述

每个组件都必须实现 build 方法,它是组件 UI 的唯一描述入口。框架在组件首次渲染与状态更新时调用 build,根据其中描述的组件树生成或更新视图。Indexbuild 方法首先构建了一个"赛博仪表条头部"——这是整个应用的顶部固定区域,采用深炭黑底色(#14141C)与电光青边框(#22D3EE)营造科技感。

头部内部嵌套了两个 Row(横向排列子组件的容器)。第一个 Row 包含三个 Text 组件:左侧的"▲▲▲"上箭头序列(电光青色)、中间的"SYNTH LAB"品牌标识(带边框与背景的徽章样式)、右侧的"▼▼▼"下箭头序列(霓虹紫色)。.justifyContent(FlexAlign.SpaceBetween) 让三个元素在水平方向上两端对齐——左侧贴左、右侧贴右、中间居中,这是导航栏与工具栏中非常经典的布局方式。

.letterSpacing(3) 设置字符间距,让"SYNTH LAB"这样的英文字母组合显得更有"仪表盘显示屏"的质感。.border({ width: 1, color: '#22D3EE' }) 为品牌标识添加 1 像素宽的电光青边框,配合 .borderRadius(4) 的圆角与 .padding 的内边距,形成一个类似电子设备指示灯的徽章效果。

第二个 Row 使用 ForEach 渲染了五个"信号条"——用"▊"字符模拟仪表盘上的电平指示。ForEach 是 ArkTS 中用于列表渲染的核心组件,它接收三个参数:数据源数组、项渲染函数、键生成函数。这里的数据源是 [30, 45, 22, 55, 38] 五个数字,项渲染函数根据索引的奇偶性决定颜色(偶数为电光青、奇数为霓虹紫),键生成函数返回 'sig' + index 作为每项的唯一标识。

技术要点:ForEach 的键生成函数
ForEach 的第三个参数(键生成函数)虽然看似多余,但至关重要。框架通过它判断列表项的唯一性——当数据源变化时,框架会比对新旧键集合,仅对真正变化的项执行更新,而非全量重建。若省略键函数或返回不稳定的值(如数组索引在某些场景下),可能导致状态错乱或性能下降。本例中用 'sig' + index 作为键,在数据源固定的情况下是安全的。

2.4 Stack 内容区与 Tab 切换

    // 内容区
    Stack() {
      if (this.currentTab === 0) {
        HomeContent({ feeds: this.feeds, modules: this.modules, mys: this.mys })
      }
      if (this.currentTab === 1) {
        ModuleContent({ modules: this.modules, mys: this.mys })
      }
      if (this.currentTab === 2) {
        VoiceContent({ voices: this.voices, mys: this.mys })
      }
      if (this.currentTab === 3) {
        PlayerContent({ players: this.players, mys: this.mys })
      }
      if (this.currentTab === 4) {
        MatchContent({ matches: this.matches, mys: this.mys })
      }
      if (this.currentTab === 5) {
        MeContent({ mys: this.mys, modules: this.modules, voices: this.voices })
      }
    }
    .layoutWeight(1)
    .width('100%')

内容区使用了 Stack 容器——这是一个层叠布局组件,子元素默认居中堆叠。但这里并非为了实现层叠效果,而是利用 Stack 配合条件渲染来实现"同一区域切换不同内容"的模式。六个 if 语句分别判断 currentTab 的值,当条件成立时渲染对应的 Tab 内容组件(HomeContentModuleContent 等)。

每个 Tab 内容组件的调用都通过参数传递了数据数组。注意 HomeContent 接收 feedsmodulesmys 三个参数,ModuleContent 接收 modulesmys 两个参数,参数的数量与该 Tab 需要的数据相关。这里传递的是 @State 变量的引用,而接收方组件会用 @Link 装饰器接收——这就建立了父子组件之间的双向数据绑定。

.layoutWeight(1) 是 ArkUI 中非常关键的权重分配属性。它告诉父级 Column:这个子元素应当占据剩余的所有可用空间。在 IndexColumn 中,头部 Column 与底部 Row 都有固定高度,而内容区 Stack 通过 layoutWeight(1) 自动填充中间的所有剩余空间。这种"固定 + 弹性"的布局组合是移动应用页面结构的经典范式。

技术要点:layoutWeight 与 Flex 布局
layoutWeight 是 ArkUI 弹性布局的核心属性,类似于 CSS Flexbox 中的 flex-grow。它的值表示在父容器分配完所有固定尺寸子元素的空间后,剩余空间按权重分配给各个设置了 layoutWeight 的子元素。设为 1 表示占据全部剩余空间,多个子元素设为 1 则均分剩余空间。这是构建自适应布局的关键工具。

2.5 底部 Tab 单排

    // 底部 Tab 单排
    Row() {
      this.tabItem('🏭', '首页', 0)
      this.tabItem('🧩', '模块', 1)
      this.tabItem('🎛️', '音色', 2)
      this.tabItem('🎸', '乐手', 3)
      this.tabItem('🏆', '赛事', 4)
      this.tabItem('👤', '我的', 5)
    }
    .width('100%')
    .backgroundColor('#14141C')
    .border({ width: { top: 1 }, color: '#22D3EE' })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#101014')
}

在这里插入图片描述

底部导航栏是一个 Row 容器,内部通过 this.tabItem(...) 六次调用前面定义的 @Builder 方法,渲染六个 Tab 项。每个 Tab 项由 Emoji 图标与中文标签组成:首页(🏭 工厂)、模块(🧩 拼图)、音色(🎛️ 控制台)、乐手(🎸 吉他)、赛事(🏆 奖杯)、我的(👤 人像)。这六个 Emoji 的选择颇具匠心——每个图标都能直观地对应其代表的业务领域,让用户一眼就能识别 Tab 功能。

底部 Row 设置了深炭黑背景与顶部 1 像素电光青边框,与头部形成视觉呼应。最外层的 Column 设置了 width('100%')height('100%'),让整个应用铺满屏幕,背景色为更深一层的 #101014(接近纯黑),营造出赛博实验室的暗色调氛围。

至此,整个应用的骨架就搭建完成了。Index 组件作为入口,负责头部、内容区、底部导航栏三段式布局的组织,其中内容区通过条件渲染切换六个 Tab 内容组件,底部导航栏通过 @Builder 复用 Tab 项渲染逻辑,状态变量 currentTab 作为唯一的数据源驱动这一切的切换。下面的流程图展示了从用户点击 Tab 到内容区更新的完整数据流。

渲染错误: Mermaid 渲染失败: Parse error on line 4: ...= tab 索引] C --> D{@State currentTab ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

在这里插入图片描述

三、首页 HomeContent:横幅、柱状图、速览与动态列表

3.1 组件声明与 @Link 双向绑定

@Component
struct HomeContent {
  @Link feeds: FeedItem[]
  @Link modules: ModuleItem[]
  @Link mys: MyItem[]
  @State showDetail: boolean = false
  @State picked: FeedItem = FEEDS[0]
  @State showPost: boolean = false
  @State postTitle: string = ''
  @State postText: string = ''
  @State tip: string = ''

HomeContent 是首页内容组件,它声明了三个 @Link 变量与五个 @State 变量。@Link 装饰器用于接收父组件传递的引用类型变量,并建立双向绑定——这意味着在 HomeContent 内部对 feedsmodulesmys 数组的修改会立即反映到父组件 Index 中的同名变量,反之亦然。这种机制是跨组件数据共享的关键。

@State 变量则用于管理组件内部的状态。showDetail 控制动弹详情弹窗的显示与隐藏(初始为 false,即不显示);picked 记录当前选中的动态项(初始为 FEEDS[0],即第一条动态);showPost 控制发布动态弹窗的显示;postTitlepostText 分别记录发布弹窗中用户输入的标题与正文;tip 是一个通用的提示文案变量,用于在页面底部显示操作反馈(如"已点赞"“动态已发布”)。

这种"外部数据用 @Link、内部状态用 @State"的分层设计是 ArkTS 组件状态管理的标准范式。外部数据需要跨组件共享与同步,所以用 @Link;而弹窗显隐、表单输入、临时提示等只与当前组件相关的状态,用 @State 即可,无需污染父组件。

技术要点:@Link 与 @Prop 的选择
@Link 建立的是双向绑定,子组件对变量的修改会同步到父组件;@Prop 建立的是单向同步,父组件的变化会同步到子组件,但子组件的修改不会回传。选择依据是:若子组件需要修改数据并让父组件感知(如本例中新增动态要让其他 Tab 也看到),用 @Link;若子组件只需读取数据(如纯展示),用 @Prop 更安全。

3.2 动态详情弹窗构建器

@Builder
detailModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Row() {
        Text('📄 动态详情')
          .fontSize(17)
          .fontColor('#E5E7EB')
          .fontWeight(FontWeight.Bold)
        Text('✕')
          .fontSize(16)
          .fontColor('#8A8A96')
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 12 })
      Row() {
        Text('#' + this.picked.tag)
          .fontSize(11)
          .fontColor('#101014')
          .backgroundColor('#22D3EE')
          .borderRadius(3)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Text(this.picked.time)
          .fontSize(11)
          .fontColor('#8A8A96')
      }
      .width('100%')
      .margin({ bottom: 10 })

detailModalOverlay 是一个 @Builder 构建器,用于渲染动态详情弹窗。它接收一个 onClose 回调函数作为参数——这是 ArkTS 中处理弹窗关闭的常见模式:父组件传入关闭逻辑,弹窗内的关闭按钮调用该回调。这种"回调注入"的方式让弹窗组件保持无状态,关闭逻辑由调用方控制。

弹窗的整体结构是一个全屏 Column(外层)包裹一个 88% 宽度的内容 Column(内层)。外层 Column 设置了半透明深色背景(rgba(10, 10, 16, 0.82),即 82% 不透明度的深炭黑),用于遮罩底层内容;内层 Column 是实际的弹窗卡片,设置了深灰背景、10 像素圆角与电光青边框。

弹窗顶部是一个 Row,左侧是"📄 动态详情"标题,右侧是"✕"关闭按钮。.justifyContent(FlexAlign.SpaceBetween) 让标题贴左、关闭按钮贴右。关闭按钮的 onClick 调用 onClose() 回调,触发父组件将 showDetail 设为 false,从而隐藏弹窗。第二行 Row 展示了动态的标签与时间——标签用"#"前缀加电光青背景的徽章样式呈现,时间则用灰色小字。

      Text(this.picked.title)
        .fontSize(16)
        .fontColor('#F3F4F6')
        .fontWeight(FontWeight.Medium)
        .width('100%')
        .margin({ bottom: 8 })
      Text(this.picked.text)
        .fontSize(13)
        .fontColor('#B9B9C4')
        .width('100%')
        .lineHeight(20)
      Row() {
        Text('👍 点赞')
          .fontSize(12)
          .fontColor('#22D3EE')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(36)
          .border({ width: 1, color: '#22D3EE' })
          .borderRadius(6)
          .onClick(() => {
            this.tip = '👍 已点赞,感谢互动'
            onClose()
          })
        Text('💬 评论')
          .fontSize(12)
          .fontColor('#E5E7EB')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(36)
          .backgroundColor('#A78BFA')
          .borderRadius(6)
          .onClick(() => {
            this.tip = '💬 评论功能已开放'
            onClose()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 12 })
    }
    .width('88%')
    .backgroundColor('#1C1C26')
    .borderRadius(10)
    .border({ width: 1, color: '#22D3EE' })
    .padding(16)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(10, 10, 16, 0.82)')
}

弹窗主体展示了动态的标题(16 号字、中粗体)与正文(13 号字、行高 20 像素以保证多行文本的可读性)。.lineHeight(20) 是一个容易被忽略但重要的细节——默认行高在某些字号下会让文字显得拥挤,显式设置行高能让长文本更易阅读。

底部的操作按钮区是一个 Row,包含"点赞"与"评论"两个按钮。两个按钮都设置了 45% 宽度与 36 像素高度,通过 FlexAlign.SpaceBetween 在水平方向上两端对齐。点赞按钮采用描边样式(电光青边框、透明背景),评论按钮采用填充样式(霓虹紫背景、深色文字)——这种"一主一次"的按钮设计是移动端的经典交互模式。点击任一按钮后,都会设置 tip 提示文案并调用 onClose() 关闭弹窗。

技术要点:弹窗的 Stack 叠层实现
这个应用中的所有弹窗都采用同一种实现模式:在组件 build 方法的 Stack 根容器中,通过条件渲染(if (this.showDetail))决定是否渲染弹窗构建器。弹窗构建器本身是一个全屏 Column,通过半透明背景遮罩底层内容。这种"Stack + 条件渲染"的方式避免了引入额外的弹窗管理框架,是轻量级应用的常见选择。

3.3 发布动态弹窗与表单输入

@Builder
postModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Row() {
        Text('🧩 发布拼装动态')
          .fontSize(17)
          .fontColor('#E5E7EB')
          .fontWeight(FontWeight.Bold)
        Text('✕')
          .fontSize(16)
          .fontColor('#8A8A96')
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 12 })
      Text('动态标题')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
      TextInput({ placeholder: '例如:我的第一台模块合成器', text: this.postTitle })
        .width('100%')
        .height(40)
        .backgroundColor('#101014')
        .fontColor('#E5E7EB')
        .placeholderColor('#5A5A66')
        .borderRadius(6)
        .margin({ top: 4, bottom: 10 })
        .onChange((v: string) => {
          this.postTitle = v
        })

postModalOverlay 是发布动态弹窗的构建器。它的结构与详情弹窗类似,但内部包含了表单输入组件 TextInputTextInput 是 ArkUI 提供的文本输入框组件,通过参数对象配置初始属性:placeholder 是占位提示文字(输入框为空时显示的灰色引导文案),text 是输入框的当前值(这里绑定了 this.postTitle 状态变量)。

TextInput 的样式配置同样采用链式调用:.width('100%') 让输入框占满容器宽度,.height(40) 设置固定高度,.backgroundColor('#101014') 设置深色背景以符合赛博主题,.fontColor('#E5E7EB') 设置输入文字为浅灰色,.placeholderColor('#5A5A66') 设置占位文字为更暗的灰色(与输入文字区分),.borderRadius(6) 添加圆角。

.onChange 事件是 TextInput 的核心交互回调——每当用户输入文字时触发,回调参数 v 是当前输入框的值。在回调中执行 this.postTitle = v,将输入值同步到状态变量。由于 postTitle@State 变量,这一赋值会让所有依赖它的 UI(如"发布动态"按钮的可用性判断)自动更新。这种"输入即更新状态"的模式是 ArkTS 表单处理的标准做法。

      Row() {
        Text('取消')
          .fontSize(13)
          .fontColor('#B9B9C4')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(38)
          .border({ width: 1, color: '#3A3A46' })
          .borderRadius(6)
          .onClick(() => {
            onClose()
          })
        Text('发布动态')
          .fontSize(13)
          .fontColor('#101014')
          .textAlign(TextAlign.Center)
          .width('45%')
          .height(38)
          .backgroundColor('#22D3EE')
          .borderRadius(6)
          .onClick(() => {
            if (this.postTitle.length > 0) {
              const nextId = this.feeds.length + 1
              this.feeds.unshift(buildFeed(nextId, this.postTitle))
              this.tip = '✅ 拼装动态已发布'
            } else {
              this.tip = '⚠️ 请先填写动态标题'
            }
            this.postTitle = ''
            this.postText = ''
            onClose()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

弹窗底部的操作区包含"取消"与"发布动态"两个按钮。点击"发布动态"时,会先校验 postTitle 是否非空——若非空,则计算新动态的 id(当前数组长度加 1),调用工厂函数 buildFeed 构造新动态对象,再通过 this.feeds.unshift(...) 将其插入数组头部。unshift 是 JavaScript/TypeScript 数组的原生方法,用于在数组开头插入元素,这里用它实现"新动态置顶"的展示效果。

由于 feeds@Link 变量,unshift 操作会触发数组变化,框架自动重新渲染依赖 feeds 的 UI——即首页的动态列表会立即出现新发布的动态。这就是声明式 UI 的威力:开发者只需修改数据,视图更新由框架自动完成。操作完成后,无论成功与否,都会清空 postTitlepostText,然后调用 onClose() 关闭弹窗。

技术要点:TextInput 的类型与场景
TextInput 通过 .type(InputType.Normal) 等方式可以指定输入类型,包括普通文本、数字、邮箱、密码等。不同类型会影响虚拟键盘的布局与输入校验。后续音色调制弹窗中会看到 .type(InputType.Number) 的用法,专门用于数字输入场景。

3.4 首页主体:实验室横幅与周热度柱状图

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          // 实验室横幅
          Row() {
            Column({ space: 6 }) {
              Text('SYNTH LAB')
                .fontSize(20)
                .fontColor('#101014')
                .fontWeight(FontWeight.Bold)
                .letterSpacing(2)
              Text('合成器拼装与音色调制中心')
                .fontSize(11)
                .fontColor('#101014')
                .opacity(0.75)
            }
            .alignItems(HorizontalAlign.Start)
            Column({ space: 4 }) {
              Text('● OPEN')
                .fontSize(11)
                .fontColor('#101014')
                .backgroundColor('#22D3EE')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              Text('今日 08:00-22:00')
                .fontSize(10)
                .fontColor('#101014')
                .opacity(0.7)
            }
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .padding(16)
          .backgroundColor('#22D3EE')
          .borderRadius(10)
          .margin({ top: 12 })

首页主体结构是 Stack 根容器包裹一个 ColumnColumn 内部是一个 Scroll(滚动容器),Scroll 内部再嵌套一个 Column 作为内容容器。这种"Scroll > Column"的嵌套是移动端长内容页面的标准结构——Scroll 提供垂直滚动能力,Column 作为内容容器纵向排列各个区块。

Scroll 是 ArkUI 的滚动容器组件,当内部内容超出可视区域时,用户可以通过上下滑动查看全部内容。.layoutWeight(1)Scroll 占据 Column 中除固定高度元素外的所有空间。

实验室横幅是一个 Row,背景为电光青色(#22D3EE),文字为深色(#101014),形成高对比度的醒目效果。横幅左侧是两行文字:"SYNTH LAB"主标题与"合成器拼装与音色调制中心"副标题。右侧是一个"● OPEN"状态徽章(圆角背景、表示营业中)与营业时间说明。.alignItems(HorizontalAlign.Start) 让左侧 Column 的子元素左对齐,与右侧的状态信息形成视觉平衡。

          // 周热度柱状图
          Row() {
            Text('📊 本周拼装热度')
              .fontSize(14)
              .fontColor('#E5E7EB')
              .fontWeight(FontWeight.Bold)
            Text('近 8 期')
              .fontSize(11)
              .fontColor('#8A8A96')
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 14, bottom: 6 })
          Row({ space: 5 }) {
            ForEach(HEAT, (v: number, index: number) => {
              Column() {
                Text(v.toString())
                  .fontSize(9)
                  .fontColor('#22D3EE')
                Row()
                  .width(16)
                  .height(heatBar(v))
                  .backgroundColor(index % 2 === 0 ? '#22D3EE' : '#A78BFA')
                  .borderRadius(2)
                Text('周' + (index + 1).toString())
                  .fontSize(9)
                  .fontColor('#8A8A96')
              }
              .width('11%')
            }, (v: number, index: number) => 'h' + index.toString())
          }
          .width('100%')
          .height(120)
          .alignItems(VerticalAlign.Bottom)
          .justifyContent(FlexAlign.SpaceBetween)
          .padding(8)
          .backgroundColor('#14141C')
          .borderRadius(8)
          .border({ width: 1, color: '#2A2A36' })

周热度柱状图是一个纯 ArkUI 组件手绘的数据可视化区块,没有引入任何图表库。它的实现思路非常巧妙:外层是一个 Row 容器,设置 .alignItems(VerticalAlign.Bottom) 让所有子元素底部对齐——这是柱状图的关键,让所有柱子从底部向上生长。.height(120) 固定了图表区域的高度。

ForEach 遍历 HEAT 数组,为每个数值渲染一个 Column(柱子)。每个 Column 内部包含三个元素:顶部的数值文字(9 号字、电光青色)、中间的柱体(一个 Row 组件,设置了固定 16 像素宽度与动态高度 heatBar(v))、底部的周次标签。柱体的背景色通过 index % 2 === 0 ? '#22D3EE' : '#A78BFA' 在电光青与霓虹紫之间交替,增加了视觉节奏感。

这种用基础组件(Row、Column、Text)组合实现图表的方式,是 ArkUI 声明式 UI 灵活性的体现。虽然不如专业图表库功能丰富,但对于简单的柱状图、进度条等可视化需求已经足够,且无需引入额外依赖,包体积更小。

技术要点:Scroll 与内容溢出
Scroll 组件只支持单一子元素,且子元素通常是一个 ColumnColumn 等容器。当子元素的总高度超过 Scroll 的高度时,会出现垂直滚动条(可通过 .scrollBar(BarState.Off) 隐藏)。Scroll 默认垂直滚动,若需水平滚动,使用 .scrollable(ScrollDirection.Horizontal)

3.5 精选速览与动态列表

          // 精选速览
          Row({ space: 8 }) {
            Column({ space: 4 }) {
              Text('🧩')
                .fontSize(22)
              Text('模块库')
                .fontSize(11)
                .fontColor('#E5E7EB')
              Text(this.modules.length.toString() + ' 款在架')
                .fontSize(9)
                .fontColor('#8A8A96')
            }
            .layoutWeight(1)
            .height(80)
            .justifyContent(FlexAlign.Center)
            .backgroundColor('#1C1C26')
            .borderRadius(8)
            .onClick(() => {
              this.tip = '🧩 模块库共 ' + this.modules.length.toString() + ' 款模块可拼装'
            })

精选速览区块是一个 Row,内部包含三个等宽的 Column 卡片(模块库、赛事、音色库),每个卡片通过 .layoutWeight(1) 均分宽度,.height(80) 设置固定高度。每个卡片内部纵向排列 Emoji 图标、标题文字、数据说明,通过 .justifyContent(FlexAlign.Center) 让内容垂直居中。

注意 .onClick 事件——点击卡片不是跳转页面,而是设置 tip 提示文案。这是原型阶段常见的简化处理:用提示文案代替真实的页面跳转,让交互流程可感知但不增加实现复杂度。this.modules.length.toString() 这种将数值转换为字符串的写法在 ArkTS 中很常见,因为 Text 组件的内容参数只接受字符串类型。

          // 动态列表
          ForEach(this.feeds, (item: FeedItem, index: number) => {
            Row() {
              Column({ space: 4 }) {
                Text(item.title)
                  .fontSize(14)
                  .fontColor('#F3F4F6')
                  .fontWeight(FontWeight.Medium)
                  .width('100%')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(item.time + ' · ' + item.tag)
                  .fontSize(11)
                  .fontColor('#8A8A96')
                  .width('100%')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              Text('详情 ›')
                .fontSize(12)
                .fontColor('#22D3EE')
                .onClick(() => {
                  this.picked = item
                  this.showDetail = true
                })
            }
            .width('100%')
            .padding(12)
            .backgroundColor('#14141C')
            .borderRadius(8)
            .border({ width: 1, color: '#24242E' })
            .margin({ bottom: 8 })
          }, (item: FeedItem, index: number) => 'feed' + item.id.toString())

动态列表通过 ForEach 遍历 this.feeds 数组渲染。每条动态是一个 Row 卡片:左侧是 Column 容纳标题与时间标签,右侧是"详情 ›"链接。这里有两个值得关注的细节:

第一,.maxLines(1) 配合 .textOverflow({ overflow: TextOverflow.Ellipsis }) 实现了单行文本溢出省略——当动态标题过长时,自动截断并显示省略号"…"。这是长文本列表展示的标配,避免长标题撑破布局。第二,键生成函数返回 'feed' + item.id.toString(),用动态的唯一 id 作为键——这比用数组索引更稳定,因为当数组头部插入新动态时,索引会全部位移,而 id 保持不变,框架能正确识别哪条是新增项。

点击"详情 ›"时,将当前 item 赋值给 this.picked,并设置 this.showDetail = true。这两个 @State 变量的变化会触发详情弹窗的渲染与显示。这种"点击列表项 → 设置选中项 → 显示详情弹窗"的模式贯穿了整个应用的列表交互设计。

技术要点:textOverflow 与 maxLines
.maxLines(n) 限制文本最多显示 n 行,超出部分根据 .textOverflow 的配置处理。TextOverflow.Ellipsis 是最常用的选项,显示省略号;TextOverflow.Clip 直接截断;TextOverflow.None 不做处理(可能溢出容器)。这两个属性通常成对使用,是控制长文本布局的关键。

3.6 提示条与弹窗条件渲染

          if (this.tip.length > 0) {
            Text(this.tip)
              .fontSize(12)
              .fontColor('#FDE68A')
              .width('100%')
              .padding(8)
              .backgroundColor('#2A2410')
              .borderRadius(6)
              .margin({ bottom: 8 })
          }
        }
        .width('100%')
        .padding({ left: 12, right: 12, bottom: 16 })
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')

    if (this.showDetail) {
      this.detailModalOverlay(() => {
        this.showDetail = false
      })
    }
    if (this.showPost) {
      this.postModalOverlay(() => {
        this.showPost = false
      })
    }
  }
  .width('100%')
  .height('100%')
}

提示条 tip 的渲染使用了条件渲染——if (this.tip.length > 0) 判断提示文案是否非空,若非空则渲染一个黄底提示条。提示条的样式是:12 号字、浅黄色文字(#FDE68A)、深黄棕色背景(#2A2410)、6 像素圆角。这种暖色调的提示条与冷色调的主体界面形成对比,能有效吸引用户注意。

Stack 根容器的最后部分是两个弹窗的条件渲染。当 this.showDetailtrue 时,调用 this.detailModalOverlay(...) 渲染详情弹窗;当 this.showPosttrue 时,调用 this.postModalOverlay(...) 渲染发布弹窗。传入的回调函数将对应的状态变量设为 false,实现关闭弹窗的效果。由于弹窗构建器返回的是一个全屏 Column,它会覆盖在 Stack 中其他内容之上(Stack 的层叠特性),形成模态遮罩效果。

四、模块库 ModuleContent:分类筛选与双列卡片

4.1 组件状态与详情弹窗

@Component
struct ModuleContent {
  @Link modules: ModuleItem[]
  @Link mys: MyItem[]
  @State pickKind: string = '全部'
  @State showDetail: boolean = false
  @State picked: ModuleItem = MODULES[0]
  @State showAdd: boolean = false
  @State addName: string = ''
  @State addKind: string = ''
  @State tip: string = ''

ModuleContent 是模块库 Tab 的内容组件。它的状态设计与 HomeContent 类似,但有几个针对模块业务的新增项:pickKind 记录当前选中的分类筛选条件(初始为"全部"),showAdd 控制模块入库登记弹窗的显示,addNameaddKind 分别记录入库弹窗中的模块名称与类型输入。

@Link 变量同样接收 modulesmys 两个数组,与父组件 Index 建立双向绑定。这意味着在模块库中新增模块或收藏模块,效果会同步到"我的"Tab 中。

模块详情弹窗 detailModalOverlay 的结构与动态详情弹窗类似,但内容针对模块业务做了定制。弹窗顶部展示了模块的封面 Emoji、名称、类型与等级,以及状态标签(通过 moduleStateTextmoduleStateColor 工具函数映射文案与颜色)。弹窗中部展示了"性能指标"区块——一个"CV 控制"标签与一个进度条(用 Row 组件模拟,灰色背景表示进度槽)。弹窗底部是"收藏"与"入库登记"两个操作按钮。

4.2 模块入库登记弹窗

@Builder
addModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Row() {
        Text('📦 模块入库登记')
          .fontSize(17)
          .fontColor('#E5E7EB')
          .fontWeight(FontWeight.Bold)
        Text('✕')
          .fontSize(16)
          .fontColor('#8A8A96')
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 12 })
      Text('模块名称')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
      TextInput({ placeholder: '例如:WAVE-X 波形整形器', text: this.addName })
        .width('100%')
        .height(40)
        .backgroundColor('#101014')
        .fontColor('#E5E7EB')
        .placeholderColor('#5A5A66')
        .borderRadius(6)
        .margin({ top: 4, bottom: 10 })
        .onChange((v: string) => {
          this.addName = v
        })

入库登记弹窗的表单设计与发布动态弹窗一致——Text 标签 + TextInput 输入框的组合。这里有两点值得注意:第一,每个 TextInput 上方都有一个 Text 标签作为字段说明(“模块名称”“模块类型”),这种"标签 + 输入框"的表单布局比纯占位符更清晰,用户始终能看到字段的含义。第二,TextInput 的占位文字提供了示例值(“例如:WAVE-X 波形整形器”),帮助用户理解期望的输入格式。

        Row() {
          Text('取消')
            .onClick(() => { onClose() })
          Text('登记入库')
            .backgroundColor('#A78BFA')
            .onClick(() => {
              if (this.addName.length > 0) {
                const nextId = this.modules.length + 1
                this.modules.unshift(buildModule(nextId, this.addName))
                this.tip = '✅ 模块已登记入库,等待审核'
              } else {
                this.tip = '⚠️ 请先填写模块名称'
              }
              this.addName = ''
              this.addKind = ''
              onClose()
            })
        }

点击"登记入库"按钮时,同样先校验 addName 非空,然后调用 buildModule 工厂函数构造新模块对象,通过 this.modules.unshift(...) 插入数组头部。这里体现了工厂函数的价值——buildModule 接收 idname 两个参数,自动填充其余字段(类型默认为"振荡"、等级为 1、状态为 ok),保证构造的对象符合 ModuleItem 接口定义。

技术要点:表单状态管理
在 ArkTS 中,表单输入的推荐做法是:每个 TextInput 绑定一个 @State 变量,在 .onChange 中将输入值同步到变量。提交时直接读取变量进行校验与处理。这种"受控输入"模式让表单状态始终可追踪,便于实现联动校验、提交前预校验等复杂逻辑。

4.3 分类横滑筛选

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          // 分类横滑
          Scroll() {
            Row({ space: 8 }) {
              ForEach(['全部', '振荡', '滤波', '调制', '效果', '音序'], (k: string, index: number) => {
                Text(k)
                  .fontSize(12)
                  .fontColor(this.pickKind === k ? '#101014' : '#B9B9C4')
                  .backgroundColor(this.pickKind === k ? '#22D3EE' : '#1C1C26')
                  .borderRadius(14)
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .onClick(() => {
                    this.pickKind = k
                    this.tip = '🔍 已筛选:' + k
                  })
              }, (k: string, index: number) => 'k' + index.toString())
            }
            .padding({ right: 12 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 12 })

分类筛选区是一个水平滚动的标签条——这是移动端分类筛选的经典交互模式。它的实现是一个 Scroll 组件,通过 .scrollable(ScrollDirection.Horizontal) 设置为水平滚动方向(与默认的垂直滚动不同),并通过 .scrollBar(BarState.Off) 隐藏滚动条,让滚动更自然。

Scroll 内部是一个 Row,通过 { space: 8 } 设置子元素间距。ForEach 遍历分类数组 ['全部', '振荡', '滤波', '调制', '效果', '音序'],为每个分类渲染一个胶囊状的 Text 标签。标签的样式根据是否选中动态变化:选中时文字为深色(#101014)、背景为电光青(#22D3EE);未选中时文字为浅灰(#B9B9C4)、背景为深灰(#1C1C26)。.borderRadius(14) 的大圆角让标签呈现胶囊形状。

点击标签时,将 this.pickKind 更新为被点击的分类,并设置提示文案。由于 pickKind@State 变量,框架会自动重新渲染所有标签,更新它们的选中样式。这种"单选切换"的交互模式在 ArkTS 中非常简洁——只需一个状态变量加三元表达式即可实现。

技术要点:水平滚动与垂直滚动的区别
Scroll 默认垂直滚动,通过 .scrollable(ScrollDirection.Horizontal) 切换为水平。水平滚动常用于标签条、图片轮播、横向卡片列表等场景。注意水平 Scroll 内部应使用 Row 作为内容容器,而非 Column

4.4 模块双列卡片与星级评分

          // 模块双列卡片
          ForEach([0, 2, 4, 6, 8], (i: number, index: number) => {
            Row({ space: 8 }) {
              Column() {
                Text(this.modules[i].cover)
                  .fontSize(30)
                Text(this.modules[i].name)
                  .fontSize(13)
                  .fontColor('#F3F4F6')
                  .fontWeight(FontWeight.Medium)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .margin({ top: 6 })
                Text(this.modules[i].kind + ' · Lv.' + this.modules[i].level.toString())
                  .fontSize(10)
                  .fontColor('#8A8A96')
                  .margin({ top: 2 })
                Row({ space: 3 }) {
                  ForEach([0, 1, 2, 3, 4], (s: number) => {
                    Text('★')
                      .fontSize(10)
                      .fontColor(s < this.modules[i].level ? '#FBBF24' : '#3A3A46')
                  }, (s: number) => 's' + s.toString())
                }
                .margin({ top: 4 })
                Text(moduleStateText(this.modules[i].state))
                  .fontSize(10)
                  .fontColor(moduleStateColor(this.modules[i].state))
                  .margin({ top: 4 })
                Text('查看 ›')
                  .fontSize(11)
                  .fontColor('#22D3EE')
                  .margin({ top: 6 })
                  .onClick(() => {
                    this.picked = this.modules[i]
                    this.showDetail = true
                  })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .height(170)
              .justifyContent(FlexAlign.Center)
              .backgroundColor('#1C1C26')
              .borderRadius(8)
              .border({ width: 1, color: '#24242E' })
              .padding(8)

模块双列卡片的实现采用了一种巧妙的"步进遍历"模式。ForEach 的数据源是 [0, 2, 4, 6, 8]——一组偶数索引。每次迭代渲染一个 RowRow 内部包含两个 Column 卡片:第一个卡片对应索引 i,第二个卡片对应索引 i + 1。这样 [0, 2, 4, 6, 8] 五次迭代就覆盖了 0-1、2-3、4-5、6-7、8-9 共十个模块,实现了双列布局。

每个卡片内部纵向排列了五个元素:封面 Emoji(30 号字)、模块名称(13 号字、单行省略)、类型与等级(10 号字灰色)、星级评分、状态文案、"查看"链接。其中星级评分是一个内嵌的 ForEach,遍历 [0, 1, 2, 3, 4] 渲染五个"★"字符,颜色根据 s < this.modules[i].level 判断——若星级索引小于模块等级,显示金黄色(#FBBF24),否则显示暗灰色(#3A3A46)。这种用字符 + 颜色控制实现星级评分的方式简洁有效,无需图片资源。

.alignItems(HorizontalAlign.Center) 让卡片内的子元素水平居中,.justifyContent(FlexAlign.Center) 让内容垂直居中,.height(170) 固定卡片高度。这些样式共同作用,让每个卡片呈现为一个内容居中的深色圆角方块。

技术要点:双列布局的两种实现方式
双列布局可以用 Grid 组件实现,也可以用本例的"步进遍历 + Row 内双 Column"方式实现。前者更声明式、适合规则网格;后者更灵活,适合每行可能有不同布局的场景。本例选择后者是因为每行的两个卡片需要独立的点击事件与数据绑定。

五、音色库 VoiceContent:波形筛选与旋钮特效

5.1 工厂函数与组件声明

function buildVoice(src: VoiceItem, hot: number, state: string): VoiceItem {
  return { id: src.id, name: src.name, osc: src.osc, wave: src.wave, hot: hot, state: state, cover: src.cover, note: src.note }
}

@Component
struct VoiceContent {
  @Link voices: VoiceItem[]
  @Link mys: MyItem[]
  @State pickWave: string = '全部'
  @State showDetail: boolean = false
  @State picked: VoiceItem = VOICES[0]
  @State showEdit: boolean = false
  @State editHot: string = ''
  @State editState: string = 'classic'
  @State tip: string = ''

音色库组件在结构上与模块库类似,但新增了一个 buildVoice 工厂函数。这个函数接收一个原始 VoiceItem、新的热度值与状态,返回一个更新了 hotstate 字段的新对象。它的用途是"音色调制"——当用户在编辑弹窗中修改了音色的热度与定位后,调用此函数构造更新后的对象,再通过 splice 替换原数组中的对应项。

VoiceContent 的状态变量中,pickWave 记录波形筛选条件(初始为"全部"),showEdit 控制调制弹窗显示,editHoteditState 分别记录编辑弹窗中的热度输入与状态选择。注意 editHot 是字符串类型而非数值——这是因为 TextInput 的值是字符串,提交时再通过 Number() 转换为数值。

5.2 音色详情弹窗与进度条特效

@Builder
detailModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      // ... 顶部标题与关闭按钮
      Row() {
        Text(this.picked.cover)
          .fontSize(34)
        Column({ space: 4 }) {
          Text(this.picked.name)
            .fontSize(17)
            .fontColor('#F3F4F6')
            .fontWeight(FontWeight.Bold)
          Text(this.picked.osc + ' · ' + this.picked.wave + ' 波形')
            .fontSize(11)
            .fontColor('#8A8A96')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })
        Text(voiceStateText(this.picked.state))
          .fontSize(11)
          .fontColor(voiceStateColor(this.picked.state))
          .border({ width: 1, color: voiceStateColor(this.picked.state) })
          .borderRadius(10)
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#101014')
      .borderRadius(8)
      .margin({ bottom: 10 })
      Text('热度 ' + this.picked.hot.toString() + ' / 100')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
        .margin({ bottom: 4 })
      Row()
        .width('100%')
        .height(8)
        .backgroundColor('#2A2A36')
        .borderRadius(4)
      Row()
        .width('70%')
        .height(8)
        .backgroundColor('#A78BFA')
        .borderRadius(4)
        .offset({ x: 0, y: -8 })

音色详情弹窗的进度条实现了一个巧妙的视觉特效。它由两个 Row 组件叠加而成:第一个 Row 是进度槽(100% 宽度、8 像素高、深灰背景),第二个 Row 是进度条(70% 宽度、8 像素高、霓虹紫背景)。第二个 Row 通过 .offset({ x: 0, y: -8 }) 向上偏移 8 像素,正好覆盖在进度槽之上,形成"槽 + 填充"的进度条效果。

.offset 属性用于在布局完成后对组件进行额外偏移,不影响其他组件的布局位置。这种用法在需要叠加效果但又不想用 Stack 时很方便。这里的进度条宽度硬编码为 70%,实际应用中应根据 this.picked.hot 动态计算,例如 .width(this.picked.hot + '%')

弹窗中还有一个"旋钮特效"区块——三个"◉"字符横向排列,颜色分别为电光青、霓虹紫、警示橙,模拟合成器面板上的旋钮指示灯。这种用字符模拟硬件面板的方式呼应了应用的合成器主题,增强了沉浸感。

5.3 音色调制编辑弹窗

@Builder
editModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      // ... 顶部标题
      Text('调制对象:' + this.picked.name)
        .fontSize(12)
        .fontColor('#22D3EE')
        .width('100%')
        .margin({ bottom: 10 })
      Text('热度值(0-100)')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
      TextInput({ placeholder: '输入 0-100 的数字', text: this.editHot })
        .type(InputType.Number)
        .width('100%')
        .height(40)
        .backgroundColor('#101014')
        .fontColor('#E5E7EB')
        .placeholderColor('#5A5A66')
        .borderRadius(6)
        .margin({ top: 4, bottom: 10 })
        .onChange((v: string) => {
          this.editHot = v
        })
      Text('音色定位')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
        .margin({ bottom: 6 })
      Row({ space: 8 }) {
        Text('经典音色')
          .fontSize(12)
          .fontColor(this.editState === 'classic' ? '#101014' : '#B9B9C4')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .height(34)
          .backgroundColor(this.editState === 'classic' ? '#A78BFA' : '#14141C')
          .borderRadius(6)
          .onClick(() => {
            this.editState = 'classic'
          })
        Text('实验音色')
          .fontSize(12)
          .fontColor(this.editState === 'exp' ? '#101014' : '#B9B9C4')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .height(34)
          .backgroundColor(this.editState === 'exp' ? '#F472B6' : '#14141C')
          .borderRadius(6)
          .onClick(() => {
            this.editState = 'exp'
          })
      }
      .width('100%')
      .margin({ bottom: 14 })

音色调制弹窗的表单包含两个输入项:热度值(数值输入框)与音色定位(单选切换)。热度输入框通过 .type(InputType.Number) 指定为数字输入类型,这会让虚拟键盘显示数字布局,方便用户输入数值。

音色定位的"经典音色"与"实验音色"两个选项用 Text 组件模拟单选按钮组——每个选项是一个等宽的胶囊按钮(.layoutWeight(1)),选中时填充对应颜色(经典为霓虹紫、实验为粉红),未选中时为深灰背景。点击任一选项会将 this.editState 更新为对应的短码(classicexp),框架自动更新两个选项的样式。这种"自定义单选按钮组"的实现在 ArkTS 中很常见,因为原生 Toggle 组件的样式定制能力有限,用 Text + 状态控制更灵活。

      Row() {
        Text('取消')
          .onClick(() => { onClose() })
        Text('保存调制')
          .backgroundColor('#22D3EE')
          .onClick(() => {
            const newHot = Number(this.editHot)
            if (newHot > 0 && newHot <= 100) {
              const idx = this.voices.indexOf(this.picked)
              if (idx >= 0) {
                this.voices.splice(idx, 1, buildVoice(this.picked, newHot, this.editState))
              }
              this.tip = '✅ 音色调制已保存,热度更新为 ' + newHot.toString()
            } else {
              this.tip = '⚠️ 热度值需在 1-100 之间'
            }
            onClose()
          })
      }

点击"保存调制"时,先通过 Number(this.editHot) 将字符串转换为数值,然后校验数值范围(1-100)。校验通过后,通过 this.voices.indexOf(this.picked) 找到当前音色在数组中的索引,再调用 this.voices.splice(idx, 1, buildVoice(...)) 替换该位置的对象。splice 的第一个参数是起始索引,第二个参数是要删除的元素数(1 表示删除一个),第三个参数起是要插入的新元素。这种"删除一个并插入一个"的写法等效于"替换"。

技术要点:splice 与数组更新
splice 是触发 ArkUI 数组状态更新的推荐方法之一。它直接修改原数组(而非返回新数组),框架能检测到这种变化并重新渲染。与之相对,直接通过索引赋值(如 arr[0] = newValue)在某些情况下可能不触发更新,因此推荐使用 splice 进行替换操作。

六、乐手 PlayerContent:指标横幅与人气热度

6.1 状态映射函数与组件声明

function playerStateText(s: string): string {
  if (s === 'online') {
    return '在线'
  }
  return '巡演中'
}

function playerStateColor(s: string): string {
  if (s === 'online') {
    return '#22D3EE'
  }
  return '#FB923C'
}

@Component
struct PlayerContent {
  @Link players: PlayerItem[]
  @Link mys: MyItem[]
  @State showDetail: boolean = false
  @State picked: PlayerItem = PLAYERS[0]
  @State showBook: boolean = false
  @State bookName: string = ''
  @State bookTime: string = '周六场'
  @State tip: string = ''

乐手 Tab 新增了 playerStateTextplayerStateColor 两个状态映射函数,将 online/tour 短码映射为"在线"/"巡演中"文案与电光青/警示橙颜色。这延续了应用中"状态短码 + 映射函数对"的设计模式。

组件状态中,showBook 控制巡演预约弹窗,bookNamebookTime 记录预约表单的姓名输入与场次选择。注意 bookTime 的初始值为"周六场",表示默认选中周六场——这种"预设默认值"的做法能减少用户的操作步骤,提升体验。

6.2 三指标横幅

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          // 3 指标横幅
          Row({ space: 8 }) {
            Column({ space: 4 }) {
              Text(this.players.length.toString())
                .fontSize(20)
                .fontColor('#22D3EE')
                .fontWeight(FontWeight.Bold)
              Text('在册乐手')
                .fontSize(10)
                .fontColor('#8A8A96')
            }
            .layoutWeight(1)
            .height(64)
            .justifyContent(FlexAlign.Center)
            .backgroundColor('#1C1C26')
            .borderRadius(8)
            Column({ space: 4 }) {
              Text('5')
                .fontSize(20)
                .fontColor('#A78BFA')
                .fontWeight(FontWeight.Bold)
              Text('当前在线')
                .fontSize(10)
                .fontColor('#8A8A96')
            }
            .layoutWeight(1)
            .height(64)
            .justifyContent(FlexAlign.Center)
            .backgroundColor('#1C1C26')
            .borderRadius(8)
            Column({ space: 4 }) {
              Text('2')
                .fontSize(20)
                .fontColor('#FB923C')
                .fontWeight(FontWeight.Bold)
              Text('巡演中')
                .fontSize(10)
                .fontColor('#8A8A96')
            }
            .layoutWeight(1)
            .height(64)
            .justifyContent(FlexAlign.Center)
            .backgroundColor('#1C1C26')
            .borderRadius(8)
          }
          .width('100%')
          .margin({ top: 12 })

三指标横幅是一个 Row,内部三个等宽的 Column 指标卡,分别展示"在册乐手"数量(电光青色)、"当前在线"数量(霓虹紫色)、"巡演中"数量(警示橙色)。每个指标卡通过 .layoutWeight(1) 均分宽度,.height(64) 固定高度,.justifyContent(FlexAlign.Center) 让数值与标签垂直居中。

注意第一个指标卡的数据是动态的——this.players.length.toString(),会随着乐手数据的变化而更新;而后两个指标卡的数据(“5"和"2”)是硬编码的。这种"部分动态、部分静态"的混合数据在原型阶段很常见,正式开发中应全部来自后端数据。

技术要点:指标卡的颜色编码
三个指标卡分别使用电光青、霓虹紫、警示橙三种颜色,与应用的整体色板呼应。这种"每个指标一种颜色"的设计让用户能快速通过颜色区分指标类型,是数据仪表盘的常见做法。

6.3 乐手详情与双进度条

乐手详情弹窗中有一个"双进度条"特效,用于展示人气热度:

        Row() {
          Text('🔥')
            .fontSize(12)
          Row()
            .layoutWeight(1)
            .height(8)
            .backgroundColor('#2A2A36')
            .borderRadius(4)
            .margin({ left: 6 })
        }
        .width('100%')
        .margin({ bottom: 4 })
        Row() {
          Text('🔥')
            .fontSize(12)
          Row()
            .layoutWeight(1)
            .height(8)
            .backgroundColor('#FB923C')
            .borderRadius(4)
            .margin({ left: 6 })
        }
        .width('100%')
        .offset({ y: -12 })

这里使用了与音色详情弹窗相同的"槽 + 填充"进度条模式,但实现方式略有不同。两个 Row 都包含了"🔥"图标与一个进度条 Row,第二个 Row 通过 .offset({ y: -12 }) 向上偏移 12 像素,让填充条覆盖在槽条之上。这里的进度条宽度没有显式设置,实际应用中应根据 this.picked.hot 动态计算宽度百分比。

6.4 巡演预约弹窗

@Builder
bookModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      // ... 顶部标题
      Text('预约对象:' + this.picked.name + '(' + this.picked.role + ')')
        .fontSize(12)
        .fontColor('#FB923C')
        .width('100%')
        .margin({ bottom: 10 })
      Text('你的姓名')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
      TextInput({ placeholder: '请输入姓名', text: this.bookName })
        .width('100%')
        .height(40)
        .backgroundColor('#101014')
        .fontColor('#E5E7EB')
        .placeholderColor('#5A5A66')
        .borderRadius(6)
        .margin({ top: 4, bottom: 10 })
        .onChange((v: string) => {
          this.bookName = v
        })
      Text('选择场次')
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
        .margin({ bottom: 6 })
      Row({ space: 8 }) {
        Text('周六场')
          .fontSize(12)
          .fontColor(this.bookTime === '周六场' ? '#101014' : '#B9B9C4')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .height(34)
          .backgroundColor(this.bookTime === '周六场' ? '#22D3EE' : '#14141C')
          .borderRadius(6)
          .onClick(() => {
            this.bookTime = '周六场'
          })
        Text('周日场')
          .onClick(() => {
            this.bookTime = '周日场'
          })
      }

巡演预约弹窗的表单包含姓名输入与场次选择。场次选择的实现与音色调制弹窗中的"经典/实验"单选切换一致——两个等宽的 Text 按钮通过 bookTime 状态变量控制选中样式。点击任一场次按钮会更新 bookTime,框架自动更新两个按钮的样式。

提交预约时校验姓名非空,然后设置提示文案并关闭弹窗。注意这里没有实际的"预约数据"被保存——只是设置了提示文案,这是原型阶段的简化处理。

七、赛事 MatchContent:横幅、规则、榜单与报名步进

7.1 状态函数与组件声明

function matchStateText(s: string): string {
  if (s === 'open') {
    return '报名中'
  }
  return '已结束'
}

function matchStateColor(s: string): string {
  if (s === 'open') {
    return '#22D3EE'
  }
  return '#8A8A96'
}

function buildMatch(id: number, name: string): MatchItem {
  return { id: id, name: name, date: '11-01 开赛', prize: 6000, quota: 20, state: 'open', note: '这是一场刚刚发布的实验室赛事,名额有限先到先得。' }
}

@Component
struct MatchContent {
  @Link matches: MatchItem[]
  @Link mys: MyItem[]
  @State showRule: boolean = false
  @State showRank: boolean = false
  @State showDetail: boolean = false
  @State picked: MatchItem = MATCHES[0]
  @State showBuy: boolean = false
  @State buyNum: number = 1
  @State showDel: boolean = false
  @State tip: string = ''

赛事 Tab 是功能最复杂的 Tab,它有五个状态变量控制不同弹窗的显示:showRule(规则弹窗)、showRank(榜单弹窗)、showDetail(详情弹窗)、showBuy(报名步进弹窗)、showDel(取消报名警告弹窗)。此外还有 picked 记录当前选中的赛事、buyNum 记录报名席位数、tip 提示文案。

buyNum 是一个数值类型的状态变量,初始为 1,用于报名步进弹窗中的席位数选择。这是应用中少数几个数值类型的 @State 变量(其他多为字符串或布尔值)。

7.2 赛事规则与年度榜单弹窗

@Builder
ruleModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Row() {
        Text('📜 赛事规则')
          .fontSize(17)
          .fontColor('#E5E7EB')
          .fontWeight(FontWeight.Bold)
        Text('✕')
          .onClick(() => { onClose() })
      }
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 12 })
      Text('1. 报名采用实名制,每人限报一场。')
        .fontSize(12)
        .fontColor('#B9B9C4')
        .width('100%')
        .margin({ bottom: 6 })
      Text('2. 开赛前 24 小时可免费取消报名。')
        .fontSize(12)
        .fontColor('#B9B9C4')
        .width('100%')
        .margin({ bottom: 6 })
      Text('3. 评委由实验室首席工程师与特邀音乐人组成。')
        .fontSize(12)
        .fontColor('#B9B9C4')
        .width('100%')
        .margin({ bottom: 6 })
      Text('4. 获奖选手将获得奖金与实验室荣誉徽章。')
        .fontSize(12)
        .fontColor('#B9B9C4')
        .width('100%')
        .margin({ bottom: 14 })
      Text('知道了')
        .fontSize(13)
        .fontColor('#101014')
        .textAlign(TextAlign.Center)
        .width('100%')
        .height(38)
        .backgroundColor('#22D3EE')
        .borderRadius(6)
        .onClick(() => { onClose() })
    }
    // ... 弹窗容器样式
  }
}

赛事规则弹窗是一个纯展示型弹窗——内部只有四条规则文字与一个"知道了"按钮,没有表单输入。这种"信息确认"型弹窗在应用中很常见,用于展示用户协议、使用说明、活动规则等内容。注意每条规则文字都设置了 .width('100%').margin({ bottom: 6 }),确保文字占满宽度且条目之间有间距。

年度榜单弹窗 rankModalOverlay 的结构与规则弹窗类似,但内容是一个排名列表:

@Builder
rankModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      // ... 顶部标题
      ForEach(['小林', '阿澈', 'Mori'], (name: string, index: number) => {
        Row() {
          Text(index === 0 ? '🥇' : (index === 1 ? '🥈' : '🥉'))
            .fontSize(20)
          Text(name)
            .fontSize(14)
            .fontColor('#F3F4F6')
            .fontWeight(FontWeight.Medium)
            .margin({ left: 10 })
          Text((9800 - index * 1200).toString() + ' 分')
            .fontSize(12)
            .fontColor('#FBBF24')
            .layoutWeight(1)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .padding(10)
        .backgroundColor('#101014')
        .borderRadius(8)
        .margin({ bottom: 6 })
      }, (name: string, index: number) => 'rk' + index.toString())

榜单使用 ForEach 遍历三个乐手姓名,每行展示奖牌 Emoji(金/银/铜)、姓名、积分。积分通过 (9800 - index * 1200) 公式计算——第一名 9800 分、第二名 8600 分、第三名 7400 分。.textAlign(TextAlign.End) 让积分靠右对齐,与左侧的奖牌与姓名形成左中右的布局结构。

技术要点:Emoji 作为图标资源
应用中大量使用 Emoji 字符(🥇🥈🥉🧩🎛️🎸🏆👤等)作为图标。这种做法的优势是无需引入图片资源,包体积小,跨平台一致;劣势是无法精细控制图标的颜色与形状,且不同设备的 Emoji 渲染样式可能略有差异。在原型阶段是合理选择,正式产品通常替换为 SVG 或图标字体。

7.3 赛事详情与名额进度条

@Builder
detailModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      // ... 顶部标题
      Text(this.picked.name)
        .fontSize(17)
        .fontColor('#F3F4F6')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .margin({ bottom: 6 })
      Row({ space: 8 }) {
        Text(this.picked.date)
          .fontSize(11)
          .fontColor('#8A8A96')
          .border({ width: 1, color: '#3A3A46' })
          .borderRadius(4)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Text('💰 奖金 ¥' + this.picked.prize.toString())
          .fontSize(11)
          .fontColor('#FBBF24')
          .border({ width: 1, color: '#3A3A46' })
          .borderRadius(4)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        Text(matchStateText(this.picked.state))
          .fontSize(11)
          .fontColor(matchStateColor(this.picked.state))
      }
      .width('100%')
      .margin({ bottom: 10 })
      Text('剩余名额:' + this.picked.quota.toString() + ' 席')
        .fontSize(12)
        .fontColor(this.picked.quota > 0 ? '#22D3EE' : '#F87171')
        .width('100%')
        .margin({ bottom: 6 })
      Row()
        .width('100%')
        .height(8)
        .backgroundColor('#2A2A36')
        .borderRadius(4)
      Row()
        .width((this.picked.quota / 60 * 100).toString() + '%')
        .height(8)
        .backgroundColor(this.picked.quota > 0 ? '#22D3EE' : '#F87171')
        .borderRadius(4)
        .offset({ x: 0, y: -8 })

赛事详情弹窗的信息密度较高。顶部是赛事名称,下方是一个 Row 展示三个信息徽章:开赛日期、奖金金额、报名状态。每个徽章都用描边样式(1 像素边框、4 像素圆角)呈现,形成统一的视觉语言。

名额进度条的实现与之前的进度条类似,但有一个关键区别——进度条宽度是动态计算的:(this.picked.quota / 60 * 100).toString() + '%'。这里将剩余名额除以 60(假设总名额为 60)再乘以 100,得到百分比字符串。当 quota 为 0 时,进度条宽度为 0%,且颜色变为红色(#F87171),视觉上提示名额已满。

这种"数值驱动的进度条"是数据可视化的基础模式——将业务数值映射为视觉属性(宽度),让用户直观感知数据的相对大小。

7.4 报名下单步进弹窗

@Builder
buyModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      // ... 顶部标题
      Text(this.picked.name)
        .fontSize(14)
        .fontColor('#F3F4F6')
        .fontWeight(FontWeight.Medium)
        .width('100%')
        .margin({ bottom: 10 })
      Text('剩余名额:' + this.picked.quota.toString() + ' 席 · 报名费 ¥' + (this.picked.prize / 100).toString())
        .fontSize(12)
        .fontColor('#8A8A96')
        .width('100%')
        .margin({ bottom: 10 })
      Row() {
        Text('−')
          .fontSize(18)
          .fontColor('#E5E7EB')
          .textAlign(TextAlign.Center)
          .width(40)
          .height(36)
          .backgroundColor('#14141C')
          .borderRadius(6)
          .onClick(() => {
            if (this.buyNum > 1) {
              this.buyNum = this.buyNum - 1
            }
          })
        Text(this.buyNum.toString() + ' 席')
          .fontSize(14)
          .fontColor('#F3F4F6')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .height(36)
          .backgroundColor('#101014')
          .borderRadius(6)
        Text('+')
          .fontSize(18)
          .fontColor('#E5E7EB')
          .textAlign(TextAlign.Center)
          .width(40)
          .height(36)
          .backgroundColor('#14141C')
          .borderRadius(6)
          .onClick(() => {
            if (this.buyNum < this.picked.quota) {
              this.buyNum = this.buyNum + 1
            }
          })
      }
      .width('100%')
      .margin({ bottom: 6 })
      Text('合计:¥' + ((this.picked.prize / 100) * this.buyNum).toString())
        .fontSize(16)
        .fontColor('#FBBF24')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.End)
        .margin({ bottom: 12 })

报名下单弹窗包含一个"步进器"(stepper)组件——由"−"按钮、数量显示、"+"按钮三部分组成。点击"−"按钮时,若 buyNum > 1 则减 1;点击"+"按钮时,若 buyNum < this.picked.quota 则加 1。这种边界检查确保数量始终在 1 到剩余名额之间。

步进器下方的"合计"金额是动态计算的:(this.picked.prize / 100) * this.buyNum。这里 prize 是奖金总额,除以 100 得到报名费单价,再乘以席位数得到总价。由于 buyNum@State 变量,每次点击步进按钮都会触发合计金额的自动更新——用户能立即看到价格变化,这是声明式 UI 在电商场景中的典型优势。

      Row() {
        Text('取消')
          .onClick(() => { onClose() })
        Text('确认报名')
          .backgroundColor('#22D3EE')
          .onClick(() => {
            if (this.picked.quota >= this.buyNum) {
              const newQuota = this.picked.quota - this.buyNum
              const idx = this.matches.indexOf(this.picked)
              if (idx >= 0) {
                this.matches.splice(idx, 1, { id: this.picked.id, name: this.picked.name, date: this.picked.date, prize: this.picked.prize, quota: newQuota, state: this.picked.state, note: this.picked.note })
              }
              this.tip = '✅ 报名成功,剩余名额 ' + newQuota.toString() + ' 席'
            } else {
              this.tip = '⚠️ 名额不足,请减少报名席位'
            }
            onClose()
          })
      }

点击"确认报名"时,先校验剩余名额是否足够(this.picked.quota >= this.buyNum),然后计算新的剩余名额 newQuota,通过 splice 替换原数组中的赛事对象。这里没有使用工厂函数,而是直接构造对象字面量——因为 MatchItem 的字段较多,且大部分字段需要从原对象复制,工厂函数的简洁优势不明显。但这种直接构造的方式在字段较多时容易出错(如遗漏字段),所以应用中其他场景仍优先使用工厂函数。

技术要点:步进器的实现
步进器(stepper)是电商与预约场景的常见组件,ArkUI 没有内置步进器,需要用 Text + onClick 自行实现。关键点是边界检查——减按钮不能低于最小值(通常为 1),加按钮不能超过最大值(如库存或剩余名额)。这种"状态变量 + 边界检查 + 自动更新"的模式是声明式 UI 处理交互的核心。

7.5 取消报名警告弹窗

@Builder
delModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Text('⚠️ 取消报名')
        .fontSize(17)
        .fontColor('#F87171')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ bottom: 10 })
      Text('确定要取消「' + this.picked.name + '」的报名吗?')
        .fontSize(13)
        .fontColor('#B9B9C4')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ bottom: 14 })
      Row() {
        Text('再想想')
          .onClick(() => { onClose() })
        Text('确认取消')
          .backgroundColor('#F87171')
          .onClick(() => {
            const idx = this.matches.findIndex((m: MatchItem) => m.id === this.picked.id)
            if (idx >= 0) {
              this.matches.splice(idx, 1)
              this.tip = '🗑️ 已取消报名'
            }
            onClose()
          })
      }
    }
    // ... 弹窗容器样式
  }
}

取消报名弹窗是一个"删除警告"型弹窗——红色标题(#F87171)、警告文案、两个按钮(“再想想"与"确认取消”)。这种弹窗在涉及不可逆操作(删除、取消、退出)时是必要的,能防止用户误操作。

点击"确认取消"时,通过 this.matches.findIndex(m => m.id === this.picked.id) 找到赛事在数组中的索引,然后调用 this.matches.splice(idx, 1) 删除该项。splice(idx, 1) 表示从索引 idx 开始删除 1 个元素,不插入新元素——这就是"删除"操作。删除后,matches 数组的变化会触发赛事列表的重新渲染,被取消的赛事从列表中消失。

技术要点:findIndex 与 indexOf 的选择
indexOf 通过引用相等查找(===),适用于查找已知引用的对象;findIndex 通过回调函数判断,适用于按字段值查找。本例中 this.picked 可能与数组中的对象不是同一引用(例如经过序列化反序列化后),因此用 findIndexid 字段查找更可靠。

7.6 赛事列表与取消按钮

            ForEach(this.matches, (item: MatchItem, index: number) => {
              Row() {
                Column({ space: 4 }) {
                  Text(item.name)
                    .fontSize(14)
                    .fontColor('#F3F4F6')
                    .fontWeight(FontWeight.Medium)
                    .width('100%')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(item.date + ' · 奖金 ¥' + item.prize.toString())
                    .fontSize(11)
                    .fontColor('#8A8A96')
                    .width('100%')
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                Column({ space: 2 }) {
                  Text('剩 ' + item.quota.toString() + ' 席')
                    .fontSize(10)
                    .fontColor(item.quota > 0 ? '#22D3EE' : '#F87171')
                  Text(matchStateText(item.state))
                    .fontSize(10)
                    .fontColor(matchStateColor(item.state))
                }
                Text('报名 ›')
                  .fontSize(12)
                  .fontColor('#FBBF24')
                  .margin({ left: 8 })
                  .onClick(() => {
                    this.picked = item
                    this.showDetail = true
                  })
              }
              .width('100%')
              .padding(10)
              .backgroundColor('#14141C')
              .borderRadius(8)
              .border({ width: 1, color: '#24242E' })
              .margin({ bottom: 8 })
              Row() {
                Text('🗑️ 取消报名')
                  .fontSize(11)
                  .fontColor('#F87171')
                  .onClick(() => {
                    this.picked = item
                    this.showDel = true
                  })
              }
              .width('100%')
              .justifyContent(FlexAlign.End)
              .margin({ top: -4, bottom: 8 })
            }, (item: MatchItem, index: number) => 'match' + item.id.toString())

赛事列表的每条卡片由两个 Row 组成:上方的赛事信息行与下方的"取消报名"按钮行。注意这里 ForEach 的每项返回的是两个 Row——这在 ArkTS 中是允许的,ForEach 的项渲染函数可以返回多个组件,它们会按顺序排列在父容器中。

"取消报名"按钮通过 .justifyContent(FlexAlign.End) 靠右对齐,.margin({ top: -4 }) 让它向上靠近赛事信息行,视觉上作为信息行的附属操作。点击它会设置 this.picked = itemthis.showDel = true,触发取消报名警告弹窗。

下面的流程图展示了赛事报名的完整业务流程,从用户点击"报名"到名额更新的数据流。

用户点击报名链接

this.picked = item

this.showDetail = true

赛事详情弹窗显示

用户点击立即报名

this.picked.quota > 0?

tip = 名额已满

this.buyNum = 1

this.showBuy = true

报名步进弹窗显示

用户调整席位

buyNum 在 1 到 quota 之间

合计金额自动更新

用户点击确认报名

quota >= buyNum?

tip = 名额不足

newQuota = quota - buyNum

splice 替换赛事对象

tip = 报名成功

赛事列表自动更新

名额显示减少

八、我的 MeContent:档案卡、四宫格与收藏管理

8.1 组件声明与四个弹窗状态

@Component
struct MeContent {
  @Link mys: MyItem[]
  @Link modules: ModuleItem[]
  @Link voices: VoiceItem[]
  @State nickname: string = '调音师小赛'
  @State showDelMy: boolean = false
  @State pickedMy: MyItem = MYS[0]
  @State showRename: boolean = false
  @State renameVal: string = ''
  @State showClear: boolean = false
  @State showExit: boolean = false
  @State tip: string = ''

"我的"Tab 是个人中心页面,它接收三个 @Link 变量(mysmodulesvoices),用于展示用户的收藏数量、模块数量、音色数量。组件内部有四个弹窗状态:showDelMy(移除收藏警告)、showRename(修改昵称)、showClear(清空缓存)、showExit(退出登录)。

nickname 是用户的昵称状态变量,初始为"调音师小赛",可通过修改昵称弹窗更新。pickedMy 记录当前选中的收藏项(用于移除收藏弹窗的确认)。renameVal 记录修改昵称弹窗中的输入值。

8.2 档案卡与四宫格

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          // 档案卡
          Row() {
            Text('👨‍🔧')
              .fontSize(36)
            Column({ space: 4 }) {
              Text(this.nickname)
                .fontSize(18)
                .fontColor('#F3F4F6')
                .fontWeight(FontWeight.Bold)
              Text('Lv.12 模块拼装师 · 音色 36 组')
                .fontSize(11)
                .fontColor('#8A8A96')
              Text('🔧 已拼装 ' + this.modules.length.toString() + ' 台 · 调制音色 ' + this.voices.length.toString() + ' 组')
                .fontSize(10)
                .fontColor('#22D3EE')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 12 })
            Text('✏️')
              .fontSize(18)
              .onClick(() => {
                this.showRename = true
              })
          }
          .width('100%')
          .padding(14)
          .backgroundColor('#1C1C26')
          .borderRadius(10)
          .border({ width: 1, color: '#22D3EE' })
          .margin({ top: 12 })

档案卡是一个 Row,左侧是用户头像 Emoji,中间是昵称与等级信息,右侧是编辑按钮。.layoutWeight(1) 让中间的 Column 占据剩余空间,让编辑按钮靠右。点击"✏️"编辑按钮会触发 this.showRename = true,显示修改昵称弹窗。

档案卡中的数据部分是动态的——"已拼装 X 台 · 调制音色 Y 组"中的 X 与 Y 分别来自 this.modules.lengththis.voices.length。由于这两个是 @Link 变量,当用户在其他 Tab 中新增模块或音色后,切回"我的"Tab 会看到数字自动更新。这就是 @Link 双向绑定的实际效果。

          // 四宫格
          Row({ space: 8 }) {
            Column({ space: 4 }) {
              Text('🧩')
                .fontSize(20)
              Text(this.modules.length.toString())
                .fontSize(15)
                .fontColor('#22D3EE')
                .fontWeight(FontWeight.Bold)
              Text('我的模块')
                .fontSize(10)
                .fontColor('#8A8A96')
            }
            .layoutWeight(1)
            .height(72)
            .justifyContent(FlexAlign.Center)
            .backgroundColor('#14141C')
            .borderRadius(8)
            Column({ space: 4 }) {
              Text('🎛️')
                .fontSize(20)
              Text(this.voices.length.toString())
                .fontSize(15)
                .fontColor('#A78BFA')
                .fontWeight(FontWeight.Bold)
              Text('我的音色')
                .fontSize(10)
                .fontColor('#8A8A96')
            }
            .layoutWeight(1)
            // ... 其余两个格子
          }
          .width('100%')
          .margin({ top: 10 })

四宫格是一个 Row,内部四个等宽的 Column 格子,分别展示"我的模块"“我的音色”“参赛场次”"我的收藏"的数量。每个格子通过 .layoutWeight(1) 均分宽度,.height(72) 固定高度,内部纵向排列 Emoji 图标、数值、标签。四个格子的数值颜色各不相同(电光青、霓虹紫、警示黄、警示橙),与各自的图标语义呼应。

技术要点:四宫格的均分布局
四个格子通过 .layoutWeight(1) 实现等宽均分,无需计算具体宽度。这种"弹性权重"的布局方式比硬编码百分比宽度更灵活——无论容器宽度如何变化,四个格子始终等宽。Row({ space: 8 }) 设置格子间的间距,让格子之间有视觉呼吸感。

8.3 收藏列表与移除功能

            ForEach(this.mys, (item: MyItem, index: number) => {
              Row() {
                Text(item.kind === '模块' ? '🧩' : (item.kind === '音色' ? '🎛️' : (item.kind === '乐手' ? '🎸' : '🏆')))
                  .fontSize(18)
                  .width(34)
                  .textAlign(TextAlign.Center)
                Column({ space: 2 }) {
                  Text(item.name)
                    .fontSize(13)
                    .fontColor('#F3F4F6')
                    .width('100%')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(item.date + ' · ' + item.note)
                    .fontSize(10)
                    .fontColor('#8A8A96')
                    .width('100%')
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 8 })
                Text('移除')
                  .fontSize(11)
                  .fontColor('#F87171')
                  .onClick(() => {
                    this.pickedMy = item
                    this.showDelMy = true
                  })
              }
              .width('100%')
              .padding(10)
              .backgroundColor('#14141C')
              .borderRadius(8)
              .border({ width: 1, color: '#24242E' })
              .margin({ bottom: 8 })
            }, (item: MyItem, index: number) => 'my' + item.id.toString())

收藏列表通过 ForEach 遍历 this.mys 数组渲染。每条收藏记录是一个 Row 卡片:左侧是根据 kind 字段动态选择的 Emoji 图标(嵌套三元表达式实现四种类型的图标映射),中间是名称与日期说明,右侧是"移除"按钮。

嵌套三元表达式 item.kind === '模块' ? '🧩' : (item.kind === '音色' ? '🎛️' : (item.kind === '乐手' ? '🎸' : '🏆')) 是根据字段值选择图标的简洁写法。这种多层嵌套虽然可读性一般,但在原型阶段足够实用。正式开发中可以提取为工具函数 myKindIcon(kind: string): string,让代码更清晰。

点击"移除"会设置 this.pickedMy = itemthis.showDelMy = true,触发移除收藏警告弹窗。弹窗中确认后通过 this.mys.splice(idx, 1) 删除收藏项。由于 mys@Link 变量,删除操作会同步到父组件 Index,其他 Tab 中的相关数据也会更新。

8.4 修改昵称弹窗

@Builder
renameModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Row() {
        Text('✏️ 修改昵称')
          .fontSize(17)
          .fontColor('#E5E7EB')
          .fontWeight(FontWeight.Bold)
        Text('✕')
          .onClick(() => { onClose() })
      }
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 12 })
      TextInput({ placeholder: '输入新昵称', text: this.renameVal })
        .width('100%')
        .height(40)
        .backgroundColor('#101014')
        .fontColor('#E5E7EB')
        .placeholderColor('#5A5A66')
        .borderRadius(6)
        .margin({ bottom: 14 })
        .onChange((v: string) => {
          this.renameVal = v
        })
      Row() {
        Text('取消')
          .onClick(() => { onClose() })
        Text('保存昵称')
          .backgroundColor('#22D3EE')
          .onClick(() => {
            if (this.renameVal.length > 0) {
              this.nickname = this.renameVal
              this.tip = '✅ 昵称已更新为 ' + this.nickname
            } else {
              this.tip = '⚠️ 昵称不能为空'
            }
            this.renameVal = ''
            onClose()
          })
      }
    }
    // ... 弹窗容器样式
  }
}

修改昵称弹窗的结构与发布动态弹窗类似——一个 TextInput 加两个按钮。点击"保存昵称"时,校验输入非空后执行 this.nickname = this.renameVal,将输入值赋给昵称状态变量。由于 nickname@State 变量,档案卡中的昵称显示会自动更新为新的值。这种"修改状态 → 自动更新视图"的体验正是声明式 UI 的核心价值。

8.5 清空缓存与退出登录弹窗

@Builder
clearModalOverlay(onClose: () => void) {
  Column() {
    Column() {
      Text('🧹 清空缓存')
        .fontSize(17)
        .fontColor('#FBBF24')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ bottom: 10 })
      Text('将清除 126MB 本地缓存数据,是否继续?')
        .fontSize(13)
        .fontColor('#B9B9C4')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ bottom: 14 })
      Row() {
        Text('暂不')
          .onClick(() => { onClose() })
        Text('立即清空')
          .backgroundColor('#FBBF24')
          .onClick(() => {
            this.tip = '🧹 缓存已清空(126MB 释放)'
            onClose()
          })
      }
    }
    // ... 弹窗容器样式
  }
}

清空缓存弹窗与退出登录弹窗都是"确认型"弹窗——警告文案 + 两个按钮。它们的结构与取消报名弹窗一致,只是文案与颜色不同。清空缓存弹窗用警示黄色(#FBBF24)作为主操作按钮颜色,退出登录弹窗用警示红色(#F87171)作为主操作按钮颜色。这种"按危险等级分配颜色"的设计(黄色警告、红色危险)是交互设计的通用规范。

这两个弹窗点击确认后都只设置提示文案,没有实际的清空或退出操作——这是原型阶段的简化处理。正式开发中,清空缓存应调用系统 API 清除本地存储,退出登录应清除登录态并跳转到登录页。

技术要点:弹窗的统一设计模式
纵观整个应用的 15 个弹窗,它们遵循统一的设计模式:全屏 Column 半透明遮罩 + 88% 宽度内容卡片 + 圆角边框 + 顶部标题与关闭按钮 + 主体内容 + 底部操作按钮。这种一致性让用户形成稳定的操作预期——无论打开哪个弹窗,关闭按钮在右上角,操作按钮在底部,交互方式一致。这是良好用户体验的基础。

九、组件体系与装饰器总览

9.1 装饰器体系

本应用使用了 ArkTS 的五个核心装饰器,它们各自承担不同的职责:

@Entry 标记入口组件,每个页面只有一个;@Component 标记自定义组件,可被复用;@State 标记组件内部状态,变化时触发重新渲染;@Link 标记从父组件接收的引用变量,建立双向绑定;@Builder 标记可复用的 UI 构建方法,用于在组件内复用 UI 片段。这五个装饰器构成了 ArkTS 声明式 UI 的最小可用集合——掌握它们,就能构建出功能完整的交互页面。

9.2 容器组件体系

应用中使用了多种容器组件:Column(纵向排列)、Row(横向排列)、Stack(层叠堆叠)、Scroll(滚动容器)。ColumnRow 是最基础的线性布局容器,通过 { space } 参数设置子元素间距,通过 .justifyContent.alignItems 控制主轴与交叉轴的对齐方式。Stack 用于层叠效果与弹窗遮罩。Scroll 提供滚动能力,支持垂直与水平两种方向。

Flex 弹性布局虽然在本应用中没有直接使用 Flex 组件,但 FlexAlign 枚举值(CenterSpaceBetweenStartEnd)被广泛用于 ColumnRow.justifyContent 属性,体现了弹性布局的思想。.layoutWeight 属性则是弹性布局的核心机制,用于按权重分配剩余空间。

9.3 交互与展示组件

应用中使用的交互组件包括 Text(文本展示)、TextInput(文本输入)。Text 是最基础的展示组件,通过 .fontSize.fontColor.fontWeight.maxLines.textOverflow 等属性配置样式。TextInput 是输入组件,通过 .type 指定输入类型,通过 .placeholder.placeholderColor 配置占位提示,通过 .onChange 监听输入变化。

虽然应用中没有直接使用 Toggle(开关组件)、Slider(滑块组件)、Button(按钮组件)等 ArkUI 内置交互组件,但通过 Text + onClick + 状态变量模拟了这些组件的功能。例如音色调制弹窗中的"经典/实验"单选用 Text 模拟,报名步进器用 Text 模拟加减按钮。这种"用基础组件模拟复杂交互"的做法在原型阶段可行,但正式开发中建议使用对应的内置组件以获得更好的可访问性与一致性。

十、技术对比总览表

下表对本应用中涉及的各种数据结构、组件、状态变量、装饰器、工具函数等进行了横向对比,帮助理解它们各自的特点与适用场景。

类别 名称 类型/签名 作用 可变性 使用位置 典型用法
接口 FeedItem interface 描述动态数据结构 不可变(结构) 全应用 动态列表数据源
接口 ModuleItem interface 描述模块数据结构 不可变(结构) 模块库/我的 模块卡片与详情
接口 VoiceItem interface 描述音色数据结构 不可变(结构) 音色库/我的 音色列表与调制
接口 PlayerItem interface 描述乐手数据结构 不可变(结构) 乐手 Tab 乐手列表与详情
接口 MatchItem interface 描述赛事数据结构 不可变(结构) 赛事 Tab 赛事列表与报名
接口 MyItem interface 描述收藏记录结构 不可变(结构) 我的/全局 收藏列表渲染
装饰器 @Entry struct 标记 标记页面入口组件 - Index 应用入口
装饰器 @Component struct 标记 标记自定义组件 - 所有组件 组件声明
装饰器 @State 变量标记 组件内部状态,变化触发渲染 可变 所有组件 弹窗显隐/表单值
装饰器 @Link 变量标记 父子双向绑定 可变(双向) 子组件 跨 Tab 数据共享
装饰器 @Builder 方法标记 可复用 UI 构建方法 - Index/各组件 弹窗/Tab 项复用
容器 Column 组件 纵向排列子元素 - 全应用 内容纵向布局
容器 Row 组件 横向排列子元素 - 全应用 卡片/按钮行
容器 Stack 组件 层叠堆放子元素 - 各组件 build 弹窗遮罩层
容器 Scroll 组件 滚动容器 - 各 Tab 内容 长内容滚动
组件 Text 组件 文本展示 - 全应用 标题/正文/标签
组件 TextInput 组件 文本输入 - 表单弹窗 标题/名称输入
组件 ForEach 组件 列表渲染 - 列表区 动态/模块/音色列表
属性 layoutWeight 属性 弹性权重分配 - 布局 内容区/卡片均分
属性 justifyContent 属性 主轴对齐 - 布局 居中/两端对齐
属性 alignItems 属性 交叉轴对齐 - 布局 左/右/居中对齐
属性 offset 属性 偏移定位 - 进度条 进度条叠加效果
属性 animation 属性 动画过渡 - Tab 项 切换动画
属性 scrollable 属性 滚动方向 - 横滑区 水平滚动
属性 maxLines 属性 最大行数 - 长文本 单行省略
属性 textOverflow 属性 溢出处理 - 长文本 省略号截断
函数 heatBar 工具函数 热度值映射为高度 纯函数 首页柱状图 数值到像素映射
函数 moduleStateText 工具函数 模块状态码转文案 纯函数 模块库 状态展示
函数 moduleStateColor 工具函数 模块状态码转颜色 纯函数 模块库 状态颜色
函数 voiceStateText 工具函数 音色状态码转文案 纯函数 音色库 状态展示
函数 voiceStateColor 工具函数 音色状态码转颜色 纯函数 音色库 状态颜色
函数 playerStateText 工具函数 乐手状态码转文案 纯函数 乐手 Tab 状态展示
函数 playerStateColor 工具函数 乐手状态码转颜色 纯函数 乐手 Tab 状态颜色
函数 matchStateText 工具函数 赛事状态码转文案 纯函数 赛事 Tab 状态展示
函数 matchStateColor 工具函数 赛事状态码转颜色 纯函数 赛事 Tab 状态颜色
函数 buildFeed 工厂函数 构造新动态对象 纯函数 首页 发布新动态
函数 buildMy 工厂函数 构造新收藏记录 纯函数 各 Tab 添加收藏
函数 buildModule 工厂函数 构造新模块对象 纯函数 模块库 入库登记
函数 buildVoice 工厂函数 构造更新音色对象 纯函数 音色库 音色调制
函数 buildMatch 工厂函数 构造新赛事对象 纯函数 赛事 Tab 新增赛事
数组方法 unshift 数组操作 头部插入元素 修改原数组 新增场景 新动态/新模块置顶
数组方法 splice 数组操作 删除/替换元素 修改原数组 编辑/删除 音色替换/收藏移除
数组方法 indexOf 数组查询 按引用查找索引 不修改 编辑场景 定位选中项
数组方法 findIndex 数组查询 按条件查找索引 不修改 删除场景 按 id 定位赛事
属性 borderRadius 属性 圆角半径 - 卡片/按钮 圆角样式
属性 border 属性 边框配置 - 卡片/徽章 描边样式
属性 backgroundColor 属性 背景颜色 - 全应用 深色主题
属性 opacity 属性 透明度 - Tab 项 选中/未选中区分

十一、总结与反思

回顾整个应用的代码实现,我们可以看到 ArkTS 声明式 UI 范式在构建复杂交互页面时的清晰与高效。整个应用包含六个 Tab 页面、十五个弹窗、六种数据结构、十余个工具函数,代码总量约三千行,但结构清晰、层次分明。这种清晰性得益于声明式 UI 的核心思想——开发者描述"界面在不同状态下应如何呈现",框架负责状态变化时的视图更新,让开发者从繁琐的命令式 DOM 操作中解放出来。

数据驱动是本应用的核心理念。从 @State 的内部状态到 @Link 的跨组件双向绑定,从工厂函数的统一数据构造到工具函数的状态映射,整个应用的数据流是单向且可追踪的。用户操作触发状态变化,状态变化驱动视图更新,视图更新反馈给用户——这个闭环是声明式 UI 的工作基础。特别是 @Link 的双向绑定机制,让六个 Tab 之间能够共享同一份数据(如 mys 收藏数组),在任一 Tab 中添加或移除收藏,其他 Tab 都能自动感知并更新,这种数据一致性在命令式 UI 模式下需要大量手动同步代码才能实现。

组件化与复用是代码组织的另一个亮点。@Builder 构建器让弹窗 UI 与 Tab 项 UI 可以在组件内复用;@Component 让六个 Tab 内容组件可以独立开发与维护;工具函数与工厂函数让数据逻辑与视图逻辑分离。这种分层设计让每个组件的职责单一——组件只负责"如何渲染",工具函数负责"渲染什么",工厂函数负责"如何构造数据"。当需要修改某部分逻辑时,开发者能快速定位到对应的层级,而不必在混杂的代码中搜索。

本应用在视觉设计上也体现了深厚的功底。赛博实验室主题通过深炭黑(#101014#14141C#1C1C26)的层次化背景色、电光青(#22D3EE)的主色调、霓虹紫(#A78BFA)的辅助色、警示橙(#FB923C)与警示黄(#FBBF24)的状态色、危险红(#F87171)的警告色,构建出一套完整的色板体系。这套色板不仅美观,更承载了语义——不同颜色对应不同的状态与操作危险等级,帮助用户快速理解信息。Emoji 图标的使用虽然简单,却与合成器主题高度契合,让应用具有独特的视觉个性。

ArkUI 的链式属性配置风格在本应用中得到了充分展现。从 .fontSize.fontColor,从 .borderRadius.backgroundColor,从 .justifyContent.layoutWeight,每个组件的样式都通过链式调用配置。这种风格的优点是可读性强——属性配置紧跟组件声明,一目了然;缺点是当属性较多时链会很长,需要合理换行与缩进保持可读性。本应用的代码格式化风格值得参考:每个属性配置独占一行,属性之间无空行,容器组件的属性在子元素之后配置。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// =====================================================================
// 应用名: SYNTH LAB 合成器工坊 · 电子合成器拼装与音色调制
// 场景: 实验室动态、模块库、音色库、乐手、赛事、我的收藏
// 风格: 赛博实验室 —— 深炭黑、电光青、霓虹紫、警示橙、模块面板
// Tab: 首页 / 模块 / 音色 / 乐手 / 赛事 / 我的(6 Tab 单排)
// 弹窗: 动态详情、拼装新增(新增)、模块详情、申请测试(新增)、音色详情、
//       音色调制(编辑)、乐手详情、巡演预约(新增)、赛事规则、报名下单(步进)、
//       取消报名(删除警告)、收藏移除(删除)、改昵称(编辑)、清缓存、退出
//       (共 15 个)
// =====================================================================

// ---------------------------- 数据结构 ----------------------------

interface FeedItem {
  id: number
  title: string
  time: string
  tag: string
  text: string
}

interface ModuleItem {
  id: number
  name: string
  kind: string
  level: number
  state: string
  cover: string
  note: string
}

interface VoiceItem {
  id: number
  name: string
  osc: string
  wave: string
  hot: number
  state: string
  cover: string
  note: string
}

interface PlayerItem {
  id: number
  name: string
  role: string
  hot: number
  state: string
  note: string
}

interface MatchItem {
  id: number
  name: string
  date: string
  prize: number
  quota: number
  state: string
  note: string
}

interface MyItem {
  id: number
  name: string
  kind: string
  date: string
  note: string
}

// ---------------------------- 写死数据 ----------------------------

const FEEDS: FeedItem[] = [
  { id: 1, title: '新模块 OSC-9 振荡器上线', time: '今天 09:00', tag: '上新', text: '实验室推出全新 OSC-9 振荡器模块,支持 8 种波形,音色上限再次突破。' },
  { id: 2, title: '周末音色调制公开课', time: '昨天 23:00', tag: '活动', text: '本周六下午三点,资深工程师带你一小时调出属于自己的招牌音色。' },
  { id: 3, title: '合成器拼装赛报名开启', time: '昨天 18:00', tag: '赛事', text: '第八届合成器拼装大赛开始报名,冠军将获得实验室首席工程师称号。' },
  { id: 4, title: '复古模拟模块复刻计划', time: '前天 21:00', tag: '企划', text: '复刻经典 70 年代模拟滤波模块,第一批样机已完成焊接调试。' },
  { id: 5, title: '新手拼装指南 V2 发布', time: '前天 15:00', tag: '教程', text: '从零开始认识合成器模块,图文教程全面更新,小白也能上手。' },
  { id: 6, title: '音色库突破 5000 组', time: '3 天前', tag: '数据', text: '社区累计分享音色预设突破 5000 组,感谢每一位调音师的贡献。' },
  { id: 7, title: '欧式机架面板限时上架', time: '4 天前', tag: '上新', text: '经典欧式机架面板补货到仓,铝镁合金材质,散热与颜值兼得。' },
  { id: 8, title: '诚邀民间调音师入驻', time: '5 天前', tag: '招募', text: '欢迎有独立音色作品的调音师入驻实验室,作品将获得平台推广。' }
]

const MODULES: ModuleItem[] = [
  { id: 1, name: 'OSC-9 振荡器', kind: '振荡', level: 5, state: 'ok', cover: '🎛️', note: '8 种波形输出,支持 FM/AM 调制,实验室旗舰级振荡模块。' },
  { id: 2, name: 'FILT-2 滤波模块', kind: '滤波', level: 4, state: 'ok', cover: '🔊', note: '经典低通滤波,带共鸣扫频,复刻 70 年代模拟声。' },
  { id: 3, name: 'ENV-3 包络发生器', kind: '包络', level: 3, state: 'cal', cover: '⏱️', note: '三段可调包络曲线,ADSR 全参数开放,正在校准中。' },
  { id: 4, name: 'LFO-1 低频振荡', kind: '调制', level: 3, state: 'ok', cover: '🌀', note: '0.01Hz 至 100Hz 超宽范围,适合颤音与扫频调制。' },
  { id: 5, name: 'VCA-8 压控放大', kind: '放大', level: 2, state: 'tune', cover: '📈', note: '八通道压控放大器,正在调音,稍后即可预约测试。' },
  { id: 6, name: 'SEQ-16 音序器', kind: '音序', level: 5, state: 'ok', cover: '🎹', note: '16 步进音序器,支持随机生成与实时翻转,现场利器。' },
  { id: 7, name: 'MIX-4 混音模块', kind: '混音', level: 2, state: 'ok', cover: '🔀', note: '四进一出的紧凑混音器,直插耳机监听。' },
  { id: 8, name: 'FX-1 延迟效果', kind: '效果', level: 4, state: 'ok', cover: '⏳', note: '模拟 BBD 延迟,带调制深度旋钮,复古回声首选。' },
  { id: 9, name: 'MOD-6 环状调制', kind: '调制', level: 3, state: 'cal', cover: '🪐', note: '金属感环状调制器,正在校准,适合实验音色。' },
  { id: 10, name: 'PWR-9 电源模块', kind: '电源', level: 1, state: 'ok', cover: '🔌', note: '9V 稳压电源模块,为整个机架提供稳定电力。' }
]

const VOICES: VoiceItem[] = [
  { id: 1, name: '霓虹脉冲', osc: '双锯齿', wave: '锯齿', hot: 97, state: 'classic', cover: '💜', note: '双锯齿叠加失谐,赛博霓虹感拉满,实验室招牌音色。' },
  { id: 2, name: '深夜低语', osc: '方波', wave: '方波', hot: 95, state: 'classic', cover: '🌙', note: '慢速 LFO 调制方波,适合氛围铺垫与深夜创作。' },
  { id: 3, name: '金属风暴', osc: '环形调制', wave: 'FM', hot: 93, state: 'classic', cover: '⚙️', note: '环状调制产生的金属感音色,工业电子场景必备。' },
  { id: 4, name: '云层之上', osc: '三角波', wave: '三角', hot: 91, state: 'exp', cover: '☁️', note: '三角波加长包络,空灵缥缈,实验音色新人友好。' },
  { id: 5, name: '心跳节拍', osc: '正弦+噪声', wave: '正弦', hot: 89, state: 'exp', cover: '💓', note: '正弦与噪声混合,模拟心跳脉冲,实验性拉满。' },
  { id: 6, name: '老旧磁带', osc: '滤波锯齿', wave: '锯齿', hot: 92, state: 'classic', cover: '📼', note: '滤波扫频加 BBD 延迟,复古磁带机的温暖质感。' },
  { id: 7, name: '极光渐层', osc: '三振荡器', wave: 'FM', hot: 88, state: 'exp', cover: '🌌', note: '三振荡器交叉调制,音色随旋钮变化如极光流动。' },
  { id: 8, name: '蒸汽阀门', osc: '噪声+滤波', wave: '噪声', hot: 86, state: 'exp', cover: '♨️', note: '噪声源通过共鸣滤波,模拟蒸汽阀门泄压声。' },
  { id: 9, name: '玻璃碎片', osc: 'FM 双模', wave: 'FM', hot: 90, state: 'exp', cover: '🪟', note: '高频 FM 调制,清脆如玻璃碎裂,实验音色热榜常客。' },
  { id: 10, name: '缓速涟漪', osc: '正弦双叠', wave: '正弦', hot: 84, state: 'exp', cover: '🌊', note: '双正弦微失谐,缓慢波动的涟漪质感,适合放松场景。' },
  { id: 11, name: '暗巷回响', osc: '方波+延迟', wave: '方波', hot: 87, state: 'classic', cover: '🏙️', note: '方波过暗调滤波,回声拉长,暗巷电影感十足。' },
  { id: 12, name: '晨雾号角', osc: '锯齿+长包络', wave: '锯齿', hot: 85, state: 'exp', cover: '🌫️', note: '长包络锯齿音,像晨雾中远处的号角,实验企划新作。' }
]

const PLAYERS: PlayerItem[] = [
  { id: 1, name: '小林', role: '模块工程师', hot: 98, state: 'online', note: '十年模块搭建经验,擅长音色架构设计,代表作《霓虹脉冲》。' },
  { id: 2, name: '阿澈', role: '现场演奏家', hot: 95, state: 'tour', note: '合成器现场即兴演奏家,以高能量现场著称,正在巡演中。' },
  { id: 3, name: 'Mori', role: '音色设计师', hot: 93, state: 'online', note: '影视配乐音色设计师,出过多套商业音色包,审美好。' },
  { id: 4, name: '老K', role: '硬件收藏家', hot: 90, state: 'online', note: '收藏上百台古董合成器,熟悉各家经典电路特性。' },
  { id: 5, name: '小满', role: '新手导师', hot: 92, state: 'online', note: '从零带新人的导师,讲解深入浅出,新手课程一票难求。' },
  { id: 6, name: 'Rex', role: '电子音乐人', hot: 89, state: 'tour', note: '独立电子音乐制作人,用模块系统完成全部专辑编曲。' },
  { id: 7, name: '青柠', role: '合成器评测', hot: 91, state: 'online', note: '专注模块评测视频,拆解透彻,实验室官方合作评测人。' },
  { id: 8, name: '铁蛋', role: '维修技师', hot: 87, state: 'online', note: '二十年维修经验,任何疑难杂症都逃不过他的焊枪。' }
]

const MATCHES: MatchItem[] = [
  { id: 1, name: '第八届拼装大赛', date: '09-01 开赛', prize: 50000, quota: 30, state: 'open', note: '限时两小时,用指定模块拼出最出彩的音色组合,全程直播评审。' },
  { id: 2, name: '音色设计挑战赛', date: '09-08 截稿', prize: 20000, quota: 50, state: 'open', note: '以「城市深夜」为主题设计原创音色,专家团打分。' },
  { id: 3, name: '模块混搭擂台', date: '09-15 开赛', prize: 10000, quota: 40, state: 'open', note: '随机抽签模块组合,现场即兴演奏,观众投票定胜负。' },
  { id: 4, name: '新人拼装速成赛', date: '09-20 开赛', prize: 5000, quota: 20, state: 'open', note: '专为新手设立的友谊赛,导师在线指导,重在参与。' },
  { id: 5, name: '复古音色复刻赛', date: '10-01 截稿', prize: 30000, quota: 25, state: 'closed', note: '复刻经典 70 年代模拟音色,最接近原作即获胜,已截稿。' },
  { id: 6, name: '音序器作曲赛', date: '10-10 开赛', prize: 15000, quota: 35, state: 'open', note: '仅使用音序器模块完成 30 秒作曲,比拼编排创意。' },
  { id: 7, name: '实验室开放挑战', date: '10-18 开赛', prize: 8000, quota: 60, state: 'open', note: '全年开放的小挑战,每月结算一次,人人可参加。' },
  { id: 8, name: '年度总决赛', date: '12-01 开赛', prize: 100000, quota: 10, state: 'closed', note: '全年积分前十的选手自动晋级,争夺年度首席称号,待报名。' }
]

const MYS: MyItem[] = [
  { id: 1, name: '霓虹脉冲', kind: '音色', date: '08-20', note: '已收藏' },
  { id: 2, name: 'OSC-9 振荡器', kind: '模块', date: '08-19', note: '已拼装' },
  { id: 3, name: '小林', kind: '乐手', date: '08-18', note: '已关注' },
  { id: 4, name: '深夜低语', kind: '音色', date: '08-15', note: '常调' },
  { id: 5, name: 'SEQ-16 音序器', kind: '模块', date: '08-12', note: '已拼装' },
  { id: 6, name: '第八届拼装大赛', kind: '赛事', date: '08-10', note: '已报名' },
  { id: 7, name: '阿澈', kind: '乐手', date: '08-08', note: '已关注' },
  { id: 8, name: '老旧磁带', kind: '音色', date: '08-05', note: '常调' }
]

const HEAT: number[] = [72, 85, 64, 90, 78, 95, 82, 88]

// ---------------------------- 工具函数 ----------------------------

function heatBar(v: number): number {
  return Math.floor(28 + v * 0.85)
}

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

function moduleStateText(s: string): string {
  if (s === 'ok') {
    return '可用'
  }
  if (s === 'cal') {
    return '校准中'
  }
  return '调音中'
}

function moduleStateColor(s: string): string {
  if (s === 'ok') {
    return '#22D3EE'
  }
  if (s === 'cal') {
    return '#FBBF24'
  }
  return '#FB923C'
}

function voiceStateText(s: string): string {
  if (s === 'classic') {
    return '经典'
  }
  return '实验'
}

function voiceStateColor(s: string): string {
  if (s === 'classic') {
    return '#A78BFA'
  }
  return '#F472B6'
}

function trendText(t: string): string {
  if (t === 'up') {
    return '↑ 上升'
  }
  if (t === 'down') {
    return '↓ 下滑'
  }
  return '→ 持平'
}

function trendColor(t: string): string {
  if (t === 'up') {
    return '#22D3EE'
  }
  if (t === 'down') {
    return '#F87171'
  }
  return '#8A8A96'
}

function buildFeed(id: number, title: string): FeedItem {
  return { id: id, title: title, time: '刚刚', tag: '动态', text: '这是一条刚刚发布的实验室动态,欢迎各位调音师围观互动。' }
}

function buildMy(id: number, name: string, kind: string): MyItem {
  return { id: id, name: name, kind: kind, date: '08-28', note: '刚刚收藏' }
}

function buildModule(id: number, name: string): ModuleItem {
  return { id: id, name: name, kind: '振荡', level: 1, state: 'ok', cover: '🧩', note: '这是一块刚刚入库的合成器模块,欢迎申请测试体验。' }
}

// =====================================================================
// Index 主入口:赛博仪表条头部(无动画)+ 6 Tab + 底部单排
// =====================================================================

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State modules: ModuleItem[] = MODULES
  @State voices: VoiceItem[] = VOICES
  @State players: PlayerItem[] = PLAYERS
  @State matches: MatchItem[] = MATCHES
  @State mys: MyItem[] = MYS

  @Builder
  tabItem(icon: string, label: string, tab: number) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(22)
        .opacity(this.currentTab === tab ? 1 : 0.55)
        .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
      Text(label)
        .fontSize(11)
        .fontColor(this.currentTab === tab ? '#22D3EE' : '#8A8A96')
        .fontWeight(this.currentTab === tab ? FontWeight.Bold : FontWeight.Normal)
    }
    .width('16.6%')
    .height(58)
    .justifyContent(FlexAlign.Center)
    .animation({ duration: 200, curve: Curve.EaseOut })
    .onClick(() => {
      this.currentTab = tab
    })
  }

  build() {
    Column() {
      // 赛博仪表条头部(无动画)
      Column() {
        Row() {
          Text('▲▲▲')
            .fontSize(12)
            .fontColor('#22D3EE')
            .letterSpacing(3)
          Text('SYNTH LAB')
            .fontSize(17)
            .fontColor('#E5E7EB')
            .fontWeight(FontWeight.Bold)
            .letterSpacing(2)
            .backgroundColor('#1C1C26')
            .border({ width: 1, color: '#22D3EE' })
            .borderRadius(4)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          Text('▼▼▼')
            .fontSize(12)
            .fontColor('#A78BFA')
            .letterSpacing(3)
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          ForEach([30, 45, 22, 55, 38], (w: number, index: number) => {
            Text('▊')
              .fontSize(10)
              .fontColor(index % 2 === 0 ? '#22D3EE' : '#A78BFA')
          }, (w: number, index: number) => 'sig' + index.toString())
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .margin({ top: 6 })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 10 })
      .backgroundColor('#14141C')
      .border({ width: { bottom: 1 }, color: '#22D3EE' })

      // 内容区
      Stack() {
        if (this.currentTab === 0) {
          HomeContent({ feeds: this.feeds, modules: this.modules, mys: this.mys })
        }
        if (this.currentTab === 1) {
          ModuleContent({ modules: this.modules, mys: this.mys })
        }
        if (this.currentTab === 2) {
          VoiceContent({ voices: this.voices, mys: this.mys })
        }
        if (this.currentTab === 3) {
          PlayerContent({ players: this.players, mys: this.mys })
        }
        if (this.currentTab === 4) {
          MatchContent({ matches: this.matches, mys: this.mys })
        }
        if (this.currentTab === 5) {
          MeContent({ mys: this.mys, modules: this.modules, voices: this.voices })
        }
      }
      .layoutWeight(1)
      .width('100%')

      // 底部 Tab 单排
      Row() {
        this.tabItem('🏭', '首页', 0)
        this.tabItem('🧩', '模块', 1)
        this.tabItem('🎛️', '音色', 2)
        this.tabItem('🎸', '乐手', 3)
        this.tabItem('🏆', '赛事', 4)
        this.tabItem('👤', '我的', 5)
      }
      .width('100%')
      .backgroundColor('#14141C')
      .border({ width: { top: 1 }, color: '#22D3EE' })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#101014')
  }
}

// =====================================================================
// Tab 1 首页:实验室横幅 + 周热度柱状图 + 精选速览 + 动态列表
// =====================================================================

@Component
struct HomeContent {
  @Link feeds: FeedItem[]
  @Link modules: ModuleItem[]
  @Link mys: MyItem[]
  @State showDetail: boolean = false
  @State picked: FeedItem = FEEDS[0]
  @State showPost: boolean = false
  @State postTitle: string = ''
  @State postText: string = ''
  @State tip: string = ''

  @Builder
  detailModalOverlay(onClose: () => void) {
    Column() {
      Column() {
        Row() {
          Text('📄 动态详情')
            .fontSize(17)
            .fontColor('#E5E7EB')
            .fontWeight(FontWeight.Bold)
          Text('✕')
            .fontSize(16)
            .fontColor('#8A8A96')
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 12 })
        Row() {
          Text('#' + this.picked.tag)
            .fontSize(11)
            .fontColor('#101014')
            .backgroundColor('#22D3EE')
            .borderRadius(3)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          Text(this.picked.time)
            .fontSize(11)
            .fontColor('#8A8A96')
        }
        .width('100%')
        .margin({ bottom: 10 })
        Text(this.picked.title)
          .fontSize(16)
          .fontColor('#F3F4F6')
          .fontWeight(FontWeight.Medium)
          .width('100%')
          .margin({ bottom: 8 })
        Text(this.picked.text)
          .fontSize(13)
          .fontColor('#B9B9C4')
          .width('100%')
          .lineHeight(20)
        Row() {
          Text('👍 点赞')
            .fontSize(12)
            .fontColor('#22D3EE')
            .textAlign(TextAlign.Center)
            .width('45%')
            .height(36)
            .border({ width: 1, color: '#22D3EE' })
            .borderRadius(6)
            .onClick(() => {
              this.tip = '👍 已点赞,感谢互动'
              onClose()
            })
          Text('💬 评论')
            .fontSize(12)
            .fontColor('#E5E7EB')
            .textAlign(TextAlign.Center)
            .width('45%')
            .height(36)
            .backgroundColor('#A78BFA')
            .borderRadius(6)
            .onClick(() => {
              this.tip = '💬 评论功能已开放'
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 12 })
      }
      .width('88%')
      .backgroundColor('#1C1C26')
      .borderRadius(10)
      .border({ width: 1, color: '#22D3EE' })
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(10, 10, 16, 0.82)')
  }

  @Builder
  postModalOverlay(onClose: () => void) {
    Column() {
      Column() {
        Row() {
          Text('🧩 发布拼装动态')
            .fontSize(17)
            .fontColor('#E5E7EB')
            .fontWeight(FontWeight.Bold)
          Text('✕')
            .fontSize(16)
            .fontColor('#8A8A96')
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 12 })
        Text('动态标题')
          .fontSize(12)
          .fontColor('#8A8A96')
          .width('100%')
        TextInput({ placeholder: '例如:我的第一台模块合成器', text: this.postTitle })
          .width('100%')
          .height(40)
          .backgroundColor('#101014')
          .fontColor('#E5E7EB')
          .placeholderColor('#5A5A66')
          .borderRadius(6)
          .margin({ top: 4, bottom: 10 })
          .onChange((v: string) => {
            this.postTitle = v
          })
        Text('内容说明')
          .fontSize(12)
          .fontColor('#8A8A96')
          .width('100%')
        TextInput({ placeholder: '记录你的拼装过程与心得', text: this.postText })
          .width('100%')
          .height(40)
          .backgroundColor('#101014')
          .fontColor('#E5E7EB')
          .placeholderColor('#5A5A66')
          .borderRadius(6)
          .margin({ top: 4, bottom: 14 })
          .onChange((v: string) => {
            this.postText = v
          })
        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor('#B9B9C4')
            .textAlign(TextAlign.Center)
            .width('45%')
            .height(38)
            .border({ width: 1, color: '#3A3A46' })
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
          Text('发布动态')
            .fontSize(13)
            .fontColor('#101014')
            .textAlign(TextAlign.Center)
            .width('45%')
            .height(38)
            .backgroundColor('#22D3EE')
            .borderRadius(6)
            .onClick(() => {
              if (this.postTitle.length > 0) {
                const nextId = this.feeds.length + 1
                this.feeds.unshift(buildFeed(nextId, this.postTitle))
                this.tip = '✅ 拼装动态已发布'
              } else {
                this.tip = '⚠️ 请先填写动态标题'
              }
              this.postTitle = ''
              this.postText = ''
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('88%')
      .backgroundColor('#1C1C26')
      .borderRadius(10)
      .border({ width: 1, color: '#A78BFA' })
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(10, 10, 16, 0.82)')
  }

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            // 实验室横幅
            Row() {
              Column({ space: 6 }) {
                Text('SYNTH LAB')
                  .fontSize(20)
                  .fontColor('#101014')
                  .fontWeight(FontWeight.Bold)
                  .letterSpacing(2)
                Text('合成器拼装与音色调制中心')
                  .fontSize(11)
                  .fontColor('#101014')
                  .opacity(0.75)
              }
              .alignItems(HorizontalAlign.Start)
              Column({ space: 4 }) {
                Text('● OPEN')
                  .fontSize(11)
                  .fontColor('#101014')
                  .backgroundColor('#22D3EE')
                  .borderRadius(10)
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                Text('今日 08:00-22:00')
                  .fontSize(10)
                  .fontColor('#101014')
                  .opacity(0.7)
              }
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .padding(16)
            .backgroundColor('#22D3EE')
            .borderRadius(10)
            .margin({ top: 12 })

            // 周热度柱状图
            Row() {
              Text('📊 本周拼装热度')
                .fontSize(14)
                .fontColor('#E5E7EB')
                .fontWeight(FontWeight.Bold)
              Text('近 8 期')
                .fontSize(11)
                .fontColor('#8A8A96')
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .margin({ top: 14, bottom: 6 })
            Row({ space: 5 }) {
              ForEach(HEAT, (v: number, index: number) => {
                Column() {
                  Text(v.toString())
                    .fontSize(9)
                    .fontColor('#22D3EE')
                  Row()
                    .width(16)
                    .height(heatBar(v))
                    .backgroundColor(index % 2 === 0 ? '#22D3EE' : '#A78BFA')
                    .borderRadius(2)
                  Text('周' + (index + 1).toString())
                    .fontSize(9)
                    .fontColor('#8A8A96')
                }
                .width('11%')
              }, (v: number, index: number) => 'h' + index.toString())
            }
            .width('100%')
            .height(120)
            .alignItems(VerticalAlign.Bottom)
            .justifyContent(FlexAlign.SpaceBetween)
            .padding(8)
            .backgroundColor('#14141C')
            .borderRadius(8)
            .border({ width: 1, color: '#2A2A36' })

            // 精选速览
            Row({ space: 8 }) {
              Column({ space: 4 
      if (this.showClear) {
        this.clearModalOverlay(() => {
          this.showClear = false
        })
      }
      if (this.showExit) {
        this.exitModalOverlay(() => {
          this.showExit = false
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}

// ===== 2174.ets END =====

在这里插入图片描述


声明式 UI 的状态管理虽然强大,但也需要开发者建立正确的思维模式。本应用展示了几个关键实践:弹窗显隐用布尔 @State 控制、表单输入用字符串 @State 承载、列表选中项用对象 @State 记录、跨组件数据用 @Link 共享。这些模式可以推广到大多数 ArkTS 应用中。需要注意的是,@State 变量应当保持"最小化"——只声明真正需要触发渲染的变量,而非把所有临时数据都设为状态。本应用中的 tip 变量就是一个例子,它只在需要显示提示时更新,不会引发不必要的重渲染。

从工程角度看,本应用的代码组织也值得学习。数据接口集中定义在文件顶部,写死数据紧随其后,工具函数与工厂函数位于中间,组件实现位于下方。这种"数据 → 逻辑 → 视图"的自上而下组织方式让代码的阅读路径清晰——先看数据结构理解业务对象,再看工具函数理解数据处理逻辑,最后看组件理解界面实现。每个组件内部也遵循类似的结构:状态声明 → 构建器定义 → build 方法实现。这种一致性让维护者能快速适应代码风格。

ArkTS 的类型系统在本应用中发挥了重要的保障作用。所有接口的字段都有明确的类型标注(numberstring),所有函数的参数与返回值都有类型声明,所有 @State@Link 变量都指定了泛型类型(如 FeedItem[])。这些类型信息不仅让编译器能在编译期捕获类型错误,更让代码本身成为一份清晰的文档——开发者通过阅读类型声明就能理解数据的结构与函数的契约,无需查阅额外的文档。这正是静态类型语言在大型应用开发中的核心价值。

Logo

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

更多推荐