一、鸿蒙开发背景与 ArkTS 声明式 UI 范式概述

鸿蒙操作系统作为华为面向全场景智慧生活打造的分布式操作系统,其应用开发框架经历了从 JavaUI 到 ArkUI 的演进。ArkUI 是鸿蒙当前主推的声明式 UI 开发范式,它以 ArkTS 语言为核心,融合了 TypeScript 的类型安全特性与声明式编程的简洁直观,为开发者提供了一套高效、流畅、可维护的界面构建方案。在 ArkUI 中,开发者不再需要像命令式编程那样手动操作 DOM 节点、逐个设置属性和监听事件,而是通过描述界面"应该是什么样子",由框架自动驱动视图的更新与渲染。这种范式转变极大降低了复杂界面的开发心智负担,使开发者能将更多精力聚焦在业务逻辑与用户体验本身。

ArkTS 在 TypeScript 的基础上做了进一步扩展与约束。它引入了一批专属装饰器,如 @Entry@Component@State@Prop@Builder 等,这些装饰器分别承担着入口声明、组件定义、状态管理、属性传递和 UI 片段复用等关键职责。与原生 TypeScript 不同,ArkTS 的类型系统更加严格,禁止使用 any 等宽泛类型,所有变量、参数、返回值都必须有明确的类型标注。这种设计在编译期就能捕获大量潜在错误,对于大型应用工程而言意义非凡。同时,ArkTS 去除了动态改变对象形状(如运行时添加属性)等特性,使得编译器能对状态变化进行精准的依赖追踪,从而实现高效的局部刷新。

声明式 UI 的核心思想是"状态驱动视图"。在 ArkUI 中,每当被 @State 装饰的状态变量发生改变时,框架会自动重新执行 build() 方法中与该状态存在依赖关系的 UI 描述部分,生成新的虚拟节点树,并与旧树进行 Diff 比对,最终只将差异部分应用到真实渲染层。这种机制保证了 UI 与状态的强一致性,开发者永远不需要手写"当数据变化后去更新某个文本框"这样的命令式代码。在本篇所解析的广场舞社区应用中,这一思想贯穿始终——从标签栏的选中态切换、弹窗的显示隐藏,到列表数据的增删改,全部依赖状态变量的自动驱动。

ArkUI 的组件体系极为丰富,涵盖了容器类、基础类、选择类、输入类、媒体类、绘制类等多种类型。容器组件如 Column(纵向线性布局)、Row(横向线性布局)、Stack(层叠布局)、Flex(弹性布局)、Scroll(滚动容器)等,构成了界面骨架的基石;基础组件如 Text(文本)、Image(图片)、Button(按钮)、Progress(进度条)、Toggle(开关)等,承载了具体的视觉内容与交互能力。这些组件通过链式调用的方式设置属性与方法,形成了一种自上而下、由外到内的树形结构描述。在本应用中,我们将看到这些组件如何被灵活组合,搭建出一个功能完整、视觉风格鲜明的社区类应用界面。

核心概念: ArkUI 的声明式范式意味着开发者只需描述界面的当前状态,框架负责将状态映射为真实渲染。状态变则视图变,状态不变则视图稳定,这是整个 ArkUI 架构的哲学基石。

二、应用整体架构与数据模型设计

本应用是一个面向广场舞爱好者群体的社区类应用,采用"霓虹夜色"视觉风格,包含首页动态、舞队管理、教学课程、赛事活动、神曲榜单和个人中心六大功能模块。整个应用由一个入口组件和六个内容子组件构成,通过底部标签栏进行模块切换,配合一套基于状态变量驱动的弹窗系统实现详情查看、表单提交、确认操作等交互。下面我们从数据模型开始逐层解析。

2.1 接口定义与类型体系

ArkTS 使用 interface 关键字定义接口,用于约束对象的结构形状。在本应用中,开发者定义了六个核心接口来描述不同业务领域的数据实体。

interface FeedItem {
  id: number
  name: string
  avatar: string
  time: string
  content: string
  likes: number
  comments: number
}

interface TeamItem {
  id: number
  name: string
  style: string
  members: number
  score: number
  quota: number
  state: number
}

interface StepItem {
  id: number
  name: string
  teacher: string
  level: string
  plays: number
  quota: number
  state: number
}

在这里插入图片描述

FeedItem 接口描述的是社区动态信息流中的单条数据,包含发布者昵称、头像(这里用 emoji 表情符号代替真实图片)、发布时间、正文内容、点赞数和评论数。这种用 emoji 充当头像的做法在原型开发和演示场景中十分常见,它避免了引入图片资源依赖,使得整个应用可以纯代码自包含运行。

TeamItem 接口描述的是舞队信息实体。其中 style 字段标识舞种类型(如民族舞、曳步舞、健身操等),members 表示当前成员数,score 是舞队评分(满分百分制),quota 是招新剩余名额,state 用数字 0 和 1 分别表示"招新中"和"已满员"两种状态。使用数字而非布尔值来表示状态,在需要扩展更多状态类型时更加灵活,同时配合工具函数可以方便地映射为文本和颜色。

StepItem 接口对应的是教学课程实体,包含课程名称、授课老师、难度级别、播放次数和可约名额。level 字段用字符串"入门"“进阶”"高级"表示课程难度梯度,这种语义化的字符串值在 UI 渲染时可以直接映射为不同的图标和颜色,比数字编码更具可读性。

interface MatchItem {
  id: number
  name: string
  place: string
  time: string
  prize: number
  quota: number
  state: number
}

interface SongItem {
  id: number
  name: string
  singer: string
  bpm: number
  plays: number
  state: number
}

interface MyItem {
  id: number
  name: string
  tag: string
  time: string
}

在这里插入图片描述

MatchItem 描述赛事活动,place 是比赛地点,prize 是冠军奖金金额(以元为单位),time 采用"月-日 时:分"的简短格式便于列表展示。SongItem 描述广场舞神曲,bpm 是每分钟节拍数(Beats Per Minute),这是舞蹈编排中极为重要的参数——不同的舞种对 BPM 有不同的适配范围,BPM 过快或过慢都会影响舞蹈动作的舒展度。MyItem 结构最简,仅包含收藏项名称、分类标签和收藏时间。

设计要点: 所有接口都包含 id: number 作为唯一标识符,这是 ForEach 列表渲染时生成 key 的基础。ArkUI 的 ForEach 组件要求每个列表项有唯一且稳定的 key,以便在数据增删时进行高效的 Diff 运算,避免全量重建。

2.2 模拟数据与常量定义

定义完接口后,开发者声明了一批全局常量数组作为模拟数据源。在实际工程中这些数据应由后端接口返回,但在原型阶段使用静态数据可以快速搭建可交互的界面原型。

const FEEDS: FeedItem[] = [
  { id: 1, name: '广场舞王姐', avatar: '🪩', time: '15分钟前',
    content: '今晚凤凰广场新学了《最炫民族风》第三套队形,姐妹们回家多练练,下周比赛见!',
    likes: 356, comments: 61 },
  { id: 2, name: '舞场李叔', avatar: '💃', time: '38分钟前',
    content: '交谊舞步加了两个新花样,慢三转圈加手部动作,视频里拆解了三遍,记得点赞收藏。',
    likes: 289, comments: 47 },
  // ... 更多动态数据
]

const TEAMS: TeamItem[] = [
  { id: 1, name: '凤凰广场舞队', style: '民族舞', members: 42, score: 98, quota: 8, state: 0 },
  { id: 2, name: '星光曳步团', style: '曳步舞', members: 28, score: 96, quota: 5, state: 0 },
  { id: 3, name: '夕阳红舞团', style: '健身操', members: 56, score: 97, quota: 0, state: 1 },
  // ... 更多舞队数据
]

FEEDS 数组预置了 8 条社区动态,内容涵盖民族舞队形学习、交谊舞步教学、曳步舞技巧分享等真实场景文案。每条动态的点赞数和评论数都经过精心设定,呈现出从几百到几十不等的自然分布,使列表展示时数据层次感更真实。

TEAMS 数组包含 10 支舞队,覆盖民族舞、曳步舞、健身操、交谊舞、秧歌、拉丁舞、爵士舞、街舞等多种舞种。state 字段有 0 和 1 两种值穿插出现,确保列表中同时展示"招新中"和"已满员"两种状态的舞队,方便开发者在调试时验证不同状态下的视觉呈现。

const STEPS: StepItem[] = [
  { id: 1, name: '民族舞·三步一抬', teacher: '小芳老师', level: '入门', plays: 26800, quota: 20, state: 0 },
  { id: 2, name: '曳步舞·蝴蝶步', teacher: '阿凯教练', level: '进阶', plays: 34200, quota: 0, state: 1 },
  // ... 更多课程数据
]

const MATCHES: MatchItem[] = [
  { id: 1, name: '凤凰广场舞大赛', place: '凤凰广场', time: '09-10 19:00', prize: 5000, quota: 12, state: 0 },
  { id: 2, name: '区中老年健身操赛', place: '文体中心', time: '09-17 09:00', prize: 3000, quota: 0, state: 1 },
  // ... 更多赛事数据
]

const SONGS: SongItem[] = [
  { id: 1, name: '最炫民族风', singer: '凤凰传奇', bpm: 126, plays: 9860000, state: 0 },
  { id: 2, name: '小苹果', singer: '筷子兄弟', bpm: 130, plays: 8450000, state: 0 },
  // ... 更多歌曲数据
]

在这里插入图片描述

STEPSMATCHES 的数据结构与上述类似,分别预置了 8 条课程和 8 场赛事。值得注意的是 SONGS 数组中 plays 字段的值达到了数百万量级,这模拟了热门广场舞神曲的真实播放量,同时也引出了后面 playsText 工具函数将大数字格式化为"万"为单位的需求。

const HEAT: number[] = [5, 7, 6, 8, 9, 7, 10, 11]

const CATS: string[] = ['全部', '民族舞', '曳步舞', '健身操', '交谊舞', '爵士舞']

HEAT 是一个数字数组,表示近 8 天每天的活跃舞队数量,用于在首页绘制简单的柱状图。CATS 是舞种分类标签数组,用于首页和舞队页的分类筛选横滑条。这两个常量虽然简单,但体现了"数据与视图分离"的设计思想——分类标签和图表数据不硬编码在组件内部,而是以常量形式独立声明,便于后续替换为接口动态数据。

2.3 工具函数体系

为了让 UI 代码保持简洁,开发者将状态文本映射、颜色映射、数字格式化等逻辑提取为独立的全局函数。

function heatBar(v: number): string {
  return (16 + v * 4) + 'vp'
}

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

在这里插入图片描述

heatBar 函数根据热度数值计算柱状图的高度,返回带 vp 单位的字符串。vp(virtual pixel)是鸿蒙推荐的尺寸单位,它会根据屏幕密度自动缩放,保证在不同分辨率设备上视觉一致性。这里将热度值乘以 4 再加 16 的基础高度,确保即使热度为最小值时柱子也有一定可见高度。

playsText 函数处理大数字的显示格式化——当播放量达到一万以上时,除以一万并保留一位小数,拼接"万"字后缀。这种格式化在中文互联网产品中极为常见,能让用户更直观地感知数量级。

function teamStateText(s: number): string {
  if (s === 0) {
    return '招新中'
  }
  return '已满员'
}

function teamStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#F87171'
}

teamStateTextteamStateColor 是一对配套函数,分别将数字状态码映射为中文文本和十六进制颜色值。状态 0(招新中)使用夜蓝色 #38BDF8,状态 1(已满员)使用红色 #F87171。这种"文本+颜色"双映射的模式在课程(stepStateText/stepStateColor)、赛事(matchStateText/matchStateColor)、神曲(songStateText/songStateColor)中完全一致,形成了统一的状态展示规范。

最佳实践: 将状态码到展示文本/颜色的映射逻辑提取为独立函数,而非散落在各组件内部,有三个好处:一是修改文案只需改一处;二是新增状态类型时影响范围清晰可控;三是组件代码中只调用函数名,可读性远高于内联的条件判断。

开发者还定义了一组 build 工厂函数,用于创建新的数据对象:

function buildFeed(id: number, name: string, avatar: string, time: string,
  content: string, likes: number, comments: number): FeedItem {
  return { id: id, name: name, avatar: avatar, time: time,
    content: content, likes: likes, comments: comments }
}

function buildTeam(id: number, name: string, style: string, members: number,
  score: number, quota: number, state: number): TeamItem {
  return { id: id, name: name, style: style, members: members,
    score: score, quota: quota, state: state }
}

在这里插入图片描述

这些工厂函数看起来只是简单地组装对象字面量并返回,但它们的意义在于:当用户执行"发布动态"“加入舞队”"报名赛事"等操作时,需要创建新的数据对象插入到列表数组中。使用工厂函数可以让对象创建逻辑集中管理,参数顺序和类型在函数签名中明确定义,避免了在业务代码中手写字面量时可能出现的字段遗漏或类型错误。

0

1

2

3

4

5

showFeedDetail=true

showTeamRecruit=true

showStepSign=true

所有弹窗=false

应用启动

入口组件 Index 初始化

加载 @State 状态变量

渲染头部霓虹横幅

currentTab 值判断

HomeContent 首页

TeamContent 舞队

StepContent 教学

MatchContent 赛事

SongContent 神曲

MeContent 我的

底部标签栏渲染

弹窗状态检测

动态详情弹窗

舞队招募弹窗

约课确认弹窗

正常浏览态

用户交互关闭弹窗

三、入口组件 Index 的状态管理与架构骨架

3.1 @Entry 与 @Component 装饰器

@Entry
@Component
struct Index {
  // ...
}

在这里插入图片描述

@Entry 装饰器标记 Index 结构体为应用的入口组件,即整个页面的根组件。一个 ArkTS 页面文件中有且只能有一个被 @Entry 修饰的组件,框架会以它为起点构建组件树。@Component 装饰器声明 Index 是一个自定义组件,使其可以被复用和组合。被 @Component 修饰的 struct 必须实现 build() 方法,该方法返回组件的 UI 描述。

装饰器解析: @Entry = 页面入口(全局唯一),@Component = 自定义组件声明。两者组合使用表示"这是一个可作为页面入口的自定义组件"。如果不加 @Entry 而只加 @Component,则该组件只能被其他组件引用,不能独立作为页面。

3.2 @State 状态变量群

入口组件内部声明了大量 @State 装饰的状态变量,它们是整个应用交互逻辑的核心驱动力。

@State currentTab: number = 0
@State feeds: FeedItem[] = FEEDS
@State teams: TeamItem[] = TEAMS
@State steps: StepItem[] = STEPS
@State matches: MatchItem[] = MATCHES
@State songs: SongItem[] = SONGS
@State mys: MyItem[] = MYS

currentTab 是当前选中的标签索引,初始值为 0(首页)。当用户点击底部标签栏时,此值改变,触发 build() 中条件渲染逻辑重新执行,显示对应的子组件内容。六个数据数组分别用预置的常量数据初始化,它们被声明为 @State 是因为用户可以在运行时对这些数据进行增删操作(如发布动态、加入舞队、退赛、移除收藏等),数据变化后列表需要自动刷新。

