鸿蒙 ArkTS 实战解析:霓虹夜色风广场舞社区应用的声明式架构与组件设计
一、鸿蒙开发背景与 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 },
// ... 更多歌曲数据
]

STEPS 和 MATCHES 的数据结构与上述类似,分别预置了 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'
}
teamStateText 和 teamStateColor 是一对配套函数,分别将数字状态码映射为中文文本和十六进制颜色值。状态 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 }
}

这些工厂函数看起来只是简单地组装对象字面量并返回,但它们的意义在于:当用户执行"发布动态"“加入舞队”"报名赛事"等操作时,需要创建新的数据对象插入到列表数组中。使用工厂函数可以让对象创建逻辑集中管理,参数顺序和类型在函数签名中明确定义,避免了在业务代码中手写字面量时可能出现的字段遗漏或类型错误。
三、入口组件 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
最后几个状态变量用于表单输入的临时存储。newFeedName 和 newFeedText 分别绑定发布动态弹窗中的昵称输入框和内容输入框,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 倍(原始大小)。animation 的 duration: 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%')
}

整个页面的根容器是 Stack。Stack 是层叠布局容器,它的子元素会按照声明顺序依次堆叠,后声明的元素覆盖在先声明的元素之上。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: 1 或 margin-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 会被挂载(加入组件树)。
子组件调用时传递了两类参数:一是数据属性(如 feeds、teams、heat 等),将父组件的状态数据向下传递给子组件渲染;二是回调函数(如 showFeedDetail、showFeedAdd 等),这是子组件向父组件"上报事件"的通道。当子组件内部发生点击交互时,调用传入的回调函数,父组件在回调中修改自身状态(设置 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 变量,状态变化后 TextInput 的 text 属性重新读取该变量,形成完整的双向绑定链路。需要注意的是,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 操作会触发框架的变更检测,自动刷新舞队列表的显示。
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,用户在子组件内部点击分类标签时更新。这种设计将"分类筛选"的状态限定在子组件内部管理,不需要上升到父组件层面,减少了不必要的状态传递。
两个回调函数 showFeedDetail 和 showFeedAdd 以普通属性形式声明(不加 @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 展示,长按退赛功能在实际工程中需要额外绑定 gesture 或 onLongPress 事件才能生效。
十一、神曲子组件 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 组件体系的灵活性和表达力。Column 和 Row 作为最基础的线性布局容器,通过嵌套组合构建了从简单卡片到复杂列表的各种界面结构;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 组合保证了布局稳定性;标签栏的缩放动画提供了操作反馈;状态码到文本/颜色的映射让用户快速识别可用/不可用状态;前三名金色高亮引导用户关注热门内容;表单的非空校验防止了空数据提交;确认弹窗的红色警示文字明确了操作后果。这些交互细节共同构成了一个完整、流畅、有温度的用户体验。在真实工程中,还可以进一步添加加载状态、骨架屏、下拉刷新、上拉加载等机制,使体验更加完善。
更多推荐




所有评论(0)