电竞战队管理平台从 interface ColorPalette 这样精确的接口定义,到 @Observed export class MatchResult 装饰器的可观察数据模型
一、技术背景
声明式 UI 范式的崛起与演进
在现代前端与移动端开发领域,声明式 UI(Declarative UI)已经从一种新兴范式演变为行业主流。与传统的命令式 UI 不同,声明式 UI 的核心思想是:开发者只需描述界面"看起来应该是什么样子",而将"如何从当前状态过渡到目标状态"的复杂逻辑交给框架底层的渲染引擎来处理。这种范式转变极大地降低了 UI 编程的心智负担——开发者不再需要手动操作 DOM 节点、管理视图树的增删改查,而是通过状态驱动视图的方式,让数据变化自动映射为界面更新。ArkUI 作为鸿蒙生态的声明式 UI 框架,正是这一趋势在国产操作系统领域的集中体现,它借鉴了业界主流声明式框架的设计哲学,同时深度适配了鸿蒙系统的分布式、多设备协同等原生能力。
ArkTS 语言与类型系统
ArkTS 是鸿蒙生态中专为 ArkUI 框架量身定制的编程语言,它在 TypeScript 的基础上进行了扩展与约束。ArkTS 保留了 TypeScript 强大的静态类型检查能力,同时引入了 @Component、@Builder、@State、@Observed 等装饰器语法,将 UI 组件的声明、状态管理、构建函数等概念以语言层面的一等公民身份融入类型系统。这种设计使得 UI 代码不再依赖于运行时的字符串模板解析或 JSX 转译,而是直接在编译期完成类型校验和优化。在本文分析的电竞战队管理平台中,我们可以清晰地看到 ArkTS 类型系统的运用:从 interface ColorPalette 这样精确的接口定义,到 @Observed export class MatchResult 这样带装饰器的可观察数据模型,再到 @Builder 修饰的构建函数签名,每一个语言构造都服务于"类型安全 + 声明式 UI"的双重目标。
鸿蒙生态与 @Observed 响应式机制
鸿蒙(HarmonyOS)生态强调"一次开发、多端部署"的分布式能力,而 ArkUI 框架中的 @Observed 装饰器则是响应式数据流的核心基础设施之一。@Observed 修饰的类实例在其属性被赋值时,会自动触发依赖追踪与视图刷新——这意味着当我们在 @State 中持有一个 @Observed 类的数组时,对数组元素属性的原地修改(如修改某个选手的 KDA 值)能够被框架精确捕获,并仅刷新依赖该数据的 UI 子树,而非整页重绘。这种细粒度的响应式更新机制,对于电竞这种数据密集、高频变化的场景尤为关键:比赛比分实时跳动、选手数据持续更新、转会状态动态流转,每一个数据变化都需要精准驱动对应的 UI 卡片刷新,而 @Observed + @State 的组合恰好提供了这种"数据变化即视图变化"的自动绑定能力。
电竞行业的 UI 特征与技术挑战
电竞行业作为一个高速增长的新兴领域,其应用产品在 UI 设计上有着鲜明的行业特征。首先是"赛博朋克"视觉风格——深色底色搭配霓虹紫青的高饱和度配色,营造出科技感与竞技张力并存的视觉氛围;其次是信息密度极高——单屏需要同时承载战队排名、比赛比分、选手 KDA、赛程时间轴、转会动态等多维数据,这对布局的紧凑性与可读性提出了严苛要求;再者是实时性诉求——直播标记、比分跳动、转会状态流转等需要以视觉化方式即时反馈给用户。本文分析的平台正是围绕这些行业特征构建的:它采用赛博暗底(#0F0F1E)+ 霓虹紫青的统一主题,通过 8 个功能 Tab 覆盖战报、战队、选手、赛程、训练、数据、转会、个人中心等全链路功能,每个 Tab 采用完全不同的布局策略,在保证视觉一致性的同时最大化信息表达效率。
二、项目概述

本项目是一个面向电竞行业的综合战队管理平台,采用 ArkTS 声明式 UI 框架开发,以"赛博格"为产品代号,集成了从赛事资讯到战队运营的全流程功能模块。以下是各功能模块的详细说明:
战报模块(Tab 0):以比赛结果大卡的形式展示近期赛事结果,每张卡片包含赛事名称、对阵双方、比分、获胜方、MVP 选手及比赛时长等核心信息,支持直播中状态标记,MVP 选手以横滑列表形式突出展示,是用户进入应用后首先接触的赛事信息入口。
战队模块(Tab 1):以两列网格卡片展示全球各大赛区(LPL、LCK、LEC、LCS)的战队信息,每张卡片包含战队图标、名称、所属赛区、胜负记录、胜率百分比及排名,并以进度条形式可视化胜率数据,顶部提供赛区筛选切换栏。
选手模块(Tab 2):以列表形式展示职业选手的详细数据,包括游戏 ID、真实姓名、头像、所属战队、位置角色、KDA、击杀/死亡/助攻、补刀数及薪资,支持按位置筛选,提供签约新选手的弹窗入口和单条编辑能力。
赛程模块(Tab 3):以时间轴形式排列即将进行的赛程,每条赛程包含比赛时间、对阵双方、赛事类型、BO3 标注及直播标记,进行中的比赛以红色 LIVE 标签和"观看"按钮突出展示,底部附赛事列表概览。
训练模块(Tab 4):展示战队训练计划与完成度数据,包含今日训练时长、完成度百分比、训练项目数量三个概览指标,训练项目列表标记完成状态,底部以柱状图展示本周每日训练时长趋势。
数据模块(Tab 5):数据中心看板,包含战队胜率、场均 KDA、场均 CS 三个核心指标卡,KDA 对比柱状图展示 Top 8 选手数据,胜率对比柱状图展示 Top 8 战队数据,英雄使用率 TOP5 排行榜。
转会模块(Tab 6):以新闻流形式展示转会动态,每条包含选手、来源战队、目标战队、转会费、状态(已完成/谈判中/传闻)、时间,支持按状态筛选,提供关注、分享、删除操作,底部附操作按钮区。
个人中心模块(Tab 7):展示用户个人档案,包含头像、昵称、段位、关注战队数、累计观赛时长,关注战队数/选手数/竞猜胜率三个统计卡,关注的战队列表,以及比赛提醒、竞猜记录、设置等入口。
三、架构设计与整体技术选型

本平台的架构设计遵循"数据模型层 — 常量配置层 — 工具函数层 — 组件构建层 — 根组件装配层"的分层架构。数据模型层使用 @Observed 装饰器定义了四个核心业务实体类(MatchResult、TeamItem、PlayerItem、TransferNews),确保数据变化可被框架响应式追踪。常量配置层以 ColorPalette 接口和 COLORS 常量对象为载体,集中管理全应用的颜色体系,实现"一处定义、处处引用"的设计目标。工具函数层封装了角色颜色映射、柱状图高度计算、转会状态着色等纯函数逻辑,保持视图构建函数的简洁性。组件构建层以 @Builder 装饰的方法为单位,将每个功能 Tab 拆分为独立的构建函数,实现关注点分离。根组件装配层通过 build() 方法以 Stack 为根容器,将头部、滚动内容区、底部 Tab 栏及各类弹窗统一组装。
在状态管理方面,项目采用 @State 管理组件内部状态,包括当前选中的 Tab 索引、各类弹窗开关布尔值、表单输入值、以及四个业务数据数组。@Observed 修饰的数据模型与 @State 持有的数组配合,实现了"数据变更自动驱动 UI 刷新"的响应式数据流。生命周期方面,通过 aboutToAppear 和 aboutToDisappear 钩子管理定时器资源,实现霓虹脉冲特效的周期性刷新与资源释放。
四、逐段代码深度分析

4.1 颜色调色板接口定义与集中化色彩管理
interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
purple: string;
purpleD: string;
cyan: string;
cyanD: string;
pink: string;
gold: string;
orange: string;
red: string;
green: string;
blue: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
这段代码定义了 ColorPalette 接口,它是整个应用颜色体系的类型契约。接口中声明了 21 个字符串类型的颜色字段,涵盖了从背景色(bg、dark、darkL)到文字色(title、sub、text3),从主题强调色(purple、cyan、pink)到语义色(gold、red、green)的完整色阶体系。
将颜色配置抽象为接口而非直接使用字面量,体现了工程化项目中"类型先行"的设计理念。通过接口约束,任何使用颜色的地方都能获得编译期的类型检查,避免拼写错误导致的颜色丢失问题。同时,这种集中式定义使得后续的主题切换、暗色模式适配等工作只需修改一个常量对象即可全局生效。
值得注意的是,色阶体系中为每个主色都配备了深色变体(purpleD、cyanD),这为柱状图、进度条等需要明暗对比的组件提供了现成的色彩资源,避免了在视图层临时计算颜色透明度或亮度值。
4.2 COLORS 常量对象与赛博朋克色彩主题

const COLORS: ColorPalette = {
bg: '#0F0F1E',
card: '#1A1A2E',
dark: '#15152A',
darkL: '#252540',
title: '#E0E0F0',
sub: '#8888AA',
text3: '#555575',
purple: '#9D4EDD',
purpleD: '#6B2DA0',
cyan: '#00E5FF',
cyanD: '#00A5BF',
pink: '#FF2D95',
gold: '#FFD700',
orange: '#FF6B35',
red: '#FF3B5C',
green: '#00E676',
blue: '#3D7DC8',
line: '#2A2A45',
tabOn: '#00E5FF',
mask: 'rgba(15,15,30,0.75)',
chip: 'rgba(0,229,255,0.10)'
};
COLORS 常量对象是 ColorPalette 接口的具体实现,它定义了应用的核心视觉基调。背景色 #0F0F1E 是一种极深的蓝黑色,营造出赛博空间般的深邃感;卡片色 #1A1A2E 在背景色基础上略微提亮,形成层次分明的"浮起"效果。
主题强调色采用了经典的霓虹三色组合:紫色 #9D4EDD、青色 #00E5FF 和粉色 #FF2D95。这三种高饱和度颜色在深色背景上具有极强的视觉冲击力,完美呼应了电竞行业的赛博朋克美学。青色同时被选为 Tab 激活态颜色(tabOn),保证了导航交互的视觉一致性。
mask 和 chip 两个字段使用了 rgba 格式定义半透明颜色。mask 用于弹窗遮罩层,75% 不透明度的深色覆盖确保背景内容在弹窗打开时仍然若隐若现,营造出空间纵深感。chip 则以 10% 透明度的青色定义标签背景,在不干扰文字阅读的前提下提供微妙的色彩点缀。
4.3 Tab 元数据接口与导航项配置

interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '📰', label: '战报' },
{ icon: '🛡️', label: '战队' },
{ icon: '🎮', label: '选手' },
{ icon: '📅', label: '赛程' },
{ icon: '🏋️', label: '训练' },
{ icon: '📊', label: '数据' },
{ icon: '🔄', label: '转会' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了底部导航栏每个 Tab 项的数据结构,包含 icon(图标 emoji)和 label(文字标签)两个字段。TAB_LIST 常量数组按顺序配置了 8 个 Tab 的元数据,从战报到个人中心,覆盖了电竞战队管理的完整功能链路。
将 Tab 配置提取为独立的数据数组而非散落在各处硬编码,是一种典型的"数据驱动 UI"实践。在 tabBar() 构建函数中,通过 ForEach 遍历 TAB_LIST 即可动态生成 Tab 项,无需为每个 Tab 重复编写结构相同的 UI 代码。这种设计使得后续新增、调整或重排 Tab 时,只需修改这一个数组即可,极大降低了维护成本。
8 个 Tab 被分为两排(4+4)布局,这一设计决策既考虑了单屏可容纳的 Tab 数量上限,也兼顾了电竞应用功能模块众多的实际需求。每个 Tab 的图标选用 emoji 而非图标字体或图片资源,在保证视觉识别度的同时避免了外部资源依赖,使应用保持高度自包含。
4.4 @Observed 装饰器与比赛结果数据模型

@Observed export class MatchResult {
id: number;
tournament: string;
teamA: string;
teamB: string;
scoreA: number;
scoreB: number;
winner: string;
mvp: string;
mvpAvatar: string;
date: string;
duration: string;
isLive: boolean;
constructor(id: number, tournament: string, teamA: string, teamB: string,
scoreA: number, scoreB: number, winner: string, mvp: string,
mvpAvatar: string, date: string, duration: string, isLive: boolean) {
this.id = id; this.tournament = tournament; this.teamA = teamA; this.teamB = teamB;
this.scoreA = scoreA; this.scoreB = scoreB; this.winner = winner;
this.mvp = mvp; this.mvpAvatar = mvpAvatar; this.date = date;
this.duration = duration; this.isLive = isLive;
}
}
MatchResult 是战报模块的核心数据模型,使用 @Observed 装饰器修饰。@Observed 的作用是使该类的实例成为"可观察对象"——当其实例被 @State 持有的数组包含时,对实例属性的修改会触发 ArkUI 框架的依赖追踪机制,自动刷新引用了该属性的 UI 组件。
这个模型包含 12 个字段,覆盖了一场电竞比赛的完整信息维度:标识(id)、赛事归属(tournament)、对阵双方(teamA、teamB)、比分(scoreA、scoreB)、获胜方(winner)、MVP 选手信息(mvp、mvpAvatar)、时间信息(date、duration)以及直播状态(isLive)。isLive 布尔字段尤为关键,它驱动了战报卡片中"直播中"红色标签与普通日期文字的条件渲染切换。
export 关键字使得该模型可在其他文件中被引用,体现了模块化的代码组织思路。构造函数采用 ArkTS 推荐的"全参构造"模式,将所有属性在构造时一次性赋值,确保对象创建后即处于完整可用状态,避免了部分初始化带来的潜在空值风险。
4.5 战队数据模型与胜率进度可视化
@Observed export class TeamItem {
id: number;
name: string;
abbr: string;
icon: string;
region: string;
wins: number;
losses: number;
winRate: number;
rank: number;
color: string;
constructor(id: number, name: string, abbr: string, icon: string,
region: string, wins: number, losses: number, winRate: number,
rank: number, color: string) {
this.id = id; this.name = name; this.abbr = abbr; this.icon = icon;
this.region = region; this.wins = wins; this.losses = losses;
this.winRate = winRate; this.rank = rank; this.color = color;
}
}
TeamItem 模型同样使用 @Observed 装饰,封装了战队的完整信息。其中 winRate(胜率百分比)和 color(战队主题色)两个字段值得特别关注——它们不仅作为数据展示,还直接驱动 UI 的视觉表达:winRate 被用于计算胜率进度条的宽度(width(TEAM_INIT[base + offset].winRate + '%')),而 color 则被用作排名数字和进度条的着色,使每张战队卡片都能以专属色彩标识自身。
rank 字段存储战队在赛区中的排名,这一数值在卡片中以 #1、#2 的形式展示,并使用战队专属颜色高亮,形成视觉焦点。region 字段(赛区归属)则服务于顶部赛区筛选栏的逻辑——虽然当前实现中筛选栏的交互尚未完全联动,但数据结构已经为后续的赛区过滤功能预留了扩展空间。
abbr(战队缩写如 TES、JDG)和 name(战队全名如"滔搏电子竞技")的双字段设计,使得在空间受限的卡片中可以展示缩写,在空间充裕时展示全名,提升了信息表达的灵活性。
4.6 选手数据模型与 KDA 竞技指标体系
@Observed export class PlayerItem {
id: number;
name: string;
ign: string;
avatar: string;
team: string;
role: string;
kda: number;
kills: number;
deaths: number;
assists: number;
cs: number;
salary: string;
constructor(id: number, name: string, ign: string, avatar: string,
team: string, role: string, kda: number, kills: number, deaths: number,
assists: number, cs: number, salary: string) {
this.id = id; this.name = name; this.ign = ign; this.avatar = avatar;
this.team = team; this.role = role; this.kda = kda; this.kills = kills;
this.deaths = deaths; this.assists = assists; this.cs = cs; this.salary = salary;
}
}
PlayerItem 模型是选手模块的数据基础,包含 12 个字段。其中 KDA 相关的四个字段(kda、kills、deaths、assists)构成了电竞领域最核心的选手竞技指标体系。KDA(Kill/Death/Assist Ratio)是衡量选手综合表现的标准数据,计算公式为 (击杀+助攻)/死亡,数值越高代表表现越出色。
ign(In-Game Name,游戏内 ID)与 name(真实姓名)的分离,体现了电竞行业"双重身份"的特点——选手在赛场上以游戏 ID 为人所知,在管理层面上又需要关联真实身份。role 字段(上单/中单/ADC/打野/辅助)不仅用于分类展示,还通过 roleColor() 函数映射为不同的主题色,使每个位置的选手在视觉上具有辨识度。
salary 字段以字符串形式存储薪资(如"¥2000万"),这是因为薪资涉及多种币种和单位表达(人民币、欧元、"自由身"等),使用字符串比数值更灵活。cs(Creep Score,补刀数)作为衡量选手发育能力的重要指标,在数据中心的统计中被进一步聚合展示。
4.7 转会新闻数据模型与状态流转
@Observed export class TransferNews {
id: number;
player: string;
fromTeam: string;
toTeam: string;
fee: string;
status: string;
time: string;
icon: string;
constructor(id: number, player: string, fromTeam: string, toTeam: string,
fee: string, status: string, time: string, icon: string) {
this.id = id; this.player = player; this.fromTeam = fromTeam;
this.toTeam = toTeam; this.fee = fee; this.status = status;
this.time = time; this.icon = icon;
}
}
TransferNews 模型封装了转会市场的新闻条目。status 字段是此模型的核心——它可以是"已完成"、“谈判中”、“传闻"或"待定”,不同的状态值通过 transferColor() 函数映射为不同的视觉颜色(绿色、金色、紫色、灰色),使用户一眼即可分辨转会动态的进展阶段。
fee(转会费)采用字符串存储而非数值,这与选手薪资字段的设计逻辑一致——转会费可能涉及"¥3000万"、“€200万”、“未公开”、“租借”、"自由身"等多种非纯数值表达,字符串类型提供了最大的表达灵活性。
fromTeam 到 toTeam 的字段对,在 UI 层以"源战队 → 目标战队"的箭头形式展示,直观表达了转会的方向性。icon 字段存储状态图标 emoji(🔄 已完成、⏳ 谈判中、❓ 传闻),与 status 文字形成图标-文字双重标识,增强了信息的视觉可读性。
4.8 比赛结果初始数据集
const MATCH_INIT: MatchResult[] = [
new MatchResult(1, 'LPL夏季赛', 'TES', 'JDG', 2, 1, 'TES', 'Knight', '⭐', '今天', '42分钟', false),
new MatchResult(2, 'LPL夏季赛', 'RNG', 'EDG', 2, 0, 'RNG', 'GALA', '🎯', '今天', '38分钟', false),
new MatchResult(3, 'LCK夏季赛', 'T1', 'GEN', 2, 1, 'T1', 'Faker', '👑', '昨天', '45分钟', false),
new MatchResult(4, 'LPL夏季赛', 'BLG', 'WBG', 2, 1, 'BLG', 'Bin', '⚔️', '昨天', '41分钟', false),
new MatchResult(5, 'LEC夏季赛', 'G2', 'FNC', 2, 0, 'G2', 'Caps', '🔮', '2天前', '35分钟', false),
new MatchResult(6, 'LCS夏季赛', 'TL', 'C9', 2, 1, 'TL', 'CoreJJ', '🛡️', '2天前', '44分钟', false),
new MatchResult(7, 'LPL夏季赛', 'LNG', 'AL', 0, 2, 'AL', 'Ale', '🔥', '3天前', '33分钟', false),
new MatchResult(8, 'LCK夏季赛', 'DK', 'KT', 2, 0, 'DK', 'ShowMaker', '💎', '3天前', '37分钟', false)
];
MATCH_INIT 是战报模块的初始数据集,包含 8 条比赛结果记录。数据覆盖了全球四大顶级赛区(LPL 中国、LCK 韩国、LEC 欧洲、LCS 北美)的夏季赛赛事,确保了内容的国际化和全面性。每条记录的比分采用 BO3(三局两胜制)格式,比分均为 2-0 或 2-1,符合实际赛事中 BO3 的可能结果。
MVP 选手字段收录了各赛区的明星选手,包括中国的 Knight、GALA、Bin,韩国的 Faker、ShowMaker,欧洲的 Caps 等——这些名字在电竞爱好者中具有极高的认知度,使得示例数据具有真实感和代入感。isLive 字段在初始数据中全部为 false,代表这些比赛已经结束;但在实际应用中,该字段会在比赛进行时被设置为 true,触发"直播中"标签的实时显示。
将初始数据定义为独立的 const 常量数组,并在组件 @State 初始化时引用,是一种清晰的数据初始化模式——它将"静态种子数据"与"运行时可变状态"分离,使得数据来源一目了然,也方便后续替换为网络请求获取的真实数据。
4.9 战队初始数据集与全球赛区覆盖
const TEAM_INIT: TeamItem[] = [
new TeamItem(1, '滔搏电子竞技', 'TES', '🛡️', '中国', 18, 4, 82, 1, COLORS.cyan),
new TeamItem(2, '京东游戏', 'JDG', '🐕', '中国', 16, 6, 73, 2, COLORS.red),
new TeamItem(3, '皇族永不言弃', 'RNG', '👑', '中国', 15, 7, 68, 3, COLORS.gold),
new TeamItem(4, '哔哩哔哩', 'BLG', '📺', '中国', 14, 8, 64, 4, COLORS.pink),
new TeamItem(5, '微博电竞', 'WBG', '📱', '中国', 12, 10, 55, 5, COLORS.orange),
new TeamItem(6, 'EDward Gaming', 'EDG', '⚔️', '中国', 11, 11, 50, 6, COLORS.purple),
new TeamItem(7, 'T1 Entertainment', 'T1', '🏆', '韩国', 20, 2, 91, 7, COLORS.red),
new TeamItem(8, 'Gen.G', 'GEN', '💎', '韩国', 17, 5, 77, 8, COLORS.gold),
new TeamItem(9, 'Dplus KIA', 'DK', '⭐', '韩国', 14, 8, 64, 9, COLORS.blue),
new TeamItem(10, 'G2 Esports', 'G2', '🔮', '欧洲', 16, 6, 73, 10, COLORS.purple),
new TeamItem(11, 'Fnatic', 'FNC', '🦊', '欧洲', 12, 10, 55, 11, COLORS.orange),
new TeamItem(12, 'Team Liquid', 'TL', '💧', '北美', 13, 9, 59, 12, COLORS.cyan)
];
TEAM_INIT 包含 12 支战队的完整信息,覆盖中国(6 支)、韩国(3 支)、欧洲(2 支)、北美(1 支)四大赛区。排名从 1 到 12 依次递增,胜率从 91%(T1)到 50%(EDG)形成梯度分布,既有顶尖强队也有中游队伍,数据分布合理且具有代表性。
每个战队都被分配了一个专属颜色(color 字段),这些颜色取自 COLORS 调色板中的不同色值。这一设计使得战队在 UI 中具有一致的视觉标识——无论出现在战队网格卡片、关注的战队列表还是胜率柱状图中,同一支战队始终以相同的颜色呈现,增强了品牌识别度。例如 TES 始终是青色、JDG 始终是红色、RNG 始终是金色。
战队图标同样使用 emoji 表达(🛡️、🐕、👑、🏆等),虽然在实际产品中可能会替换为战队官方 Logo 图片资源,但 emoji 方案在开发原型阶段提供了足够的表现力且零资源依赖,体现了"快速验证、渐进增强"的工程思路。
4.10 选手初始数据集与多维度竞技指标
const PLAYER_INIT: PlayerItem[] = [
new PlayerItem(1, '卓定', 'Knight', '⭐', 'TES', '中单', 8.2, 5.8, 2.1, 8.5, 285, '¥2000万'),
new PlayerItem(2, '陈伟', '369', '🛡️', 'TES', '上单', 5.6, 4.2, 2.8, 6.2, 256, '¥1500万'),
new PlayerItem(3, '赵嘉豪', 'JackeyLove', '🏹', 'TES', 'ADC', 7.8, 6.5, 2.3, 7.1, 312, '¥2200万'),
new PlayerItem(4, '李相赫', 'Faker', '👑', 'T1', '中单', 9.1, 6.2, 1.8, 9.0, 298, '¥5000万'),
new PlayerItem(5, '崔宇植', 'Zeus', '⚡', 'T1', '上单', 6.8, 4.8, 2.5, 5.5, 240, '¥1800万'),
new PlayerItem(6, '王柳羿', 'GALA', '🎯', 'RNG', 'ADC', 8.5, 7.0, 2.0, 7.8, 305, '¥2000万'),
new PlayerItem(7, '李元浩', 'Xiaohu', '🔥', 'RNG', '中单', 7.2, 5.5, 2.5, 6.8, 278, '¥1800万'),
new PlayerItem(8, '胡嘉乐', 'Bin', '⚔️', 'BLG', '上单', 7.5, 5.8, 2.2, 6.5, 262, '¥1700万'),
new PlayerItem(9, '曾洪韵', 'knight', '💎', 'JDG', '中单', 8.0, 6.0, 2.0, 7.5, 290, '¥2100万'),
new PlayerItem(10, '白家浩', '369', '🦁', 'JDG', '上单', 6.2, 4.5, 2.8, 5.8, 248, '¥1600万'),
new PlayerItem(11, '许秀', 'ShowMaker', '💎', 'DK', '中单', 7.9, 5.9, 2.2, 7.2, 282, '¥1900万'),
new PlayerItem(12, '拉尔斯·克劳斯', 'Caps', '🔮', 'G2', '中单', 8.3, 6.8, 2.0, 8.0, 295, '¥1800万')
];
PLAYER_INIT 包含 12 位选手的详细数据,覆盖上单、中单、ADC 三个核心位置。KDA 数值从 Faker 的 9.1(最高)到 369 的 5.6(最低)形成合理梯度。击杀、死亡、助攻三个原始数据与 KDA 综合值并存,使得 UI 既可展示聚合指标也可展开原始明细。
特别值得注意的是 Faker 的数据——作为电竞史上最知名的职业选手,其 KDA 9.1、薪资"¥5000万"均为全场最高,这一数据设置既符合现实认知,又为数据中心的 KDA 柱状图提供了一个视觉突出的峰值(图中第 4 个柱子以粉色高亮标记 Faker)。
选手的 ign 与 name 对照设计也值得一提:中国选手使用真实中文名(卓定、陈伟、赵嘉豪),韩国选手使用音译名(李相赫、崔宇植),欧洲选手使用本名(拉尔斯·克劳斯)。这种本地化命名策略使得数据具有真实感,也展示了电竞作为国际化产业的多文化特征。
4.11 转会新闻初始数据集与状态多样性
const TRANSFER_INIT: TransferNews[] = [
new TransferNews(1, 'Knight', 'TES', 'JDG', '¥3000万', '已完成', '2小时前', '🔄'),
new TransferNews(2, 'TheShy', 'WBG', 'NIP', '¥1500万', '已完成', '昨天', '🔄'),
new TransferNews(3, 'Doinb', 'LNG', 'FPX', '自由身', '已完成', '2天前', '🔄'),
new TransferNews(4, 'Viper', 'EDG', 'HLE', '€200万', '已完成', '3天前', '🔄'),
new TransferNews(5, 'Canyon', 'DK', 'GEN', '未公开', '已完成', '4天前', '🔄'),
new TransferNews(6, 'Peanut', 'GEN', 'T1', '租借', '谈判中', '5小时前', '⏳'),
new TransferNews(7, 'Chovy', 'GEN', 'DK', '未公开', '传闻', '今天', '❓'),
new TransferNews(8, 'Ruler', 'GEN', 'JDG', '¥2500万', '已完成', '上周', '🔄'),
new TransferNews(9, 'Scout', 'EDG', 'LNG', '¥2000万', '已完成', '上周', '🔄'),
new TransferNews(10, 'Tarzan', 'LNG', '待定', '自由身', '待定', '2周前', '❓')
];
TRANSFER_INIT 包含 10 条转会新闻,状态分布体现了转会市场的多样性:7 条"已完成"、1 条"谈判中"、1 条"传闻"、1 条"待定"。这种状态分布既反映了现实中已完成转会是主流的事实,也保留了其他状态的展示样本,确保 UI 的状态筛选和颜色标记逻辑都有数据可验证。
转会费的多样性同样值得关注:有人民币计价(¥3000万、¥1500万)、欧元计价(€200万)、还有"自由身"、“未公开”、"租借"等非数值表达。这验证了 fee 字段使用字符串类型的合理性——如果用数值类型,这些非数值表达就无法被统一存储。time 字段使用相对时间(“2小时前”、“昨天”、“今天”、“上周”、“2周前”),这是新闻类产品的标准做法,使得用户无需换算即可感知信息的时效性。
toTeam 字段中出现了"待定"这一特殊值(Tarzan 的转出),配合"待定"状态,完整呈现了转会市场中"选手成为自由身但尚未确定下家"的常见情境,数据的真实性和完整性在这里得到了体现。
4.12 图表数据常量与赛程配置
const KDA_DATA: number[] = [8.2, 5.6, 7.8, 9.1, 6.8, 8.5, 7.2, 7.5];
const KDA_LABELS: string[] = ['Knight', '369', 'JKL', 'Faker', 'Zeus', 'GALA', 'Xiaohu', 'Bin'];
const MAX_KDA: number = 10;
const WIN_DATA: number[] = [82, 73, 68, 64, 55, 50, 91, 77];
const WIN_LABELS: string[] = ['TES', 'JDG', 'RNG', 'BLG', 'WBG', 'EDG', 'T1', 'GEN'];
const MAX_WIN: number = 100;
const SCHEDULE: string[] = ['TES vs JDG', 'RNG vs EDG', 'T1 vs GEN', 'BLG vs WBG', 'LNG vs AL', 'DK vs KT'];
const SCHEDULE_TIME: string[] = ['今天 19:00', '今天 21:00', '明天 16:00', '明天 19:00', '后天 18:00', '后天 21:00'];
const SCHEDULE_LIVE: boolean[] = [true, false, false, false, false, false];
这组常量为数据中心的两个柱状图和赛程模块提供数据支撑。KDA_DATA 和 WIN_DATA 分别存储 8 位选手的 KDA 值和 8 支战队的胜率值,对应的 KDA_LABELS 和 WIN_LABELS 存储 X 轴标签。MAX_KDA(10)和 MAX_WIN(100)定义了柱状图的满刻度值,用于在 kdaBarH() 和 winBarH() 函数中将原始数据按比例映射为柱子高度。
赛程配置采用了三个平行数组(SCHEDULE、SCHEDULE_TIME、SCHEDULE_LIVE)而非对象数组来组织数据。这种"结构数组"模式在数据量固定且简单的场景下是可接受的——它避免了为仅 6 条数据定义一个额外的接口和类。但平行数组的维护成本较高(增删一条数据需要同时修改三个数组),在数据量增长或逻辑复杂化时,应重构为对象数组。
SCHEDULE_LIVE 数组中只有第一条(TES vs JDG)为 true,表示该比赛正在直播中。这一标记将在赛程模块中驱动红色 LIVE 标签、红色时间轴竖线、红色"观看"按钮等一系列视觉差异化表达,形成"直播中比赛"在全模块的视觉突出。
4.13 工具函数层:角色着色与柱状图高度计算
function roleColor(r: string): string {
if (r === '上单') { return COLORS.purple; }
if (r === '中单') { return COLORS.cyan; }
if (r === 'ADC') { return COLORS.pink; }
if (r === '打野') { return COLORS.green; }
if (r === '辅助') { return COLORS.gold; }
return COLORS.sub;
}
function kdaBarH(v: number): number {
return Math.round(v / MAX_KDA * 70);
}
function winBarH(v: number): number {
return Math.round(v / MAX_WIN * 70);
}
function transferColor(s: string): string {
if (s === '已完成') { return COLORS.green; }
if (s === '谈判中') { return COLORS.gold; }
if (s === '传闻') { return COLORS.purple; }
return COLORS.sub;
}
这组纯函数构成了应用的工具函数层,为视图层提供数据到视觉的映射服务。roleColor() 将选手位置(上单/中单/ADC/打野/辅助)映射为主题色,使得每个位置的选手在 UI 上具有专属色彩标识——这种"位置即颜色"的视觉编码体系,让用户通过颜色即可快速识别选手位置,降低了信息认知成本。
kdaBarH() 和 winBarH() 是柱状图高度计算函数,它们将原始数值除以满刻度值再乘以 70(像素),得到柱子的实际高度。使用 Math.round() 取整确保高度值为整数像素,避免亚像素渲染导致的模糊。这两个函数的对称设计(除以不同的 MAX 值,乘以相同的 70 像素最大高度)保证了两个柱状图在视觉上高度可比——KDA 9.1 和胜率 91% 这两个"满值附近"的数据在各自的图中都接近满高度。
transferColor() 将转会状态映射为语义颜色:绿色表示已完成(安全/确定)、金色表示谈判中(关注/待定)、紫色表示传闻(神秘/不确定)、灰色表示其他(弱化)。这种状态-颜色映射是信息可视化的经典实践,使用户无需阅读文字即可感知转会进展。
4.14 根组件声明与 @State 状态变量体系
@Entry
@Component
struct Page825 {
@State tabIdx: number = 0;
@State glow: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addName: string = '';
@State addRole: string = '中单';
@State addTeam: string = 'TES';
@State editName: string = '';
@State matchList: MatchResult[] = MATCH_INIT;
@State teamList: TeamItem[] = TEAM_INIT;
@State playerList: PlayerItem[] = PLAYER_INIT;
@State transferList: TransferNews[] = TRANSFER_INIT;
private timer: number = -1;
Page825 是应用的根组件,通过 @Entry 装饰器标记为页面入口组件,@Component 声明它是一个自定义组件。组件内部声明了 13 个 @State 状态变量和 1 个普通成员变量 timer。
@State 装饰的变量是 ArkUI 响应式系统的核心——当这些变量的值发生变化时,框架会自动检测哪些 UI 部分依赖了该变量,并仅刷新这些部分。tabIdx 控制当前激活的 Tab,它的变化会驱动 build() 中的 if-else 链切换显示不同的 Tab 内容。三个弹窗布尔值(addModal、editModal、delModal)各自控制一个弹窗的显示与隐藏,它们的变化会触发对应弹窗的条件渲染。
表单状态(addName、addRole、addTeam、editName)存储弹窗中的用户输入值,初始值设置了合理的默认值(如 addRole 默认为"中单"、addTeam 默认为"TES"),减少用户操作步骤。四个业务数据数组直接引用 *_INIT 常量进行初始化,使得组件在 aboutToAppear 之前即拥有完整的种子数据可供渲染。
timer 变量没有使用 @State 装饰——因为定时器 ID 的变化不需要驱动 UI 刷新,它仅作为资源句柄在生命周期中被使用。将不需要响应式的变量排除在 @State 体系之外,是一种性能优化意识,避免了不必要的依赖追踪开销。
4.15 生命周期钩子与定时器资源管理
aboutToAppear(): void {
this.timer = setInterval(() => { this.glow = !this.glow; }, 600);
}
aboutToDisappear(): void {
if (this.timer > 0) { clearInterval(this.timer); }
}
aboutToAppear() 和 aboutToDisappear() 是 ArkUI 组件的生命周期回调钩子。aboutToAppear 在组件创建后、build() 执行前被调用,适合进行数据初始化、事件订阅、定时器启动等准备工作;aboutToDisappear 在组件销毁前被调用,用于资源释放和清理。
这里在 aboutToAppear 中启动了一个 600 毫秒间隔的 setInterval 定时器,每次触发时切换 glow 布尔值。由于 glow 是 @State 变量,它的每次切换都会触发 UI 刷新——具体来说,它会控制 build() 中霓虹脉冲特效层的条件渲染,使该特效层以 600ms 频率闪烁出现和消失,营造出赛博朋克风格的"脉冲"视觉韵律。
aboutToDisappear 中的 clearInterval 调用是资源管理的必要操作。如果不清理定时器,组件销毁后定时器仍会继续运行,不仅造成内存泄漏,还可能尝试操作已销毁组件的状态导致异常。if (this.timer > 0) 的守卫检查确保只在定时器确实被创建过(ID 为正整数)时才执行清理,避免了无效的 clearInterval 调用。
4.16 头部构建器:战报头部大卡与赛程横条
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('⚡').fontSize(16)
Text('赛博格').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('🔍').fontSize(16)
Text('🔔').fontSize(16)
}
.width('100%')
Column({ space: 6 }) {
Row({ space: 10 }) {
Column() {
Text('🛡️').fontSize(28)
}
.width(44).height(44).borderRadius(10).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('TES · 滔搏电竞').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('LPL #1 · 18胜4负 · 胜率82%').fontSize(9).fontColor(COLORS.cyan)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('#1').fontSize(20).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('排名').fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(14)
.backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 1 }) {
Text('3').fontSize(16).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('今日比赛').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('🔴').fontSize(14)
Text('直播中').fontSize(8).fontColor(COLORS.red)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('48').fontSize(16).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
Text('总选手').fontSize(8).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('赛程 ›').fontSize(9).fontColor(COLORS.cyan)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(COLORS.bg)
}
header() 是头部区域的 @Builder 构建函数,它构建了一个三段式的头部布局。第一段是顶栏,包含应用名"赛博格"和搜索、通知图标,使用 Column().layoutWeight(1) 作为弹性间隔将品牌名推向左侧、操作图标推向右侧。
第二段是战队排名大卡,展示当前排名第一的战队(TES)的核心信息。卡片采用三列布局:左侧是 44x44 的圆角图标容器,中间是战队名称和战绩统计(使用 layoutWeight(1) 占据剩余空间),右侧是排名"#1"以金色大字号突出。这种"图标-信息-排名"的三段式结构是排行榜头部卡片的经典设计模式。
第三段是赛程横条,使用 1px 宽度的 Column 作为分隔线将三个信息块(今日比赛数、直播状态、总选手数)均匀排列。分隔线的颜色使用 COLORS.line(#2A2A45),与卡片背景色微妙区分,既提供了视觉分割又不至于过于突兀。右侧"赛程 ›"作为快捷入口链接,以青色文字提示可点击性。
整个头部使用 Column({ space: 10 }) 管理三段之间的 10px 垂直间距,外层 padding 控制安全区域,背景色使用 COLORS.bg 与页面整体保持一致。
4.17 底部 Tab 栏构建器:两排导航与激活态视觉
@Builder
tabBar() {
Column({ space: 0 }) {
Row() {
ForEach([0, 1, 2, 3], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(8)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(14).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%')
Row() {
ForEach([4, 5, 6, 7], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(8)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(14).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%')
}
.width('100%').backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
tabBar() 构建了底部两排导航栏。8 个 Tab 被分为两组(0-3 和 4-7),分别放在两个 Row 中,每个 Row 内通过 ForEach 遍历索引数组动态生成 4 个 Tab 项。每个 Tab 项使用 layoutWeight(1) 均分宽度,确保 4 个 Tab 在一行中等距排列。
激活态的视觉表达采用了三重手段:图标 opacity 从 0.4 提升到 1.0(非激活态半透明,激活态完全显示)、文字颜色从 COLORS.sub 切换为 COLORS.tabOn(青色)、字重从 Normal 切换为 Bold。此外,激活态还会条件渲染一个 14x2 像素的青色圆角下划线指示条,这是当前选中 Tab 的最直观视觉标记。
onClick 回调简单地设置 this.tabIdx = i,由于 tabIdx 是 @State 变量,赋值后框架自动触发 build() 重新执行,if-else 链切换到对应 Tab 的内容构建函数。这种"点击即赋值、赋值即刷新"的极简交互模式是声明式 UI 的核心优势——开发者无需手动管理 Tab 切换的动画、内容替换等细节。
底部 Tab 栏顶部有一条 1px 的 COLORS.line 分隔线,通过 border({ width: { top: 1 } }) 实现,仅设置上边框而其他三边为零,这是一种精确控制边框的技巧。
4.18 弹窗遮罩层构建器与通用关闭逻辑
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
modalOverlay() 是一个通用的弹窗遮罩层构建器,它接受一个 onClose 回调函数作为参数。遮罩层是一个铺满全屏的 Column,背景色使用 COLORS.mask(75% 不透明度的深色),点击时执行传入的 onClose 回调关闭弹窗。
这个构建器体现了 @Builder 方法接受参数的能力——通过参数化关闭逻辑,同一个遮罩层构建器可以被多个弹窗复用,每个弹窗只需传入自己的关闭函数(如 () => { this.addModal = false; })。这种设计避免了为每个弹窗重复编写遮罩层代码,是 DRY(Don’t Repeat Yourself)原则在 UI 构建层的体现。
遮罩层的全屏点击关闭是弹窗交互的标准模式——用户点击遮罩区域视为"放弃操作"的意图,弹窗自动关闭。但弹窗内容区域的点击不应触发关闭,这通过将遮罩层放在 Stack 的底层、弹窗内容放在上层来实现——点击弹窗内容区域的事件不会冒泡到遮罩层。
4.19 战报 Tab 构建器:比赛结果大卡与 MVP 展示
@Builder
matchTab() {
Column({ space: 10 }) {
Text('📰 今日战报').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 8 }) {
ForEach(this.matchList, (m: MatchResult, idx: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(m.tournament).fontSize(9).fontColor(COLORS.purple)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
if (m.isLive) {
Text('🔴 直播中').fontSize(9).fontColor(COLORS.red)
} else {
Text(m.date).fontSize(9).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text(m.duration).fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('🛡️').fontSize(24)
Text(m.teamA).fontSize(12).fontColor(m.winner === m.teamA ? COLORS.gold : COLORS.sub)
.fontWeight(m.winner === m.teamA ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(m.scoreA + ' : ' + m.scoreB).fontSize(20).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text(m.winner + ' 获胜').fontSize(8).fontColor(COLORS.green)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('🐕').fontSize(24)
Text(m.teamB).fontSize(12).fontColor(m.winner === m.teamB ? COLORS.gold : COLORS.sub)
.fontWeight(m.winner === m.teamB ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
Row({ space: 8 }) {
Text('🏆 MVP').fontSize(9).fontColor(COLORS.gold)
Text(m.mvpAvatar).fontSize(14)
Text(m.mvp).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('查看详情 ›').fontSize(9).fontColor(COLORS.cyan)
}
.width('100%').padding({ top: 4 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
matchTab() 是战报模块的构建函数。整体采用 Column 垂直排列标题和比赛卡片列表,卡片列表通过 ForEach 遍历 this.matchList 动态生成。每张比赛卡片包含三个信息行:顶部信息行(赛事名称标签 + 直播/日期 + 比赛时长)、中部对阵行(双方队标+名称、比分大字、获胜方提示)、底部 MVP 行(MVP 标签+选手头像+选手名+查看详情链接)。
对阵行的设计亮点在于获胜方的视觉突出——通过三元条件表达式 m.winner === m.teamA ? COLORS.gold : COLORS.sub,获胜方的队名变为金色并加粗,失败方则保持灰色普通字重。这种"胜者金、败者灰"的视觉编码使用户一眼即可识别每场比赛的胜负关系。
比分 m.scoreA + ' : ' + m.scoreB 以 20px 青色加粗大字居中展示,是整张卡片的视觉焦点。比分下方的"X 获胜"绿色小字进一步确认结果。isLive 字段驱动了顶部行的条件渲染:直播中显示红色"🔴 直播中"标签,非直播显示灰色日期文字。这种条件渲染是 @Observed 数据模型驱动 UI 的典型体现——当某场比赛从"未开始"变为"直播中"时,只需修改 isLive 属性,卡片自动切换显示状态。
4.20 战队 Tab 构建器:网格卡片与胜率进度条
@Builder
teamTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LPL').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LCK').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LEC').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LCS').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
}
.width('100%')
Column({ space: 8 }) {
ForEach([0, 2, 4, 6, 8, 10], (base: number) => {
Row({ space: 8 }) {
ForEach([0, 1], (offset: number) => {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(TEAM_INIT[base + offset].icon).fontSize(22)
Column({ space: 1 }) {
Text(TEAM_INIT[base + offset].name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(TEAM_INIT[base + offset].region).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('#' + TEAM_INIT[base + offset].rank).fontSize(12).fontColor(TEAM_INIT[base + offset].color).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 4 }) {
Text(TEAM_INIT[base + offset].wins + '胜' + TEAM_INIT[base + offset].losses + '负').fontSize(8).fontColor(COLORS.sub)
Text('胜率' + TEAM_INIT[base + offset].winRate + '%').fontSize(8).fontColor(COLORS.green)
}
.width('100%')
Row() {
Column()
.width(TEAM_INIT[base + offset].winRate + '%')
.height(3).backgroundColor(TEAM_INIT[base + offset].color).borderRadius(2)
Column().layoutWeight(1)
}
.width('100%').height(3).backgroundColor(COLORS.darkL).borderRadius(2)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
})
}
.width('100%')
}
.width('100%')
}
teamTab() 构建了战队模块的两列网格布局。顶部是赛区筛选栏,"全部"标签以青色背景白字表示当前激活态,其他赛区标签以卡片色背景灰色字表示非激活态。这种"激活态填充色 + 非激活态空心"的标签视觉模式在筛选场景中是标准设计。
网格布局的核心技巧在于双 ForEach 嵌套:外层遍历 [0, 2, 4, 6, 8, 10] 作为每行的起始索引(base),内层遍历 [0, 1] 作为列偏移(offset),通过 base + offset 计算出当前卡片对应的战队索引。这种"以索引数组模拟二维网格"的方式避开了 ArkUI 当前缺乏原生 Grid 组件的限制,用 Row + Column 组合实现了等宽两列网格效果。
每张战队卡片的胜率进度条是一个精巧的视觉组件:外层 Row 以 COLORS.darkL 为背景色代表进度槽,内层的第一个 Column 宽度设为 winRate + '%'(如 82%),以战队专属颜色填充代表已完成的胜率部分。这种"槽+填充"的双层进度条结构简洁但表达力强,用户通过进度条长度即可直观比较各战队的胜率差异。排名数字 #1 使用战队专属颜色,使每张卡片在视觉上具有品牌识别度。
4.21 选手 Tab 构建器:列表卡片与多维度数据展示
@Builder
playerTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('上单').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('中单').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('ADC').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Column().layoutWeight(1)
Text('+ 签约').fontSize(10).fontColor(COLORS.cyan)
.onClick(() => { this.addModal = true; })
}
.width('100%')
Column({ space: 6 }) {
ForEach(this.playerList, (p: PlayerItem, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.avatar).fontSize(24)
Text(p.role).fontSize(8).fontColor(roleColor(p.role))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
.width(48).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(p.ign).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('(' + p.name + ')').fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
Text(p.team).fontSize(9).fontColor(COLORS.cyan)
Text('KDA ' + p.kda.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text('CS ' + p.cs).fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 8 }) {
Text('击杀 ' + p.kills).fontSize(8).fontColor(COLORS.green)
Text('死亡 ' + p.deaths).fontSize(8).fontColor(COLORS.red)
Text('助攻 ' + p.assists).fontSize(8).fontColor(COLORS.cyan)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text(p.salary).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('✏️').fontSize(12).fontColor(COLORS.cyan)
.onClick(() => { this.editIdx = idx; this.editModal = true; })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
playerTab() 构建了选手列表模块。顶部筛选栏与战队 Tab 类似,但右侧多了"+ 签约"按钮,点击后设置 this.addModal = true 打开签约弹窗。这种将交互入口直接嵌入筛选栏的设计,节省了屏幕空间。
每张选手卡片采用三列布局:左侧 48px 宽度的头像区(emoji 头像 + 角色标签),中间是 layoutWeight(1) 的信息区,右侧是薪资和编辑按钮。信息区内嵌三个 Row:第一行展示游戏 ID(加粗)和真实姓名(括号灰色),第二行展示战队(青色)、KDA(金色)、CS(灰色),第三行展示击杀(绿色)、死亡(红色)、助攻(青色)三个原始数据。
角色标签的颜色通过 roleColor(p.role) 函数动态计算,使上单为紫、中单为青、ADC 为粉——这种"位置即颜色"的编码让用户通过标签颜色即可识别选手位置。击杀/死亡/助攻分别使用绿色/红色/青色,是电竞数据展示的语义化色彩惯例:绿色代表积极(击杀)、红色代表消极(死亡)、青色代表中性(助攻)。
编辑按钮"✏️"点击后同时设置 editIdx(当前编辑的选手索引)和 editModal(弹窗开关),两个状态变量的组合使得编辑弹窗打开时即可知道要编辑哪条选手记录。这种"索引+开关"的弹窗参数传递模式在缺乏路由参数传递的单组件应用中是一种简洁有效的方案。
4.22 赛程 Tab 构建器:时间轴布局与直播标记
@Builder
scheduleTab() {
Column({ space: 10 }) {
Text('📅 赛程安排').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(SCHEDULE_TIME[i].split(' ')[0]).fontSize(11).fontColor(SCHEDULE_LIVE[i] ? COLORS.red : COLORS.gold).fontWeight(FontWeight.Bold)
Text(SCHEDULE_TIME[i].split(' ')[1]).fontSize(9).fontColor(COLORS.sub)
}
.width(56).alignItems(HorizontalAlign.Center)
Column().width(2).height(36)
.backgroundColor(SCHEDULE_LIVE[i] ? COLORS.red : COLORS.cyan).borderRadius(1)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(SCHEDULE[i]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
if (SCHEDULE_LIVE[i]) {
Text('🔴 LIVE').fontSize(8).fontColor('#FFFFFF')
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.red)
}
}
Text('LPL/LCK 夏季赛 · BO3').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text(SCHEDULE_LIVE[i] ? '观看' : '提醒').fontSize(9)
.fontColor('#FFFFFF')
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(6)
.backgroundColor(SCHEDULE_LIVE[i] ? COLORS.red : COLORS.darkL)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('🏆 赛事列表').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 4 }) {
ForEach(['LPL夏季赛', 'LCK夏季赛', 'LEC夏季赛', 'LCS夏季赛', 'MSI季中赛', 'S赛全球总决赛'], (t: string) => {
Row({ space: 8 }) {
Text('🏆').fontSize(14)
Text(t).fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
Text('进行中').fontSize(9).fontColor(COLORS.green)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(8).borderRadius(8)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
scheduleTab() 构建了赛程时间轴。每条赛程采用四列布局:左侧 56px 的时间区(日期 + 时分)、2px 宽度的时间轴竖线、中间的比赛信息区、右侧的操作按钮。时间区的日期和时分通过 SCHEDULE_TIME[i].split(' ') 拆分获得,将"今天 19:00"拆分为"今天"和"19:00"两行展示。
时间轴竖线的颜色根据直播状态动态切换:直播中为红色,非直播为青色。这根 2px 宽、36px 高的竖线在视觉上串联了所有赛程条目,形成了时间轴的骨架。直播中的赛程还会在比赛信息行附加一个红底白字的"🔴 LIVE"标签,以及将右侧按钮文字从"提醒"改为"观看"、背景色从暗灰改为红色——整套视觉差异化使得直播中的赛程在时间轴中极为突出。
底部赛事列表以简单的行列表展示六大顶级赛事(LPL/LCK/LEC/LCS 夏季赛、MSI 季中赛、S 赛全球总决赛),每行附"进行中"绿色标签,表明当前正值夏季赛赛季。这一区域虽结构简单,但为用户提供了赛事层级的全局视角。
4.23 训练 Tab 构建器:概览卡片与周训练柱状图
@Builder
trainTab() {
Column({ space: 10 }) {
Text('🏋️ 训练计划').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('6h').fontSize(18).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('今日时长').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('85%').fontSize(18).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('完成度').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('12').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('训练项目').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Column({ space: 6 }) {
ForEach(['个人对线训练', '团队战术演练', '录像复盘分析', '体能训练', '心理辅导', 'BP策略讨论'], (item: string, idx: number) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text((idx < 4 ? '✅' : '⬜')).fontSize(16)
}
.width(28).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(item).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text((idx + 1) + '小时 · ' + (idx < 4 ? '已完成' : '待开始')).fontSize(9).fontColor(idx < 4 ? COLORS.green : COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('›').fontSize(14).fontColor(COLORS.sub)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('📈 本周训练时长').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
Column({ space: 3 }) {
Text(['5h', '6h', '4h', '7h', '6h', '8h', '6h'][d]).fontSize(8).fontColor(COLORS.cyan)
Column()
.width(18).height([50, 60, 40, 70, 60, 80, 60][d])
.backgroundColor(d === 5 ? COLORS.pink : COLORS.purpleD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(['一', '二', '三', '四', '五', '六', '日'][d]).fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
}
.width('100%')
}
trainTab() 构建了训练管理模块。顶部三个等宽概览卡片分别以青色、绿色、金色展示今日训练时长(6h)、完成度(85%)、训练项目数(12),三色对应不同维度的数据,形成色彩区分。
训练计划列表通过 ForEach 遍历六个训练项目,前四个以"✅"标记为已完成(绿色状态文字),后两个以"⬜"标记为待开始(灰色状态文字)。这种基于索引 idx < 4 的完成状态判断是简化的原型实现——在实际产品中,完成状态应来自数据模型的布尔属性。
本周训练时长柱状图是该模块的视觉亮点。七个柱子代表周一到周日,高度通过内联数组 [50, 60, 40, 70, 60, 80, 60][d] 直接指定像素值。周六(索引 5)的柱子以粉色(COLORS.pink)高亮,代表当日训练时长最长(8h/80px),其他柱子使用深紫色(COLORS.purpleD)。柱子顶部圆角通过 borderRadius({ topLeft: 3, topRight: 3 }) 仅设置上方两角实现,形成"圆顶平底"的柱状形态。柱子上方标注时长数值,下方标注星期缩写,构成了完整的"数值-柱体-标签"三段式图表元素。
4.24 数据 Tab 构建器:多图表数据看板
@Builder
statTab() {
Column({ space: 10 }) {
Text('📊 数据中心').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('82%').fontSize(18).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('战队胜率').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('8.2').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('场均KDA').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('285').fontSize(18).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
Text('场均CS').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('📈 选手KDA对比').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (d: number) => {
Column({ space: 3 }) {
Text(KDA_DATA[d].toFixed(1)).fontSize(8).fontColor(COLORS.gold)
Column()
.width(16).height(kdaBarH(KDA_DATA[d]))
.backgroundColor(d === 3 ? COLORS.pink : COLORS.cyanD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(KDA_LABELS[d]).fontSize(7).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Text('📊 战队胜率对比').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (d: number) => {
Column({ space: 3 }) {
Text(WIN_DATA[d].toString() + '%').fontSize(8).fontColor(COLORS.cyan)
Column()
.width(16).height(winBarH(WIN_DATA[d]))
.backgroundColor(d === 6 ? COLORS.gold : COLORS.purpleD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(WIN_LABELS[d]).fontSize(7).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Text('🎯 英雄使用率TOP5').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 4 }) {
ForEach(['亚索', '劫', '盲僧', '阿狸', '金克丝'], (hero: string, idx: number) => {
Row({ space: 8 }) {
Text((idx + 1).toString()).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text(hero).fontSize(11).fontColor(COLORS.title).layoutWeight(1)
Text(['32%', '28%', '25%', '22%', '18%'][idx]).fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(8).borderRadius(8)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
statTab() 构建了数据中心模块,这是全应用信息密度最高的 Tab。顶部三个概览卡(战队胜率 82%、场均 KDA 8.2、场均 CS 285)分别以青、金、紫三色展示核心聚合指标。随后是两个柱状图:KDA 对比图和胜率对比图。
两个柱状图的实现模式高度一致——都通过 ForEach 遍历 8 个数据项,每项包含数值标签、柱体和 X 轴标签三部分。柱体高度分别通过 kdaBarH() 和 winBarH() 函数计算,将原始数值按比例映射为像素高度。两个图中各有一个柱子被特殊高亮:KDA 图中索引 3(Faker,KDA 9.1)以粉色标记,胜率图中索引 6(T1,胜率 91%)以金色标记。这种"峰值高亮"设计引导用户关注数据中的最高值及其所属对象。
英雄使用率 TOP5 以简单行列表展示,每行包含排名序号(金色)、英雄名称和使用率百分比(青色),数据按降序排列。虽然仅是文字列表,但通过排名数字的视觉权重和颜色的区分,仍然形成了清晰的排行视觉层次。
4.25 转会 Tab 构建器:新闻流与操作按钮区
@Builder
transferTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('已完成').fontSize(10).fontColor(COLORS.green)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('谈判中').fontSize(10).fontColor(COLORS.gold)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('传闻').fontSize(10).fontColor(COLORS.purple)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
}
.width('100%')
Column({ space: 6 }) {
ForEach(this.transferList, (t: TransferNews, idx: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(t.icon).fontSize(22)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(t.player).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(t.status).fontSize(8).fontColor(transferColor(t.status))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
Row({ space: 6 }) {
Text(t.fromTeam).fontSize(10).fontColor(COLORS.sub)
Text('→').fontSize(10).fontColor(COLORS.cyan)
Text(t.toTeam).fontSize(10).fontColor(COLORS.cyan)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text(t.fee).fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text(t.time).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('关注').fontSize(9).fontColor(COLORS.cyan) }
.layoutWeight(1).padding({ top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Center)
Column() { Text('分享').fontSize(9).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Text('🗑️').fontSize(11).fontColor(COLORS.red)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
transferTab() 构建了转会新闻流模块。顶部筛选栏与之前的 Tab 不同——四个筛选标签各自使用对应状态的语义色作为文字色(全部-青、已完成-绿、谈判中-金、传闻-紫),使用户通过筛选栏的颜色即可联想到各状态的视觉编码。
每条转会新闻卡片分为上下两行:上行是信息行,包含状态图标、选手名+状态标签、转会方向(源→目标)、转会费+时间;下行是操作按钮区,包含"关注"(青色 chip 背景)和"分享"(灰色 card 背景)两个等宽按钮,以及右侧的删除图标。
转会方向的展示 t.fromTeam + ' → ' + t.toTeam 使用青色箭头"→"连接源战队和目标战队,源战队灰色、目标战队青色,形成"从暗到亮"的视觉流转感。状态标签的颜色通过 transferColor(t.status) 动态计算,与筛选栏的颜色编码保持一致。
删除按钮"🗑️"点击后设置 editIdx 和 delModal,复用了选手编辑的"索引+开关"弹窗模式。删除操作直接操作 this.transferList.splice(this.editIdx, 1) 数组——由于 transferList 是 @State 持有的数组,且 TransferNews 是 @Observed 类,splice 操作会被框架捕获并触发列表的重新渲染,被删除的卡片自动从 UI 中消失。
4.26 个人中心 Tab 构建器:用户档案与设置入口
@Builder
profileTab() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 12 }) {
Column() {
Text('🎮').fontSize(32)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('电竞观察者').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('段位: 钻石I · 关注12支战队').fontSize(10).fontColor(COLORS.sub)
Text('⚡ 累计观赛: 486小时').fontSize(9).fontColor(COLORS.cyan)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16)
.backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('12').fontSize(20).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('关注战队').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('48').fontSize(20).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('关注选手').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('3').fontSize(20).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
Text('竞猜胜率').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('⭐ 关注的战队').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3], (i: number) => {
Row({ space: 10 }) {
Text(TEAM_INIT[i].icon).fontSize(22)
Column({ space: 2 }) {
Text(TEAM_INIT[i].name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(TEAM_INIT[i].wins + '胜' + TEAM_INIT[i].losses + '负 · 胜率' + TEAM_INIT[i].winRate + '%').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('#' + TEAM_INIT[i].rank).fontSize(11).fontColor(TEAM_INIT[i].color).fontWeight(FontWeight.Bold)
Text('✏️').fontSize(12).fontColor(COLORS.cyan)
.onClick(() => { this.editIdx = i; this.editModal = true; })
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('⚙️ 设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🔔').fontSize(16)
Text('比赛提醒').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('已开启 ›').fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🎲').fontSize(16)
Text('竞猜记录').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('胜率72% ›').fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('⚙️').fontSize(16)
Text('设置').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('›').fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
profileTab() 构建了个人中心模块,整体分为四个区块:用户档案大卡、统计指标三连卡、关注的战队列表、设置入口列表。
用户档案大卡采用左图右文的经典布局:左侧 56x56 的圆形头像容器(borderRadius(28) 实现圆形),右侧三行文字——昵称(加粗)、段位与关注数(灰色)、累计观赛时长(青色)。borderRadius(28) 取容器宽度的一半形成完美圆形,是 CSS/ArkUI 中圆形容器的标准做法。
关注的战队列表复用了 TEAM_INIT 数据的前四项,通过 ForEach([0, 1, 2, 3]) 遍历索引。每行展示战队图标、名称、战绩摘要、排名(战队专属色)和编辑按钮。这里的编辑按钮复用了选手编辑弹窗(this.editModal = true),虽然语义上略有混淆(编辑战队 vs 编辑选手),但在原型阶段这种弹窗复用减少了代码量。
设置列表以三个 Row 平铺,每行包含图标、设置项名称(layoutWeight(1) 占据中间空间)、当前状态值(青色)和箭头。Column({ space: 0 }) 使三行之间无间距,配合各自的圆角和背景色形成连续的列表视觉效果。
4.27 build() 根装配:Stack 层叠与内容区条件渲染
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) { this.matchTab() }
else if (this.tabIdx === 1) { this.teamTab() }
else if (this.tabIdx === 2) { this.playerTab() }
else if (this.tabIdx === 3) { this.scheduleTab() }
else if (this.tabIdx === 4) { this.trainTab() }
else if (this.tabIdx === 5) { this.statTab() }
else if (this.tabIdx === 6) { this.transferTab() }
else { this.profileTab() }
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
build() 是根组件的构建函数,是整个 UI 的装配入口。它使用 Stack 作为根容器,alignContent: Alignment.Center 使所有子元素居中对齐。Stack 内的第一层是一个全屏的 Column,包含三个垂直排列的部分:header() 头部、Scroll() 滚动内容区、tabBar() 底部导航。
滚动内容区使用 Scroll 组件包裹一个 Column,内部通过 if-else 链根据 tabIdx 的值条件渲染对应的 Tab 构建函数。这种"条件渲染 + Tab 索引"的模式实现了 Tab 切换的核心逻辑——tabIdx 变化时,if-else 链重新求值,框架卸载旧的 Tab 内容、加载新的 Tab 内容。由于每个 Tab 的构建函数都是独立的 @Builder 方法,切换时仅重建当前 Tab 的组件树,不影响头部和底部导航。
Scroll 的 layoutWeight(1) 使其占据头部和底部导航之间的所有剩余空间,scrollBar(BarState.Off) 隐藏滚动条以保持视觉简洁,align(Alignment.Top) 使内容从顶部开始排列。内层 Column 的 padding 设置了 14px 的左右边距和 10px/16px 的上下边距,为内容提供了安全呼吸空间。
4.28 霓虹脉冲特效层与条件渲染动画
// 霓虹脉冲特效层
if (this.glow) {
Row({ space: 50 }) {
Text('◆').fontSize(8).fontColor(COLORS.cyan).opacity(0.4)
Text('◇').fontSize(6).fontColor(COLORS.purple).opacity(0.3)
Text('◆').fontSize(10).fontColor(COLORS.pink).opacity(0.2)
Text('◇').fontSize(7).fontColor(COLORS.cyan).opacity(0.3)
}
.width('100%').height(16).position({ x: 0, y: 70 }).zIndex(1)
}
霓虹脉冲特效层是 Stack 中的第二层,通过 if (this.glow) 条件渲染控制。glow 变量在 aboutToAppear 中启动的 600ms 定时器驱动下每秒切换约 1.7 次,使这组几何符号(◆◇◆◇)在屏幕上方 70px 位置以该频率闪烁出现和消失。
四个符号分别使用青色、紫色、粉色、青色,字号从 6px 到 10px 不等,透明度从 0.2 到 0.4 递变。这种"多色、多尺寸、多透明度"的参数变化使脉冲效果不至于单调,而是呈现出层次丰富的视觉韵律。position({ x: 0, y: 70 }) 将特效层绝对定位在头部下方,zIndex(1) 确保它浮在主内容之上。
这种用定时器 + 条件渲染实现"闪烁"效果的方式,虽然不如 ArkUI 的原生动画 API(如 animateTo)流畅,但在"纯显隐"场景下足够使用,且实现极简。在实际产品中,可考虑替换为 opacity 的动画过渡以获得更平滑的淡入淡出效果。
4.29 签约新选手弹窗与表单交互
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🎮 签约新选手').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('选手ID').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入选手游戏ID', text: this.addName })
.onChange((v: string) => { this.addName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Text('所属战队').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['TES', 'JDG', 'RNG'], (t: string) => {
Text(t).fontSize(10)
.fontColor(this.addTeam === t ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addTeam === t ? COLORS.cyan : COLORS.dark)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.addTeam = t; })
})
}
.width('100%')
Text('位置').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['上单', '中单', 'ADC'], (r: string) => {
Text(r).fontSize(10)
.fontColor(this.addRole === r ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addRole === r ? COLORS.cyan : COLORS.dark)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.addRole = r; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('确认签约').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.cyan).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
签约弹窗是三个弹窗中最复杂的一个,包含文本输入框和两组单选标签。TextInput 组件通过 onChange 回调将用户输入实时同步到 this.addName 状态变量,实现双向数据绑定。text: this.addName 参数使输入框的初始值与状态变量同步——这在弹窗重新打开时恢复上次输入内容。
战队和位置的选择采用了标签组而非下拉选择器的交互模式——三个标签横排排列,当前选中项以青色背景白字高亮,未选项以暗色背景灰字弱化。onClick 回调将选中值赋给 this.addTeam 或 this.addRole,由于这些是 @State 变量,赋值后标签组的视觉自动更新,选中态切换为被点击的标签。这种"标签组单选"交互比下拉选择器更直观、操作步数更少,适合选项数量少的场景。
弹窗宽度设为 82%,constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%,防止内容过多时溢出屏幕。zIndex(999) 确保弹窗浮在所有内容之上。底部"取消"和"确认签约"按钮以等宽布局排列,取消按钮暗灰色弱化、确认按钮青色突出,通过视觉权重引导用户优先选择确认操作。
4.30 编辑弹窗与状态预填
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✏️ 编辑信息').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('当前: ' + (this.editIdx < this.playerList.length ? this.playerList[this.editIdx].ign : '')).fontSize(11).fontColor(COLORS.cyan)
Text('新名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入新的名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.cyan).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
编辑弹窗的结构与签约弹窗类似,但增加了"当前值预填"的逻辑。Text('当前: ' + (this.editIdx < this.playerList.length ? this.playerList[this.editIdx].ign : '')) 这一行的三元条件表达式包含了一个边界守卫——当 editIdx 超出 playerList 长度时(如从转会列表的删除操作中设置的索引),显示空字符串而非访问越界索引导致崩溃。这种防御性编程在多个列表共享一个 editIdx 的场景中是必要的。
编辑弹窗被选手列表和关注的战队列表共同复用——选手列表的编辑按钮设置 editIdx 为选手索引,战队列表的编辑按钮设置 editIdx 为战队索引。但由于"当前值"预填逻辑使用了 this.playerList[this.editIdx].ign,当从战队列表打开编辑弹窗时,预填的将是与战队索引对应的选手的 ign——这是一个已知的语义偏差,在原型阶段被接受,在产品化时需要为不同来源的编辑操作设计独立的弹窗或参数化预填逻辑。
4.31 删除确认弹窗与数组 splice 操作
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 12 }) {
Text('🗑️').fontSize(36)
Text('删除这条转会新闻?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('删除后将无法恢复').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.transferList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
删除确认弹窗是三个弹窗中最简单的一个,采用居中卡片布局,包含删除图标(🗑️ 36px)、确认提示文字和红色警告文字。与签约/编辑弹窗不同,删除弹窗没有表单输入,只有取消和确认两个操作按钮。
"确认删除"按钮的 onClick 回调执行了两个操作:this.transferList.splice(this.editIdx, 1) 从转会列表数组中删除指定索引的元素,然后 this.delModal = false 关闭弹窗。splice 是 JavaScript/TypeScript 数组的原地修改方法——它直接修改原数组而非创建新数组。在 ArkUI 的响应式体系中,@State 持有的数组被 splice 修改后,框架能够检测到数组长度和内容的变化,自动触发 ForEach 的重新渲染,被删除的转会卡片从列表中消失。
确认按钮使用红色背景(COLORS.red)而非青色,与取消按钮形成"危险操作-安全操作"的视觉对比。弹窗宽度 76%(比其他弹窗的 82% 更窄),maxHeight 限制为 60%,使删除确认弹窗在视觉上更紧凑、更聚焦——这种"重要操作用小弹窗"的设计哲学,通过视觉聚焦强化了操作的严肃性,减少用户误操作的风险。
至此,build() 方法闭合了 Stack 根容器,整个组件的 UI 树构建完毕。外层的 .width('100%').height('100%').backgroundColor(COLORS.bg) 设置了根容器的全屏尺寸和背景色,确保整个屏幕被赛博暗底色覆盖。
五、完整流程图
以下是该电竞战队管理平台从组件生命周期到数据渲染再到用户交互的完整流程:
六、技术对比表
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 响应式数据模型 | @Observed 装饰器修饰业务实体类 |
属性级变更追踪,自动通知依赖方 | 细粒度更新,仅刷新依赖属性对应的 UI 子树 |
| 组件状态管理 | @State 装饰器持有 13 个状态变量 |
单组件内状态集中管理,赋值即触发刷新 | 简单值类型变更开销极低,数组 splice 可被追踪 |
| 视图构建函数 | @Builder 装饰的方法分模块构建 |
8 个 Tab 各自独立 Builder,关注点分离 | 构建函数仅在状态变更时按需执行 |
| Tab 切换机制 | if-else 链条件渲染 + tabIdx 索引 |
单组件内路由,无需页面跳转 | 切换时仅重建当前 Tab 组件树,头部和导航不重建 |
| 列表渲染 | ForEach 遍历数据数组生成卡片 |
数据驱动 UI,数组变化自动同步视图 | 大数据量时需配合 LazyForEach 优化 |
| 网格布局 | 双 ForEach 嵌套 + layoutWeight 等分 |
以索引数组模拟二维网格,无需 Grid 组件 | 布局计算简单,无复杂网格测量开销 |
| 柱状图实现 | Column 组件指定像素高度 + 辅助函数计算 |
纯声明式 UI 绘制柱状图,无 Canvas 依赖 | 轻量级图表方案,渲染开销低但交互能力有限 |
| 进度条实现 | 双层 Column,内层宽度设为百分比 |
CSS 式进度条,简洁直观 | 仅涉及宽度属性变更,性能开销极低 |
| 弹窗管理 | Stack 层叠 + 布尔 @State + zIndex |
单组件内弹窗,无路由参数传递 | 弹窗显隐仅条件渲染切换,无页面入栈出栈开销 |
| 弹窗遮罩 | @Builder modalOverlay 参数化构建器 |
回调函数参数化关闭逻辑,多弹窗复用 | 遮罩层为单一 Column,渲染开销可忽略 |
| 表单输入 | TextInput + onChange 双向绑定 |
实时同步输入值到 @State,无表单库依赖 |
每次按键触发状态更新,高频输入需防抖 |
| 单选标签组 | ForEach 生成标签 + onClick 赋值选中态 |
标签组替代下拉选择器,操作步数少 | 选项少时性能优秀,选项多时布局压力大 |
| 定时器特效 | setInterval + @State 布尔值条件渲染 |
600ms 频率显隐切换实现脉冲闪烁 | 显隐切换不如 animateTo 流畅,但实现极简 |
| 颜色管理 | interface 接口 + const 常量对象集中定义 |
类型安全的全局色彩系统,一处定义处处引用 | 编译期确定颜色值,无运行时查找开销 |
| 数据初始化 | const *_INIT 常量数组 + @State 引用 |
种子数据与运行时状态分离 | 初始数据在编译期确定,首次渲染无需等待 |
| 工具函数 | 纯函数,无副作用 | roleColor/kdaBarH/winBarH/transferColor |
纯函数无缓存需求,计算开销极低 |
| 生命周期 | aboutToAppear/aboutToDisappear 钩子 |
定时器在出现时启动、消失时清理 | 确保无内存泄漏,组件销毁后定时器不再触发 |
| 删除操作 | Array.splice 原地修改 @State 数组 |
原地删除触发 ForEach 自动重新渲染 |
splice 后框架仅移除对应索引的卡片,不全量重建 |
七、总结与工程实践启示
声明式 UI 的数据驱动哲学
本平台的实现完整地诠释了声明式 UI 的核心理念:UI 是状态的函数。整个应用的界面可以被视为 build() 函数在任意时刻对当前 @State 变量值的"快照渲染"——开发者无需关心如何从"Tab 0 的界面"过渡到"Tab 1 的界面",只需修改 tabIdx 的值,框架自动处理组件树的卸载与重建。同样,删除一条转会新闻时,开发者只需执行 splice 修改数组,UI 上的卡片自动消失——这种"修改数据即修改界面"的开发模式,将开发者从繁琐的 DOM/视图树操作中彻底解放,使其能将注意力集中在业务逻辑和数据模型上。@Observed 与 @State 的配合使用,进一步将这种数据驱动扩展到了对象属性级别,实现了真正意义上的"细粒度响应式更新"。
分层架构的可维护性价值
本项目虽然是一个单文件应用,但其内部的结构组织清晰地体现了分层架构思想。从底层的 ColorPalette 接口和 COLORS 常量(配置层),到 MatchResult/TeamItem/PlayerItem/TransferNews 数据模型(模型层),到 roleColor/kdaBarH 等纯函数(工具层),再到 8 个 @Builder Tab 构建函数(视图层),最后到 build() 根装配(装配层)——每一层都有明确的职责边界和依赖方向。这种分层使得后续的修改可以精准定位到特定层级:修改配色只需改 COLORS 对象,修改业务逻辑只需改模型类,修改某个 Tab 的布局只需改对应的 @Builder 方法。在更大规模的项目中,这些层级可以进一步拆分为独立的文件和模块,但分层原则不变。
@Builder 构建器的复用策略
本项目中 @Builder 装饰器的运用展示了构建函数复用的两种模式。第一种是"模块化拆分"——8 个 Tab 各自独立的 Builder 实现了关注点分离,每个 Builder 只负责一个功能模块的 UI 构建,互不干扰。第二种是"参数化复用"——modalOverlay(onClose) 构建器接受回调函数参数,使三个弹窗(签约、编辑、删除)的遮罩层共用同一个构建器,避免了三份重复的遮罩层代码。这两种模式的组合使得代码既有模块化的清晰边界,又有复用带来的简洁性。在实际工程中,@Builder 还可以通过 @BuilderParam 实现更灵活的构建函数传递和组合,进一步提升组件的复用能力。
电竞行业 UI 设计的工程化表达
从行业特征的角度来看,本平台在 UI 设计上做出了多个贴合电竞行业特点的工程化决策。首先是"赛博朋克"色彩体系——深色底色(#0F0F1E)配合霓虹紫青粉的高饱和度强调色,不仅营造了竞技张力,还从工程角度减少了 OLED 屏幕的功耗(深色像素几乎不发光)。其次是"信息高密度"布局策略——单屏通过 ForEach 和紧凑的间距配置承载了大量数据,每个 Tab 采用完全不同的布局模式(大卡、网格、列表、时间轴、柱状图等),在视觉一致性的前提下最大化信息表达效率。再者是"实时性"视觉编码——直播状态通过红色系颜色(红标签、红竖线、红按钮)在多个模块中一致表达,使用户无需阅读文字即可感知"直播中"这一关键状态。这些设计决策不仅是视觉层面的选择,更是对电竞行业信息消费习惯的工程化回应。
状态管理的边界与扩展方向
本项目的状态管理采用"单组件 @State + @Observed 模型"的方案,在当前的功能规模下运行良好。但随着应用功能的扩展,这种方案将面临若干挑战。首先,editIdx 被多个列表(选手列表、战队列表、转会列表)共享,导致编辑弹窗的"当前值预填"逻辑出现语义偏差——这是一个典型的"共享状态污染"问题。解决方案是为不同来源的编辑操作引入独立的索引变量或使用路由参数传递上下文。其次,所有数据都在组件内以 @State 持有,缺乏跨组件共享能力——如果应用拆分为多个子组件,数据共享需要引入 @Provide/@Consume 或状态管理框架。最后,初始数据硬编码为 const 数组,在产品化阶段需要替换为网络请求获取的动态数据,这要求引入异步数据加载和加载状态管理(如 loading 骨架屏)。这些扩展方向虽未在当前原型中实现,但代码的分层架构已经为这些演进预留了清晰的改造路径。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// ============================================================
// 825 赛博格 · 电竞战队管理平台(现代行业:电竞 / 战队管理)
// 头部样式:电竞战报头部(战队排名大卡 + 赛程横条 + 搜索栏)无动画
// 布局风格:8 个 tab 两排(4+4),每个布局完全不同
// 战报=比赛结果大卡+MVP横滑 / 战队=战队卡片网格+胜率
// 选手=选手列表+KDA数据 / 赛程=赛程时间轴+直播标记
// 训练=训练计划列表+完成度 / 数据=数据看板+柱状图
// 转会=转会新闻流 / 我的=个人档案+关注的战队
// 底部 8 tab 两排(4+4)
// 主题:赛博暗底 + 霓虹紫青
// ============================================================
interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
purple: string;
purpleD: string;
cyan: string;
cyanD: string;
pink: string;
gold: string;
orange: string;
red: string;
green: string;
blue: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#0F0F1E',
card: '#1A1A2E',
dark: '#15152A',
darkL: '#252540',
title: '#E0E0F0',
sub: '#8888AA',
text3: '#555575',
purple: '#9D4EDD',
purpleD: '#6B2DA0',
cyan: '#00E5FF',
cyanD: '#00A5BF',
pink: '#FF2D95',
gold: '#FFD700',
orange: '#FF6B35',
red: '#FF3B5C',
green: '#00E676',
blue: '#3D7DC8',
line: '#2A2A45',
tabOn: '#00E5FF',
mask: 'rgba(15,15,30,0.75)',
chip: 'rgba(0,229,255,0.10)'
};
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '📰', label: '战报' },
{ icon: '🛡️', label: '战队' },
{ icon: '🎮', label: '选手' },
{ icon: '📅', label: '赛程' },
{ icon: '🏋️', label: '训练' },
{ icon: '📊', label: '数据' },
{ icon: '🔄', label: '转会' },
{ icon: '👤', label: '我的' }
];
@Observed export class MatchResult {
id: number;
tournament: string;
teamA: string;
teamB: string;
scoreA: number;
scoreB: number;
winner: string;
mvp: string;
mvpAvatar: string;
date: string;
duration: string;
isLive: boolean;
constructor(id: number, tournament: string, teamA: string, teamB: string, scoreA: number, scoreB: number, winner: string, mvp: string, mvpAvatar: string, date: string, duration: string, isLive: boolean) {
this.id = id; this.tournament = tournament; this.teamA = teamA; this.teamB = teamB;
this.scoreA = scoreA; this.scoreB = scoreB; this.winner = winner;
this.mvp = mvp; this.mvpAvatar = mvpAvatar; this.date = date;
this.duration = duration; this.isLive = isLive;
}
}
@Observed export class TeamItem {
id: number;
name: string;
abbr: string;
icon: string;
region: string;
wins: number;
losses: number;
winRate: number;
rank: number;
color: string;
constructor(id: number, name: string, abbr: string, icon: string, region: string, wins: number, losses: number, winRate: number, rank: number, color: string) {
this.id = id; this.name = name; this.abbr = abbr; this.icon = icon;
this.region = region; this.wins = wins; this.losses = losses;
this.winRate = winRate; this.rank = rank; this.color = color;
}
}
@Observed export class PlayerItem {
id: number;
name: string;
ign: string;
avatar: string;
team: string;
role: string;
kda: number;
kills: number;
deaths: number;
assists: number;
cs: number;
salary: string;
constructor(id: number, name: string, ign: string, avatar: string, team: string, role: string, kda: number, kills: number, deaths: number, assists: number, cs: number, salary: string) {
this.id = id; this.name = name; this.ign = ign; this.avatar = avatar;
this.team = team; this.role = role; this.kda = kda; this.kills = kills;
this.deaths = deaths; this.assists = assists; this.cs = cs; this.salary = salary;
}
}
@Observed export class TransferNews {
id: number;
player: string;
fromTeam: string;
toTeam: string;
fee: string;
status: string;
time: string;
icon: string;
constructor(id: number, player: string, fromTeam: string, toTeam: string, fee: string, status: string, time: string, icon: string) {
this.id = id; this.player = player; this.fromTeam = fromTeam;
this.toTeam = toTeam; this.fee = fee; this.status = status;
this.time = time; this.icon = icon;
}
}
const MATCH_INIT: MatchResult[] = [
new MatchResult(1, 'LPL夏季赛', 'TES', 'JDG', 2, 1, 'TES', 'Knight', '⭐', '今天', '42分钟', false),
new MatchResult(2, 'LPL夏季赛', 'RNG', 'EDG', 2, 0, 'RNG', 'GALA', '🎯', '今天', '38分钟', false),
new MatchResult(3, 'LCK夏季赛', 'T1', 'GEN', 2, 1, 'T1', 'Faker', '👑', '昨天', '45分钟', false),
new MatchResult(4, 'LPL夏季赛', 'BLG', 'WBG', 2, 1, 'BLG', 'Bin', '⚔️', '昨天', '41分钟', false),
new MatchResult(5, 'LEC夏季赛', 'G2', 'FNC', 2, 0, 'G2', 'Caps', '🔮', '2天前', '35分钟', false),
new MatchResult(6, 'LCS夏季赛', 'TL', 'C9', 2, 1, 'TL', 'CoreJJ', '🛡️', '2天前', '44分钟', false),
new MatchResult(7, 'LPL夏季赛', 'LNG', 'AL', 0, 2, 'AL', 'Ale', '🔥', '3天前', '33分钟', false),
new MatchResult(8, 'LCK夏季赛', 'DK', 'KT', 2, 0, 'DK', 'ShowMaker', '💎', '3天前', '37分钟', false)
];
const TEAM_INIT: TeamItem[] = [
new TeamItem(1, '滔搏电子竞技', 'TES', '🛡️', '中国', 18, 4, 82, 1, COLORS.cyan),
new TeamItem(2, '京东游戏', 'JDG', '🐕', '中国', 16, 6, 73, 2, COLORS.red),
new TeamItem(3, '皇族永不言弃', 'RNG', '👑', '中国', 15, 7, 68, 3, COLORS.gold),
new TeamItem(4, '哔哩哔哩', 'BLG', '📺', '中国', 14, 8, 64, 4, COLORS.pink),
new TeamItem(5, '微博电竞', 'WBG', '📱', '中国', 12, 10, 55, 5, COLORS.orange),
new TeamItem(6, 'EDward Gaming', 'EDG', '⚔️', '中国', 11, 11, 50, 6, COLORS.purple),
new TeamItem(7, 'T1 Entertainment', 'T1', '🏆', '韩国', 20, 2, 91, 7, COLORS.red),
new TeamItem(8, 'Gen.G', 'GEN', '💎', '韩国', 17, 5, 77, 8, COLORS.gold),
new TeamItem(9, 'Dplus KIA', 'DK', '⭐', '韩国', 14, 8, 64, 9, COLORS.blue),
new TeamItem(10, 'G2 Esports', 'G2', '🔮', '欧洲', 16, 6, 73, 10, COLORS.purple),
new TeamItem(11, 'Fnatic', 'FNC', '🦊', '欧洲', 12, 10, 55, 11, COLORS.orange),
new TeamItem(12, 'Team Liquid', 'TL', '💧', '北美', 13, 9, 59, 12, COLORS.cyan)
];
const PLAYER_INIT: PlayerItem[] = [
new PlayerItem(1, '卓定', 'Knight', '⭐', 'TES', '中单', 8.2, 5.8, 2.1, 8.5, 285, '¥2000万'),
new PlayerItem(2, '陈伟', '369', '🛡️', 'TES', '上单', 5.6, 4.2, 2.8, 6.2, 256, '¥1500万'),
new PlayerItem(3, '赵嘉豪', 'JackeyLove', '🏹', 'TES', 'ADC', 7.8, 6.5, 2.3, 7.1, 312, '¥2200万'),
new PlayerItem(4, '李相赫', 'Faker', '👑', 'T1', '中单', 9.1, 6.2, 1.8, 9.0, 298, '¥5000万'),
new PlayerItem(5, '崔宇植', 'Zeus', '⚡', 'T1', '上单', 6.8, 4.8, 2.5, 5.5, 240, '¥1800万'),
new PlayerItem(6, '王柳羿', 'GALA', '🎯', 'RNG', 'ADC', 8.5, 7.0, 2.0, 7.8, 305, '¥2000万'),
new PlayerItem(7, '李元浩', 'Xiaohu', '🔥', 'RNG', '中单', 7.2, 5.5, 2.5, 6.8, 278, '¥1800万'),
new PlayerItem(8, '胡嘉乐', 'Bin', '⚔️', 'BLG', '上单', 7.5, 5.8, 2.2, 6.5, 262, '¥1700万'),
new PlayerItem(9, '曾洪韵', 'knight', '💎', 'JDG', '中单', 8.0, 6.0, 2.0, 7.5, 290, '¥2100万'),
new PlayerItem(10, '白家浩', '369', '🦁', 'JDG', '上单', 6.2, 4.5, 2.8, 5.8, 248, '¥1600万'),
new PlayerItem(11, '许秀', 'ShowMaker', '💎', 'DK', '中单', 7.9, 5.9, 2.2, 7.2, 282, '¥1900万'),
new PlayerItem(12, '拉尔斯·克劳斯', 'Caps', '🔮', 'G2', '中单', 8.3, 6.8, 2.0, 8.0, 295, '¥1800万')
];
const TRANSFER_INIT: TransferNews[] = [
new TransferNews(1, 'Knight', 'TES', 'JDG', '¥3000万', '已完成', '2小时前', '🔄'),
new TransferNews(2, 'TheShy', 'WBG', 'NIP', '¥1500万', '已完成', '昨天', '🔄'),
new TransferNews(3, 'Doinb', 'LNG', 'FPX', '自由身', '已完成', '2天前', '🔄'),
new TransferNews(4, 'Viper', 'EDG', 'HLE', '€200万', '已完成', '3天前', '🔄'),
new TransferNews(5, 'Canyon', 'DK', 'GEN', '未公开', '已完成', '4天前', '🔄'),
new TransferNews(6, 'Peanut', 'GEN', 'T1', '租借', '谈判中', '5小时前', '⏳'),
new TransferNews(7, 'Chovy', 'GEN', 'DK', '未公开', '传闻', '今天', '❓'),
new TransferNews(8, 'Ruler', 'GEN', 'JDG', '¥2500万', '已完成', '上周', '🔄'),
new TransferNews(9, 'Scout', 'EDG', 'LNG', '¥2000万', '已完成', '上周', '🔄'),
new TransferNews(10, 'Tarzan', 'LNG', '待定', '自由身', '待定', '2周前', '❓')
];
const KDA_DATA: number[] = [8.2, 5.6, 7.8, 9.1, 6.8, 8.5, 7.2, 7.5];
const KDA_LABELS: string[] = ['Knight', '369', 'JKL', 'Faker', 'Zeus', 'GALA', 'Xiaohu', 'Bin'];
const MAX_KDA: number = 10;
const WIN_DATA: number[] = [82, 73, 68, 64, 55, 50, 91, 77];
const WIN_LABELS: string[] = ['TES', 'JDG', 'RNG', 'BLG', 'WBG', 'EDG', 'T1', 'GEN'];
const MAX_WIN: number = 100;
const SCHEDULE: string[] = ['TES vs JDG', 'RNG vs EDG', 'T1 vs GEN', 'BLG vs WBG', 'LNG vs AL', 'DK vs KT'];
const SCHEDULE_TIME: string[] = ['今天 19:00', '今天 21:00', '明天 16:00', '明天 19:00', '后天 18:00', '后天 21:00'];
const SCHEDULE_LIVE: boolean[] = [true, false, false, false, false, false];
function roleColor(r: string): string {
if (r === '上单') { return COLORS.purple; }
if (r === '中单') { return COLORS.cyan; }
if (r === 'ADC') { return COLORS.pink; }
if (r === '打野') { return COLORS.green; }
if (r === '辅助') { return COLORS.gold; }
return COLORS.sub;
}
function kdaBarH(v: number): number {
return Math.round(v / MAX_KDA * 70);
}
function winBarH(v: number): number {
return Math.round(v / MAX_WIN * 70);
}
function transferColor(s: string): string {
if (s === '已完成') { return COLORS.green; }
if (s === '谈判中') { return COLORS.gold; }
if (s === '传闻') { return COLORS.purple; }
return COLORS.sub;
}
@Entry
@Component
struct Page825 {
@State tabIdx: number = 0;
@State glow: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addName: string = '';
@State addRole: string = '中单';
@State addTeam: string = 'TES';
@State editName: string = '';
@State matchList: MatchResult[] = MATCH_INIT;
@State teamList: TeamItem[] = TEAM_INIT;
@State playerList: PlayerItem[] = PLAYER_INIT;
@State transferList: TransferNews[] = TRANSFER_INIT;
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval(() => { this.glow = !this.glow; }, 600);
}
aboutToDisappear(): void {
if (this.timer > 0) { clearInterval(this.timer); }
}
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('⚡').fontSize(16)
Text('赛博格').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('🔍').fontSize(16)
Text('🔔').fontSize(16)
}
.width('100%')
// 战队排名大卡
Column({ space: 6 }) {
Row({ space: 10 }) {
Column() {
Text('🛡️').fontSize(28)
}
.width(44).height(44).borderRadius(10).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('TES · 滔搏电竞').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('LPL #1 · 18胜4负 · 胜率82%').fontSize(9).fontColor(COLORS.cyan)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('#1').fontSize(20).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('排名').fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(14)
.backgroundColor(COLORS.card)
// 赛程横条
Row({ space: 10 }) {
Column({ space: 1 }) {
Text('3').fontSize(16).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('今日比赛').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('🔴').fontSize(14)
Text('直播中').fontSize(8).fontColor(COLORS.red)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('48').fontSize(16).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
Text('总选手').fontSize(8).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('赛程 ›').fontSize(9).fontColor(COLORS.cyan)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(COLORS.bg)
}
@Builder
tabBar() {
Column({ space: 0 }) {
Row() {
ForEach([0, 1, 2, 3], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(8)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(14).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%')
Row() {
ForEach([4, 5, 6, 7], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(8)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(14).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%')
}
.width('100%').backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
// ===== Tab 0: 战报 =====
@Builder
matchTab() {
Column({ space: 10 }) {
Text('📰 今日战报').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 8 }) {
ForEach(this.matchList, (m: MatchResult, idx: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(m.tournament).fontSize(9).fontColor(COLORS.purple)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
if (m.isLive) {
Text('🔴 直播中').fontSize(9).fontColor(COLORS.red)
} else {
Text(m.date).fontSize(9).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text(m.duration).fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('🛡️').fontSize(24)
Text(m.teamA).fontSize(12).fontColor(m.winner === m.teamA ? COLORS.gold : COLORS.sub)
.fontWeight(m.winner === m.teamA ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(m.scoreA + ' : ' + m.scoreB).fontSize(20).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text(m.winner + ' 获胜').fontSize(8).fontColor(COLORS.green)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('🐕').fontSize(24)
Text(m.teamB).fontSize(12).fontColor(m.winner === m.teamB ? COLORS.gold : COLORS.sub)
.fontWeight(m.winner === m.teamB ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
Row({ space: 8 }) {
Text('🏆 MVP').fontSize(9).fontColor(COLORS.gold)
Text(m.mvpAvatar).fontSize(14)
Text(m.mvp).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('查看详情 ›').fontSize(9).fontColor(COLORS.cyan)
}
.width('100%').padding({ top: 4 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 1: 战队 =====
@Builder
teamTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LPL').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LCK').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LEC').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('LCS').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
}
.width('100%')
// 战队卡片网格 2列
Column({ space: 8 }) {
ForEach([0, 2, 4, 6, 8, 10], (base: number) => {
Row({ space: 8 }) {
ForEach([0, 1], (offset: number) => {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(TEAM_INIT[base + offset].icon).fontSize(22)
Column({ space: 1 }) {
Text(TEAM_INIT[base + offset].name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(TEAM_INIT[base + offset].region).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('#' + TEAM_INIT[base + offset].rank).fontSize(12).fontColor(TEAM_INIT[base + offset].color).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 4 }) {
Text(TEAM_INIT[base + offset].wins + '胜' + TEAM_INIT[base + offset].losses + '负').fontSize(8).fontColor(COLORS.sub)
Text('胜率' + TEAM_INIT[base + offset].winRate + '%').fontSize(8).fontColor(COLORS.green)
}
.width('100%')
Row() {
Column()
.width(TEAM_INIT[base + offset].winRate + '%')
.height(3).backgroundColor(TEAM_INIT[base + offset].color).borderRadius(2)
Column().layoutWeight(1)
}
.width('100%').height(3).backgroundColor(COLORS.darkL).borderRadius(2)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 2: 选手 =====
@Builder
playerTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('上单').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('中单').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('ADC').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Column().layoutWeight(1)
Text('+ 签约').fontSize(10).fontColor(COLORS.cyan)
.onClick(() => { this.addModal = true; })
}
.width('100%')
Column({ space: 6 }) {
ForEach(this.playerList, (p: PlayerItem, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.avatar).fontSize(24)
Text(p.role).fontSize(8).fontColor(roleColor(p.role))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
.width(48).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(p.ign).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('(' + p.name + ')').fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
Text(p.team).fontSize(9).fontColor(COLORS.cyan)
Text('KDA ' + p.kda.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text('CS ' + p.cs).fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 8 }) {
Text('击杀 ' + p.kills).fontSize(8).fontColor(COLORS.green)
Text('死亡 ' + p.deaths).fontSize(8).fontColor(COLORS.red)
Text('助攻 ' + p.assists).fontSize(8).fontColor(COLORS.cyan)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text(p.salary).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('✏️').fontSize(12).fontColor(COLORS.cyan)
.onClick(() => { this.editIdx = idx; this.editModal = true; })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 3: 赛程 =====
@Builder
scheduleTab() {
Column({ space: 10 }) {
Text('📅 赛程安排').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(SCHEDULE_TIME[i].split(' ')[0]).fontSize(11).fontColor(SCHEDULE_LIVE[i] ? COLORS.red : COLORS.gold).fontWeight(FontWeight.Bold)
Text(SCHEDULE_TIME[i].split(' ')[1]).fontSize(9).fontColor(COLORS.sub)
}
.width(56).alignItems(HorizontalAlign.Center)
Column().width(2).height(36)
.backgroundColor(SCHEDULE_LIVE[i] ? COLORS.red : COLORS.cyan).borderRadius(1)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(SCHEDULE[i]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
if (SCHEDULE_LIVE[i]) {
Text('🔴 LIVE').fontSize(8).fontColor('#FFFFFF')
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.red)
}
}
Text('LPL/LCK 夏季赛 · BO3').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text(SCHEDULE_LIVE[i] ? '观看' : '提醒').fontSize(9)
.fontColor('#FFFFFF')
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(6)
.backgroundColor(SCHEDULE_LIVE[i] ? COLORS.red : COLORS.darkL)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('🏆 赛事列表').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 4 }) {
ForEach(['LPL夏季赛', 'LCK夏季赛', 'LEC夏季赛', 'LCS夏季赛', 'MSI季中赛', 'S赛全球总决赛'], (t: string) => {
Row({ space: 8 }) {
Text('🏆').fontSize(14)
Text(t).fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
Text('进行中').fontSize(9).fontColor(COLORS.green)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(8).borderRadius(8)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 4: 训练 =====
@Builder
trainTab() {
Column({ space: 10 }) {
Text('🏋️ 训练计划').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 今日训练概览
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('6h').fontSize(18).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('今日时长').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('85%').fontSize(18).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('完成度').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('12').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('训练项目').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Column({ space: 6 }) {
ForEach(['个人对线训练', '团队战术演练', '录像复盘分析', '体能训练', '心理辅导', 'BP策略讨论'], (item: string, idx: number) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text((idx < 4 ? '✅' : '⬜')).fontSize(16)
}
.width(28).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(item).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text((idx + 1) + '小时 · ' + (idx < 4 ? '已完成' : '待开始')).fontSize(9).fontColor(idx < 4 ? COLORS.green : COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('›').fontSize(14).fontColor(COLORS.sub)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('📈 本周训练时长').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
Column({ space: 3 }) {
Text(['5h', '6h', '4h', '7h', '6h', '8h', '6h'][d]).fontSize(8).fontColor(COLORS.cyan)
Column()
.width(18).height([50, 60, 40, 70, 60, 80, 60][d])
.backgroundColor(d === 5 ? COLORS.pink : COLORS.purpleD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(['一', '二', '三', '四', '五', '六', '日'][d]).fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
}
.width('100%')
}
// ===== Tab 5: 数据 =====
@Builder
statTab() {
Column({ space: 10 }) {
Text('📊 数据中心').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 数据看板
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('82%').fontSize(18).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('战队胜率').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('8.2').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('场均KDA').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('285').fontSize(18).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
Text('场均CS').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('📈 选手KDA对比').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// KDA柱状图
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (d: number) => {
Column({ space: 3 }) {
Text(KDA_DATA[d].toFixed(1)).fontSize(8).fontColor(COLORS.gold)
Column()
.width(16).height(kdaBarH(KDA_DATA[d]))
.backgroundColor(d === 3 ? COLORS.pink : COLORS.cyanD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(KDA_LABELS[d]).fontSize(7).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Text('📊 战队胜率对比').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 胜率柱状图
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (d: number) => {
Column({ space: 3 }) {
Text(WIN_DATA[d].toString() + '%').fontSize(8).fontColor(COLORS.cyan)
Column()
.width(16).height(winBarH(WIN_DATA[d]))
.backgroundColor(d === 6 ? COLORS.gold : COLORS.purpleD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(WIN_LABELS[d]).fontSize(7).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Text('🎯 英雄使用率TOP5').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 4 }) {
ForEach(['亚索', '劫', '盲僧', '阿狸', '金克丝'], (hero: string, idx: number) => {
Row({ space: 8 }) {
Text((idx + 1).toString()).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text(hero).fontSize(11).fontColor(COLORS.title).layoutWeight(1)
Text(['32%', '28%', '25%', '22%', '18%'][idx]).fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(8).borderRadius(8)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 6: 转会 =====
@Builder
transferTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('已完成').fontSize(10).fontColor(COLORS.green)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('谈判中').fontSize(10).fontColor(COLORS.gold)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('传闻').fontSize(10).fontColor(COLORS.purple)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
}
.width('100%')
Column({ space: 6 }) {
ForEach(this.transferList, (t: TransferNews, idx: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(t.icon).fontSize(22)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(t.player).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(t.status).fontSize(8).fontColor(transferColor(t.status))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
Row({ space: 6 }) {
Text(t.fromTeam).fontSize(10).fontColor(COLORS.sub)
Text('→').fontSize(10).fontColor(COLORS.cyan)
Text(t.toTeam).fontSize(10).fontColor(COLORS.cyan)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text(t.fee).fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text(t.time).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('关注').fontSize(9).fontColor(COLORS.cyan) }
.layoutWeight(1).padding({ top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Center)
Column() { Text('分享').fontSize(9).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Text('🗑️').fontSize(11).fontColor(COLORS.red)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 7: 我的 =====
@Builder
profileTab() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 12 }) {
Column() {
Text('🎮').fontSize(32)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('电竞观察者').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('段位: 钻石I · 关注12支战队').fontSize(10).fontColor(COLORS.sub)
Text('⚡ 累计观赛: 486小时').fontSize(9).fontColor(COLORS.cyan)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16)
.backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('12').fontSize(20).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
Text('关注战队').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('48').fontSize(20).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('关注选手').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('3').fontSize(20).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
Text('竞猜胜率').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('⭐ 关注的战队').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3], (i: number) => {
Row({ space: 10 }) {
Text(TEAM_INIT[i].icon).fontSize(22)
Column({ space: 2 }) {
Text(TEAM_INIT[i].name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(TEAM_INIT[i].wins + '胜' + TEAM_INIT[i].losses + '负 · 胜率' + TEAM_INIT[i].winRate + '%').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('#' + TEAM_INIT[i].rank).fontSize(11).fontColor(TEAM_INIT[i].color).fontWeight(FontWeight.Bold)
Text('✏️').fontSize(12).fontColor(COLORS.cyan)
.onClick(() => { this.editIdx = i; this.editModal = true; })
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('⚙️ 设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🔔').fontSize(16)
Text('比赛提醒').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('已开启 ›').fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🎲').fontSize(16)
Text('竞猜记录').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('胜率72% ›').fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('⚙️').fontSize(16)
Text('设置').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('›').fontSize(10).fontColor(COLORS.cyan)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) { this.matchTab() }
else if (this.tabIdx === 1) { this.teamTab() }
else if (this.tabIdx === 2) { this.playerTab() }
else if (this.tabIdx === 3) { this.scheduleTab() }
else if (this.tabIdx === 4) { this.trainTab() }
else if (this.tabIdx === 5) { this.statTab() }
else if (this.tabIdx === 6) { this.transferTab() }
else { this.profileTab() }
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
// 霓虹脉冲特效层
if (this.glow) {
Row({ space: 50 }) {
Text('◆').fontSize(8).fontColor(COLORS.cyan).opacity(0.4)
Text('◇').fontSize(6).fontColor(COLORS.purple).opacity(0.3)
Text('◆').fontSize(10).fontColor(COLORS.pink).opacity(0.2)
Text('◇').fontSize(7).fontColor(COLORS.cyan).opacity(0.3)
}
.width('100%').height(16).position({ x: 0, y: 70 }).zIndex(1)
}
// ===== 新增选手弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🎮 签约新选手').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('选手ID').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入选手游戏ID', text: this.addName })
.onChange((v: string) => { this.addName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Text('所属战队').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['TES', 'JDG', 'RNG'], (t: string) => {
Text(t).fontSize(10)
.fontColor(this.addTeam === t ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addTeam === t ? COLORS.cyan : COLORS.dark)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.addTeam = t; })
})
}
.width('100%')
Text('位置').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['上单', '中单', 'ADC'], (r: string) => {
Text(r).fontSize(10)
.fontColor(this.addRole === r ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addRole === r ? COLORS.cyan : COLORS.dark)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.addRole = r; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('确认签约').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.cyan).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 编辑弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✏️ 编辑信息').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('当前: ' + (this.editIdx < this.playerList.length ? this.playerList[this.editIdx].ign : '')).fontSize(11).fontColor(COLORS.cyan)
Text('新名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入新的名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.cyan).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 删除确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 12 }) {
Text('🗑️').fontSize(36)
Text('删除这条转会新闻?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('删除后将无法恢复').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.transferList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
性能优化的潜在改进空间

从性能角度看,当前实现存在几个可优化的方向。第一,ForEach 在数据量大时会导致所有列表项一次性创建——当比赛结果或选手数量增长到几十条时,应替换为 LazyForEach 实现按需渲染,仅创建可视区域内的卡片。第二,霓虹脉冲特效使用定时器驱动的条件渲染实现,每次切换都会触发组件树的创建和销毁——更优的方案是使用 animateTo 或 animation 属性对 opacity 进行动画过渡,避免组件的反复创建销毁。第三,柱状图的柱体高度通过辅助函数计算像素值并直接赋给 Column 的 height 属性——如果需要实现动画效果(如柱体从 0 高度增长到目标高度),可以使用 animateTo 包裹高度赋值操作。第四,当前所有 Tab 内容在切换时完全重建,如果某些 Tab 的构建开销较大(如数据中心的多图表渲染),可以考虑使用 if 的 visibility 属性替代条件渲染,保持组件树常驻但通过 Visibility.Hidden 隐藏,以空间换时间减少切换延迟。这些优化在当前的原型规模下并非必需,但在产品化阶段是值得关注的性能提升方向。
更多推荐



所有评论(0)