@State pickedFeed: FeedItem | null = null
@State pickedTeam: TeamItem | null = null
@State pickedStep: StepItem | null = null
@State pickedMatch: MatchItem | null = null
@State pickedSong: SongItem | null = null
@State pickedMy: MyItem | null = null

这六个 picked* 变量采用联合类型 T | null,初始值为 null。当用户点击列表中某一项时,对应的数据项会被赋值给这些变量,然后配合下方的 show* 布尔标志一起控制弹窗的显示。联合类型的设计确保了"未选中任何项"这一状态可以被明确表达——null 表示无选中项,非 null 表示有具体数据传入弹窗。

@State showFeedDetail: boolean = false
@State showFeedAdd: boolean = false
@State showTeamDetail: boolean = false
@State showTeamRecruit: boolean = false
@State showStepDetail: boolean = false
@State showStepSign: boolean = false
@State showMatchRule: boolean = false
@State showMatchDetail: boolean = false
@State showMatchSign: boolean = false
@State showMatchQuit: boolean = false
@State showSongDetail: boolean = false
@State showSongQuit: boolean = false
@State showMyRemove: boolean = false
@State showMyNick: boolean = false
@State showMyCache: boolean = false
@State showMyExit: boolean = false

在这里插入图片描述

这一组布尔变量构成了弹窗系统的控制矩阵。每一个弹窗对应一个布尔标志,值为 true 时显示弹窗,false 时隐藏。这种"一弹窗一标志"的设计虽然看起来变量数量很多,但逻辑极其清晰——任何一个弹窗的显示和隐藏都只由它自己对应的变量控制,互不干扰。当需要关闭弹窗时,只需将对应标志置为 false 即可。

@State newFeedName: string = ''
@State newFeedText: string = ''
@State newTeamName: string = ''
@State newTeamStyle: string = ''
@State newNick: string = ''
@State catSel: number = 0

最后几个状态变量用于表单输入的临时存储。newFeedNamenewFeedText 分别绑定发布动态弹窗中的昵称输入框和内容输入框,newNick 绑定修改昵称弹窗的输入框。这些变量通过 TextInput 组件的 onChange 回调实时更新,提交后清空归位。

3.3 @Builder tabItem 标签项构建器

@Builder
tabItem(icon: string, label: string, tab: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(22)
      .fontColor(this.currentTab === tab ? '#F472B6' : '#7C6A8C')
    Text(label)
      .fontSize(11)
      .fontColor(this.currentTab === tab ? '#F472B6' : '#7C6A8C')
  }
  .width('16.66%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
  .animation({ duration: 200, curve: Curve.EaseOut })
  .onClick(() => {
    this.currentTab = tab
  })
}

@Builder 装饰器用于声明一个可复用的 UI 构建方法。与普通方法不同,@Builder 方法的返回值是一段 UI 描述而非数据值,它可以在 build() 中被多次调用以生成相同的 UI 结构。这里 tabItem 方法接收图标、标签文字和标签索引三个参数,构建一个标签栏按钮。

Column 组件是纵向线性布局容器,它将子元素沿主轴(垂直方向)依次排列。{ space: 3 } 参数指定子元素之间的间距为 3vp。这里 Column 内部包含两个 Text 组件——上方是 emoji 图标(22vp 字号),下方是中文标签(11vp 字号)。

颜色采用三元条件表达式动态决定:当 this.currentTab === tab 时(即当前标签是选中态),图标和文字都使用霓虹粉 #F472B6;否则使用暗紫色 #7C6A8C。这种选中态的颜色对比在底部标签栏中非常关键,它给用户明确的视觉指引——“你当前在哪个页面”。

.scale({ x: ..., y: ... }) 配合 .animation() 实现了选中标签的放大动画效果。选中标签的缩放比例为 1.12 倍(横向和纵向同时放大),未选中的为 1 倍(原始大小)。animationduration: 200 表示动画持续 200 毫秒,curve: Curve.EaseOut 表示动画曲线为先快后慢的缓出效果。当用户切换标签时,新选中的标签会平滑放大,旧标签平滑缩小,这种微交互动画让界面感觉更加生动有温度。

@Builder 的价值: 六个标签按钮的结构完全一致,只是参数不同。如果不使用 @Builder,就需要在 build() 中重复书写六次相同的 Column+Text 结构,代码将冗长且难以维护。@Builder 实现了 UI 片段的参数化复用,一次定义、多处调用。

3.4 build() 主布局与 Stack 层叠容器

build() {
  Stack({ alignContent: Alignment.Bottom }) {
    Column() {
      // ===== 头部:霓虹夜色风 =====
      Column() {
        // 头部内容...
      }
      .width('100%')
      .backgroundColor('#1E1028')

      // ===== 内容区 =====
      Column() {
        if (this.currentTab === 0) {
          HomeContent({ ... })
        }
        if (this.currentTab === 1) {
          TeamContent({ ... })
        }
        // ... 其他标签页
      }
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#12081A')

      // ===== 底部 tab 栏 =====
      Row() {
        this.tabItem('🏠', '首页', 0)
        this.tabItem('🕺', '舞队', 1)
        this.tabItem('📺', '教学', 2)
        this.tabItem('🏆', '赛事', 3)
        this.tabItem('🎵', '神曲', 4)
        this.tabItem('👤', '我的', 5)
      }
      .width('100%')
      .height(58)
      .backgroundColor('#1E1028')
      .border({ width: { top: 1 }, color: 'rgba(244,114,182,0.2)' })
    }
    .width('100%')
    .height('100%')

    // ===== 弹窗层 =====
    if (this.showFeedDetail && this.pickedFeed !== null) {
      this.feedDetailOverlay(this.pickedFeed, () => {
        this.showFeedDetail = false
      })
    }
    // ... 其他弹窗
  }
  .width('100%')
  .height('100%')
}

在这里插入图片描述

整个页面的根容器是 StackStack 是层叠布局容器,它的子元素会按照声明顺序依次堆叠,后声明的元素覆盖在先声明的元素之上。alignContent: Alignment.Bottom 参数设置子元素的对齐方式为底部对齐,但这里主要利用的是 Stack 的层叠特性——将弹窗层覆盖在主内容层之上。

Stack 内部首先是一个 Column,它构成了页面的主体骨架,自上而下依次排列三个区域:头部区域、内容区域和底部标签栏。内容区域设置了 .layoutWeight(1),这是 ArkUI 中的权重分配机制——在 Column 容器中,layoutWeight 为 1 的子元素会占据父容器中剩余的所有空间。头部和底部标签栏的高度是固定的(由内容撑开),内容区域则弹性填充中间区域,这种布局保证了在不同屏幕尺寸下头部和底部始终可见,中间内容区域自适应。

底部标签栏使用 Row 横向布局容器,内部调用六次 this.tabItem(...) 生成六个标签按钮。每个标签的宽度由 tabItem 内部的 .width('16.66%') 决定(100% 除以 6 约等于 16.66%)。border({ width: { top: 1 }, color: 'rgba(244,114,182,0.2)' }) 仅在顶部绘制一条半透明的霓虹粉分隔线,将标签栏与内容区视觉分隔。

弹窗层的条件渲染逻辑是整个 build() 中最精妙的部分。每个弹窗由两个条件共同控制:一是对应的 show* 布尔标志为 true,二是对应的 picked* 数据不为 null。只有两个条件同时满足时弹窗才会渲染。关闭回调以箭头函数形式传入,执行 this.showXxx = false 将标志置为假,弹窗随即消失。

Stack 层叠的妙用: 弹窗之所以放在 Stack 的最后声明(即最顶层),是因为 Stack 后声明的子元素覆盖在先声明的之上。这样弹窗自然会覆盖在主内容之上,配合半透明背景遮罩,实现了"模态对话框"的视觉效果,无需额外的 zIndex 控制。

四、霓虹夜色风头部设计解析

4.1 头部顶栏

Column() {
  Row() {
    Row() {
      Text('🪩')
        .fontSize(26)
      Column({ space: 2 }) {
        Text('DANCE SQUARE')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('广场舞社区 · 官方')
          .fontSize(10)
          .fontColor('#C4B5FD')
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
    }
    .alignItems(VerticalAlign.Center)
    Text('').layoutWeight(1)
    Text('🔔')
      .fontSize(22)
      .opacity(0.9)
    Text('🔍')
      .fontSize(22)
      .margin({ left: 14 })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 10 })

头部顶栏由一个 Row 横向布局构成,内部从左到右依次是品牌标识区域和通知/搜索图标。品牌标识区域是一个嵌套的 Row,包含一个旋转镜球 emoji(🪩)和一个纵向排列的 Column——Column 内放了两行文字,上方是英文品牌名"DANCE SQUARE"(加粗白色),下方是中文副标题"广场舞社区 · 官方"(淡紫色)。

alignItems(HorizontalAlign.Start) 使 Column 内的文字左对齐(起始端对齐),alignItems(VerticalAlign.Center) 使外层 Row 内的元素垂直居中对齐。这里出现了一个经典布局技巧:Text('').layoutWeight(1) 插入了一个空文本占位符并赋予全部剩余权重,它的作用是"推开"两侧元素——通知铃铛和搜索图标被推到最右侧,品牌标识留在最左侧。这种"弹性空格"模式在 Row 横向布局中极为常用,等价于 CSS 中的 flex: 1margin-left: auto

4.2 霓虹横幅与渐变背景

Column({ space: 6 }) {
  Row() {
    Text('🪩 舞场开放中')
      .fontSize(11)
      .fontColor('#FDF4FF')
      .backgroundColor('rgba(244,114,182,0.3)')
      .borderRadius(20)
      .padding({ left: 10, right: 10, top: 3, bottom: 3 })
    Text('').layoutWeight(1)
    Text('今晚 326 个舞队在线')
      .fontSize(11)
      .fontColor('#C4B5FD')
  }
  .width('100%')

  Text('广场舞社区')
    .fontSize(26)
    .fontWeight(FontWeight.Bold)
    .fontColor('#FDF4FF')
    .letterSpacing(2)
  Text('DANCE SQUARE · 舞出快乐 舞出健康')
    .fontSize(11)
    .fontColor('#C4B5FD')
    .letterSpacing(1)

横幅区域首先展示一个状态胶囊标签——“🪩 舞场开放中”,采用半透明霓虹粉背景配合圆角 20vp 形成药丸形状。右侧显示"今晚 326 个舞队在线"的实时统计数据。下方是大号标题"广场舞社区",letterSpacing(2) 设置字间距为 2vp,让标题文字之间有呼吸感,显得更加优雅大气。再下方是品牌口号"DANCE SQUARE · 舞出快乐 舞出健康",字间距 1vp。

4.3 霓虹灯带装饰与 ForEach 渲染

Row() {
  ForEach([10, 14, 8, 16, 12, 9, 15, 11, 13, 10], (s: number, i: number) => {
    Text(i % 2 === 0 ? '▲' : '▼')
      .fontSize(s)
      .fontColor(i % 3 === 0 ? '#F472B6' : (i % 3 === 1 ? '#A78BFA' : '#38BDF8'))
      .opacity(0.8)
  }, (s: number, i: number) => 'neon' + i)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20 })

这里是整个头部设计中最有视觉特色的部分——霓虹灯带装饰。ForEach 组件接收一个数字数组 [10, 14, 8, 16, 12, 9, 15, 11, 13, 10],为每个元素生成一个三角形文本符号。偶数索引位置显示向上的三角形 ,奇数位置显示向下的 ,形成参差错落的灯带效果。

字号直接取自数组元素的值(10vp 到 16vp 不等),让每个灯符号大小各异。颜色按索引对 3 取余分为三组:余 0 用霓虹粉 #F472B6,余 1 用霓虹紫 #A78BFA,余 2 用夜蓝 #38BDF8。三种颜色交替出现,模拟霓虹灯的多彩闪烁感。opacity(0.8) 统一设置了 80% 的不透明度,让灯带看起来柔和而不刺眼。

ForEach 的第三个参数是键值生成函数 (s: number, i: number) => 'neon' + i,它为每个渲染项生成唯一标识 'neon' + 索引。当数组数据发生变化时,框架通过这个 key 判断哪些项是新增的、哪些被删除、哪些只是位置移动,从而进行最小化的 DOM 操作。

ForEach 深入: ForEach 是 ArkUI 中处理列表渲染的核心组件。它接收三个参数:数据源数组、item 生成函数(定义每个数据项如何渲染为 UI)、key 生成函数(为每项生成唯一标识)。key 的质量直接影响 Diff 效率——如果 key 不唯一或不稳定,可能导致渲染异常或性能下降。

4.4 渐变背景与搜索胶囊

.linearGradient({
  angle: 180,
  colors: [['#3B0764', 0.0], ['#2A1538', 0.55], ['#1E1028', 1.0]]
})

头部整个 Column 设置了线性渐变背景。angle: 180 表示渐变方向为从上到下(180 度)。colors 数组定义了三个色标:起始位置(0.0)为深紫色 #3B0764,中间位置(0.55)为更暗的紫黑 #2A1538,结束位置(1.0)为面板背景色 #1E1028。这种从深紫到暗黑的渐变营造出夜空般的氛围感,与霓虹色彩形成鲜明对比,是"霓虹夜色风"视觉设计的核心手法。

Row() {
  Text('🔍')
    .fontSize(14)
  Text('搜索舞队、教学、赛事、广场舞神曲')
    .fontSize(13)
    .fontColor('#C4B5FD')
    .margin({ left: 6 })
}
.width('90%')
.height(38)
.backgroundColor('rgba(42,21,56,0.7)')
.borderRadius(19)
.padding({ left: 14 })
.alignItems(VerticalAlign.Center)

搜索胶囊是一个 Row 容器,宽度 90%、高度 38vp,圆角设为高度的一半(19vp)形成完全的胶囊形状。背景色 rgba(42,21,56,0.7) 是半透明的深紫色,让搜索栏在渐变背景上微微透出底层颜色,层次感更强。内部左侧是搜索图标,右侧是占位提示文字。这里使用 Text 而非 TextInput 是因为搜索栏目前仅作展示用途,未实现实际搜索逻辑——在真实工程中可以替换为 TextInput 组件或添加点击跳转。

五、内容区条件渲染与子组件通信

5.1 基于 currentTab 的条件渲染

Column() {
  if (this.currentTab === 0) {
    HomeContent({
      feeds: this.feeds,
      heat: HEAT,
      catSel: this.catSel,
      showFeedDetail: (item: FeedItem) => {
        this.pickedFeed = item
        this.showFeedDetail = true
      },
      showFeedAdd: () => {
        this.showFeedAdd = true
      }
    })
  }
  if (this.currentTab === 1) {
    TeamContent({
      teams: this.teams,
      catSel: this.catSel,
      showTeamDetail: (item: TeamItem) => {
        this.pickedTeam = item
        this.showTeamDetail = true
      },
      showTeamRecruit: (item: TeamItem) => {
        this.pickedTeam = item
        this.showTeamRecruit = true
      }
    })
  }
  // ... 其余标签页类似
}
.layoutWeight(1)
.width('100%')
.backgroundColor('#12081A')

内容区通过一系列 if (this.currentTab === N) 条件判断来决定渲染哪个子组件。ArkUI 的条件渲染遵循"只有条件为真的分支才会被渲染到组件树中"的原则,当 currentTab 从 0 变为 1 时,HomeContent 会被卸载(从组件树中移除),TeamContent 会被挂载(加入组件树)。

子组件调用时传递了两类参数:一是数据属性(如 feedsteamsheat 等),将父组件的状态数据向下传递给子组件渲染;二是回调函数(如 showFeedDetailshowFeedAdd 等),这是子组件向父组件"上报事件"的通道。当子组件内部发生点击交互时,调用传入的回调函数,父组件在回调中修改自身状态(设置 picked 数据、置 show 标志为 true),从而触发弹窗显示。

这种"数据向下、事件向上"的单向数据流模式是 ArkUI 组件通信的核心范式。父组件拥有数据的唯一修改权,子组件只负责展示数据和上报事件,不直接修改数据。这保证了状态变化的可追踪性——所有状态变更都发生在父组件中,调试时只需关注一处。

单向数据流: 父组件通过属性向子组件传递数据,子组件通过回调函数向父组件上报事件。数据只能从上往下流,事件只能从下往上冒泡。这种约束让组件间的依赖关系清晰可溯,是大型应用可维护性的基石。

5.2 六大子组件的数据传递契约

每个子组件接收的属性和回调各不相同,形成了一组明确的通信契约:

  • HomeContent:接收 feeds(动态列表)、heat(热度数据)、catSel(分类选中),回调 showFeedDetail(查看动态详情)、showFeedAdd(发布动态)
  • TeamContent:接收 teams(舞队列表)、catSel(分类选中),回调 showTeamDetail(查看舞队详情)、showTeamRecruit(加入舞队招募)
  • StepContent:接收 steps(课程列表),回调 showStepDetail(查看课程详情)、showStepSign(约课确认)
  • MatchContent:接收 matches(赛事列表),回调 showMatchRule(查看规则)、showMatchDetail(查看赛事详情)、showMatchSign(报名确认)、showMatchQuit(退赛确认)
  • SongContent:接收 songs(神曲列表),回调 showSongDetail(查看歌曲详情)、showSongQuit(移出榜单确认)
  • MeContent:接收 mys(收藏列表),回调 showMyRemove(移除收藏)、showMyNick(修改昵称)、showMyCache(清理缓存)、showMyExit(退出登录)

可以看到,功能越复杂的模块其回调数量越多。赛事模块有四个回调(规则、详情、报名、退赛),个人中心也有四个回调(移除、改昵称、清缓存、退出),这些回调覆盖了该模块所有可能的用户交互路径。

六、弹窗系统全面解析

6.1 弹窗的统一架构模式

本应用中的所有弹窗都遵循一套统一的架构模式:外层 Column 全屏覆盖 + 半透明遮罩背景,内层 Column 居中显示卡片内容。

@Builder
feedDetailOverlay(item: FeedItem, onClose: () => void) {
  Column() {
    Column({ space: 10 }) {
      // 弹窗卡片内容
    }
    .width('88%')
    .constraintSize({ maxHeight: '80%' })
    .padding(18)
    .backgroundColor('#1E1028')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(244,114,182,0.4)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(0,0,0,0.65)')
}

外层 Column 设置 width('100%')height('100%') 占满整个屏幕,backgroundColor('rgba(0,0,0,0.65)') 设置 65% 不透明度的黑色背景作为遮罩层,让底层内容变暗但不完全隐藏。justifyContent(FlexAlign.Center) 使内层卡片在遮罩层中居中显示。

内层卡片宽度 88%(两侧各留 6% 边距),constraintSize({ maxHeight: '80%' }) 限制最大高度不超过屏幕的 80%,防止内容过多时卡片溢出屏幕。borderRadius(14) 圆角 14vp,border 添加半透明霓虹粉边框,与整体视觉风格保持一致。

constraintSize 的作用: 与直接设置 height 不同,constraintSize 设置的是尺寸的上下限约束。当内容不足以撑满时,卡片高度由内容决定;当内容过多时,高度不会超过 maxHeight 限制。这种"弹性高度"在弹窗场景中非常实用,因为不同弹窗的内容量可能差异很大。

6.2 动态详情弹窗

@Builder
feedDetailOverlay(item: FeedItem, onClose: () => void) {
  Column() {
    Column({ space: 10 }) {
      Row() {
        Text(item.avatar)
          .fontSize(36)
        Column({ space: 3 }) {
          Text(item.name)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FDF4FF')
          Text(item.time)
            .fontSize(11)
            .fontColor('#C4B5FD')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 10 })
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(18)
          .fontColor('#C4B5FD')
          .padding(6)
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')

      Text(item.content)
        .fontSize(14)
        .fontColor('#EDE4F7')
        .lineHeight(22)

      Row() {
        Text('👍 ' + item.likes)
          .fontSize(12)
          .fontColor('#F472B6')
        Text('💬 ' + item.comments)
          .fontSize(12)
          .fontColor('#F472B6')
          .margin({ left: 16 })
        Text('').layoutWeight(1)
        Text('🎵 配神曲')
          .fontSize(12)
          .fontColor('#38BDF8')
      }
      .width('100%')

      Row() {
        Text('TA 的舞队')
          .fontSize(13)
          .fontColor('#A78BFA')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('rgba(167,139,250,0.15)')
          .borderRadius(6)
          .onClick(() => { onClose() })
        Text('去看看')
          .fontSize(13)
          .fontColor('#12081A')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('#F472B6')
          .borderRadius(6)
          .onClick(() => { onClose() })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 6 })
    }
    // ... 卡片样式
  }
  // ... 遮罩样式
}

动态详情弹窗展示了选中动态的完整信息。头部区域采用 Row 横向布局:左侧是放大到 36vp 字号的头像 emoji,中间是纵向排列的昵称和时间,右侧是关闭按钮 。关闭按钮绑定了 onClose() 回调,点击后触发父组件将 showFeedDetail 置为 false,弹窗消失。

正文区域使用 Text 组件展示动态内容,lineHeight(22) 设置行高为 22vp,让多行文字的阅读体验更加舒适。行高是文本排版中常被忽略但极为重要的属性——过小的行高会让文字挤在一起难以阅读,过大的行高则破坏段落整体感。

底部操作区是一对并排按钮,使用 justifyContent(FlexAlign.SpaceBetween) 让两个按钮分别贴向左右两端。左侧"TA 的舞队"是次要操作,使用半透明紫色背景;右侧"去看看"是主要操作,使用实色霓虹粉背景配深色文字。这种"次要操作淡色 + 主要操作亮色"的视觉对比,是移动端双按钮布局的通行做法,引导用户优先点击主要操作。

6.3 发布动态弹窗与 TextInput 输入框

@Builder
feedAddOverlay(onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('发布舞场动态')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text('记录今天的舞步与汗水')
        .fontSize(11)
        .fontColor('#C4B5FD')

      TextInput({ placeholder: '你的昵称', text: this.newFeedName })
        .fontSize(13)
        .fontColor('#FDF4FF')
        .placeholderColor('#7C6A8C')
        .height(42)
        .backgroundColor('rgba(42,21,56,0.7)')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newFeedName = v
        })

      TextInput({ placeholder: '写下动态内容…', text: this.newFeedText })
        .fontSize(13)
        .fontColor('#FDF4FF')
        .placeholderColor('#7C6A8C')
        .height(88)
        .backgroundColor('rgba(42,21,56,0.7)')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newFeedText = v
        })

发布动态弹窗是应用中少数包含表单输入的弹窗。TextInput 是 ArkUI 的文本输入组件,它通过构造参数 { placeholder: '...', text: this.xxx } 同时设置占位提示文本和当前值。placeholder 是输入框为空时显示的灰色提示文字,text 绑定到状态变量实现双向同步。

onChange 回调在用户每次输入时触发,参数 v 是最新的输入值。回调体内将 v 赋值给对应的 @State 变量,状态变化后 TextInputtext 属性重新读取该变量,形成完整的双向绑定链路。需要注意的是,ArkUI 中 TextInput 的 text 参数是单向的——状态变会更新输入框,但输入框的变化需要通过 onChange 手动同步回状态。这种设计避免了频繁的状态回写导致的性能问题。

两个输入框的高度不同:昵称输入框 42vp(单行高度),内容输入框 88vp(多行高度)。虽然 TextInput 默认是单行输入,但通过增加高度可以让输入区域更大,配合较长的 placeholder 文本(“写下动态内容…”)暗示用户可以输入较多文字。

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor('#C4B5FD')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(42)
          .backgroundColor('rgba(167,139,250,0.15)')
          .borderRadius(8)
          .onClick(() => {
            this.newFeedName = ''
            this.newFeedText = ''
            onClose()
          })
        Text('发布')
          .fontSize(14)
          .fontColor('#12081A')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(42)
          .backgroundColor('#F472B6')
          .borderRadius(8)
          .onClick(() => {
            if (this.newFeedName.length > 0 && this.newFeedText.length > 0) {
              this.feeds.unshift(buildFeed(99, this.newFeedName, '💃', '刚刚', this.newFeedText, 0, 0))
              this.newFeedName = ''
              this.newFeedText = ''
              onClose()
            }
          })
      }

底部按钮区包含"取消"和"发布"两个操作。取消按钮点击后会清空两个输入框的状态变量并关闭弹窗。发布按钮点击后先做非空校验——只有当昵称和内容都不为空字符串时才执行发布逻辑。发布操作调用 this.feeds.unshift(...) 将新构建的 FeedItem 插入到数组头部,unshift 是 JavaScript/TypeScript 数组的原生方法,在数组开头添加元素并返回新长度。由于 feeds@State 变量,数组变化后首页的动态列表会自动刷新,新动态出现在列表顶部。

这里调用 buildFeed 工厂函数创建新数据对象,传入固定 id 99、用户输入的昵称和内容、默认 emoji 头像 💃、"刚刚"作为时间戳、0 个赞和 0 条评论。在实际工程中 id 应由后端生成,时间戳应取当前系统时间。

表单校验的位置: 本例将校验逻辑放在按钮点击回调中,这是一种"提交时校验"的模式。对于简单表单这是足够的;对于复杂表单,可以考虑"实时校验"——在 onChange 中即时验证并显示错误提示,给用户更快的反馈。

6.4 舞队招募弹窗与数组原地修改

@Builder
teamRecruitOverlay(item: TeamItem, onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('招募舞友')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text('「' + item.name + '」当前招新 ' + item.quota + ' 人')
        .fontSize(12)
        .fontColor('#C4B5FD')

      Row() {
        Text('💃')
          .fontSize(30)
        Column({ space: 3 }) {
          Text('加入你将获得')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FDF4FF')
          Text('每周三次排练 + 队形教学 + 参赛机会')
            .fontSize(11)
            .fontColor('#C4B5FD')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 10 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('rgba(244,114,182,0.1)')
      .borderRadius(10)

      Row() {
        Text('取消')
          // ... 取消按钮样式
          .onClick(() => { onClose() })
        Text('确认加入')
          // ... 确认按钮样式
          .onClick(() => {
            if (item.quota > 0) {
              const idx = this.teams.findIndex((t: TeamItem) => t.id === item.id)
              if (idx >= 0) {
                const cur = this.teams[idx]
                const nq = cur.quota - 1
                this.teams.splice(idx, 1, buildTeam(cur.id, cur.name, cur.style, cur.members + 1, cur.score, nq, nq <= 0 ? 1 : 0))
              }
              onClose()
            }
          })
      }
    }
  }
}

舞队招募弹窗的"确认加入"按钮包含了一段重要的数组操作逻辑。当用户确认加入时,代码首先通过 findIndex 在 teams 数组中查找对应 id 的舞队索引。找到后取出当前舞队数据 cur,计算新的招新名额 nq = cur.quota - 1(减一),然后使用 splice 方法原地替换该位置的元素。

splice(idx, 1, newItem) 的含义是:从索引 idx 处删除 1 个元素,并在同一位置插入 newItem。这里用 buildTeam 工厂函数构建一个新的 TeamItem,成员数加一(cur.members + 1),招新名额减一(nq),状态根据剩余名额判断——如果 nq <= 0 则状态变为 1(已满员),否则保持 0(招新中)。

这种"原地替换"而非"先删后增"的做法,保证了数组长度不变、其他元素位置不变,ForEAch 的 Diff 算法能更高效地只更新变化的那一项。同时,由于 this.teams@State 变量,splice 操作会触发框架的变更检测,自动刷新舞队列表的显示。

用户点击确认加入

item.quota > 0?

不执行操作

findIndex 查找舞队索引

idx >= 0?

舞队不存在

取出当前数据 cur

计算 nq = quota - 1

splice 原地替换为新对象

nq <= 0?

state 改为 1 已满员

state 保持 0 招新中

teams 数组变更触发 UI 刷新

关闭弹窗

6.5 退赛与移除类弹窗的危险操作确认

@Builder
matchQuitOverlay(item: MatchItem, onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('⚠️ 退赛确认')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text('确认退出「' + item.name + '」?')
        .fontSize(13)
        .fontColor('#EDE4F7')
      Text('退赛后该赛事名额将立即释放,参赛证书与直播资格作废,此操作不可撤销。')
        .fontSize(11)
        .fontColor('#F87171')

      Row() {
        Text('再想想')
          .fontSize(14)
          .fontColor('#C4B5FD')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(42)
          .backgroundColor('rgba(167,139,250,0.15)')
          .borderRadius(8)
          .onClick(() => { onClose() })
        Text('确认退赛')
          .fontSize(14)
          .fontColor('#FDF4FF')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(42)
          .backgroundColor('#DC2626')
          .borderRadius(8)
          .onClick(() => {
            const idx = this.matches.findIndex((m: MatchItem) => m.id === item.id)
            if (idx >= 0) {
              this.matches.splice(idx, 1)
            }
            onClose()
          })
      }
    }
  }
}

退赛确认弹窗属于"危险操作确认"类弹窗,与发布动态、加入舞队等"正向操作"弹窗在视觉上有明显区别。标题前缀 ⚠️ 警示符号,说明文字使用红色 #F87171 强调不可撤销的后果。确认按钮"确认退赛"使用红色背景 #DC2626(而非其他弹窗的霓虹粉),这是危险操作按钮的通用颜色编码——红色代表警告和破坏性,让用户在点击前产生心理预期。

退赛操作的数组处理与加入舞队不同——这里使用 splice(idx, 1) 只删除不插入,即将该赛事从 matches 数组中完全移除。这种差异体现了不同业务场景的数据处理逻辑:加入是"修改现有项",退赛是"删除现有项"。

应用中类似的危险确认弹窗还有"移出神曲榜"(songQuitOverlay)、“移除收藏”(myRemoveOverlay)和"退出登录"(myExitOverlay),它们都遵循相同的红色警示视觉规范。

6.6 赛事规则弹窗与纯文本列表

@Builder
matchRuleOverlay(onClose: () => void) {
  Column() {
    Column({ space: 10 }) {
      Text('🏆 广场舞赛事规则')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text('1. 参赛队伍须为实名注册舞队,人数 8-40 人')
        .fontSize(13)
        .fontColor('#EDE4F7')
      Text('2. 曲目时长 3-6 分钟,服装统一')
        .fontSize(13)
        .fontColor('#EDE4F7')
      Text('3. 评分按编排、整齐度、表现力综合评定')
        .fontSize(13)
        .fontColor('#EDE4F7')
      Text('4. 冠军队伍将登上「舞队风云榜」')
        .fontSize(13)
        .fontColor('#EDE4F7')
      Text('5. 赛事报名以队伍为单位,报满即止')
        .fontSize(13)
        .fontColor('#EDE4F7')
      Text('知道了')
        .fontSize(14)
        .fontColor('#12081A')
        .textAlign(TextAlign.Center)
        .width('100%')
        .height(42)
        .backgroundColor('#F472B6')
        .borderRadius(8)
        .margin({ top: 8 })
        .onClick(() => { onClose() })
    }
  }
}

赛事规则弹窗是一个纯展示型弹窗,不接收数据参数,只有 onClose 一个回调。内部用 5 个 Text 组件依次展示规则条款,每条以数字序号开头。底部是一个通栏的"知道了"按钮,宽度 100%,与前面的双按钮布局形成区分——纯信息展示弹窗只需一个确认按钮即可。

这种弹窗不传 item 参数,因为它展示的内容是全局性的规则文本,不依赖任何特定数据项。在 build() 中的调用也因此更简洁——只需检查 showMatchRule 标志,不需要额外的 picked* 数据判断。

6.7 修改昵称弹窗与表单提交

@Builder
myNickOverlay(onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('修改昵称')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text('起一个闪亮的舞场昵称')
        .fontSize(11)
        .fontColor('#C4B5FD')

      TextInput({ placeholder: '新昵称', text: this.newNick })
        .fontSize(14)
        .fontColor('#FDF4FF')
        .placeholderColor('#7C6A8C')
        .height(44)
        .backgroundColor('rgba(42,21,56,0.7)')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newNick = v
        })

      Row() {
        Text('取消')
          .onClick(() => {
            this.newNick = ''
            onClose()
          })
        Text('保存')
          .onClick(() => {
            if (this.newNick.length > 0) {
              this.newNick = ''
              onClose()
            }
          })
      }
    }
  }
}

修改昵称弹窗的结构与发布动态弹窗类似,但只有一个输入框。"保存"按钮点击后做非空校验,通过后清空输入框并关闭弹窗。这里没有实际修改用户数据的逻辑(因为是原型演示),在真实工程中此处应调用后端接口更新用户资料,成功后再清空和关闭。

七、首页子组件 HomeContent 深度解析

7.1 组件声明与 @Prop 属性接收

@Component
struct HomeContent {
  @Prop feeds: FeedItem[] = []
  @Prop heat: number[] = []
  @Prop catSel: number = 0
  @State localCat: number = 0
  showFeedDetail: (item: FeedItem) => void = (item: FeedItem) => {}
  showFeedAdd: () => void = () => {}

  build() {
    // ...
  }
}

HomeContent 是首页内容子组件,使用 @Component 声明(注意没有 @Entry,因为它不是页面入口)。它通过 @Prop 接收父组件传入的数据。

@Prop 装饰器用于接收父组件传递的只读属性。与 @State 不同,@Prop 变量在子组件内部不能直接修改(尝试赋值会触发编译警告或运行时错误),它只能跟随父组件中对应数据的变化而更新。当父组件的 feeds 数组发生变化(如新增动态)时,子组件的 @Prop feeds 会自动同步新值并触发 UI 刷新。

localCat 是子组件内部的 @State 变量,用于记录当前选中的分类标签索引。它不接收父组件的值,初始为 0,用户在子组件内部点击分类标签时更新。这种设计将"分类筛选"的状态限定在子组件内部管理,不需要上升到父组件层面,减少了不必要的状态传递。

两个回调函数 showFeedDetailshowFeedAdd 以普通属性形式声明(不加 @Prop@State),并赋予了默认的空函数实现。这种写法确保了即使父组件没有传入回调,子组件调用时也不会报错。

@Prop vs @State: @Prop 是"父传子"的只读数据通道,数据归父组件所有,子组件只读不写。@State 是组件内部的可变状态,数据归当前组件所有,可读可写。当需要在子组件中修改父组件数据时,应通过回调函数通知父组件,而非直接修改 @Prop。

7.2 舞场头条横幅卡片

Scroll() {
  Column({ space: 12 }) {
    Row() {
      Column({ space: 6 }) {
        Text('🪩 舞场头条')
          .fontSize(11)
          .fontColor('#F472B6')
          .backgroundColor('rgba(244,114,182,0.2)')
          .borderRadius(20)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        Text('区广场舞大赛开始报名\n冠军奖金 5000 元 · 全程直播')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
          .lineHeight(26)
        Text('9月10日 凤凰广场 · 报满即止')
          .fontSize(11)
          .fontColor('#C4B5FD')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text('🏆')
        .fontSize(52)
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#2A1538')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(244,114,182,0.3)' })
    .onClick(() => {
      this.showFeedDetail(this.feeds[0])
    })

整个首页内容包裹在 Scroll 滚动容器中。Scroll 是 ArkUI 的滚动容器组件,当内容超出可视区域时自动支持上下滚动。内部是一个 Column 纵向布局,通过 space: 12 设置子元素间距为 12vp。

第一张卡片是"舞场头条"横幅,采用 Row 横向布局:左侧是文字信息区域(Column 纵向排列标签、标题、时间),右侧是放大到 52vp 字号的奖杯 emoji。左侧 Column 设置 .layoutWeight(1) 占据剩余宽度,奖杯 emoji 固定在右侧。

标题文本中使用了 \n 换行符实现两行显示,配合 lineHeight(26) 设置行高。整个卡片设置了 onClick 点击事件,点击后调用 this.showFeedDetail(this.feeds[0])——即弹出第一条动态的详情。这种"头条卡片点击跳转到相关详情"是资讯类应用的常见交互模式。

7.3 热度柱状图与 ForEach 动态渲染

Column({ space: 8 }) {
  Row() {
    Text('📊 本周舞场热度')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FDF4FF')
    Text('').layoutWeight(1)
    Text('近 8 天活跃舞队数')
      .fontSize(11)
      .fontColor('#7C6A8C')
  }
  .width('100%')
  Row() {
    ForEach(this.heat, (v: number, i: number) => {
      Column({ space: 4 }) {
        Text(v.toString())
          .fontSize(9)
          .fontColor('#C4B5FD')
        Row()
          .width(22)
          .height(heatBar(v))
          .backgroundColor(i % 2 === 0 ? '#F472B6' : '#A78BFA')
          .borderRadius(4)
        Text('周' + (i + 1))
          .fontSize(9)
          .fontColor('#7C6A8C')
      }
      .layoutWeight(1)
      .height(120)
      .justifyContent(FlexAlign.End)
    }, (v: number, i: number) => 'heat' + i)
  }
  .width('100%')
}

热度柱状图区域是一个自定义绘制的简易图表。外层 Row 的标题栏同样使用 Text('').layoutWeight(1) 分隔左右文字。下方的 ForEach 遍历 this.heat 数组,为每个数值生成一个柱子。

每个柱子是一个 Column 纵向布局,从上到下依次是:数值标签、柱体(一个设置了宽高的空 Row)、周次标签。柱体的宽度固定为 22vp,高度由 heatBar(v) 函数计算得出(热度值越大柱子越高)。柱体颜色按索引奇偶交替使用霓虹粉和霓虹紫,形成双色柱状图效果。

justifyContent(FlexAlign.End) 是这里的关键设置——它使 Column 内的子元素向底部对齐(主轴末端)。因为 Column 高度固定为 120vp,而柱子高度可能只有几十 vp,设置底部对齐后柱子会"立"在底部,数值标签在柱子上方,周次标签在最底部,形成标准的柱状图视觉结构。

每个柱子的 layoutWeight(1) 让它们在 Row 中等分宽度,8 个柱子均匀排列在一行内。key 生成函数返回 'heat' + i,以索引为标识,在热度数据不变时 key 稳定。

7.4 速览三格与 layoutWeight 等分

Row({ space: 10 }) {
  Column({ space: 4 }) {
    Text('326')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#F472B6')
    Text('在线舞队')
      .fontSize(10)
      .fontColor('#7C6A8C')
    Text('+26 今晚')
      .fontSize(10)
      .fontColor('#38BDF8')
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .backgroundColor('#1E1028')
  .borderRadius(12)
  .alignItems(HorizontalAlign.Center)
  // ... 另外两格结构相同
}
.width('100%')

速览三格是三个并排的数据卡片,分别展示"在线舞队数"“今日跟练人次”“本周新编舞数”。每个卡片是一个 Column,内部纵向排列数值、标签、增量。三个卡片都设置了 .layoutWeight(1),在父 Row 中等分宽度(各占三分之一)。alignItems(HorizontalAlign.Center) 使卡片内文字水平居中对齐。

Row({ space: 10 })space 参数设置了卡片之间的间距为 10vp。这种"等分+间距"的网格布局在数据看板类界面中极为常见,layoutWeight 等分确保了不同屏幕宽度下三格始终等宽排列。

7.5 舞种标签横滑与 Scroll 方向控制

Row({ space: 8 }) {
  Scroll() {
    Row({ space: 8 }) {
      ForEach(CATS, (c: string, i: number) => {
        Text(c)
          .fontSize(12)
          .fontColor(this.localCat === i ? '#12081A' : '#C4B5FD')
          .backgroundColor(this.localCat === i ? '#F472B6' : 'rgba(30,16,40,0.9)')
          .borderRadius(16)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .onClick(() => {
            this.localCat = i
          })
      }, (c: string, i: number) => 'cat' + i)
    }
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .layoutWeight(1)
  Text('发布')
    .fontSize(12)
    .fontColor('#12081A')
    .backgroundColor('#F472B6')
    .borderRadius(16)
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .onClick(() => {
      this.showFeedAdd()
    })
}
.width('100%')

分类标签区域使用了横向滚动的 Scroll 容器。scrollable(ScrollDirection.Horizontal) 设置滚动方向为水平方向(默认是垂直方向)。scrollBar(BarState.Off) 隐藏滚动条,让横滑区域看起来更干净。内部的 Row 包含 ForEach 生成的分类标签,每个标签的选中态由 this.localCat === i 判断——选中时文字深色背景亮粉(反白效果),未选中时文字淡紫背景暗色。

"发布"按钮固定在标签区域右侧,不参与横滑。外层 Row 中,Scroll 设置了 .layoutWeight(1) 占据剩余宽度,"发布"按钮宽度由内容撑开。这种"弹性横滑区 + 固定操作按钮"的布局在社交类应用的分类筛选场景中十分常见。

7.6 动态列表与文本溢出处理

ForEach(this.feeds, (item: FeedItem, idx: number) => {
  Row() {
    Text(item.avatar)
      .fontSize(30)
    Column({ space: 4 }) {
      Row() {
        Text(item.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('').layoutWeight(1)
        Text(item.time)
          .fontSize(10)
          .fontColor('#7C6A8C')
      }
      .width('100%')
      Text(item.content)
        .fontSize(12)
        .fontColor('#EDE4F7')
        .lineHeight(19)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Row({ space: 14 }) {
        Text('👍 ' + item.likes)
          .fontSize(11)
          .fontColor('#F472B6')
        Text('💬 ' + item.comments)
          .fontSize(11)
          .fontColor('#F472B6')
        Text('').layoutWeight(1)
        Text('看舞队 ›')
          .fontSize(11)
          .fontColor('#38BDF8')
      }
      .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#160D20')
  .borderRadius(12)
  .border({ width: 1, color: 'rgba(244,114,182,0.15)' })
  .onClick(() => {
    this.showFeedDetail(item)
  })
}, (item: FeedItem, idx: number) => 'feed' + item.id)

动态列表的每项采用"左头像 + 右内容"的经典列表布局。左侧是 30vp 字号的 emoji 头像,右侧是 Column 内容区(layoutWeight(1) 占据剩余宽度),包含昵称时间行、正文文本、互动数据行。

正文文本区域有两个关键属性:.maxLines(2) 限制最多显示 2 行,.textOverflow({ overflow: TextOverflow.Ellipsis }) 设置溢出时显示省略号。这两个属性配合使用,确保过长的动态内容在列表中被截断为两行并末尾添加"…",保持列表项高度一致,避免某条超长内容破坏整体布局。在详情弹窗中,同一内容会完整显示(不设 maxLines),形成"列表摘要 + 详情全文"的阅读体验。

key 生成函数使用 'feed' + item.id,以数据项的 id 作为标识。当新动态通过 unshift 插入数组头部时,其 id(99)与已有项不同,ForEach 能正确识别为新增项并渲染,不影响其他已有项。

maxLines + textOverflow: 这对属性组合是处理长文本列表展示的标准方案。maxLines 限制行数保证布局稳定,textOverflow 提供省略号提示用户"内容未完",引导点击查看详情。如果只设 maxLines 不设 textOverflow,截断处可能出现在文字中间,视觉上不完整。

八、舞队子组件 TeamContent 与双列卡片布局

8.1 分类标签的下划线指示器

@Component
struct TeamContent {
  @Prop teams: TeamItem[] = []
  @Prop catSel: number = 0
  @State localCat: number = 0
  showTeamDetail: (item: TeamItem) => void = (item: TeamItem) => {}
  showTeamRecruit: (item: TeamItem) => void = (item: TeamItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Scroll() {
          Row({ space: 8 }) {
            ForEach(CATS, (c: string, i: number) => {
              Column({ space: 4 }) {
                Text(c)
                  .fontSize(12)
                  .fontColor(this.localCat === i ? '#F472B6' : '#7C6A8C')
                  .fontWeight(this.localCat === i ? FontWeight.Bold : FontWeight.Normal)
                Row()
                  .width(16)
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor(this.localCat === i ? '#F472B6' : 'transparent')
              }
              .padding({ left: 10, right: 10, top: 6, bottom: 2 })
              .onClick(() => {
                this.localCat = i
              })
            }, (c: string, i: number) => 'cat' + i)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

舞队页的分类标签与首页的胶囊样式不同,采用了"文字 + 下划线指示器"的设计。每个分类标签是一个 Column,上方是文字,下方是一个宽度 16vp、高度 3vp 的小条作为下划线指示器。选中时下划线显示霓虹粉色,未选中时设为 transparent(透明)。

选中态不仅通过下划线颜色区分,文字本身也有变化——选中时字号不变但加粗(FontWeight.Bold)且颜色变为霓虹粉,未选中时正常字重且暗紫色。这种"多维度视觉反馈"(颜色+字重+下划线)让选中态更加醒目,用户能快速定位当前分类。

8.2 双列卡片网格与 ForEach 分组

ForEach([0, 2, 4, 6, 8], (start: number, row: number) => {
  Row({ space: 10 }) {
    if (start < this.teams.length) {
      this.teamCard(this.teams[start])
    }
    if (start + 1 < this.teams.length) {
      this.teamCard(this.teams[start + 1])
    } else {
      Column()
        .layoutWeight(1)
        .height(170)
    }
  }
  .width('100%')
}, (start: number, row: number) => 'row' + row)

舞队卡片采用双列网格布局。这里使用了一个巧妙的 ForEach 遍历方式——数据源是 [0, 2, 4, 6, 8],即每行的起始索引(偶数递增)。对于每个起始索引 start,渲染 teams[start]teams[start+1] 两张卡片,形成一行两列的网格。

当数据项数为奇数时,最后一行只有一张卡片。代码中通过 if (start + 1 < this.teams.length) 判断第二张卡片是否存在,如果不存在则渲染一个空的 Column().layoutWeight(1).height(170) 作为占位符。这个占位符与卡片具有相同的 layoutWeight(1) 和高度,确保单独一张卡片时不会撑满整行宽度,而是保持与双列时相同的半行宽度,视觉上整齐对齐。

@Builder teamCard 方法定义了单张舞队卡片的完整结构,在 ForEach 中通过 this.teamCard(item) 调用。这种将卡片 UI 抽取为 @Builder 的做法,使网格布局代码保持简洁——ForEach 只负责行列划分和索引计算,卡片的具体渲染逻辑由 teamCard 负责。

8.3 舞队卡片的表情图标映射

@Builder
teamCard(item: TeamItem) {
  Column({ space: 8 }) {
    Text(item.style === '民族舞' ? '🌺' : (item.style === '曳步舞' ? '🕺' : (item.style === '健身操' ? '🏃' : (item.style === '交谊舞' ? '💃' : (item.style === '拉丁舞' ? '💃' : (item.style === '爵士舞' ? '🎷' : (item.style === '街舞' ? '🕶️' : '🥁')))))))
      .fontSize(34)
      .height(54)
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FDF4FF')
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(item.style + ' · ' + item.members + ' 人')
      .fontSize(10)
      .fontColor('#7C6A8C')
    Row() {
      Text('⭐ ' + item.score)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FBBF24')
      Text('').layoutWeight(1)
      Text(teamStateText(item.state))
        .fontSize(9)
        .fontColor(teamStateColor(item.state))
    }
    .width('100%')
    Row({ space: 6 }) {
      Text('看详情')
        .fontSize(11)
        .fontColor('#A78BFA')
        .textAlign(TextAlign.Center)
        .width('48%')
        .height(30)
        .backgroundColor('rgba(167,139,250,0.15)')
        .borderRadius(6)
        .onClick(() => { this.showTeamDetail(item) })
      Text('加入')
        .fontSize(11)
        .fontColor('#12081A')
        .textAlign(TextAlign.Center)
        .width('48%')
        .height(30)
        .backgroundColor('#F472B6')
        .borderRadius(6)
        .onClick(() => { this.showTeamRecruit(item) })
    }
    .width('100%')
  }
  .layoutWeight(1)
  .padding(12)
  .backgroundColor('#1E1028')
  .borderRadius(14)
  .border({ width: 1, color: 'rgba(56,189,248,0.25)' })
  .alignItems(HorizontalAlign.Start)
}

舞队卡片的表情图标通过一串嵌套的三元条件表达式实现——根据 item.style 的值映射为不同的 emoji。虽然嵌套三元表达式在可读性上不如 switch-case 或映射表,但在 ArkTS 中这种写法是常见模式,因为 ArkTS 对 switch 在 UI 描述中的支持有限。

卡片底部是一对操作按钮:“看详情”(次要操作,淡紫色)和"加入"(主要操作,霓虹粉)。两个按钮分别绑定了不同的回调——"看详情"调用 showTeamDetail(item),"加入"调用 showTeamRecruit(item)。这种"一卡双操作"的设计让用户可以在列表中直接执行加入操作,无需先进入详情再加入,减少了交互步数。

卡片名称同样使用了 maxLines(1)textOverflow 处理溢出,确保长名称被截断为省略号而非换行。卡片整体设置 .alignItems(HorizontalAlign.Start) 使内部内容左对齐,与双列网格中右侧卡片形成对称。

九、教学子组件 StepContent 与课程列表

9.1 三指标横幅与难度图标

@Component
struct StepContent {
  @Prop steps: StepItem[] = []
  showStepDetail: (item: StepItem) => void = (item: StepItem) => {}
  showStepSign: (item: StepItem) => void = (item: StepItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text('520')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F472B6')
            Text('教学课程')
              .fontSize(10)
              .fontColor('#7C6A8C')
          }
          .layoutWeight(1)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor('#1E1028')
          .borderRadius(12)
          .alignItems(HorizontalAlign.Center)
          // ... 另外两个指标
        }
        .width('100%')

教学页顶部是三个数据指标卡片,结构与首页速览三格类似——等分布局、居中对齐、数值+标签的两行结构。三个指标分别是"教学课程 520 节"“金牌教练 86 位”“累计学习 68 万人次”,让用户在进入页面时快速了解教学资源的规模。

ForEach(this.steps, (item: StepItem, idx: number) => {
  Row() {
    Text(item.level === '入门' ? '🌱' : (item.level === '进阶' ? '🔥' : '💎'))
      .fontSize(28)
    Column({ space: 4 }) {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(item.teacher + ' · ' + item.level + ' · 👀 ' + playsText(item.plays))
        .fontSize(11)
        .fontColor('#7C6A8C')
      Row({ space: 10 }) {
        Text('可约 ' + item.quota + ' 人')
          .fontSize(10)
          .fontColor('#F472B6')
        Text(stepStateText(item.state))
          .fontSize(10)
          .fontColor(stepStateColor(item.state))
      }
      .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
    Text(item.state === 0 ? '约课' : '已满')
      .fontSize(11)
      .fontColor(item.state === 0 ? '#12081A' : '#7C6A8C')
      .textAlign(TextAlign.Center)
      .width(56)
      .height(30)
      .backgroundColor(item.state === 0 ? '#F472B6' : 'rgba(124,106,140,0.25)')
      .borderRadius(6)
      .onClick(() => {
        if (item.state === 0) {
          this.showStepSign(item)
        }
      })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#160D20')
  .borderRadius(12)
  .border({ width: 1, color: 'rgba(167,139,250,0.15)' })
  .onClick(() => {
    this.showStepDetail(item)
  })
}, (item: StepItem, idx: number) => 'step' + item.id)

课程列表每项的左侧图标根据难度级别动态映射:入门用 🌱(幼苗,象征初学者),进阶用 🔥(火焰,象征进阶挑战),高级用 💎(钻石,象征高级稀有)。这种语义化的图标映射让用户一眼就能识别课程难度。

右侧的操作按钮文本和样式都根据 item.state 动态变化:可约课时显示"约课"(霓虹粉背景深色文字),已满时显示"已满"(灰色背景灰色文字)。同时 onClick 回调内部也有条件判断——只有 item.state === 0(可约课)时才调用 showStepSign,已满状态下点击不触发任何操作。这种"视觉禁用 + 逻辑禁用"的双重保护,避免了用户对不可用操作的误触。

整个列表项还绑定了外层 onClick 调用 showStepDetail,即点击卡片任意区域查看详情。而"约课"按钮的 onClick 也会触发事件冒泡——在 ArkUI 中,子元素的点击事件会冒泡到父元素,因此点击"约课"按钮时可能同时触发详情弹窗和约课弹窗。在实际工程中应使用 stopPropagation 阻止冒泡,或调整事件绑定层级。

十、赛事子组件 MatchContent 与联赛横幅

10.1 联赛横幅与奖杯装饰

@Component
struct MatchContent {
  @Prop matches: MatchItem[] = []
  showMatchRule: () => void = () => {}
  showMatchDetail: (item: MatchItem) => void = (item: MatchItem) => {}
  showMatchSign: (item: MatchItem) => void = (item: MatchItem) => {}
  showMatchQuit: (item: MatchItem) => void = (item: MatchItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Row() {
            Text('🏆 广场舞联赛')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text('').layoutWeight(1)
            Text('规则 ›')
              .fontSize(12)
              .fontColor('#F472B6')
              .onClick(() => {
                this.showMatchRule()
              })
          }
          .width('100%')
          Text('舞出精彩 · 舞动全城 · 联赛季火热进行中')
            .fontSize(11)
            .fontColor('#C4B5FD')
          Row() {
            ForEach([1, 2, 3, 4, 5, 6], (s: number, i: number) => {
              Text('🏆')
              .fontSize(16)
              .opacity(0.5 + s * 0.08)
            }, (s: number, i: number) => 't' + s)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .padding({ left: 20, right: 20 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#2A1538')
        .borderRadius(14)
        .border({ width: 1, color: 'rgba(251,191,36,0.3)' })

赛事横幅的标题栏右侧有"规则 ›"链接,点击后调用 showMatchRule() 弹出规则弹窗(注意这个回调不传 item 参数,因为规则是全局的)。横幅底部是一排奖杯 emoji 装饰,使用 ForEach 遍历 [1, 2, 3, 4, 5, 6] 生成 6 个奖杯。每个奖杯的不透明度通过 0.5 + s * 0.08 计算——s 值从 1 到 6,不透明度从 0.58 递增到 0.98,形成从淡到浓的渐变效果,模拟"联赛热度逐渐升温"的视觉隐喻。

justifyContent(FlexAlign.SpaceBetween) 让 6 个奖杯在行内均匀分布,首尾两个分别贴向左右边缘。这种 SpaceBetween 对齐在装饰性元素排列中常用,比居中对齐更有节奏感。

10.2 赛事列表与底部双按钮

ForEach(this.matches, (item: MatchItem, idx: number) => {
  Row() {
    Text('🏆')
      .fontSize(26)
    Column({ space: 4 }) {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text(item.place + ' · ' + item.time)
        .fontSize(11)
        .fontColor('#7C6A8C')
      Row({ space: 10 }) {
        Text('💎 ¥' + item.prize)
          .fontSize(10)
          .fontColor('#FBBF24')
        Text('剩余 ' + item.quota + ' 名额')
          .fontSize(10)
          .fontColor('#F472B6')
      }
      .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
    Text(matchStateText(item.state))
      .fontSize(10)
      .fontColor(matchStateColor(item.state))
      .backgroundColor('rgba(56,189,248,0.15)')
      .borderRadius(10)
      .padding({ left: 8, right: 8, top: 2, bottom: 2 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#160D20')
  .borderRadius(12)
  .border({ width: 1, color: 'rgba(251,191,36,0.15)' })
  .onClick(() => {
    this.showMatchDetail(item)
  })
}, (item: MatchItem, idx: number) => 'match' + item.id)

Row({ space: 8 }) {
  Text('报名参赛')
    .fontSize(13)
    .fontColor('#12081A')
    .textAlign(TextAlign.Center)
    .width('48%')
    .height(42)
    .backgroundColor('#F472B6')
    .borderRadius(8)
    .onClick(() => {
      if (this.matches.length > 0) {
        this.showMatchSign(this.matches[0])
      }
    })
  Text('长按列表项退赛')
    .fontSize(13)
    .fontColor('#F87171')
    .textAlign(TextAlign.Center)
    .width('48%')
    .height(42)
    .backgroundColor('rgba(220,38,38,0.15)')
    .borderRadius(8)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)

赛事列表项左侧是奖杯图标,中间是赛事名称、地点时间、奖金和名额信息,右侧是状态标签(胶囊形状)。奖金使用舞台金 #FBBF24 颜色突出显示,与奖杯图标形成视觉呼应。

列表底部有一对全局操作按钮:“报名参赛”(默认报名第一场赛事 this.matches[0])和"长按列表项退赛"(提示性文字,非实际按钮)。退赛操作的触发方式是"长按列表项",但在当前代码中列表项只绑定了 onClick(查看详情),并未实现 onLongPress 长按事件。这说明该提示文字目前仅作 UI 展示,长按退赛功能在实际工程中需要额外绑定 gestureonLongPress 事件才能生效。

十一、神曲子组件 SongContent 与榜单布局

11.1 神曲榜横幅与音符装饰

@Component
struct SongContent {
  @Prop songs: SongItem[] = []
  showSongDetail: (item: SongItem) => void = (item: SongItem) => {}
  showSongQuit: (item: SongItem) => void = (item: SongItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Row() {
            Text('🎵 广场舞神曲榜')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text('').layoutWeight(1)
            Text('每日 00:00 更新')
              .fontSize(11)
              .fontColor('#7C6A8C')
          }
          .width('100%')
          Text('依据全国舞队点播量实时排名 · 配乐跟跳首选')
            .fontSize(11)
            .fontColor('#C4B5FD')
          Row() {
            ForEach([1, 2, 3, 4, 5, 6], (s: number, i: number) => {
              Text('♪')
              .fontSize(16)
              .opacity(0.5 + s * 0.08)
            }, (s: number, i: number) => 'm' + s)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .padding({ left: 20, right: 20 })
        }

神曲榜横幅的结构与赛事横幅几乎一致,只是将奖杯 emoji 替换为音符符号 ♪,边框颜色改为夜蓝系 rgba(56,189,248,0.3)。这种"同一布局结构 + 不同主题色 + 不同装饰图标"的复用模式在多模块应用中非常高效——开发者可以复制已有横幅结构,只需替换主题色和图标即可快速创建新模块的横幅。

11.2 排名编号与前三名高亮

ForEach(this.songs, (item: SongItem, idx: number) => {
  Row() {
    Text((idx + 1).toString())
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(idx < 3 ? '#FBBF24' : '#7C6A8C')
      .width(28)
    Text('🎵')
      .fontSize(26)
    Column({ space: 4 }) {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text(item.singer + ' · ⚡' + item.bpm + ' BPM')
        .fontSize(11)
        .fontColor('#7C6A8C')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 8 })
    Column({ space: 4 }) {
      Text('▶ ' + playsText(item.plays))
        .fontSize(10)
        .fontColor('#F472B6')
      Text(songStateText(item.state))
        .fontSize(9)
        .fontColor(songStateColor(item.state))
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#160D20')
  .borderRadius(12)
  .border({ width: 1, color: 'rgba(244,114,182,0.15)' })
  .onClick(() => {
    this.showSongDetail(item)
  })
}, (item: SongItem, idx: number) => 'song' + item.id)

神曲列表的每项左侧是排名编号,编号由 idx + 1 计算得出(ForEach 的索引从 0 开始,排名从 1 开始)。前三名(idx < 3)的编号使用舞台金 #FBBF24,其余排名使用暗紫色。这种"前三名特殊颜色"的视觉处理在各类榜单应用中极为常见,金色代表荣誉和突出,让用户一眼识别热门内容。

列表项右侧是播放量和状态信息,使用 alignItems(HorizontalAlign.End) 右对齐,与左侧的排名编号形成"左编号 + 中信息 + 右数据"的三段式布局。BPM 值前加了闪电符号 ⚡,暗示节奏速度。

十二、个人中心子组件 MeContent 与设置项

12.1 档案卡与星光装饰

@Component
struct MeContent {
  @Prop mys: MyItem[] = []
  showMyRemove: (item: MyItem) => void = (item: MyItem) => {}
  showMyNick: () => void = () => {}
  showMyCache: () => void = () => {}
  showMyExit: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row() {
            Text('🕺')
              .fontSize(44)
            Column({ space: 4 }) {
              Text('舞动青春')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FDF4FF')
              Text('ID 77 366 21 · 舞龄 LV.7')
                .fontSize(11)
                .fontColor('#C4B5FD')
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
            Text('').layoutWeight(1)
            Text('编辑 ›')
              .fontSize(12)
              .fontColor('#F472B6')
              .onClick(() => {
                this.showMyNick()
              })
          }
          .width('100%')
          Text('已加入 3 支舞队 · 学习课程 12 节 · 参赛 4 场')
            .fontSize(11)
            .fontColor('#C4B5FD')
          Row() {
            ForEach([1, 2, 3, 4, 5, 6, 7], (s: number, i: number) => {
              Text('✨')
              .fontSize(12)
              .opacity(0.4 + s * 0.08)
            }, (s: number, i: number) => 'spark' + s)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .padding({ left: 16, right: 16 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#2A1538')
        .borderRadius(14)
        .border({ width: 1, color: 'rgba(244,114,182,0.3)' })

个人中心顶部的档案卡展示了用户的头像、昵称、ID、舞龄等级和活动统计。右侧"编辑 ›"链接调用 showMyNick() 弹出修改昵称弹窗。档案卡底部是一排星光装饰——7 个 ✨ emoji,不透明度从 0.48 递增到 0.96,与赛事横幅的奖杯渐变装饰手法完全一致,但换成了星光符号以匹配个人中心的主题氛围。

12.2 四宫格统计与收藏列表

Row({ space: 10 }) {
  Column({ space: 4 }) {
    Text('🕺')
      .fontSize(24)
    Text('我的舞队')
      .fontSize(11)
      .fontColor('#7C6A8C')
    Text('3 支')
      .fontSize(11)
      .fontColor('#F472B6')
  }
  .layoutWeight(1)
  .padding(12)
  .backgroundColor('#160D20')
  .borderRadius(12)
  .alignItems(HorizontalAlign.Center)
  // ... 另外三格
}
.width('100%')

四宫格统计区展示"我的舞队"“在学课程”“参赛记录”"荣誉徽章"四项数据。每格采用"图标+标签+数值"的三行结构,layoutWeight(1) 等分宽度,alignItems(HorizontalAlign.Center) 居中对齐。与首页速览三格和教学页三指标的区别仅在于数量——这里是四格,因为是四等分所以每格宽度更窄,图标字号也相应缩小到 24vp。

ForEach(this.mys, (item: MyItem, idx: number) => {
  Row() {
    Text('⭐')
      .fontSize(20)
    Column({ space: 3 }) {
      Text(item.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FDF4FF')
      Text(item.tag + ' · ' + item.time)
        .fontSize(10)
        .fontColor('#7C6A8C')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
    Text('移除')
      .fontSize(11)
      .fontColor('#F87171')
      .backgroundColor('rgba(220,38,38,0.15)')
      .borderRadius(12)
      .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      .onClick(() => {
        this.showMyRemove(item)
      })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#160D20')
  .borderRadius(12)
  .border({ width: 1, color: 'rgba(244,114,182,0.12)' })
}, (item: MyItem, idx: number) => 'my' + item.id)

收藏列表每项右侧是"移除"按钮,使用红色系配色(文字红 + 背景半透明红)暗示其为删除操作。点击后调用 showMyRemove(item) 弹出移除确认弹窗。与其他列表项不同,收藏列表项没有外层 onClick(即点击卡片本身不触发任何操作),只有"移除"按钮绑定了点击事件。这是因为收藏项不需要查看详情——名称和标签已经在列表中完整展示,无需弹窗展开。

12.3 设置项与退出登录

Row() {
  Text('🧹 清理缓存')
    .fontSize(13)
    .fontColor('#C4B5FD')
  Text('').layoutWeight(1)
  Text('96MB ›')
    .fontSize(12)
    .fontColor('#F472B6')
    .onClick(() => {
      this.showMyCache()
    })
}
.width('100%')
.padding(12)
.backgroundColor('#160D20')
.borderRadius(12)

Row() {
  Text('🚪 退出登录')
    .fontSize(13)
    .fontColor('#F87171')
  Text('').layoutWeight(1)
  Text('›')
    .fontSize(14)
    .fontColor('#F87171')
    .onClick(() => {
      this.showMyExit()
    })
}
.width('100%')
.padding(12)
.backgroundColor('#160D20')
.borderRadius(12)

设置区域包含"清理缓存"和"退出登录"两个条目,每项都是独立的 Row 行——左侧图标+文字,右侧箭头或数值。清理缓存显示当前缓存大小"96MB",退出登录使用红色文字暗示其危险性。两个条目的右侧箭头 分别绑定了 showMyCache()showMyExit() 回调,弹出对应的确认弹窗。

这种"左图标 + 左文字 + 右值/箭头"的行布局是设置页面的标准模式,在鸿蒙系统的系统设置、各类应用的设置页面中都能看到类似结构。它的优势在于信息密度高、操作目标明确、视觉一致性强。

设置项的设计规范: 左侧图标提供视觉识别,文字说明功能,右侧值显示当前状态,箭头暗示"可点击进入下一级"。退出登录等危险操作通过红色文字与普通操作区分,无需额外添加警告图标。

十三、核心技术点对比与总结

下表对本文涉及的数据结构、组件、状态变量、装饰器等核心技术点进行了系统性对比:

技术要素 类别 作用描述 使用位置 特点与注意事项
interface 类型定义 定义对象结构的形状约束 六个业务接口 ArkTS 中所有对象必须有明确接口类型
FeedItem 数据模型 社区动态实体 首页列表、详情弹窗 包含点赞数和评论数
TeamItem 数据模型 舞队实体 舞队页卡片、详情弹窗 state 字段用数字表示状态
StepItem 数据模型 教学课程实体 教学页列表、详情弹窗 level 字段为字符串枚举
MatchItem 数据模型 赛事活动实体 赛事页列表、详情弹窗 包含奖金和名额信息
SongItem 数据模型 神曲实体 神曲页列表、详情弹窗 bpm 字段为舞蹈核心参数
MyItem 数据模型 收藏项实体 个人中心收藏列表 结构最简,仅四字段
@Entry 装饰器 标记页面入口组件 Index 组件 每个页面文件唯一一个
@Component 装饰器 声明自定义组件 所有 struct 必须实现 build() 方法
@State 状态装饰器 组件内部可变状态 Index 和子组件 变化触发 UI 局部刷新
@Prop 属性装饰器 接收父组件只读数据 六个子组件 不可在子组件内修改
@Builder 构建装饰器 声明可复用 UI 片段 tabItem、各弹窗、teamCard 参数化复用 UI 结构
Column 容器组件 纵向线性布局 几乎所有区域 space 参数设子元素间距
Row 容器组件 横向线性布局 几乎所有区域 space 参数设子元素间距
Stack 容器组件 层叠布局 根容器、弹窗层 后声明覆盖先声明
Scroll 容器组件 滚动容器 各子组件内容区 支持水平和垂直滚动
ForEach 渲染组件 列表渲染 所有列表区域 需要提供唯一 key 生成函数
Text 基础组件 文本显示 全文最常用组件 支持 maxLines 和溢出处理
TextInput 输入组件 文本输入框 发布弹窗、改昵称弹窗 onChange 回调实现双向绑定
layoutWeight 布局属性 权重分配 等分布局、弹性占位 值为 1 时占据剩余空间
linearGradient 样式属性 线性渐变背景 头部霓虹横幅 需指定角度和色标数组
constraintSize 约束属性 尺寸上下限约束 弹窗卡片 maxHeight 弹性高度防止溢出
animation 动画属性 属性变化动画 标签栏缩放动画 需指定 duration 和 curve
justifyContent 对齐属性 主轴对齐方式 多处布局 FlexAlign.Center/End/SpaceBetween
alignItems 对齐属性 交叉轴对齐 多处布局 HorizontalAlign/VerticalAlign
borderRadius 样式属性 圆角半径 卡片、按钮、弹窗 胶囊形 = 高度的一半
opacity 样式属性 不透明度 装饰元素、遮罩 0 到 1 之间
backgroundColor 样式属性 背景颜色 全文所有组件 支持 rgba 半透明格式

十四、全文总结

通过对这款广场舞社区应用的完整代码解析,我们可以清晰地看到鸿蒙 ArkUI 声明式开发范式的强大表达力和工程化优势。整个应用以一个入口组件 Index 为核心,通过 @State 状态变量群驱动六大功能模块的条件渲染,配合 @Builder 构建器实现弹窗系统和卡片组件的参数化复用,最终在 Stack 层叠容器中完成了"主内容 + 弹窗覆盖"的双层架构。这种架构模式在中等复杂度的社区类应用中具有很高的参考价值——它既不像简单的单页应用那样缺乏模块化组织,又不至于引入路由框架的额外复杂度,在可维护性和开发效率之间取得了良好平衡。

从状态管理的角度看,本应用展示了 ArkUI 状态体系的三个层次:@State 管理组件内部可变状态(如 currentTab、show* 标志群、表单输入变量),@Prop 实现父到子的只读数据传递(如子组件接收的 feeds、teams 等数据数组),回调函数实现子到父的事件上报(如 showFeedDetail、showTeamRecruit 等回调)。这三层机制构成了完整的单向数据流闭环:数据在父组件中集中管理,向下通过 @Prop 分发,事件向上通过回调冒泡,父组件响应事件后修改状态,状态变化自动驱动 UI 刷新。这种模式确保了状态变更的可追踪性,对于调试和维护至关重要。

从 UI 组件组合的角度看,本应用充分展现了 ArkUI 组件体系的灵活性和表达力。ColumnRow 作为最基础的线性布局容器,通过嵌套组合构建了从简单卡片到复杂列表的各种界面结构;Stack 的层叠特性被巧妙用于弹窗覆盖层;Scroll 容器为内容超出屏幕的场景提供了滚动支持;ForEach 配合 key 生成函数高效渲染各类列表。在样式层面,layoutWeight 实现了弹性等分布局,linearGradient 营造了霓虹夜色的氛围感,borderRadius 配合不同的宽高比生成了从圆角矩形到胶囊形的多种形状,animation 为标签切换添加了微交互动画。这些组件和属性的有机组合,证明 ArkUI 具备覆盖从基础到高级的各类界面需求的能力。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 霓虹夜色风:bg #12081A / 面板 #1E1028 / 霓虹粉 #F472B6 / 霓虹紫 #A78BFA / 舞台金 #FBBF24 / 夜蓝 #38BDF8

interface FeedItem {
  id: number
  name: string
  avatar: string
  time: string
  content: string
  likes: number
  comments: number
}

interface TeamItem {
  id: number
  name: string
  style: string
  members: number
  score: number
  quota: number
  state: number
}

interface StepItem {
  id: number
  name: string
  teacher: string
  level: string
  plays: number
  quota: number
  state: number
}

interface MatchItem {
  id: number
  name: string
  place: string
  time: string
  prize: number
  quota: number
  state: number
}

interface SongItem {
  id: number
  name: string
  singer: string
  bpm: number
  plays: number
  state: number
}

interface MyItem {
  id: number
  name: string
  tag: string
  time: string
}

const FEEDS: FeedItem[] = [
  { id: 1, name: '广场舞王姐', avatar: '🪩', time: '15分钟前', content: '今晚凤凰广场新学了《最炫民族风》第三套队形,姐妹们回家多练练,下周比赛见!', likes: 356, comments: 61 },
  { id: 2, name: '舞场李叔', avatar: '💃', time: '38分钟前', content: '交谊舞步加了两个新花样,慢三转圈加手部动作,视频里拆解了三遍,记得点赞收藏。', likes: 289, comments: 47 },
  { id: 3, name: '曳步阿凯', avatar: '🕺', time: '1小时前', content: '曳步舞「鬼步」教学第二课:蝴蝶步要点是脚掌先着地,跟着节拍 120BPM 练更顺。', likes: 267, comments: 39 },
  { id: 4, name: '广场舞阿珍', avatar: '🎵', time: '2小时前', content: '社区广场新增了照明灯和音响位,今晚七点半准时开跳,新来的姐妹在第三排跟着跳。', likes: 213, comments: 31 },
  { id: 5, name: '健身操陈姐', avatar: '🏃', time: '4小时前', content: '健身操「本草纲目」跟练版来了,动作要领:核心收紧、摆臂到位,出汗效果翻倍!', likes: 324, comments: 52 },
  { id: 6, name: '民族舞小芳', avatar: '🌺', time: '6小时前', content: '藏族舞「卓玛」排练完成,长袖甩起来的那一刻,感觉自己年轻了二十岁。', likes: 245, comments: 36 },
  { id: 7, name: '广场老张', avatar: '🎪', time: '昨天', content: '区里广场舞大赛开始报名了,冠军奖金五千!我们队伍还差三个人,会跳的都来!', likes: 412, comments: 88 },
  { id: 8, name: '秧歌刘婶', avatar: '🥁', time: '昨天', content: '大秧歌配上红绸子,锣鼓一响,浑身是劲,视频里教大家秧歌步的十字步法。', likes: 238, comments: 34 }
]

const TEAMS: TeamItem[] = [
  { id: 1, name: '凤凰广场舞队', style: '民族舞', members: 42, score: 98, quota: 8, state: 0 },
  { id: 2, name: '星光曳步团', style: '曳步舞', members: 28, score: 96, quota: 5, state: 0 },
  { id: 3, name: '夕阳红舞团', style: '健身操', members: 56, score: 97, quota: 0, state: 1 },
  { id: 4, name: '交谊舞俱乐部', style: '交谊舞', members: 36, score: 95, quota: 10, state: 0 },
  { id: 5, name: '秧歌锣鼓队', style: '民族舞', members: 30, score: 93, quota: 12, state: 0 },
  { id: 6, name: '广场拉丁组', style: '拉丁舞', members: 22, score: 92, quota: 0, state: 1 },
  { id: 7, name: '晨练太极舞', style: '健身操', members: 48, score: 94, quota: 6, state: 0 },
  { id: 8, name: '舞台爵士队', style: '爵士舞', members: 18, score: 91, quota: 4, state: 0 },
  { id: 9, name: '木兰扇队', style: '民族舞', members: 26, score: 90, quota: 7, state: 0 },
  { id: 10, name: '街舞广场组', style: '街舞', members: 16, score: 89, quota: 3, state: 0 }
]

const STEPS: StepItem[] = [
  { id: 1, name: '民族舞·三步一抬', teacher: '小芳老师', level: '入门', plays: 26800, quota: 20, state: 0 },
  { id: 2, name: '曳步舞·蝴蝶步', teacher: '阿凯教练', level: '进阶', plays: 34200, quota: 0, state: 1 },
  { id: 3, name: '健身操·本草纲目', teacher: '陈姐教练', level: '入门', plays: 52800, quota: 30, state: 0 },
  { id: 4, name: '交谊舞·慢三花样', teacher: '李叔老师', level: '进阶', plays: 19600, quota: 14, state: 0 },
  { id: 5, name: '秧歌·十字步法', teacher: '刘婶老师', level: '入门', plays: 15800, quota: 25, state: 0 },
  { id: 6, name: '拉丁·基本步组合', teacher: '拉丁小美', level: '进阶', plays: 22400, quota: 0, state: 1 },
  { id: 7, name: '爵士·甩头wave', teacher: '爵士阿凯', level: '高级', plays: 18600, quota: 8, state: 0 },
  { id: 8, name: '太极舞·云手', teacher: '太极王老', level: '入门', plays: 13200, quota: 18, state: 0 }
]

const MATCHES: MatchItem[] = [
  { id: 1, name: '凤凰广场舞大赛', place: '凤凰广场', time: '09-10 19:00', prize: 5000, quota: 12, state: 0 },
  { id: 2, name: '区中老年健身操赛', place: '文体中心', time: '09-17 09:00', prize: 3000, quota: 0, state: 1 },
  { id: 3, name: '全市曳步舞交流赛', place: '体育公园', time: '09-24 14:00', prize: 8000, quota: 18, state: 0 },
  { id: 4, name: '交谊舞邀请赛', place: '文化馆', time: '10-01 15:00', prize: 4000, quota: 8, state: 0 },
  { id: 5, name: '民族舞汇演大会', place: '大剧院广场', time: '10-08 19:30', prize: 6000, quota: 15, state: 0 },
  { id: 6, name: '秧歌锣鼓争霸赛', place: '老街戏台', time: '10-15 10:00', prize: 2500, quota: 0, state: 1 },
  { id: 7, name: '拉丁舞表演赛', place: '会展中心', time: '10-22 18:00', prize: 5500, quota: 6, state: 0 },
  { id: 8, name: '全民健身舞大赛', place: '滨江广场', time: '10-29 19:00', prize: 10000, quota: 20, state: 0 }
]

const SONGS: SongItem[] = [
  { id: 1, name: '最炫民族风', singer: '凤凰传奇', bpm: 126, plays: 9860000, state: 0 },
  { id: 2, name: '小苹果', singer: '筷子兄弟', bpm: 130, plays: 8450000, state: 0 },
  { id: 3, name: '酒醉的蝴蝶', singer: '崔伟立', bpm: 108, plays: 7620000, state: 0 },
  { id: 4, name: '月亮之上', singer: '凤凰传奇', bpm: 118, plays: 6930000, state: 0 },
  { id: 5, name: '站在草原望北京', singer: '乌兰图雅', bpm: 122, plays: 5870000, state: 0 },
  { id: 6, name: '火红的萨日朗', singer: '乌兰托娅', bpm: 116, plays: 5340000, state: 0 },
  { id: 7, name: '倍儿爽', singer: '大张伟', bpm: 138, plays: 4210000, state: 0 },
  { id: 8, name: '桃花朵朵开', singer: '阿牛', bpm: 124, plays: 3860000, state: 0 }
]

const MYS: MyItem[] = [
  { id: 1, name: '最炫民族风·队形三', tag: '广场舞', time: '收藏于 08-23' },
  { id: 2, name: '曳步舞·蝴蝶步教学', tag: '曳步舞', time: '收藏于 08-21' },
  { id: 3, name: '本草纲目跟练版', tag: '健身操', time: '收藏于 08-18' },
  { id: 4, name: '慢三花样教学', tag: '交谊舞', time: '收藏于 08-15' },
  { id: 5, name: '卓玛·藏族舞', tag: '民族舞', time: '收藏于 08-12' },
  { id: 6, name: '十字秧歌步', tag: '秧歌', time: '收藏于 08-09' },
  { id: 7, name: '甩头 wave 教学', tag: '爵士舞', time: '收藏于 08-06' },
  { id: 8, name: '云手太极舞', tag: '太极舞', time: '收藏于 08-03' }
]

const HEAT: number[] = [5, 7, 6, 8, 9, 7, 10, 11]

const CATS: string[] = ['全部', '民族舞', '曳步舞', '健身操', '交谊舞', '爵士舞']

function heatBar(v: number): string {
  return (16 + v * 4) + 'vp'
}

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

function teamStateText(s: number): string {
  if (s === 0) {
    return '招新中'
  }
  return '已满员'
}

function teamStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#F87171'
}

function stepStateText(s: number): string {
  if (s === 0) {
    return '可约课'
  }
  return '约满'
}

function stepStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#F87171'
}

function matchStateText(s: number): string {
  if (s === 0) {
    return '报名中'
  }
  return '已报满'
}

function matchStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#F87171'
}

function songStateText(s: number): string {
  if (s === 0) {
    return '热榜中'
  }
  return '已下榜'
}

function songStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#F87171'
}

function trendText(t: number): string {
  if (t > 0) {
    return '+' + t + '%'
  }
  return t + '%'
}

function trendColor(t: number): string {
  if (t >= 0) {
    return '#38BDF8'
  }
  return '#F87171'
}

function buildFeed(id: number, name: string, avatar: string, time: string, content: string, likes: number, comments: number): FeedItem {
  return { id: id, name: name, avatar: avatar, time: time, content: content, likes: likes, comments: comments }
}

function buildMy(id: number, name: string, tag: string, time: string): MyItem {
  return { id: id, name: name, tag: tag, time: time }
}

function buildTeam(id: number, name: string, style: string, members: number, score: number, quota: number, state: number): TeamItem {
  return { id: id, name: name, style: style, members: members, score: score, quota: quota, state: state }
}

function buildStep(id: number, name: string, teacher: string, level: string, plays: number, quota: number, state: number): StepItem {
  return { id: id, name: name, teacher: teacher, level: level, plays: plays, quota: quota, state: state }
}

function buildMatch(id: number, name: string, place: string, time: string, prize: number, quota: number, state: number): MatchItem {
  return { id: id, name: name, place: place, time: time, prize: prize, quota: quota, state: state }
}

function buildSong(id: number, name: string, singer: string, bpm: number, plays: number, state: number): SongItem {
  return { id: id, name: name, singer: singer, bpm: bpm, plays: plays, state: state }
}

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State teams: TeamItem[] = TEAMS
  @State steps: StepItem[] = STEPS
  @State matches: MatchItem[] = MATCHES
  @State songs: SongItem[] = SONGS
  @State mys: MyItem[] = MYS
  @State pickedFeed: FeedItem | null = null
  @State pickedTeam: TeamItem | null = null
  @State pickedStep: StepItem | null = null
  @State pickedMatch: MatchItem | null = null
  @State pickedSong: SongItem | null = null
  @State pickedMy: MyItem | null = null
  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showTeamDetail: boolean = false
  @State showTeamRecruit: boolean = false
  @State showStepDetail: boolean = false
  @State showStepSign: boolean = false
  @State showMatchRule: boolean = false
  @State showMatchDetail: boolean = false
  @State showMatchSign: boolean = false
  @State showMatchQuit: boolean = false
  @State showSongDetail: boolean = false
  @State showSongQuit: boolean = false
  @State showMyRemove: boolean = false
  @State showMyNick: boolean = false
  @State showMyCache: boolean = false
  @State showMyExit: boolean = false
  @State newFeedName: string = ''
  @State newFeedText: string = ''
  @State newTeamName: string = ''
  @State newTeamStyle: string = ''
  @State newNick: string = ''
  @State catSel: number = 0

  @Builder
  tabItem(icon: string, label: string, tab: number) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(22)
        .fontColor(this.currentTab === tab ? '#F472B6' : '#7C6A8C')
      Text(label)
        .fontSize(11)
        .fontColor(this.currentTab === tab ? '#F472B6' : '#7C6A8C')
    }
    .width('16.66%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
    .animation({ duration: 200, curve: Curve.EaseOut })
    .onClick(() => {
      this.currentTab = tab
    })
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        // ===== 头部:霓虹夜色风 =====
        Column() {
          Row() {
            Row() {
              Text('🪩')
                .fontSize(26)
              Column({ space: 2 }) {
                Text('DANCE SQUARE')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FDF4FF')
                Text('广场舞社区 · 官方')
                  .fontSize(10)
                  .fontColor('#C4B5FD')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
            }
            .alignItems(VerticalAlign.Center)
            Text('').layoutWeight(1)
            Text('🔔')
              .fontSize(22)
              .opacity(0.9)
            Text('🔍')
              .fontSize(22)
              .margin({ left: 14 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 10 })

          // 霓虹横幅
          Column({ space: 6 }) {
            Row() {
              Text('🪩 舞场开放中')
                .fontSize(11)
                .fontColor('#FDF4FF')
                .backgroundColor('rgba(244,114,182,0.3)')
                .borderRadius(20)
                .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              Text('').layoutWeight(1)
              Text('今晚 326 个舞队在线')
                .fontSize(11)
                .fontColor('#C4B5FD')
            }
            .width('100%')

            Text('广场舞社区')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
              .letterSpacing(2)
            Text('DANCE SQUARE · 舞出快乐 舞出健康')
              .fontSize(11)
              .fontColor('#C4B5FD')
              .letterSpacing(1)

            // 霓虹灯带装饰
            Row() {
              ForEach([10, 14, 8, 16, 12, 9, 15, 11, 13, 10], (s: number, i: number) => {
                Text(i % 2 === 0 ? '▲' : '▼')
                  .fontSize(s)
                  .fontColor(i % 3 === 0 ? '#F472B6' : (i % 3 === 1 ? '#A78BFA' : '#38BDF8'))
                  .opacity(0.8)
              }, (s: number, i: number) => 'neon' + i)
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .padding({ left: 20, right: 20 })

            // 搜索胶囊
            Row() {
              Text('🔍')
                .fontSize(14)
              Text('搜索舞队、教学、赛事、广场舞神曲')
                .fontSize(13)
                .fontColor('#C4B5FD')
                .margin({ left: 6 })
            }
            .width('90%')
            .height(38)
            .backgroundColor('rgba(42,21,56,0.7)')
            .borderRadius(19)
            .padding({ left: 14 })
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 14 })
          .linearGradient({
            angle: 180,
            colors: [['#3B0764', 0.0], ['#2A1538', 0.55], ['#1E1028', 1.0]]
          })
        }
        .width('100%')
        .backgroundColor('#1E1028')

        // ===== 内容区 =====
        Column() {
          if (this.currentTab === 0) {
            HomeContent({
              feeds: this.feeds,
              heat: HEAT,
              catSel: this.catSel,
              showFeedDetail: (item: FeedItem) => {
                this.pickedFeed = item
                this.showFeedDetail = true
              },
              showFeedAdd: () => {
                this.showFeedAdd = true
              }
            })
          }
          if (this.currentTab === 1) {
            TeamContent({
              teams: this.teams,
              catSel: this.catSel,
              showTeamDetail: (item: TeamItem) => {
                this.pickedTeam = item
                this.showTeamDetail = true
              },
              showTeamRecruit: (item: TeamItem) => {
                this.pickedTeam = item
                this.showTeamRecruit = true
              }
            })
          }
          if (this.currentTab === 2) {
            StepContent({
              steps: this.steps,
              showStepDetail: (item: StepItem) => {
                this.pickedStep = item
                this.showStepDetail = true
              },
              showStepSign: (item: StepItem) => {
                this.pickedStep = item
                this.showStepSign = true
              }
            })
          }
          if (this.currentTab === 3) {
            MatchContent({
              matches: this.matches,
              showMatchRule: () => {
                this.showMatchRule = true
              },
              showMatchDetail: (item: MatchItem) => {
                this.pickedMatch = item
                this.showMatchDetail = true
              },
              showMatchSign: (item: MatchItem) => {
                this.pickedMatch = item
                this.showMatchSign = true
              },
              showMatchQuit: (item: MatchItem) => {
                this.pickedMatch = item
                this.showMatchQuit = true
              }
            })
          }
          if (this.currentTab === 4) {
            SongContent({
              songs: this.songs,
              showSongDetail: (item: SongItem) => {
                this.pickedSong = item
                this.showSongDetail = true
              },
              showSongQuit: (item: SongItem) => {
                this.pickedSong = item
                this.showSongQuit = true
              }
            })
          }
          if (this.currentTab === 5) {
            MeContent({
              mys: this.mys,
              showMyRemove: (item: MyItem) => {
                this.pickedMy = item
                this.showMyRemove = true
              },
              showMyNick: () => {
                this.showMyNick = true
              },
              showMyCache: () => {
                this.showMyCache = true
              },
              showMyExit: () => {
                this.showMyExit = true
              }
            })
          }
        }
        .layoutWeight(1)
        .width('100%')
        .backgroundColor('#12081A')

        // ===== 底部 tab 栏 =====
        Row() {
          this.tabItem('🏠', '首页', 0)
          this.tabItem('🕺', '舞队', 1)
          this.tabItem('📺', '教学', 2)
          this.tabItem('🏆', '赛事', 3)
          this.tabItem('🎵', '神曲', 4)
          this.tabItem('👤', '我的', 5)
        }
        .width('100%')
        .height(58)
        .backgroundColor('#1E1028')
        .border({ width: { top: 1 }, color: 'rgba(244,114,182,0.2)' })
      }
      .width('100%')
      .height('100%')

      // ===== 弹窗层 =====
      if (this.showFeedDetail && this.pickedFeed !== null) {
        this.feedDetailOverlay(this.pickedFeed, () => {
          this.showFeedDetail = false
        })
      }
      if (this.showFeedAdd) {
        this.feedAddOverlay(() => {
          this.showFeedAdd = false
        })
      }
      if (this.showTeamDetail && this.pickedTeam !== null) {
        this.teamDetailOverlay(this.pickedTeam, () => {
          this.showTeamDetail = false
        })
      }
      if (this.showTeamRecruit && this.pickedTeam !== null) {
        this.teamRecruitOverlay(this.pickedTeam, () => {
          this.showTeamRecruit = false
        })
      }
      if (this.showStepDetail && this.pickedStep !== null) {
        this.stepDetailOverlay(this.pickedStep, () => {
          this.showStepDetail = false
        })
      }
      if (this.showStepSign && this.pickedStep !== null) {
        this.stepSignOverlay(this.pickedStep, () => {
          this.showStepSign = false
        })
      }
      if (this.showMatchRule) {
        this.matchRuleOverlay(() => {
          this.showMatchRule = false
        })
      }
      if (this.showMatchDetail && this.pickedMatch !== null) {
        this.matchDetailOverlay(this.pickedMatch, () => {
          this.showMatchDetail = false
        })
      }
      if (this.showMatchSign && this.pickedMatch !== null) {
        this.matchSignOverlay(this.pickedMatch, () => {
          this.showMatchSign = false
        })
      }
      if (this.showMatchQuit && this.pickedMatch !== null) {
        this.matchQuitOverlay(this.pickedMatch, () => {
          this.showMatchQuit = false
        })
      }
      if (this.showSongDetail && this.pickedSong !== null) {
        this.songDetailOverlay(this.pickedSong, () => {
          this.showSongDetail = false
        })
      }
      if (this.showSongQuit && this.pickedSong !== null) {
        this.songQuitOverlay(this.pickedSong, () => {
          this.showSongQuit = false
        })
      }
      if (this.showMyRemove && this.pickedMy !== null) {
        this.myRemoveOverlay(this.pickedMy, () => {
          this.showMyRemove = false
        })
      }
      if (this.showMyNick) {
        this.myNickOverlay(() => {
          this.showMyNick = false
        })
      }
      if (this.showMyCache) {
        this.myCacheOverlay(() => {
          this.showMyCache = false
        })
      }
      if (this.showMyExit) {
        this.myExitOverlay(() => {
          this.showMyExit = false
        })
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  feedDetailOverlay(item: FeedItem, onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Row() {
          Text(item.avatar)
            .fontSize(36)
          Column({ space: 3 }) {
            Text(item.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text(item.time)
              .fontSize(11)
              .fontColor('#C4B5FD')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor('#C4B5FD')
            .padding(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')

        Text(item.content)
          .fontSize(14)
          .fontColor('#EDE4F7')
          .lineHeight(22)

        Row() {
          Text('👍 ' + item.likes)
            .fontSize(12)
            .fontColor('#F472B6')
          Text('💬 ' + item.comments)
            .fontSize(12)
            .fontColor('#F472B6')
            .margin({ left: 16 })
          Text('').layoutWeight(1)
          Text('🎵 配神曲')
            .fontSize(12)
            .fontColor('#38BDF8')
        }
        .width('100%')

        Row() {
          Text('TA 的舞队')
            .fontSize(13)
            .fontColor('#A78BFA')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
          Text('去看看')
            .fontSize(13)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('#F472B6')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 6 })
      }
      .width('88%')
      .constraintSize({ maxHeight: '80%' })
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(244,114,182,0.4)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  feedAddOverlay(onClose: () => void) {
    Column() {
      Column({ space: 12 }) {
        Text('发布舞场动态')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('记录今天的舞步与汗水')
          .fontSize(11)
          .fontColor('#C4B5FD')

        TextInput({ placeholder: '你的昵称', text: this.newFeedName })
          .fontSize(13)
          .fontColor('#FDF4FF')
          .placeholderColor('#7C6A8C')
          .height(42)
          .backgroundColor('rgba(42,21,56,0.7)')
          .borderRadius(8)
          .onChange((v: string) => {
            this.newFeedName = v
          })

        TextInput({ placeholder: '写下动态内容…', text: this.newFeedText })
          .fontSize(13)
          .fontColor('#FDF4FF')
          .placeholderColor('#7C6A8C')
          .height(88)
          .backgroundColor('rgba(42,21,56,0.7)')
          .borderRadius(8)
          .onChange((v: string) => {
            this.newFeedText = v
          })

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor('#C4B5FD')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(8)
            .onClick(() => {
              this.newFeedName = ''
              this.newFeedText = ''
              onClose()
            })
          Text('发布')
            .fontSize(14)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('#F472B6')
            .borderRadius(8)
            .onClick(() => {
              if (this.newFeedName.length > 0 && this.newFeedText.length > 0) {
                this.feeds.unshift(buildFeed(99, this.newFeedName, '💃', '刚刚', this.newFeedText, 0, 0))
                this.newFeedName = ''
                this.newFeedText = ''
                onClose()
              }
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(56,189,248,0.45)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  teamDetailOverlay(item: TeamItem, onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Row() {
          Text('🕺')
            .fontSize(38)
          Column({ space: 3 }) {
            Text(item.name)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text('舞种 ' + item.style + ' · 成员 ' + item.members + ' 人')
              .fontSize(11)
              .fontColor('#C4B5FD')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor('#C4B5FD')
            .padding(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')

        Row() {
          Text('⭐ 舞队评分 ' + item.score)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FBBF24')
          Text('招新 ' + item.quota + ' 人')
            .fontSize(12)
            .fontColor('#C4B5FD')
            .margin({ left: 12 })
          Text('').layoutWeight(1)
          Text(teamStateText(item.state))
            .fontSize(11)
            .fontColor(teamStateColor(item.state))
            .backgroundColor('rgba(56,189,248,0.15)')
            .borderRadius(10)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        }
        .width('100%')

        Text('舞队每周二四六在凤凰广场固定排练,老带新教学,零基础也能加入,队服由舞队统一订购。')
          .fontSize(13)
          .fontColor('#EDE4F7')
          .lineHeight(21)

        Row() {
          Text('排练花絮')
            .fontSize(13)
            .fontColor('#A78BFA')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
          Text('申请加入')
            .fontSize(13)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('#F472B6')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 6 })
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(244,114,182,0.4)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  teamRecruitOverlay(item: TeamItem, onClose: () => void) {
    Column() {
      Column({ space: 12 }) {
        Text('招募舞友')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('「' + item.name + '」当前招新 ' + item.quota + ' 人')
          .fontSize(12)
          .fontColor('#C4B5FD')

        Row() {
          Text('💃')
            .fontSize(30)
          Column({ space: 3 }) {
            Text('加入你将获得')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text('每周三次排练 + 队形教学 + 参赛机会')
              .fontSize(11)
              .fontColor('#C4B5FD')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor('rgba(244,114,182,0.1)')
        .borderRadius(10)

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor('#C4B5FD')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(8)
            .onClick(() => {
              onClose()
            })
          Text('确认加入')
            .fontSize(14)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('#F472B6')
            .borderRadius(8)
            .onClick(() => {
              if (item.quota > 0) {
                const idx = this.teams.findIndex((t: TeamItem) => t.id === item.id)
                if (idx >= 0) {
                  const cur = this.teams[idx]
                  const nq = cur.quota - 1
                  this.teams.splice(idx, 1, buildTeam(cur.id, cur.name, cur.style, cur.members + 1, cur.score, nq, nq <= 0 ? 1 : 0))
                }
                onClose()
              }
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(56,189,248,0.45)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  stepDetailOverlay(item: StepItem, onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Row() {
          Text('📺')
            .fontSize(36)
          Column({ space: 3 }) {
            Text(item.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text(item.teacher + ' · ' + item.level + '课程')
              .fontSize(11)
              .fontColor('#C4B5FD')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor('#C4B5FD')
            .padding(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')

        Row() {
          Text('👀 ' + playsText(item.plays) + ' 次学习')
            .fontSize(12)
            .fontColor('#F472B6')
          Text('可约 ' + item.quota + ' 人')
            .fontSize(12)
            .fontColor('#C4B5FD')
            .margin({ left: 12 })
          Text('').layoutWeight(1)
          Text(stepStateText(item.state))
            .fontSize(11)
            .fontColor(stepStateColor(item.state))
            .backgroundColor('rgba(56,189,248,0.15)')
            .borderRadius(10)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        }
        .width('100%')

        Text('课程包含高清分解教学与慢速跟练,配合节拍器讲解,跟着老师一步步跳,轻松学会。')
          .fontSize(13)
          .fontColor('#EDE4F7')
          .lineHeight(21)

        Row() {
          Text('教学大纲')
            .fontSize(13)
            .fontColor('#A78BFA')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
          Text('约课学习')
            .fontSize(13)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('#F472B6')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 6 })
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(244,114,182,0.4)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  stepSignOverlay(item: StepItem, onClose: () => void) {
    Column() {
      Column({ space: 12 }) {
        Text('约课学习')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('「' + item.name + '」剩余 ' + item.quota + ' 个学习名额')
          .fontSize(12)
          .fontColor('#C4B5FD')

        Text('约课后可在「我的-课程」中随时回看,支持加入舞队一起线下练习。')
          .fontSize(11)
          .fontColor('#7C6A8C')

        Row() {
          Text('再想想')
            .fontSize(14)
            .fontColor('#C4B5FD')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(8)
            .onClick(() => {
              onClose()
            })
          Text('确认约课')
            .fontSize(14)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('#F472B6')
            .borderRadius(8)
            .onClick(() => {
              if (item.quota > 0) {
                const idx = this.steps.findIndex((s: StepItem) => s.id === item.id)
                if (idx >= 0) {
                  const cur = this.steps[idx]
                  const nq = cur.quota - 1
                  this.steps.splice(idx, 1, buildStep(cur.id, cur.name, cur.teacher, cur.level, cur.plays, nq, nq <= 0 ? 1 : 0))
                }
                onClose()
              }
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(56,189,248,0.45)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  matchRuleOverlay(onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Text('🏆 广场舞赛事规则')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('1. 参赛队伍须为实名注册舞队,人数 8-40 人')
          .fontSize(13)
          .fontColor('#EDE4F7')
        Text('2. 曲目时长 3-6 分钟,服装统一')
          .fontSize(13)
          .fontColor('#EDE4F7')
        Text('3. 评分按编排、整齐度、表现力综合评定')
          .fontSize(13)
          .fontColor('#EDE4F7')
        Text('4. 冠军队伍将登上「舞队风云榜」')
          .fontSize(13)
          .fontColor('#EDE4F7')
        Text('5. 赛事报名以队伍为单位,报满即止')
          .fontSize(13)
          .fontColor('#EDE4F7')
        Text('知道了')
          .fontSize(14)
          .fontColor('#12081A')
          .textAlign(TextAlign.Center)
          .width('100%')
          .height(42)
          .backgroundColor('#F472B6')
          .borderRadius(8)
          .margin({ top: 8 })
          .onClick(() => {
            onClose()
          })
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(56,189,248,0.4)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  matchDetailOverlay(item: MatchItem, onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Row() {
          Text('🏆')
            .fontSize(36)
          Column({ space: 3 }) {
            Text(item.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text(item.place + ' · ' + item.time)
              .fontSize(11)
              .fontColor('#C4B5FD')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor('#C4B5FD')
            .padding(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')

        Row() {
          Text('💎 冠军奖金 ¥' + item.prize)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FBBF24')
          Text('剩余 ' + item.quota + ' 个名额')
            .fontSize(12)
            .fontColor('#C4B5FD')
            .margin({ left: 12 })
          Text('').layoutWeight(1)
          Text(matchStateText(item.state))
            .fontSize(11)
            .fontColor(matchStateColor(item.state))
            .backgroundColor('rgba(56,189,248,0.15)')
            .borderRadius(10)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        }
        .width('100%')

        Text('赛事当天将进行现场直播,评委为市级舞蹈协会成员,获奖队伍有机会参加省级汇演。')
          .fontSize(13)
          .fontColor('#EDE4F7')
          .lineHeight(21)

        Row() {
          Text('赛事详情')
            .fontSize(13)
            .fontColor('#A78BFA')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
          Text('报名参赛')
            .fontSize(13)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(40)
            .backgroundColor('#F472B6')
            .borderRadius(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 6 })
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(244,114,182,0.4)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  matchSignOverlay(item: MatchItem, onClose: () => void) {
    Column() {
      Column({ space: 12 }) {
        Text('报名参赛')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('「' + item.name + '」剩余 ' + item.quota + ' 个名额')
          .fontSize(12)
          .fontColor('#C4B5FD')

        Row() {
          Text('🎖️')
            .fontSize(30)
          Column({ space: 3 }) {
            Text('报名即获得')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
            Text('参赛证书 + 赛事直播出镜 + 冠军争夺资格')
              .fontSize(11)
              .fontColor('#C4B5FD')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor('rgba(251,191,36,0.1)')
        .borderRadius(10)

        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor('#C4B5FD')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(8)
            .onClick(() => {
              onClose()
            })
          Text('确认报名')
            .fontSize(14)
            .fontColor('#12081A')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('#F472B6')
            .borderRadius(8)
            .onClick(() => {
              if (item.quota > 0) {
                const idx = this.matches.findIndex((m: MatchItem) => m.id === item.id)
                if (idx >= 0) {
                  const cur = this.matches[idx]
                  const nq = cur.quota - 1
                  this.matches.splice(idx, 1, buildMatch(cur.id, cur.name, cur.place, cur.time, cur.prize, nq, nq <= 0 ? 1 : 0))
                }
                onClose()
              }
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(56,189,248,0.45)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  matchQuitOverlay(item: MatchItem, onClose: () => void) {
    Column() {
      Column({ space: 12 }) {
        Text('⚠️ 退赛确认')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FDF4FF')
        Text('确认退出「' + item.name + '」?')
          .fontSize(13)
          .fontColor('#EDE4F7')
        Text('退赛后该赛事名额将立即释放,参赛证书与直播资格作废,此操作不可撤销。')
          .fontSize(11)
          .fontColor('#F87171')

        Row() {
          Text('再想想')
            .fontSize(14)
            .fontColor('#C4B5FD')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('rgba(167,139,250,0.15)')
            .borderRadius(8)
            .onClick(() => {
              onClose()
            })
          Text('确认退赛')
            .fontSize(14)
            .fontColor('#FDF4FF')
            .textAlign(TextAlign.Center)
            .width('46%')
            .height(42)
            .backgroundColor('#DC2626')
            .borderRadius(8)
            .onClick(() => {
              const idx = this.matches.findIndex((m: MatchItem) => m.id === item.id)
              if (idx >= 0) {
                this.matches.splice(idx, 1)
              }
              onClose()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('88%')
      .padding(18)
      .backgroundColor('#1E1028')
      .borderRadius(14)
      .border({ width: 1, color: 'rgba(220,38,38,0.5)' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.65)')
  }

  @Builder
  songDetailOverlay(item: SongItem, onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Row() {
          Text('🎵')
            .fontSize(36)
          Column({ space: 3 }) {
            Text(item.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FDF4FF')
        .padding(12)
        .backgroundColor('#160D20')
        .borderRadius(12)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

// ===== 2181.ets END =====


在这里插入图片描述

从工程实践的角度看,本应用在代码组织上有几个值得借鉴的做法。一是将状态文本映射、颜色映射、数字格式化等逻辑提取为独立工具函数,避免了在 UI 代码中散落条件判断;二是使用 build* 工厂函数集中管理数据对象的创建,保证了对象创建的一致性;三是弹窗系统采用统一的架构模式(外层全屏遮罩 + 内层居中卡片),所有弹窗的样式和交互方式高度一致,用户学习成本低;四是危险操作(退赛、移除、退出)统一使用红色警示视觉规范,正向操作统一使用霓虹粉主色调,形成了清晰的视觉语言体系。这些做法虽然看起来微小,但在大型项目中积累起来能显著提升代码的可读性和可维护性。

从交互设计的角度看,本应用的用户体验考虑较为周全。列表项的 maxLines + textOverflow 组合保证了布局稳定性;标签栏的缩放动画提供了操作反馈;状态码到文本/颜色的映射让用户快速识别可用/不可用状态;前三名金色高亮引导用户关注热门内容;表单的非空校验防止了空数据提交;确认弹窗的红色警示文字明确了操作后果。这些交互细节共同构成了一个完整、流畅、有温度的用户体验。在真实工程中,还可以进一步添加加载状态、骨架屏、下拉刷新、上拉加载等机制,使体验更加完善。

Logo

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

更多推荐