引言

在这里插入图片描述

随着电子竞技产业进入职业化与数据化的深水区,俱乐部运营、选手档案、赛事排期、皮肤商业化和粉丝运营之间形成了紧密耦合的数据生态。一款合格的电竞数据中台,不仅要在信息密度上满足硬核观众与俱乐部经理的双重视角,更要在视觉表达上呼应"赛博、霓虹、竞技"的亚文化审美。本文剖析的源码,正是一套面向 HarmonyOS 生态、采用 ArkTS 声明式范式打造的"职业联赛数据中台",其核心目标是把战队、选手、赛事、皮肤、粉丝五大业务域聚合到统一的深紫霓虹界面下,并通过斜切电竞风视觉语言强化沉浸感。

技术选型层面,本应用选择 ArkTS 作为开发语言,这与传统前端 React/Vue 范式既有相似之处又有本质区别。ArkTS 在 TypeScript 静态类型的基础上,引入了 @Entry@Component@State@Prop@Observed@Builder 等装饰器,构成"状态驱动—声明式渲染—单向数据流"的完整闭环。@Observed 让数据模型具备可观察性,@State 让组件内部状态可变并触发重渲染,@Prop 实现父子单向数据传递,配合 ForEach 的键值生成函数,整个应用的状态管理呈现出清晰的分层结构。这种选型的优势在于编译期类型安全、运行时低开销、组件粒度可控,非常适合数据密集型的中台场景。

设计理念上,源码贯彻了"视觉即数据、数据即视觉"的原则。每一个业务实体(战队、选手、赛事、皮肤、粉丝)都被赋予一个主题色,该颜色同时驱动卡片阴影、进度条填充、徽章描边、图表柱体等多处视觉元素,形成贯穿全应用的色彩记忆点。深紫底色(#12092B/#0B0620)营造赛博空间纵深感,电光蓝(#00E5FF)与品红(#FF2E97)作为霓虹双主色,金(#FFC24B)作为荣耀色,绿(#4ADE80)作为状态色,紫(#A78BFA)作为辅助色,构成一套高度自洽的色彩体系。通过 EsportPalette 接口的统一约束,所有颜色都被中心化管理在 COLORS 常量中,避免了散落各处的"魔法值",为后续主题切换与品牌扩展留出空间。

色彩体系并非孤立存在,而是与数据模型深度绑定。TeamItemcolor 字段、SkinItemcolor 字段、KdaMetacolor 字段、TierMetacolor 字段,无一例外地引用同一套调色板。这种"实体自带主题色"的设计,让列表渲染时每条数据都能自带视觉身份,无需在视图层进行复杂的条件映射,大幅降低了组件复杂度。同时,四个工具函数(getTierColorgetStatusColorgetRarityColorgetRoleColor)将业务语义到颜色的映射逻辑收敛到独立函数,遵循单一职责原则,便于单元测试与规则演进。

组件化策略上,应用采用"一个入口 + 五大内容页 + 四类弹窗 + 一个通用徽章"的分层结构。EsportApp 作为 @Entry 入口,持有全局状态与底部 Tab 栏,通过 curTab 枚举驱动五个内容页的条件渲染;TeamContentPlayerContentMatchContentSkinContentFanContent 各自独立负责一个业务域的滚动列表与内嵌图表;AddMatchModalEditPlayerModalDeleteSkinModalDetailTeamModal 四个模态框处理写操作与详情查看;NeonBadge 作为最细粒度的通用组件,被多个页面复用。这种拆分既保证了每个组件的职责单一,又通过 @Prop 的单向数据流确保了状态可预测性,是中大型 ArkTS 应用的典型架构范式。整体而言,这套源码在工程严谨度与视觉表现力之间取得了精妙平衡,是研究 HarmonyOS 声明式 UI 与电竞业务建模的优质样本。

逐段代码分析

在这里插入图片描述

段 1:调色板接口定义

在这里插入图片描述

interface EsportPalette {
  bg: string;
  deepBg: string;
  panelBg: string;
  cardBg: string;
  line: string;
  textMain: string;
  textSub: string;
  textHint: string;
  neon: string;
  neonDeep: string;
  magenta: string;
  gold: string;
  green: string;
  red: string;
  purple: string;
  white: string;
}

这是整套色彩体系的"契约层"。通过 interface 而非 class 定义,体现 ArkTS 对结构化类型的偏好:接口只描述形状不产生运行时开销,符合声明式 UI 对轻量元数据的诉求。十六个字段覆盖了背景三层(bg/deepBg/panelBg/cardBg 实为四层,后两者用于卡片与面板)、文本三级(main/sub/hint)、霓虹主色(neon/neonDeep)、强调色(magenta/gold/green/red/purple)与纯白。这种分层不是随意的:背景四层对应"页面—滚动区—面板—卡片"的视觉纵深,文本三级对应"标题—副文—提示"的信息层级,强调色则承担业务语义映射。接口设计的好处在于,未来若要切换"暗夜霓虹"为"白昼运动风",只需替换实现而无需改动消费方代码,这是开闭原则在主题系统的直接体现。

段 2:调色板常量实现

在这里插入图片描述

const COLORS: EsportPalette = {
  bg: '#12092B',
  deepBg: '#0B0620',
  panelBg: '#1D1140',
  cardBg: '#241548',
  line: '#3A2870',
  textMain: '#F4EFFF',
  textSub: '#B9A8E8',
  textHint: '#7A68B0',
  neon: '#00E5FF',
  neonDeep: '#0091EA',
  magenta: '#FF2E97',
  gold: '#FFC24B',
  green: '#4ADE80',
  red: '#FF5C7A',
  purple: '#A78BFA',
  white: '#FFFFFF'
};

COLORS 是全局唯一不可变色彩实例,用 const 保证引用稳定。颜色取值经过精心调配:背景层从 #12092B#241548 明度递增,模拟物理空间中"远暗近亮"的视差感;#00E5FF 是高饱和青蓝,在深紫底上具备强穿透力,适合做主交互色与图表主色;#FF2E97 品红与青蓝形成互补色对比,强化竞技对抗的视觉张力;金色 #FFC24B 专司"荣耀/排名",绿色 #4ADE80 与红色 #FF5C7A 偏柔化处理,避免传统纯红纯绿在深色背景上的刺眼。值得注意的是文本色用紫调过渡(#F4EFFF/#B9A8E8/#7A68B0),保证文字与背景的色相统一,阅读时不会有"白字浮于紫底"的割裂感,这是深色主题设计的高级技巧。

段 3:Tab 枚举定义

在这里插入图片描述

enum EsportTab {
  TEAM,
  PLAYER,
  MATCH,
  SKIN,
  FAN
}

enum 而非字符串常量定义 Tab 标识,是类型安全的体现。枚举值默认从 0 递增,与 curTab: number 状态字段天然契合,比较时 this.curTab === EsportTab.TEAM 即可。相比字符串字面量,枚举在 IDE 补全、重构改名、编译期拼写检查上都更胜一筹,且不会产生重复值。这里只列五个业务域,未包含"我的"或"设置"等通用 Tab,说明应用聚焦"数据消费"而非"账户管理",定位清晰。枚举的另一个工程价值在于可扩展:未来若新增"训练营"或"转会市场"Tab,只需追加成员并同步 TABS 配置,无需改动既有渲染逻辑,符合对扩展开放、对修改关闭的原则。

段 4:Tab 元信息接口与配置

在这里插入图片描述

interface TabMeta {
  label: string;
  icon: string;
  color: string;
}

const TABS: Record<string, TabMeta> = {
  'team': { label: '战队', icon: '🛡️', color: COLORS.neon },
  'player': { label: '选手', icon: '🎮', color: COLORS.magenta },
  'match': { label: '赛事', icon: '🏆', color: COLORS.gold },
  'skin': { label: '皮肤', icon: '✨', color: COLORS.purple },
  'fan': { label: '粉丝', icon: '💜', color: COLORS.neon }
};

TabMeta 把"标签文字、图标、主题色"打包成一个值对象,每个 Tab 携带自己的视觉身份。TABSRecord<string, TabMeta> 而非数组,是为了让"key → meta"的查找是 O(1) 的,后续底部 Tab 栏渲染时通过 TABS[k] 直接取值,无需遍历。每个 Tab 的 color 都来自 COLORS,确保调色板单一来源。这里用 Emoji 作为图标而非资源文件,是工程上的折中:Emoji 跨平台一致、零资源开销、即写即用,适合 demo 与中台原型;若要上线生产,可平滑替换为 Image($r('app.media.xxx')) 资源引用,接口契约不变。这种"数据即配置"的思路让 UI 元数据与渲染逻辑解耦,是配置驱动设计的典型范例。

段 5:Tab 键名顺序数组

const TAB_KEYS: string[] = ['team', 'player', 'match', 'skin', 'fan'];

虽然 TABS 已经包含全部键,但 Object.keys 在不同引擎下的顺序并不保证语义稳定,且无法表达"自定义排列"。因此单独维护 TAB_KEYS 数组来显式声明 Tab 的展示顺序。这个细节体现了工程严谨度:渲染顺序不应依赖运行时字典迭代行为,而应由业务显式控制。该数组还在状态切换中扮演角色——this.curTab = TAB_KEYS.indexOf(k) 把字符串键转回数字索引,与 EsportTab 枚举值对齐。这种"键名数组 + 索引状态"的双轨设计,既保留了字符串键的可读性(TABS[k].label),又复用了数字索引的简洁性,是声明式 UI 中处理列表与选中态的常见且稳健的模式。

段 6:战队数据模型

在这里插入图片描述

interface TeamItem {
  id: number;
  name: string;
  tag: string;
  region: string;
  rank: number;
  winRate: number;
  points: number;
  color: string;
  slogan: string;
}

TeamItem 是战队域的实体契约。九个字段覆盖了身份(id/name/tag)、地域(region)、竞技(rank/winRate/points)、视觉(color)与品牌(slogan)五个维度。tag 是战队缩写(如 TDG),在卡片中以"TAG · 地域"形式呈现,信息密度高。color 字段的存在再次印证"实体自带主题色"的设计哲学——每支战队在榜单中的阴影、进度条、积分数字都使用自己的颜色,观众一眼即可建立"颜色—战队"的视觉映射。rankpoints 分离,前者是序数名次后者是累计积分,分别支撑榜单排序与积分柱状图两种可视化,避免了单一字段强行复用的歧义。slogan 作为情感化字段,让冷冰冰的数据带上战队气质,是电竞内容运营不可或缺的一环。

段 7:选手数据模型

interface PlayerItem {
  id: number;
  name: string;
  nick: string;
  role: string;
  kda: number;
  mvp: number;
  team: string;
  country: string;
  win: number;
}

选手模型同样九字段,但语义聚焦"人"而非"组织"。name 为本名、nick 为游戏 ID,二者并列体现电竞圈"真名 + 昵称"的双身份文化。role 是位置(打野/上单/中单/ADC/辅助),后续由 getRoleColor 映射为颜色,让不同位置在列表中可颜色区分。kdamvp 是核心竞技指标,前者综合击杀死亡助攻比,后者是赛季 MVP 次数,二者共同构成选手价值画像。team 用字符串而非 teamId 引用,是 demo 阶段的简化取舍:避免了关联查询的复杂度,但牺牲了一致性约束(重命名战队时需同步更新选手表)。countrywin(胜场数)补充了国籍与赛季表现,让选手档案足够丰满以支撑"TOP 18"榜单与"胜场排行"柱状图两种视图。

段 8:赛事数据模型

interface MatchItem {
  id: number;
  title: string;
  home: string;
  away: string;
  score: string;
  date: string;
  status: string;
  viewers: string;
}

赛事模型有意采用 string 类型的 score(如 '3:1')和 viewers(如 '528万'),而非数字。这是基于"展示优先"的取舍:比分类本身就是"3:1"这样的文本,观看人数带"万"后缀也更直观,直接以字符串存储避免了渲染时的格式化逻辑。status 用中文枚举值(‘即将开战’/‘已结束’),配合 getStatusColor 实现语义到颜色的映射。home/away 同样用战队名字符串而非 ID 引用。这种"宽模型"的设计在中台原型阶段很常见:它牺牲了关系完整性,换取了数据自包含与渲染直接性,适合数据量不大、写操作少的展示型场景。date'08-20 19:00' 这样的短格式,舍弃年份暗示当季赛事,紧凑而聚焦。

段 9:皮肤数据模型

interface SkinItem {
  id: number;
  name: string;
  hero: string;
  price: number;
  rarity: string;
  sales: number;
  color: string;
  desc: string;
}

皮肤是商业化核心实体。pricenumber(点券)而非字符串,因为后续需要参与"price + ’ 点券’“拼接与排序,数字类型更可靠。rarity 取’限定’/‘传说’/‘史诗’/'稀有’四档,由 getRarityColor 映射,让稀有度在卡片上以颜色徽章呈现,是游戏 UI 的经典做法。sales 为销量数值,直接驱动"本周销量 TOP 5"与"销量分布"两个图表的柱体高度。color 字段让每个皮肤拥有独立主题色,卡片边框使用 s.color + '44' 的半透明描边,形成"皮肤色光晕"的视觉个性。herodesc 组合提供"所属英雄 + 特效描述”,是皮肤购物决策的关键信息。整个模型在商业属性与游戏属性间取得平衡,既能驱动榜单也能驱动商城。

段 10:粉丝数据模型

interface FanItem {
  id: number;
  user: string;
  gift: string;
  points: number;
  msg: string;
  time: string;
}

粉丝模型聚焦"应援行为"而非粉丝档案本身。user 是昵称而非用户 ID,gift 是"火箭×10"这样的礼物描述串,points 是应援值数值,msg 是弹幕留言,time 是相对时间。这种设计把粉丝行为视为"事件流"而非"用户档案",更贴合直播应援的实时性本质。points 作为唯一数值字段,既是应援榜排序依据,也是进度条填充依据。giftmsg 并存,让每条粉丝记录既是消费记录又是情感表达,呼应了电竞粉丝文化的双重属性。time'08-18 09:12' 短格式,舍弃秒级精度,符合"留言流"而非"日志流"的展示定位。模型虽简,却完整刻画了"谁—送了什么—值多少—说了什么—何时"的应援事件五元组。

段 11:可观察数据模型——战队数据

@Observed
class EsportModel {
  teams: TeamItem[] = [
    { id: 1, name: '雷霆电竞', tag: 'TDG', region: '上海', rank: 1, winRate: 78, points: 168, color: COLORS.neon, slogan: '雷霆万钧 直指巅峰' },
    { id: 2, name: '烈焰之翼', tag: 'FWG', region: '成都', rank: 2, winRate: 72, points: 152, color: COLORS.magenta, slogan: '烈焰不熄 翼展苍穹' },
    { id: 3, name: '星海战队', tag: 'SSC', region: '北京', rank: 3, winRate: 69, points: 141, color: COLORS.purple, slogan: '以星为誓 以海为证' },
    { id: 4, name: '苍狼部落', tag: 'CLB', region: '西安', rank: 4, winRate: 65, points: 130, color: COLORS.gold, slogan: '苍狼啸月 战无不克' },
    { id: 5, name: '冰川之刃', tag: 'ICB', region: '哈尔滨', rank: 5, winRate: 61, points: 118, color: COLORS.neonDeep, slogan: '寒刃出鞘 破冰而行' },
    { id: 6, name: '疾风战队', tag: 'GSW', region: '广州', rank: 6, winRate: 57, points: 105, color: COLORS.green, slogan: '疾风知劲 快刀斩乱' }
  ];

@Observed 装饰器是 ArkTS 响应式系统的核心,它让 EsportModel 实例的属性变更能被框架追踪并触发依赖组件的重渲染。将所有业务数据集中到一个 Model 类,而非散落在各 @State,实现了"数据岛"的统一治理。战队数据六支,rank 从 1 到 6 单调递增,winRate 与 points 同向递减,符合真实榜单的统计规律,让 demo 数据具备可信度。每支战队的 color 分别取自调色板不同色相,保证榜单中六色彩虹般的视觉区分度。slogan 四字对仗(“雷霆万钧 直指巅峰”),既有文学性又贴合战队气质,是内容运营的细节用心。region 覆盖上海、成都、北京、西安、哈尔滨、广州,体现地域广度,也为未来"地域赛区"维度留出扩展空间。

段 12:选手数据(上)

  players: PlayerItem[] = [
    { id: 1, name: '林昊', nick: 'Hunter', role: '打野', kda: 8.6, mvp: 12, team: '雷霆电竞', country: '中国', win: 34 },
    { id: 2, name: '陈泽', nick: 'Zeus', role: '上单', kda: 7.9, mvp: 9, team: '雷霆电竞', country: '中国', win: 31 },
    { id: 3, name: '王启', nick: 'Kiwi', role: '中单', kda: 9.2, mvp: 14, team: '烈焰之翼', country: '中国', win: 33 },
    { id: 4, name: '李昂', nick: 'Aron', role: 'ADC', kda: 8.1, mvp: 11, team: '烈焰之翼', country: '韩国', win: 30 },
    { id: 5, name: '赵奕', nick: 'Yve', role: '辅助', kda: 7.2, mvp: 6, team: '星海战队', country: '中国', win: 28 },
    { id: 6, name: '孙铭', nick: 'Mint', role: '中单', kda: 8.8, mvp: 13, team: '星海战队', country: '中国', win: 29 }
  ];

前六位选手覆盖了雷霆、烈焰、星海三支头部战队,每队两人正好对应"上野/中辅/AD"的典型双核阵容,体现了对电竞阵容结构的理解。role 字段在六人中分布为打野、上单、中单、ADC、辅助、中单,覆盖五个位置,让 getRoleColor 的颜色映射在列表中得以充分展示。kda 取值在 7.2 到 9.2 之间,符合职业选手的真实区间(业余通常在 3 以下),让"TOP 18"榜单的可信度成立。country 出现"韩国"一项,呼应了电竞圈外援常态,让数据更有真实感。win 胜场数与战队排名正相关,头部战队选手胜场更高,这种内部一致性是高质量 mock 数据的标志,避免了"数据看起来像随机生成"的廉价感。

段 13:选手数据(下)

    { id: 7, name: '周凯', nick: 'Kay', role: '打野', kda: 7.5, mvp: 8, team: '苍狼部落', country: '中国', win: 26 },
    { id: 8, name: '吴迪', nick: 'Dio', role: '上单', kda: 6.9, mvp: 5, team: '苍狼部落', country: '中国', win: 24 },
    { id: 9, name: '郑浩', nick: 'Hawk', role: 'ADC', kda: 8.3, mvp: 10, team: '冰川之刃', country: '中国', win: 25 },
    { id: 10, name: '冯雪', nick: 'Snow', role: '辅助', kda: 7.0, mvp: 4, team: '冰川之刃', country: '中国', win: 22 },
    { id: 11, name: '何俊', nick: 'June', role: '中单', kda: 7.7, mvp: 9, team: '疾风战队', country: '中国', win: 23 },
    { id: 12, name: '马超', nick: 'Mars', role: '打野', kda: 8.0, mvp: 11, team: '疾风战队', country: '中国', win: 25 },
    { id: 13, name: '高翔', nick: 'Sky', role: '上单', kda: 6.6, mvp: 3, team: '雷霆电竞', country: '中国', win: 20 },
    { id: 14, name: '罗阳', nick: 'Roy', role: 'ADC', kda: 7.4, mvp: 7, team: '星海战队', country: '中国', win: 22 },
    { id: 15, name: '韩冰', nick: 'Ice', role: '辅助', kda: 6.8, mvp: 3, team: '烈焰之翼', country: '中国', win: 19 },
    { id: 16, name: '邓超', nick: 'Chao', role: '中单', kda: 7.1, mvp: 6, team: '苍狼部落', country: '中国', win: 21 },
    { id: 17, name: '曾毅', nick: 'Zen', role: 'ADC', kda: 7.8, mvp: 9, team: '冰川之刃', country: '中国', win: 24 },
    { id: 18, name: '谢航', nick: 'Hang', role: '打野', kda: 6.7, mvp: 4, team: '疾风战队', country: '中国', win: 18 }
  ];

后十二位选手把阵容扩展到全部六支战队,每队三人共十八人,正好对应榜单"TOP 18"的标题。ID 从 7 到 18 连续,保证 ForEach 的 key 函数 p.id.toString() 唯一稳定。可以看到同战队选手的胜场数相近(如冰川之刃三人胜场 25/22/24),这种"战队战绩影响选手胜场"的内部关联,让数据具备业务逻辑而非纯随机。nick 全部为英文,呼应电竞圈的国际化命名习惯;name 全部为中文双字,符合中文姓名真实分布。role 在十八人中分布均匀(打野4、上单3、中单4、ADC4、辅助3),让位置颜色徽章在列表中呈现均衡的视觉节奏。整体而言,这份选手数据在数量、结构、数值合理性上都达到了"以假乱真"的水平,是高质量内容设计的体现。

段 14:赛事数据(上)

  matches: MatchItem[] = [
    { id: 1, title: '夏季赛 总决赛', home: '雷霆电竞', away: '烈焰之翼', score: '3:1', date: '08-20 19:00', status: '即将开战', viewers: '528万' },
    { id: 2, title: '夏季赛 半决赛', home: '星海战队', away: '苍狼部落', score: '3:2', date: '08-15 18:00', status: '已结束', viewers: '461万' },
    { id: 3, title: '夏季赛 半决赛', home: '烈焰之翼', away: '冰川之刃', score: '3:0', date: '08-14 18:00', status: '已结束', viewers: '389万' },
    { id: 4, title: '常规赛 第12轮', home: '雷霆电竞', away: '疾风战队', score: '2:0', date: '08-10 17:00', status: '已结束', viewers: '256万' },
    { id: 5, title: '常规赛 第11轮', home: '星海战队', away: '烈焰之翼', score: '1:2', date: '08-08 17:00', status: '已结束', viewers: '312万' },
    { id: 6, title: '常规赛 第10轮', home: '苍狼部落', away: '冰川之刃', score: '2:1', date: '08-05 17:00', status: '已结束', viewers: '198万' },
    { id: 7, title: '常规赛 第9轮', home: '疾风战队', away: '雷霆电竞', score: '0:2', date: '08-03 17:00', status: '已结束', viewers: '224万' },
    { id: 8, title: '常规赛 第8轮', home: '烈焰之翼', away: '星海战队', score: '2:0', date: '08-01 17:00', status: '已结束', viewers: '276万' }
  ];

赛事数据按时间倒序排列,最新(08-20 总决赛)在最前,最旧(07-15 第1轮)在最后,这是赛事列表的标准排序,让用户首先看到最重要赛事。title 用"夏季赛 总决赛/半决赛/常规赛 第N轮/全明星表演赛"四类,覆盖了赛事体系的层级结构。score 用"主队分:客队分"格式,胜方分数在前,符合电竞比分书写习惯。status 只有第一条为"即将开战",其余为"已结束",让 getStatusColor 的霓虹蓝只点亮未来赛事,过去赛事统一为灰紫,形成"未赛醒目、已赛沉稳"的视觉区分。viewers 数值与赛事重要性正相关(决赛528万 > 半决赛461万 > 常规赛200-300万),让"观赛热度"图表的柱体高度有业务依据而非随机。这种"数据自洽性"是专业 mock 的核心特征。

段 15:赛事数据(下)

    { id: 9, title: '常规赛 第7轮', home: '冰川之刃', away: '苍狼部落', score: '1:2', date: '07-29 17:00', status: '已结束', viewers: '167万' },
    { id: 10, title: '常规赛 第6轮', home: '雷霆电竞', away: '星海战队', score: '2:1', date: '07-26 17:00', status: '已结束', viewers: '301万' },
    { id: 'id: 11', title: '常规赛 第5轮', home: '疾风战队', away: '烈焰之翼', score: '1:2', date: '07-24 17:00', status: '已结束', viewers: '189万' }
  ];

后段赛事数据延续倒序,viewers 在常规赛区间(167万-312万)波动,体现了"雷霆/烈焰等头部战队比赛热度更高"的隐性规律(如雷霆参与的赛事普遍在 250 万以上)。比分出现"3:0""3:1""3:2""2:0""2:1""1:2""0:2"等多种组合,避免了重复模板的廉价感。date 全部为 17:00 或 18:00 或 19:00 的整点,符合电竞赛事"固定时段开赛"的排期惯例。这十六场赛事覆盖了 07-15 到 08-20 约五周的赛程,节奏真实可信。整体看,赛事数据在"赛制层级、时间节奏、热度分布、比分多样性"四个维度都做到了合理性与丰富性的平衡,足以支撑"赛事中心"页面的赛程列表、比分展示、观赛热度图表三种可视化需求,是整个数据集中信息密度最高的一块。

段 16:皮肤数据(上)

  skins: SkinItem[] = [
    { id: 1, name: '雷霆龙魂', hero: '猎影·雷恩', price: 168, rarity: '传说', sales: 85200, color: COLORS.neon, desc: '雷光特效 龙形回城' },
    { id: 2, name: '暗夜魅影', hero: '刺客·影', price: 128, rarity: '史诗', sales: 76400, color: COLORS.purple, desc: '隐身时留下紫影' },
    { id: 3, name: '烈焰凤凰', hero: '法师·炎姬', price: 168, rarity: '传说', sales: 73100, color: COLORS.magenta, desc: '大招化凤 焚天灼地' },
    { id: 4, name: '冰川战神', hero: '战士·冰魁', price: 118, rarity: '史诗', sales: 68900, color: COLORS.neonDeep, desc: '冻伤减速 冰甲加身' },
    { id: 5, name: '黄金圣衣', hero: '射手·金羽', price: 198, rarity: '限定', sales: 65800, color: COLORS.gold, desc: '冠军限定 金光环绕' },
    { id: 6, name: '机甲风暴', hero: '坦克·钢盾', price: 108, rarity: '史诗', sales: 62400, color: COLORS.neon, desc: '变形机甲 机械音效' },
    { id: 7, name: '星海漫游', hero: '辅助·月灵', price: 128, rarity: '史诗', sales: 59100, color: COLORS.purple, desc: '星河缠绕 治疗带星光' },
    { id: 8, name: '绯红月刃', hero: '刺客·影', price: 98, rarity: '稀有', sales: 56800, color: COLORS.red, desc: '红月特效 刃锋染血' },
    { id: 9, name: '苍狼图腾', hero: '战士·蛮王', price: 108, rarity: '史诗', sales: 53400, color: COLORS.gold, desc: '狼魂附体 嚎叫登场' },
    { id: 10, name: '极光行者', hero: '打野·风', price: 128, rarity: '史诗', sales: 51200, color: COLORS.neonDeep, desc: '极光拖尾 全图闪现' }
  ];

前十款皮肤按销量降序排列,第一名"雷霆龙魂"85200 销量,第十名"极光行者"51200 销量,呈自然衰减曲线,让"本周销量 TOP 5"榜单的柱体高度有真实梯度。rarity 分布为传说、史诗、限定、稀有四档,让 getRarityColor 的四色映射在列表中充分展示。price 在 98-198 区间,传说与限定价格更高(168/198),稀有最低(98),符合游戏定价惯例。hero 字段使用"定位·名字"格式(如"猎影·雷恩"),既表明英雄定位又给出名字,信息完整。desc 四字描述特效(“雷光特效 龙形回城”),简练而有画面感,是文案功力。color 与皮肤主题强相关——"烈焰凤凰"用 magenta、"冰川战神"用 neonDeep、"黄金圣衣"用 gold,让皮肤卡片视觉与命名语义统一。

段 17:皮肤数据(下)

    { id: 11, name: '糖果甜心', hero: '辅助·兔兔', price: 88, rarity: '稀有', sales: 49800, color: COLORS.magenta, desc: '糖果弹幕 萌系特效' },
    { id: 12, name: '黑曜领主', hero: '法师·冥', price: 168, rarity: '传说', sales: 47600, color: COLORS.purple, desc: '黑曜石阵 冥火燃烧' },
    { id: 13, name: '天使降临', hero: '辅助·圣光', price: 138, rarity: '史诗', sales: 45300, color: COLORS.gold, desc: '圣光羽翼 治愈光环' },
    { id: 14, name: '赛博朋克', hero: '射手·骇客', price: 158, rarity: '传说', sales: 43100, color: COLORS.neon, desc: '赛博纹路 霓虹弹道' },
    { id: 15, name: '海龙咆哮', hero: '坦克·潮汐', price: 118, rarity: '史诗', sales: 40200, color: COLORS.neonDeep, desc: '海啸范围 浪击特效' },
    { id: 16, name: '幻影游侠', hero: '打野·风', price: 98, rarity: '稀有', sales: 38700, color: COLORS.purple, desc: '幻影分身 虚虚实实' },
    { id: 17, name: '熔岩之心', hero: '战士·炎魁', price: 128, rarity: '史诗', sales: 36500, color: COLORS.red, desc: '熔岩裂地 灼烧光环' },
    { id: 18, name: '月光女神', hero: '辅助·月灵', price: 138, rarity: '史诗', sales: 34800, color: COLORS.gold, desc: '月光洒落 静谧治愈' },
    { id: 19, name: '雷霆风暴', hero: '猎影·雷恩', price: 98, rarity: '稀有', sales: 32600, color: COLORS.neon, desc: '落雷范围 电光特效' },
    { id: 20, name: '青花瓷韵', hero: '法师·青鸾', price: 158, rarity: '限定', sales: 30100, color: COLORS.neonDeep, desc: '国风限定 青花纹理' }
  ];

后十款皮肤延续降序,"青花瓷韵"以 30100 销量收尾,整体二十款皮肤销量从 85200 到 30100 跨度合理。注意"猎影·雷恩"英雄出现两次(雷霆龙魂、雷霆风暴),"辅助·月灵"出现两次(星海漫游、月光女神),“刺客·影"出现两次(暗夜魅影、绯红月刃),这种"一英雄多皮肤"的结构符合游戏真实情况,也让"下架皮肤"操作具备业务意义(同一英雄有替代皮肤)。rarity 最后十款中传说、史诗、稀有、限定四档齐备,让全量列表的颜色徽章保持视觉多样性。desc 文案风格统一为"特效名 + 简述”(“国风限定 青花纹理”),四字对仗工整,是商业化文案的规范写法。二十款皮肤的色彩分布覆盖了调色板全部六种强调色,让"销量分布"图表呈现彩虹般的多彩柱体。

段 18:粉丝数据

  fans: FanItem[] = [
    { id: 1, user: '电竞少女小鹿', gift: '火箭×10', points: 12800, msg: '雷霆加油!冠军是我们的!', time: '08-18 09:12' },
    { id: 2, user: '夜夜观赛', gift: '应援棒×66', points: 6600, msg: 'Hunter 打野节奏太强了', time: '08-18 08:47' },
    { id: 3, user: '老周爱电竞', gift: '能量饮料×20', points: 5000, msg: '星海战队今天状态拉满', time: '08-18 08:20' },
    { id: 4, user: '小柒不吃辣', gift: '灯牌×88', points: 8800, msg: 'Kiwi 中单细节神了!', time: '08-18 07:55' },
    { id: 5, user: '电竞小钢炮', gift: '火箭×5', points: 6400, msg: '苍狼部落冲进前三!', time: '08-18 07:30' },
    { id: 6, user: '糖糖观赛日记', gift: '应援棒×30', points: 3000, msg: '现场氛围太炸裂了', time: '08-18 06:58' },
    { id: 7, user: '午夜战神', gift: '锦旗×12', points: 3600, msg: '冰川之刃雪耻之战!', time: '08-18 06:26' },
    { id: 8, user: '元气电竞团', gift: '灯牌×120', points: 12000, msg: '全场最佳 MVP 锁定', time: '08-17 23:49' },
    { id: 9, user: '追梦少年阿凯', gift: '能量饮料×40', points: 10000, msg: '决赛门票已到手!', time: '08-17 22:31' },
    { id: 10, user: '软软团子', gift: '应援棒×50', points: 5000, msg: '烈焰之翼永不熄灭', time: '08-17 21:15' },
    { id: 11, user: '峡谷侦探', gift: '锦旗×8', points: 2400, msg: '战术复盘:换线决策满分', time: '08-17 20:02' },
    { id: 12, user: '阿峰不加班', gift: '火箭×3', points: 3840, msg: '疾风战队新人很亮眼', time: '08-17 18:44' },
    { id: 13, user: '奶茶配观赛', gift: '灯牌×40', points: 4000, msg: '看完比赛来杯奶茶!', time: '08-17 17:28' },
    { id: 14, user: '电竞老炮儿', gift: '锦旗×20', points: 6000, msg: '十年老粉不请自来', time: '08-17 16:07' }
  ];
}

粉丝数据十四条,时间从 08-18 09:12 倒序到 08-17 16:07,跨两天,呈现"实时留言流"的时间感。gift 出现"火箭/应援棒/能量饮料/灯牌/锦旗"五类礼物,数量带"×N"后缀,是直播平台礼物文案的标准格式。points 与礼物价值正相关(火箭12800 > 灯牌8800 > 应援棒6600 > 锦旗3600 > 能量饮料5000),让"应援榜"排序有业务依据。msg 留言风格各异——有应援口号(“雷霆加油”)、有选手点评(“Hunter 打野节奏太强”)、有战术分析(“换线决策满分”)、有生活化吐槽(“看完比赛来杯奶茶”),还原了真实弹幕的多元声音。user 昵称个性化(“电竞少女小鹿”“老周爱电竞”“阿峰不加班”),避免了"用户1234"的廉价感。数据集到此结束,EsportModel 共持有 teams/players/matches/skins/fans 五个数组,构成完整的中台数据底座。

段 19:KDA 能力元数据

interface KdaMeta {
  label: string;
  value: number;
  color: string;
}

const KDA_SKILLS: KdaMeta[] = [
  { label: '击杀', value: 92, color: COLORS.neon },
  { label: '助攻', value: 85, color: COLORS.magenta },
  { label: '生存', value: 78, color: COLORS.purple },
  { label: '输出', value: 88, color: COLORS.gold },
  { label: '价值', value: 82, color: COLORS.green }
];

KdaMeta 是"五维能力雷达"的配置元数据,但这里并未真正渲染雷达图,而是用水平条形图替代——每项能力的 value 通过 Math.round(s.value * 2.4) 转换为柱体宽度。这种"降维可视化"是工程上的务实取舍:雷达图需要 Canvas 或 SVG,实现复杂;水平条形图用纯 Column 即可实现,且在移动端竖屏下更易阅读。五维覆盖击杀、助攻、生存、输出、经济,是 MOBA 游戏选手能力评估的经典五维模型,让"雷达拆解"标题名副其实。每维配独立颜色,让五条进度条在视觉上各自分明。value 取值在 78-92 区间,体现顶级选手的能力均衡性,避免某项过低破坏"职业选手"的可信度。这种"业务建模 + 配置驱动"的思路,让能力维度可随时扩展而不改组件代码。

段 20:段位阶梯元数据

interface TierMeta {
  name: string;
  min: number;
  color: string;
}

const TIERS: TierMeta[] = [
  { name: '青铜', min: 0, color: '#B08D57' },
  { name: '白银', min: 20, color: '#C0C0C8' },
  { name: '黄金', min: 40, color: COLORS.gold },
  { name: '铂金', min: 60, color: COLORS.neon },
  { name: '钻石', min: 80, color: COLORS.purple },
  { name: '王者', min: 100, color: COLORS.magenta }
];

TIERS 定义了六段位阶梯,min 是该段位的最低分阈值,color 是段位色。这里青铜、白银用了独立的金属色调(#B08D57/#C0C0C8)而非调色板色,因为这两个段位色彩语义特殊(金属本色),强行套用霓虹色反而失真。黄金及以上段位才复用 COLORS,形成"低段位金属、高段位霓虹"的视觉过渡,呼应"段位越高越荣耀"的心理预期。min 从 0 到 100 等差递增,每段位 20 分跨度,简单清晰。在 TeamContent 页面,这组数据被渲染为六根递增高度的柱子(Math.round(g.min * 0.5) + 12),形成"段位阶梯"的可视化,既是数据展示也是品牌氛围装饰。该元数据虽小,却体现了"配置即内容"的设计哲学:段位规则变更只需改数组,不影响渲染逻辑。

段 21:赛季积分图表元数据

interface ChartMeta {
  label: string;
  value: number;
  color: string;
}

const SEASON_POINTS: ChartMeta[] = [
  { label: '雷霆', value: 168, color: COLORS.neon },
  { label: '烈焰', value: 152, color: COLORS.magenta },
  { label: '星海', value: 141, color: COLORS.purple },
  { label: '苍狼', value: 130, color: COLORS.gold },
  { label: '冰川', value: 118, color: COLORS.neonDeep },
  { value: 105, color: COLORS.green }
];

SEASON_POINTS 是赛季积分柱状图的配置,六支战队取两字简称(雷霆/烈焰/星海/苍狼/冰川/疾风)作为 label,数值与 EsportModel.teamspoints 字段保持一致(168/152/141/130/118/105),这是数据一致性的重要体现——同一份业务事实不应在两处出现矛盾。颜色同样与战队 color 对齐,让积分榜柱体颜色与战队卡片颜色形成视觉呼应。这种"主数据 + 图表配置"的双表设计看似冗余,实则有工程价值:图表配置只取展示所需字段(label/value/color),剥离了战队卡片的 region/slogan 等,让渲染层数据精简。但代价是两表需手动同步,若 teams 积分变更需同步更新此处。在无后端的 demo 阶段这是可接受的折中,生产环境应改为从 teams 派生。

段 22:赛事公告元数据

interface NewsMeta {
  title: string;
  tag: string;
  color: string;
}

const NEWS: NewsMeta[] = [
  { title: '夏季赛总决赛门票今日开售', tag: '公告', color: COLORS.gold },
  { title: '雷霆战队锁定常规赛头名', tag: '战报', color: COLORS.neon },
  { title: '新版本补丁:打野生态大改', tag: '版本', color: COLORS.purple },
  { title: '青花瓷韵皮肤限时返场', tag: '皮肤', color: COLORS.magenta }
];

NEWS 是粉丝页的公告流配置,四条新闻覆盖"公告/战报/版本/皮肤"四类标签,每类配独立颜色,让标签徽章在列表中呈现四色彩虹。tag 用两字短标签,符合移动端徽章的紧凑要求。title 内容与赛事数据呼应——"雷霆锁定头名"对应 teams 中雷霆 rank:1,“青花瓷韵返场"对应 skins 中的同名皮肤,形成跨业务域的内容关联,让中台各页面之间产生叙事连贯感。这种"数据互文"是高质量内容设计的标志:用户在不同页面看到的信息相互印证,强化了数据可信度与品牌叙事。color 全部来自 COLORS,保证调色板统一。公告流虽小,却是粉丝页的"信息入口”,承担了引导用户关注赛事/战队/皮肤的导航职责。

段 23:粉丝应援榜元数据

interface FanBoardMeta {
  rank: number;
  user: string;
  points: number;
}

const FAN_BOARD: FanBoardMeta[] = [
  { rank: 1, user: '电竞少女小鹿', points: 12800 },
  { rank: 2, user: '元气电竞团', points: 12000 },
  { rank: 3, user: '追梦少年阿凯', points: 10000 },
  { rank: 4, user: '小柒不吃辣', points: 8800 },
  { rank: 5, user: '夜夜观赛', points: 6600 },
  { rank: 6, user: '电竞小钢炮', points: 6400 },
  { rank: 7, user: '电竞老炮儿', points: 6000 }
];

FAN_BOARD 是应援榜 TOP 7,数据与 EsportModel.fans 中的 points 字段一致(12800/12000/10000/8800/6600/6400/6000),从 fans 派生而来。这里同样采用了"主数据 + 排行配置"的双表模式,排行榜只保留 rank/user/points 三字段,比 fans 的完整结构更精简,适合榜单渲染。rank 字段显式存储而非靠数组索引,增强了数据自描述性——即便数组顺序被打乱,排名仍正确。用户名与 fans 表对齐,确保跨表引用一致。这种"派生配置"在无后端时是手动维护的,生产中应由 fans 按 points 排序自动生成。榜单前三名用金/霓虹/霓虹色突出,后四名用紫色弱化,形成"头部荣耀、尾部沉稳"的视觉层级,是排行榜设计的通用范式。

段 24:段位颜色映射函数

function getTierColor(rate: number): string {
  if (rate >= 80) {
    return COLORS.magenta;
  }
  if (rate >= 60) {
    return COLORS.neon;
  }
  if (rate >= 40) {
    return COLORS.gold;
  }
  return COLORS.purple;
}

getTierColor 把数值(如 KDA、胜率)映射到段位色,阈值 80/60/40 分别对应王者/铂金/黄金/其他。这是一个纯函数——输入决定输出,无副作用,易于测试与推理。用 if 阶梯而非 switch 或查表,是因为阈值是非连续的数值区间,if 链最直观。注意阈值与 TIERS 数组的 min 字段呼应(80 对应钻石/王者分界),保持了段位语义的一致性。该函数被 PlayerContent 用于选手 KDA 数值的着色——KDA 8.6 对应 magenta,6.9 对应 purple,让选手的 KDA 数字颜色随实力变化,形成"实力越强颜色越亮"的视觉激励。把这类业务规则收敛到独立函数而非散落在 build 中,是关注点分离的工程实践,便于规则演进与单元测试。

段 25:赛事状态颜色映射函数

function getStatusColor(status: string): string {
  if (status === '即将开战') {
    return COLORS.neon;
  }
  return COLORS.textHint;
}

getStatusColor 是最简单的二值映射——"即将开战"返回霓虹蓝,其余返回灰紫。这种极简设计是有意为之:赛事状态本质上就是"未开始/已结束"二态,未来赛事需醒目提醒,过去赛事需弱化处理。用单一判断即可完成语义到颜色的映射,避免过度设计。该函数被 MatchContent 用于赛事卡片的 status 徽章,让总决赛(即将开战)的徽章在列表中独占霓虹蓝,其余十六场已结束赛事统一灰紫,形成"一点亮、全屏沉"的视觉焦点,引导用户关注即将到来的最重要赛事。函数虽小,却承担了"状态视觉化"的关键职责,是列表可读性的重要保障。把这类映射提到组件外,也让 build 函数更聚焦于结构而非条件判断。

段 26:皮肤稀有度颜色映射函数

function getRarityColor(rarity: string): string {
  if (rarity === '限定') {
    return COLORS.magenta;
  }
  if (rarity === '传说') {
    return COLORS.gold;
  }
  if (rarity === '史诗') {
    return COLORS.purple;
  }
  return COLORS.neon;
}

getRarityColor 把四档稀有度映射到四色——限定品红、传说金、史诗紫、稀有霓虹蓝。颜色选择遵循游戏行业惯例:限定最稀有故用品红(最强视觉冲击)、传说用金(荣耀感)、史诗用紫(神秘感)、稀有用蓝(基础感)。这种"稀有度—颜色"的映射是玩家心智模型的一部分,无需学习即可识别。函数用 if 链处理四种已知稀有度,默认返回霓虹蓝兜底,保证未知稀有度不会渲染为透明色。该函数被 SkinContent 用于皮肤卡片的稀有度徽章,让二十款皮肤在列表中呈现四色徽章的视觉节奏。把稀有度规则集中到此函数,意味着未来若新增"神话"稀有度,只需加一行 if,所有皮肤徽章自动应用新色,是开闭原则的典型应用。

段 27:选手位置颜色映射函数

function getRoleColor(role: string): string {
  if (role === '打野' || role === '上单') {
    return COLORS.neon;
  }
  if (role === '中单') {
    return COLORS.magenta;
  }
  if (role === 'ADC') {
    return COLORS.gold;
  }
  return COLORS.green;
}

getRoleColor 把五个位置映射到四色——打野/上单霓虹蓝、中单品红、ADC金、辅助绿。打野与上单同色是有意的分组:二者都是"前线上野"的对抗位,视觉上归为一组;中单独占品红突出其核心地位;ADC用金呼应"输出核心"的荣耀感;辅助用绿传递"守护"的语义。这种位置—颜色映射让选手列表中的位置徽章五色分明,用户扫一眼即可识别阵容结构。函数用 || 合并打野/上单的判断,简洁表达"同组"语义。该函数被 PlayerContent 用于选手卡片的位置徽章背景与文字色,是选手列表视觉分层的核心。四个工具函数至此介绍完毕,它们共同构成了"业务语义 → 视觉颜色"的映射层,是声明式 UI 中"纯逻辑"与"纯渲染"分离的典范。

段 28:主入口组件状态声明

@Entry
@Component
struct EsportApp {
  @State model: EsportModel = new EsportModel();
  @State curTab: number = 0;
  @State showAddMatch: boolean = false;
  @State showEditPlayer: boolean = false;
  @State showDeleteSkin: boolean = false;
  @State showDetail: boolean = false;
  @State delSkinName: string = '';
  @State detailTeam: string = '';
  @State neonPulse: boolean = false;

@Entry 标记这是应用入口组件,@Component 声明其为可复用组件。@State 装饰的字段是组件内部可变状态,变更会触发重渲染。model 持有全部业务数据,作为单一数据源贯穿五大内容页;curTab 控制当前激活的 Tab;四个 show* 布尔值分别控制四个弹窗的显隐;delSkinNamedetailTeam 是弹窗所需的上下文参数(待删除皮肤名、待查看战队名)。这种"显隐状态 + 上下文参数"的拆分,让弹窗的打开具备完整的语义——既要切换显隐,也要传递操作目标。neonPulse 是一个纯交互态(标题栏闪电图标的脉冲动画开关),与业务数据无关,体现了状态管理的"业务态与交互态分离"原则。所有状态集中在入口组件,子组件通过 @Prop 接收只读副本,形成单向数据流。

段 29:模态遮罩 Builder

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
      }
    .width(1)
    .height(1)
    .opacity(0)
  }

@Builder modalOverlay 定义了一个可复用的构建函数,但其实现颇为特殊——它渲染了一个 1x1 像素、opacity 为 0 的隐形按钮,点击时触发 onClose 回调。这其实是 ArkTS 在缺乏原生"点击遮罩关闭弹窗"能力时的 hack 技巧:把一个透明按钮置于弹窗下层,捕获遮罩区域的点击事件。虽然本应用的弹窗并未实际调用此 builder(弹窗内部已自带关闭逻辑),但它的存在体现了对"模态交互模式"的预留设计。@Builder 相比 @Component 更轻量,适合这种无状态的辅助构建单元。参数 onClose: () => void 通过闭包传递关闭逻辑,是 ArkTS 中回调传递的标准方式。该段虽未被主流程使用,却展示了开发者对模态交互完整性的思考,是工程预留的体现。

段 30:主入口 build——标题栏

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        Row() {
          Column() {
            Text('ESPORTS ARENA')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .letterSpacing(4)
            Text('职业联赛 · 数据中台')
              .fontSize(10)
              .fontColor(COLORS.textSub)
              .letterSpacing(1)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column() {
            Text('⚡')
              .fontSize(20)
              .onClick(() => {
                this.neonPulse = !this.neonPulse;
              })
              .scale({ x: this.neonPulse ? 1.3 : 1, y: this.neonPulse ? 1.3 : 1 })
              .animation({ duration: 500, curve: Curve.EaseOut })
          }

build 是组件的渲染入口,根节点用 Stack 实现内容区与底部 Tab 栏的层叠布局,alignContent: Alignment.Bottom 让 Tab 栏贴底。标题栏左侧是双行文字——“ESPORTS ARENA” 主标题用 22 号粗体加 4 字间距,营造电竞海报的字距张力;"职业联赛 · 数据中台"副标题用 10 号紫调,信息层级分明。右侧是一个闪电图标按钮,点击切换 neonPulse,配合 .scale.animation 实现 500ms 缓出的放大动画,让标题栏具备"可玩"的交互细节。这种"标题 + 可交互图标"的模式既是品牌强化也是情绪出口,符合电竞应用的活力调性。.layoutWeight(1) 让左侧标题占满剩余空间,右侧图标固定 44x44,是 Flex 布局的标准分配。标题栏整体使用 panelBg 圆角背景与霓虹描边阴影,形成"霓虹徽章"质感。

段 31:主入口 build——Tab 路由

        if (this.curTab === EsportTab.TEAM) {
          TeamContent({ model: this.model, onDetail: (n: string) => {
            this.detailTeam = n;
            this.showDetail = true;
          } })
        } else if (this.curTab === EsportTab.PLAYER) {
          PlayerContent({ model: this.model, onEdit: () => {
            this.showEditPlayer = true;
          } })
        } else if (this.curTab === EsportTab.MATCH) {
          MatchContent({ model: this.model, onAdd: () => {
            this.showAddMatch = true;
          } })
        } else if (this.curTab === EsportTab.SKIN) {
          SkinContent({ model: this.model, onDel: (n: string) => {
            this.delSkinName = n;
            this.showDeleteSkin = true;
          } })
        } else {
          FanContent({ model: this.model })
        }
      }
      .width('100%')
      .height('100%')

这段是 Tab 路由的核心——根据 curTab 枚举值条件渲染五个内容页组件。每个内容页接收 model 作为数据源(@Prop 单向传递),并可选接收一个回调:onDetail/onEdit/onAdd/onDel。回调内部修改入口组件的 @State,触发弹窗显隐与上下文参数设置。这是典型的"状态提升"模式——子组件不直接管理弹窗状态,而是通过回调把"需要打开弹窗"的意图上报给父组件,由父组件统一调度。这种模式保证了弹窗状态的单点管理,避免了多个子组件各自维护弹窗导致的同步问题。if/else if 链而非 switch 是 ArkTS 条件渲染的惯例,每个分支只渲染一个组件,未激活的 Tab 组件不进入渲染树,节省开销。FanContent 无回调,因其是纯展示页无写操作。

段 32:主入口 build——底部 Tab 栏

      Row() {
        ForEach(TAB_KEYS, (k: string) => {
          Column() {
            Text(TABS[k].icon)
              .fontSize(19)
            Text(TABS[k].label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_KEYS.indexOf(k) ? TABS[k].color : COLORS.textHint)
              .fontWeight(this.curTab === TAB_KEYS.indexOf(k) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_KEYS.indexOf(k) ? COLORS.panelBg : COLORS.bg)
          .borderRadius(6)
          .shadow(this.curTab === TAB_KEYS.indexOf(k) ? { radius: 12, color: TABS[k].color + '44', offsetY: 2 } : { radius: 0, color: '#00000000', offsetY: 0 })
          .border(this.curTab === TAB_KEYS.indexOf(k) ? { width: 1, color: TABS[k].color + '55' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_KEYS.indexOf(k);
          })
        }, (k: string) => k)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.deepBg)
      .border({ width: 1, color: COLORS.line })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

底部 Tab 栏是应用导航的核心。ForEach 遍历 TAB_KEYS 渲染五个 Tab 项,每项包含图标与文字双行。选中态判断 this.curTab === TAB_KEYS.indexOf(k) 把字符串键转回索引与状态比较。选中项使用 panelBg 背景、TABS[k].color 文字色、该色半透明阴影与描边,形成"霓虹高亮"效果;未选中项统一 bg 背景、textHint 灰色、line 描边,弱化为背景。.onClickthis.curTab = TAB_KEYS.indexOf(k) 完成切换。这种"选中即亮、未选即沉"的视觉反馈是 Tab 栏的标准范式,配合每 Tab 独立颜色,形成"五色霓虹 Tab"的电竞特色。.layoutWeight(1) 让五项均分宽度,.height(60) 固定高度。整个 Tab 栏置于 Stack 底部,与上方内容区层叠,是移动端应用的经典布局。

段 33:霓虹徽章通用组件

@Component
struct NeonBadge {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor(this.color)
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .borderRadius(12)
      .border({ width: 1, color: this.color })
      .shadow({ radius: 8, color: this.color + '55', offsetY: 1 })
  }
}

NeonBadge 是应用中最细粒度的通用组件,被四个内容页复用(LIVE/TOP 18/新赛事/SALE/HOT 等徽章)。@Prop 接收 textcolor,单向数据流保证子组件只读。徽章样式为:10 号粗体文字、该色描边、该色半透明阴影、12 圆角胶囊形。这种"文字色 + 描边色 + 阴影色"三色统一的霓虹徽章,是电竞 UI 的标志性元素,营造"发光胶囊"的赛博质感。color + '55' 的 alpha 拼接是 ArkTS 中实现半透明阴影的常见手法(‘55’ 为十六进制 alpha 约 33%)。把徽章抽成独立组件而非每处重复样式,是 DRY 原则的体现,也让徽章样式变更只需改一处。该组件虽小,却是"视觉一致性"的基石——所有页面的状态徽章共享同一套视觉语言,强化了应用的统一感。

段 34:战队页——标题与积分柱状图

@Component
struct TeamContent {
  @Prop model: EsportModel;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('赛季积分榜')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('SUMMER 2026 · 常规赛')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'LIVE', color: COLORS.magenta })
        }
        .width('100%')

        Row() {
          ForEach(SEASON_POINTS, (p: ChartMeta) => {
            Column() {
              Text(p.label)
                .fontSize(9)
                .fontColor(COLORS.textSub)
              Column()
                .width(18)
                .height(Math.round(p.value * 0.32))
                .backgroundColor(p.color)
                .borderRadius(5)
                .shadow({ radius: 6, color: p.color + '55', offsetY: 1 })
                .margin({ top: 6 })
              Text(Math.round(p.value).toString())
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(p.color)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (p: ChartMeta) => p.label)
        }
        .width('100%')
        .height(150)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

TeamContent 是战队页主体,@Prop model 接收数据,onDetail 回调用于打开战队详情弹窗。页面顶部是"赛季积分榜"标题与 LIVE 徽章并排,下方是六支战队的积分柱状图。柱状图用纯 Column 实现——每根柱子是一个 Column,高度 Math.round(p.value * 0.32) 把积分(105-168)缩放到 34-54 像素区间,配合 alignItems(VerticalAlign.Bottom) 让柱子从底部生长。每根柱子带该战队色的阴影(p.color + '55'),形成"霓虹光柱"效果。柱子上下分别标注战队简称与积分值,文字色与柱色一致,强化"颜色—战队"映射。整个图表容器 150 高、panelBg 背景、14 圆角,是标准的"面板卡片"样式。这种"纯声明式柱状图"无需 Canvas 即可实现,是 ArkTS 在数据可视化上的轻量方案。

段 35:战队页——战队档案列表

        Text('战队档案 · 6 支')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.teams, (t: TeamItem) => {
          Column() {
            Row() {
              Text('#' + t.rank.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(t.rank <= 2 ? COLORS.gold : COLORS.textHint)
                .width(34)
              Column() {
                Text(t.name)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text(t.tag + ' · ' + t.region)
                  .fontSize(10)
                  .fontColor(COLORS.textSub)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 8 })
              Text(t.slogan)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width(90)
              Text(t.points.toString() + '分')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(t.color)
                .margin({ left: 8 })
            }
            .width('100%')

战队档案列表用 ForEach 渲染六支战队卡片。每张卡片首行是"排名 + 战队名/标签/地域 + 口号 + 积分"的横向布局:排名前两名用金色突出(t.rank <= 2 ? COLORS.gold : COLORS.textHint),呼应"金银铜"的奖牌心智;口号用 maxLines(1) + textOverflow(Ellipsis) 限制单行省略,避免长口号破坏布局;积分用战队主题色,让每张卡片的积分数字自带颜色身份。.width(34) 固定排名列宽,.layoutWeight(1) 让中间战队信息列占满剩余,口号与积分固定宽度靠右,是表格化布局的精确控制。卡片整体用 cardBg 背景、12 圆角、line 描边,配合 t.color + '22' 的极淡主题色阴影,形成"每张卡片自带战队色光晕"的视觉个性,是"实体主题色"设计哲学的集中体现。

段 36:战队页——胜率条与详情入口

            Row() {
              Text('胜率')
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .width(36)
              Row() {
                Column()
                  .width(Math.round(t.winRate * 2.6))
                  .height(8)
                  .backgroundColor(t.color)
                  .borderRadius(4)
                  .shadow({ radius: 4, color: t.color + '55', offsetX: 0, offsetY: 0 })
              }
              .layoutWeight(1)
              .height(8)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(4)
              Text(t.winRate.toString() + '%')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(t.color)
                .width(42)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 10 })

            Row() {
              Text('查看详情')
                .fontSize(11)
                .fontColor(t.color)
                .onClick(() => {
                  this.onDetail(t.name);
                })
              Text('→')
                .fontSize(11)
                .fontColor(t.color)
                .margin({ left: 4 })
            }
            .width('100%')
            .justifyContent(FlexAlign.End)
            .margin({ top: 8 })
          }
          .width('100%')
          .padding(14)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
          .border({ width: 1, color: COLORS.line })
          .shadow({ radius: 8, color: t.color + '22', offsetY: 2 })
          .margin({ bottom: 10 })
        }, (t: TeamItem) => t.id.toString())

卡片第二行是胜率进度条——外层 RowdeepBg 深底模拟轨道,内层 Column 宽度 Math.round(t.winRate * 2.6) 把胜率(57-78)映射到 148-203 像素,填充该战队色并带同色阴影,形成"霓虹进度条"。左侧"胜率"标签固定 36 宽,右侧百分比固定 42 宽右对齐,让进度条在中间自由伸缩。这种"标签—条—数值"的三段式是进度条组件的标准结构。第三行是"查看详情 →"链接,右对齐用 justifyContent(FlexAlign.End),点击触发 this.onDetail(t.name) 把战队名上报给父组件打开详情弹窗。ForEach 的 key 用 t.id.toString() 保证列表 diff 稳定。卡片整体 14 内边距、12 圆角、line 描边、t.color + '22' 阴影,与首段卡片样式统一,维持视觉节奏。

段 37:战队页——段位阶梯

        Text('段位阶梯 · 粉丝牌')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(TIERS, (g: TierMeta) => {
            Column() {
              Column()
                .width(14)
                .height(Math.round(g.min * 0.5) + 12)
                .backgroundColor(g.color)
                .borderRadius(7)
                .shadow({ radius: 6, color: g.color + '55', offsetY: 1 })
              Text(g.name)
                .fontSize(9)
                .fontColor(g.min >= 80 ? g.color : COLORS.textSub)
                .fontWeight(g.min >= 80 ? FontWeight.Bold : FontWeight.Normal)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (g: TierMeta) => g.name)
        }
        .width('100%')
        .height(90)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

战队页末尾是"段位阶梯"可视化——六根递增高度的柱子代表青铜到王者六段位,高度 Math.round(g.min * 0.5) + 12 把阈值(0-100)映射到 12-62 像素,形成阶梯上升造型。柱子宽度 14、圆角 7(半圆胶囊头),配该段位色阴影。段位名文字在 min >= 80(钻石/王者)时用段位色加粗,低段位用灰紫常规,形成"高段位醒目、低段位沉稳"的视觉层级。整个容器 90 高、panelBg 背景、底部对齐,让阶梯从底部生长。页面整体用 Scroll 包裹,.scrollable(ScrollDirection.Vertical) 启用竖向滚动,.edgeEffect(EdgeEffect.Spring) 配置回弹动效,模拟原生滚动体验。.padding({ bottom: 76 }) 为底部 Tab 栏预留空间,避免内容被遮挡,是移动端布局的细节考量。

段 38:选手页——标题与五维能力

@Component
struct PlayerContent {
  @Prop model: EsportModel;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('选手数据榜')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('KDA / MVP / 胜场')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'TOP 18', color: COLORS.neon })
        }
        .width('100%')

        Column() {
          Text('五维能力 · 雷达拆解')
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .alignSelf(ItemAlign.Start)
          ForEach(KDA_SKILLS, (s: KdaMeta) => {
            Row() {
              Text(s.label)
                .fontSize(10)
                .fontColor(COLORS.textSub)
                .width(40)
              Row() {
                Column()
                  .width(Math.round(s.value * 2.4))
                  .height(8)
                  .backgroundColor(s.color)
                  .borderRadius(4)
                  .shadow({ radius: 4, color: s.color + '66', offsetY: 0 })
              }
              .layoutWeight(1)
              .height(8)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(4)
              Text(s.value.toString())
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(s.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (s: KdaMeta) => s.label)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

选手页结构与战队页同构——标题行 + 面板卡片。标题"选手数据榜"配"KDA / MVP / 胜场"副标与"TOP 18"霓虹徽章。下方"五维能力 · 雷达拆解"面板用 ForEach 渲染五条水平进度条,每条由"标签—进度条—数值"三段组成。进度条宽度 Math.round(s.value * 2.4) 把能力值(78-92)映射到 187-220 像素,填充该维颜色并带同色阴影。外层 RowdeepBg 深底作轨道,与胜率条同构。标题虽写"雷达拆解"但实为条形图,是文案与实现的轻微错位——或许未来可扩展为真正的雷达图,但当前条形图在移动端竖屏下更易读。五个维度的颜色各异(neon/magenta/purple/gold/green),让五条进度条在面板内形成彩虹梯度,视觉丰富而不杂乱。

段 39:选手页——名单标题行

        Row() {
          Text('选手名单')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
          Text('共 ' + this.model.players.length.toString() + ' 人')
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .margin({ left: 6 })
          Text('录入新选手')
            .fontSize(11)
            .fontColor(COLORS.neon)
            .layoutWeight(1)
            .textAlign(TextAlign.End)
            .onClick(() => {
              this.onEdit();
            })
        }
        .width('100%')
        .margin({ top: 16, bottom: 8 })

选手名单的标题行是"标题 + 计数 + 操作"三段式布局。"选手名单"为主标题,“共 N 人"用 players.length 动态计算人数,保证数据变更时计数自动更新——这是声明式 UI 的优势,数据驱动渲染无需手动同步。右侧"录入新选手"是操作入口,霓虹蓝文字右对齐,点击触发 this.onEdit() 回调打开编辑弹窗。这种"标题 + 计数 + 操作"的标题行模式在五个内容页中反复出现,形成统一的页面节奏,用户能快速定位"这是什么数据—有多少—能做什么”。.layoutWeight(1) 让操作入口占满剩余空间并右对齐,是 Flex 布局的灵活运用。整行 margin 上下 16/8,与上方面板卡片、下方列表保持呼吸间距,避免信息拥挤。

段 40:选手页——选手列表项

        ForEach(this.model.players, (p: PlayerItem) => {
          Row() {
            Text(p.nick)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .width(74)
            Column() {
              Text(p.name + ' · ' + p.country)
                .fontSize(10)
                .fontColor(COLORS.textSub)
              Text(p.team)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(p.role)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(getRoleColor(p.role))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(getRoleColor(p.role) + '22')
            }
            Column() {
              Text(p.kda.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(getTierColor(p.kda))
              Text('KDA')
                .fontSize(8)
                .fontColor(COLORS.textHint)
            }
            .alignItems(HorizontalAlign.End)
            .width(56)
            Column() {
              Text(p.mvp.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gold)
              Text('MVP')
                .fontSize(8)
                .fontColor(COLORS.textHint)
            }
            .alignItems(HorizontalAlign.End)
            .width(48)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 10, bottom: 10 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (p: PlayerItem) => p.id.toString())

选手列表项是横向五列布局:昵称(74宽)、姓名+国籍+战队(layoutWeight 占满)、位置徽章、KDA、MVP。昵称用粗体主色突出游戏身份;姓名与国籍合并一行,战队名单独小字,信息分层清晰。位置徽章用 getRoleColor 取色,背景用该色 + ‘22’ 的极淡填充,形成"位置色块"标签。KDA 数值用 getTierColor 按实力段位着色——KDA 8.6 对应 magenta,6.9 对应 purple,让选手的 KDA 数字颜色随实力变化。MVP 统一金色,突出其荣耀属性。KDA 与 MVP 各自下方带小字标签,形成"数值 + 单位"的双行结构。卡片样式与战队卡片同构(cardBg/10圆角/line描边),但未带主题色阴影,因选手无独立 color 字段。ForEach key 用 id 保证稳定,18 位选手渲染为 18 张卡片。

段 41:选手页——胜场排行图

        Text('胜场排行')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(this.model.players.slice(0, 6), (p: PlayerItem) => {
            Column() {
              Text(p.nick)
                .fontSize(9)
                .fontColor(COLORS.textSub)
              Column()
                .width(16)
                .height(Math.round(p.win * 1.1))
                .backgroundColor(getTierColor(p.kda))
                .borderRadius(5)
                .margin({ top: 5 })
              Text(p.win.toString())
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (p: PlayerItem) => p.id.toString())
        }
        .width('100%')
        .height(110)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

选手页末尾是"胜场排行"柱状图,取前六位选手(players.slice(0, 6))渲染六根柱子。柱高 Math.round(p.win * 1.1) 把胜场(18-34)映射到 20-37 像素,颜色用 getTierColor(p.kda) 按选手 KDA 段位着色——这让柱子颜色既反映位置也反映实力,信息密度高。柱顶标注昵称,柱底标注胜场数,形成"名—柱—数"的三层信息结构。容器 110 高、底部对齐、panelBg 背景,与前述图表样式统一。.slice(0, 6) 是截取前六,避免图表柱子过多挤压可读性,是数据可视化的"少即是多"原则。整个选手页通过"五维能力条 + 选手列表 + 胜场排行"三段可视化,把选手的"能力—档案—战绩"三个维度完整呈现,信息架构清晰。

段 42:赛事页——标题与发布按钮

@Component
struct MatchContent {
  @Prop model: EsportModel;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('赛事中心')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('赛程 · 比分 · 观看热度')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: '+ 新赛事', color: COLORS.gold })
        }
        .width('100%')

        Row() {
          Text('发布新赛程')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.bg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .height(36)
            .backgroundColor(COLORS.gold)
            .borderRadius(10)
            .shadow({ radius: 8, color: COLORS.gold + '55', offsetY: 2 })
            .onClick(() => {
              this.onAdd();
            })
        }
        .width('100%')
        .margin({ top: 12 })

赛事页标题行与其他页同构——"赛事中心"主标 + "赛程 · 比分 · 观看热度"副标 + “+ 新赛事"金色徽章。与众不同的是标题下方紧跟一个"发布新赛程"实心按钮——金色背景、深紫文字(COLORS.bg)、36 高、10 圆角、金色半透明阴影,形成"霓虹金按钮"的强号召样式。按钮文字用 COLORS.bg 深紫而非白色,是因为金色背景上深紫比白色更有对比度且更"电竞”。点击触发 this.onAdd() 打开新增赛事弹窗。这种"标题行 + 主操作按钮"的组合,把写操作的入口前置到页面顶部,降低了用户发现成本,是 CRUD 应用的常见做法。按钮独占一行而非内联,强化其作为"主要 CTA"的视觉权重。

段 43:赛事页——赛程列表

        Text('近期赛程 · 16 场')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.matches, (m: MatchItem) => {
          Column() {
            Row() {
              Text(m.title)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
                .layoutWeight(1)
              Text(m.status)
                .fontSize(9)
                .fontColor(getStatusColor(m.status))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .border({ width: 1, color: getStatusColor(m.status) })
            }
            .width('100%')

            Row() {
              Column() {
                Text(m.home)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.neon)
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.End)
              Column() {
                Text(m.score)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text(m.viewers)
                  .fontSize(9)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .padding({ left: 10, right: 10 })
              Column() {
                Text(m.away)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.magenta)
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
            }
            .width('100%')
            .margin({ top: 10 })

            Text(m.date + ' · ' + m.viewers + ' 人在线')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .width('100%')
              .textAlign(TextAlign.Center)
              .margin({ top: 8 })
          }
          .width('100%')
          .padding(14)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 10 })
        }, (m: MatchItem) => m.id.toString())

赛程列表是赛事页的核心。每张卡片分三行:首行"赛事标题 + 状态徽章"——标题占满左侧,状态徽章用 getStatusColor 着色,"即将开战"为霓虹蓝描边、"已结束"为灰紫描边,一眼区分未来与过去赛事。次行是"主队—比分—客队"的三列对阵布局:主队用霓虹蓝、客队用品红,形成"蓝红对抗"的视觉张力,呼应电竞比赛的阵营对立;比分用 18 号粗体居中,下方小字标注观看人数,形成"大比分 + 小热度"的视觉焦点。末行是"时间 · 观看人数 在线"的居中说明,补充赛事元信息。卡片样式统一(cardBg/12圆角/line描边),但未带主题色阴影,因赛事无独立 color 字段。十六场赛事渲染为十六张卡片,信息量饱满但节奏清晰。

段 44:赛事页——观赛热度图

        Text('观赛热度')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(this.model.matches.slice(0, 8), (m: MatchItem) => {
            Column() {
              Text(m.home.substring(0, 2))
                .fontSize(8)
                .fontColor(COLORS.textSub)
              Column()
                .width(14)
                .height(Math.round(Number(m.viewers.substring(0, 3)) * 0.09))
                .backgroundColor(COLORS.magenta)
                .borderRadius(4)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (m: MatchItem) => m.id.toString())
        }
        .width('100%')
        .height(100)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

赛事页末尾是"观赛热度"柱状图,取前八场赛事(matches.slice(0, 8))渲染八根柱子。柱高 Math.round(Number(m.viewers.substring(0, 3)) * 0.09)——先从"528万"截取前三位"528"转为数字,再乘 0.09 映射到约 47 像素,让百万级观看人数缩放到图表高度。这种字符串解析虽不优雅,但在 demo 阶段可接受,生产环境应将 viewers 改为 number 存储。柱子统一品红色,与对阵卡片的客队色呼应,形成"红色=热度"的视觉语义。柱顶标注主队前两字(m.home.substring(0, 2)),让用户识别哪场比赛热度高。容器 100 高、底部对齐、panelBg 背景,样式与其他图表统一。整个赛事页通过"发布按钮 + 赛程列表 + 热度图"三段,把赛事的"创建—浏览—分析"闭环完整呈现。

段 45:皮肤页——标题与销量榜

@Component
struct SkinContent {
  @Prop model: EsportModel;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('皮肤商城')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('限定 · 传说 · 史诗 · 稀有')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'SALE', color: COLORS.magenta })
        }
        .width('100%')

        Column() {
          Text('本周销量 TOP 5')
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .alignSelf(ItemAlign.Start)
          ForEach(this.model.skins.slice(0, 5), (s: SkinItem) => {
            Row() {
              Text(s.name)
                .fontSize(10)
                .fontColor(COLORS.textMain)
                .width(72)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Column()
                  .width(Math.round(s.sales * 0.0011))
                  .height(7)
                  .backgroundColor(s.color)
                  .borderRadius(3)
              }
              .layoutWeight(1)
              .height(7)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(3)
              Text(s.sales.toString())
                .fontSize(9)
                .fontColor(s.color)
                .width(44)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (s: SkinItem) => s.id.toString())
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

皮肤页标题"皮肤商城"配"限定 · 传说 · 史诗 · 稀有"四档稀有度副标与"SALE"品红徽章。下方"本周销量 TOP 5"面板取前五款皮肤渲染水平进度条——宽度 Math.round(s.sales * 0.0011) 把销量(51200-85200)映射到 56-94 像素,填充该皮肤主题色。皮肤名用 maxLines(1) + Ellipsis 限制 72 宽内单行省略,避免长名破坏进度条对齐。销量数值右对齐 44 宽,用皮肤色着色,强化"颜色—皮肤"映射。五条进度条颜色各异(neon/purple/magenta/neonDeep/gold),形成彩虹梯度,视觉丰富。面板样式与其他页一致(panelBg/14圆角/line描边),保持应用统一感。ForEach key 用 id,slice(0,5) 截取前五,是"TOP N 榜单"的标准实现。

段 46:皮肤页——皮肤列表

        Text('全部皮肤 · 20 款')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.skins, (s: SkinItem) => {
          Row() {
            Column() {
              Text(s.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
              Text(s.hero + ' · ' + s.desc)
                .fontSize(9)
                .fontColor(COLORS.textSub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(s.rarity)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(getRarityColor(s.rarity))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(getRarityColor(s.rarity) + '22')
              Text(s.price.toString() + ' 点券')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gold)
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.End)
            .margin({ left: 8 })
            Text('🗑')
              .fontSize(14)
              .margin({ left: 10 })
              .onClick(() => {
                this.onDel(s.name);
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: s.color + '44' })
          .margin({ bottom: 8 })
        }, (s: SkinItem) => s.id.toString())

皮肤列表是横向三列布局:左侧"皮肤名 + 英雄·描述"占满,右侧"稀有度徽章 + 价格",最右"🗑"删除图标。稀有度徽章用 getRarityColor 着色,背景用该色 + ‘22’ 极淡填充,让四档稀有度呈现四色徽章。价格统一金色粗体,突出商业属性。删除图标点击触发 this.onDel(s.name) 把皮肤名上报给父组件打开删除确认弹窗。卡片描边用 s.color + '44' 的皮肤主题色半透明,让每张皮肤卡片自带皮肤色光晕,是"实体主题色"在皮肤域的体现。英雄与描述合并一行用 maxLines(1) 省略,保证卡片高度统一。二十款皮肤渲染为二十张卡片,每张通过颜色徽章与主题色描边形成视觉个性,避免了长列表的枯燥感。

段 47:皮肤页——销量分布图

        Text('销量分布')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(this.model.skins.slice(0, 10), (s: SkinItem) => {
            Column() {
              Text(Math.round(s.sales * 0.0001).toString() + 'w')
                .fontSize(8)
                .fontColor(COLORS.textHint)
              Column()
                .width(12)
                .height(Math.round(s.sales * 0.0016))
                .backgroundColor(s.color)
                .borderRadius(4)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (s: SkinItem) => s.id.toString())
        }
        .width('100%')
        .height(90)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

皮肤页末尾是"销量分布"柱状图,取前十款皮肤渲染十根柱子。柱高 Math.round(s.sales * 0.0016) 把销量(32600-85200)映射到 52-136 像素,柱顶标注 Math.round(s.sales * 0.0001).toString() + 'w'(如"8.5w")表示万级销量。柱子用各皮肤主题色,让十根柱子呈现十色彩虹,是应用中色彩最丰富的图表。容器 90 高、底部对齐、panelBg 背景,与其他图表样式统一。.slice(0, 10) 截取前十,避免柱子过多挤压可读性。整个皮肤页通过"销量榜 + 皮肤列表 + 销量分布图"三段,把皮肤的"热销—档案—分布"三个维度完整呈现,是商业数据可视化的典型结构。

段 48:粉丝页——标题与公告

@Component
struct FanContent {
  @Prop model: EsportModel;

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('粉丝应援')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('弹幕 · 礼物 · 应援榜')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'HOT', color: COLORS.magenta })
        }
        .width('100%')

        Column() {
          Text('赛事公告')
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .alignSelf(ItemAlign.Start)
          ForEach(NEWS, (n: NewsMeta) => {
            Row() {
              Text(n.tag)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(n.color)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(6)
                .border({ width: 1, color: n.color })
              Text(n.title)
                .fontSize(10)
                .fontColor(COLORS.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .layoutWeight(1)
                .margin({ left: 8 })
            }
            .width('100%')
            .margin({ top: 6 })
          }, (n: NewsMeta) => n.title)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

粉丝页是唯一无写操作回调的内容页(无 onXxx 参数),纯展示。标题"粉丝应援"配"弹幕 · 礼物 · 应援榜"副标与"HOT"品红徽章。下方"赛事公告"面板渲染四条新闻——每条由"标签徽章 + 标题"组成,标签用 n.color 着色描边,四类标签四色,形成"公告金/战报蓝/版本紫/皮肤红"的视觉分类。标题用 maxLines(1) + Ellipsis 单行省略,保证公告行高度统一。公告流作为粉丝页的首屏内容,承担了"引导用户关注赛事/战队/皮肤"的导航职责,是跨业务域的内容枢纽。面板样式统一(panelBg/14圆角/line描边)。ForEach key 用 n.title,因 NEWS 无 id 字段,以标题为键,在标题唯一的前提下可接受。

段 49:粉丝页——应援榜

        Row() {
          Text('应援榜')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
          Text('TOP 7')
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .margin({ left: 6 })
        }
        .width('100%')
        .margin({ top: 16, bottom: 8 })

        ForEach(FAN_BOARD, (f: FanBoardMeta) => {
          Row() {
            Text(f.rank.toString())
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(f.rank === 1 ? COLORS.gold : (f.rank <= 3 ? COLORS.neon : COLORS.textHint))
              .width(28)
            Column() {
              Text(f.user)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
              Row() {
                Column()
                  .width(Math.round(f.points * 0.004))
                  .height(6)
                  .backgroundColor(f.rank <= 3 ? COLORS.magenta : COLORS.purple)
                  .borderRadius(3)
              }
              .width(100)
              .height(6)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(3)
              .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 6 })
            Text(f.points.toString() + ' 应援值')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 9, bottom: 9 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 7 })
        }, (f: FanBoardMeta) => f.rank.toString())

应援榜 TOP 7 用 ForEach 渲染七条记录。每条是"排名 + 用户名+进度条 + 应援值"三列布局:排名颜色按名次分层——第一名金、二至三名霓虹蓝、四至七名灰紫,形成"金银铜"的奖牌视觉;用户名粗体主色,下方是 100 宽的应援值进度条,宽度 Math.round(f.points * 0.004) 把应援值(2400-12800)映射到 10-51 像素,前三名用品红填充、后四名用紫色填充,形成"头部热色、尾部冷色"的层级。应援值统一金色粗体,突出数值的荣耀感。卡片样式统一(cardBg/10圆角/line描边),margin 7 保持紧凑。ForEach key 用 f.rank,因排名唯一。整个榜单通过"排名色 + 进度条色 + 数值色"三重视觉编码,让七条记录的层级一目了然,是排行榜设计的优秀范例。

段 50:粉丝页——应援留言

        Text('应援留言 · 14 条')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.fans, (f: FanItem) => {
          Row() {
            Column() {
              Text(f.user)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.neon)
              Text(f.msg)
                .fontSize(11)
                .fontColor(COLORS.textMain)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(f.gift)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.magenta)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(COLORS.magenta + '22')
              Text(f.time)
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.End)
            .margin({ left: 8 })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (f: FanItem) => f.id.toString())
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

应援留言列表渲染十四条粉丝留言。每条是"用户名+留言 + 礼物徽章+时间"的左右布局:用户名用霓虹蓝粗体,留言用主色 11 号,形成"蓝名 + 白话"的弹幕视觉;右侧礼物徽章用品红描边背景,下方 8 号灰时间,信息紧凑。留言文字不限行(无 maxLines),允许长留言换行,保证内容完整呈现——这与公告/口号的单行省略不同,因留言是"情感表达"需完整展示。卡片样式统一。整个粉丝页通过"公告 + 应援榜 + 留言"三段,把粉丝的"资讯—贡献—互动"三个维度完整呈现,是社区运营的典型结构。ForEach key 用 f.id,十四条留言渲染为十四张卡片。至此五个内容页全部介绍完毕,它们共享一致的"标题行 + 面板卡片 + 列表 + 图表"四段结构,形成统一的页面节奏。

段 51:新增赛事弹窗

@Component
struct AddMatchModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('新增赛事')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textSub)
            .width(32)
            .height(32)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('🏆')
          .fontSize(40)
          .width(70)
          .height(70)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(35)
          .border({ width: 2, color: COLORS.gold })
          .shadow({ radius: 14, color: COLORS.gold + '55', offsetY: 3 })
          .margin({ top: 14 })
        Text('录入一场新对局')
          .fontSize(12)
          .fontColor(COLORS.textSub)
          .margin({ top: 8 })

AddMatchModal 是新增赛事弹窗,onClose 回调用于关闭。弹窗结构为"遮罩层(外层 Column,#00000088 半透明黑底)+ 内容卡片(内层 Column,cardBg 背景/16 圆角)"。内容卡片顶部是"标题 + ✕ 关闭按钮"的标题行,关闭按钮用 panelBg 圆形背景,点击触发 onClose。下方是 70x70 的金色奖杯图标圆,40 号 Emoji 配 2 像素金描边与金色阴影,形成"荣耀图标"的视觉焦点,强化"新增赛事"的仪式感。再下方是"录入一场新对局"说明文字。整个弹窗的视觉风格与内容页一致——深紫底、霓虹强调、圆角卡片,保证模态与非模态的视觉连贯。弹窗通过 justifyContent(FlexAlign.End) 让内容卡片贴底,是底部抽屉式弹窗的标准布局。

段 52:新增赛事弹窗——表单与按钮

        Column() {
          Text('对阵双方')
            .fontSize(11)
            .fontColor(COLORS.textSub)
          Text('雷霆电竞 VS 苍狼部落')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .margin({ top: 4 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(10)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Row() {
          Column() {
            Text('比赛时间')
              .fontSize(11)
              .fontColor(COLORS.textSub)
            Text('08-22 19:00')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 10, right: 8 })
          Column() {
            Text('赛制')
              .fontSize(11)
              .fontColor(COLORS.textSub)
            Text('BO5 三胜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 10 })
        }
        .width('100%')

        Row() {
          Text('提交赛程')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.bg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .height(40)
            .backgroundColor(COLORS.neon)
            .borderRadius(10)
            .shadow({ radius: 8, color: COLORS.neon + '55', offsetY: 2 })
            .onClick(() => {
              this.onClose();
            })
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.textSub)
            .textAlign(TextAlign.Center)
            .width(80)
            .height(40)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(10)
            .margin({ left: 10 })
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '78%' })
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .border({ width: 1, color: COLORS.line })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .padding({ left: 12, right: 12, bottom: 76 })
    .backgroundColor('#00000088')
  }
}

弹窗表单包含"对阵双方"整行卡片与"比赛时间 / 赛制"双列卡片,每个字段用"标签 + 值"的上下结构呈现,值用粗体主色或金色,赛制用金色突出。表单字段目前是静态文本(“雷霆电竞 VS 苍狼部落”"08-22 19:00"“BO5 三胜”),未接入输入框,是 demo 阶段的简化——真实应用应替换为 TextInput/DatePicker/Picker 等表单组件。底部是"提交赛程"霓虹蓝主按钮 + "取消"灰按钮的双按钮组合,主按钮占满剩余宽度(layoutWeight(1)),取消按钮固定 80 宽,形成"主次分明"的 CTA 布局。两个按钮均点击触发 onClose,因 demo 不做真实数据写入。.constraintSize({ maxHeight: '78%' }) 限制内容卡片最大高度,避免内容过多超出屏幕,是模态弹窗的关键约束。

段 53:编辑选手弹窗

@Component
struct EditPlayerModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('编辑选手')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textSub)
            .width(32)
            .height(32)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('🎮')
          .fontSize(40)
          .width(70)
          .height(70)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(35)
          .border({ width: 2, color: COLORS.magenta })
          .shadow({ radius: 14, color: COLORS.magenta + '55', offsetY: 3 })
          .margin({ top: 14 })
        Text('更新选手档案')
          .fontSize(12)
          .fontColor(COLORS.textSub)
          .margin({ top: 8 })

EditPlayerModalAddMatchModal 结构高度同构——标题行 + 圆形图标 + 说明文字 + 表单字段 + 双按钮。差异在于:标题为"编辑选手",图标用🎮品红圆(2 像素品红描边 + 品红阴影),说明为"更新选手档案"。这种"同构弹窗"体现了组件复用的设计思路——若进一步重构,可抽象出一个 BaseModal 通用弹窗组件,接收 title/icon/iconColor/description 等参数,再通过具名插槽或子组件填充表单内容。当前未抽象是因为各弹窗表单字段差异较大,强行抽象反而增加复杂度。弹窗的圆形图标用 70x70、borderRadius(35) 实现正圆,40 号 Emoji 居中,配合该色描边与阴影,形成"霓虹图标徽章"的视觉焦点,是弹窗的视觉锚点。弹窗整体仍遵循底部抽屉式布局。

段 54:编辑选手弹窗——表单与按钮

        Row() {
          Column() {
            Text('选手昵称')
              .fontSize(11)
              .fontColor(COLORS.textSub)
            Text('Hunter')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 12, right: 8 })
          Column() {
            Text('位置')
              .fontSize(11)
              .fontColor(COLORS.textSub)
            Text('打野')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.neon)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 12 })
        }
        .width('100%')

        Column() {
          Text('KDA 数据')
            .fontSize(11)
            .fontColor(COLORS.textSub)
          Text('8.6')
            .fontSize(26)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.neon)
            .margin({ top: 4 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(10)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 10 })

        Row() {
          Text('保存修改')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.bg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .height(40)
            .backgroundColor(COLORS.magenta)
            .borderRadius(10)
            .shadow({ radius: 8, color: COLORS.magenta + '55', offsetY: 2 })
            .onClick(() => {
              this.onClose();
            })
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.textSub)
            .textAlign(TextAlign.Center)
            .width(80)
            .height(40)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(10)
            .margin({ left: 10 })
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '78%' })
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .border({ width: 1, color: COLORS.line })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .padding({ left: 12, right: 12, bottom: 76 })
    .backgroundColor('#00000088')
  }
}

编辑选手弹窗的表单为"昵称 / 位置"双列卡片 + "KDA 数据"整行卡片。位置字段用霓虹蓝着色(呼应 getRoleColor 的打野映射),KDA 用 26 号大字霓虹蓝突出,让核心数据成为视觉焦点。双按钮"保存修改"用品红主按钮 + "取消"灰按钮,与新增赛事弹窗的"金/霓虹"主按钮色不同——每个弹窗的主按钮色与其图标色一致(编辑选手品红、新增赛事金、下架皮肤红、战队详情霓虹),形成"弹窗主题色"的一致性。这种"图标色即按钮色"的设计让每个弹窗具备独立的视觉身份,用户在快速切换弹窗时能通过颜色识别当前操作类型。.constraintSize({ maxHeight: '78%' }) 同样限制高度,.padding({ bottom: 76 }) 为底部 Tab 栏预留空间。弹窗整体结构与新增赛事弹窗完全同构,仅内容字段与主题色不同。

段 55:下架皮肤弹窗

@Component
struct DeleteSkinModal {
  @Prop name: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('下架皮肤')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textSub)
            .width(32)
            .height(32)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('⚠️')
          .fontSize(40)
          .width(70)
          .height(70)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(35)
          .border({ width: 2, color: COLORS.red })
          .shadow({ radius: 14, color: COLORS.red + '55', offsetY: 3 })
          .margin({ top: 14 })
        Text('确认下架该皮肤?')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 10 })
        Text(this.name)
          .fontSize(12)
          .fontColor(COLORS.red)
          .margin({ top: 6 })
        Text('下架后玩家将无法购买,已购玩家保留使用权')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .margin({ top: 6 })

DeleteSkinModal 是危险操作确认弹窗,@Prop name 接收待删除皮肤名。与新增/编辑弹窗的"表单填写"定位不同,此弹窗是"确认对话"——图标用⚠️警告符配红色圆(2 像素红描边 + 红阴影),强化危险操作警示。标题"确认下架该皮肤?“用粗体主色,皮肤名用红色显示(this.name),让用户明确看到操作对象。下方补充说明"下架后玩家将无法购买,已购玩家保留使用权”,把操作后果讲清楚,是危险操作确认的最佳实践——让用户在确认前理解影响范围,避免误操作。这种"操作前告知后果"的设计在 CRUD 应用中至关重要,是用户体验的护栏。红色作为危险操作色,与新增金色、编辑品红形成"操作类型—颜色"的语义映射,让用户通过颜色即可预判操作风险。

段 56:下架皮肤弹窗——按钮

        Row() {
          Text('确认下架')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .height(40)
            .backgroundColor(COLORS.red)
            .borderRadius(10)
            .shadow({ radius: 8, color: COLORS.red + '55', offsetY: 2 })
            .onClick(() => {
              this.onClose();
            })
          Text('再想想')
            .fontSize(14)
            .fontColor(COLORS.textSub)
            .textAlign(TextAlign.Center)
            .width(80)
            .height(40)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(10)
            .margin({ left: 10 })
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 14 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '78%' })
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .border({ width: 1, color: COLORS.line })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .padding({ left: 12, right: 12, bottom: 76 })
    .backgroundColor('#00000088')
  }
}

下架弹窗的按钮组合是"确认下架"红主按钮 + "再想想"灰副按钮。主按钮用红色背景配白色文字(COLORS.white),与新增/编辑弹窗的"深紫文字(COLORS.bg)“不同——因为红色背景上白色比深紫对比度更高且更符合"危险操作"的视觉惯例(红底白字=警告/删除)。副按钮文字"再想想"而非"取消”,用口语化表达软化危险操作的紧张感,是文案的人性化设计。两个按钮均触发 onClose,因 demo 不做真实删除。.constraintSize.padding 的配置与其他弹窗完全一致,保证了四个弹窗的视觉尺寸统一。整个下架弹窗通过"红色图标 + 红色皮肤名 + 红色按钮 + 后果说明"四重视觉与文本警示,构建了完整的危险操作确认流程,是 CRUD 应用中删除类操作的范本。

段 57:战队详情弹窗

@Component
struct DetailTeamModal {
  @Prop name: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('战队详情')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textSub)
            .width(32)
            .height(32)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('🛡️')
          .fontSize(40)
          .width(70)
          .height(70)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(35)
          .border({ width: 2, color: COLORS.neon })
          .shadow({ radius: 14, color: COLORS.neon + '55', offsetY: 3 })
          .margin({ top: 14 })
        Text(this.name)
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8 })

DetailTeamModal 是战队详情查看弹窗,@Prop name 接收战队名。与前三个写操作弹窗不同,这是只读详情弹窗——图标用🛡️盾牌(战队 Tab 同款图标)配霓虹蓝圆,呼应战队 Tab 的主题色。标题下方直接显示战队名(this.name),17 号粗体,让用户立即确认查看对象。弹窗的视觉风格与其他四个弹窗完全同构——70x70 圆形图标、2 像素描边、该色阴影、panelBg 图标背景、cardBg 内容卡片、#00000088 遮罩。这种"五弹窗同构"的设计保证了模态层的视觉统一,用户在任何弹窗中都能快速定位"标题—图标—内容—按钮"的布局位置,降低了学习成本。详情弹窗的图标色用霓虹蓝,与下架红、编辑品红、新增金形成"操作类型—颜色"的完整映射,五个弹窗恰好覆盖调色板的五个强调色,是色彩体系的完整展示。

段 58:战队详情弹窗——数据卡片

        Row() {
          Column() {
            Text('当前排名')
              .fontSize(10)
              .fontColor(COLORS.textHint)
            Text('#1')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding(10)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 12, right: 8 })
          Column() {
            Text('胜率')
              .fontSize(10)
              .fontColor(COLORS.textHint)
            Text('78%')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.neon)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding(10)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 12, right: 8 })
          Column() {
            Text('赛季积分')
              .fontSize(10)
              .fontColor(COLORS.textHint)
            Text('168')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.magenta)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding(10)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 12 })
        }
        .width('100%')

战队详情弹窗的数据区是三列等宽卡片——“当前排名 / 胜率 / 赛季积分”,每列用 layoutWeight(1) 均分宽度,alignItems(HorizontalAlign.Center) 让内容居中。三个数值分别用金/霓虹蓝/品红着色,恰好复用战队榜的颜色映射(rank<=2 金、winRate 用战队色、points 用战队色),保持视觉一致性。数值用 20 号粗体,标签用 10 号灰,形成"大数 + 小标"的统计卡片结构。三个卡片之间用 margin({ right: 8 }) 保留间距,是三列布局的标准间距处理。目前数值是静态"#1/78%/168",对应雷霆电竞数据,真实应用应从 model.teams 查询对应战队动态填充。这种"静态 demo 数据"的取舍在中台原型阶段可接受,生产时需接入数据绑定。

段 59:战队详情弹窗——口号与按钮

        Column() {
          Text('战队口号')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('雷霆万钧 直指巅峰')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.neon)
            .margin({ top: 4 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(10)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 10 })

        Text('了解更多')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.bg)
          .textAlign(TextAlign.Center)
          .width('100%')
          .height(40)
          .backgroundColor(COLORS.neon)
          .borderRadius(10)
          .shadow({ radius: 8, color: COLORS.neon + '55', offsetY: 2 })
          .margin({ top: 14 })
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .constraintSize({ maxHeight: '78%' })
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .border({ width: 1, color: COLORS.line })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .padding({ left: 12, right: 12, bottom: 76 })
    .backgroundColor('#00000088')
  }
}

战队详情弹窗末尾是"战队口号"整行卡片 + "了解更多"霓虹蓝全宽按钮。口号用霓虹蓝粗体,呼应战队详情的主题色。"了解更多"按钮用 width('100%') 全宽,霓虹蓝背景配深紫文字,40 高圆角,与其他弹窗的主按钮样式一致。点击触发 onClose 关闭弹窗——在真实应用中应跳转到战队详情页或展开更多信息。整个弹窗通过"图标 + 名称 + 三数据卡 + 口号 + 按钮"五段结构,把战队的关键信息浓缩在一屏内呈现,是"概览式详情"的典型设计。.constraintSize({ maxHeight: '78%' }) 保证弹窗不超出屏幕。至此四个弹窗组件全部介绍完毕——新增赛事(金)、编辑选手(品红)、下架皮肤(红)、战队详情(霓虹蓝),它们共享同构的布局与样式,仅主题色与表单内容不同,是弹窗组件化设计的优秀范例。

技术对比表格

技术要点实现方式优势适用场景
色彩体系管理EsportPalette 接口 + COLORS 单例常量类型安全、单一来源、便于主题切换需要统一品牌色的中大型应用
响应式数据模型@Observed 装饰 EsportModel 聚合类数据变更自动驱动视图、集中治理数据岛多业务域共享数据的场景
状态提升模式@State 集中入口 + @Prop 单向传递状态路径可预测、便于调试审计父子组件需共享状态的层级
Tab 导航枚举 + Record 配置 + ForEach 渲染类型安全、O(1) 查找、顺序可控底部 Tab 或顶部导航栏
条件路由if/else if 链按枚举渲染内容页未激活页不进渲染树、节省开销页面切换数量适中的场景
纯函数映射层getTierColor 等四函数分离视觉逻辑单一职责、易测试、规则可演进业务语义到颜色的映射
实体主题色实体 color 字段驱动多处视觉元素视觉个性、颜色—实体强映射列表项需视觉区分的场景
声明式柱状图Column 宽高 + Math.round 缩放无需 Canvas、纯声明式、移动端友好轻量数据可视化的场景
进度条组件外层 Row 深底 + 内层 Column 填充结构清晰、颜色灵活、阴影可控百分比或数值的进度展示
霓虹徽章NeonBadge 通用组件 + @Prop 传参DRY、视觉一致、复用性强多处状态标签的场景
列表键值稳定ForEach 第三参数用 id 唯一字段diff 稳定、避免不必要重渲染数据会动态变化的列表
文本溢出处理maxLines(1) + textOverflow(Ellipsis)卡片高度一致、布局不破长文本字段的卡片场景
弹窗同构设计四弹窗共享布局、仅主题色与内容不同视觉统一、降低学习成本CRUD 类弹窗的场景
危险操作确认红色图标 + 后果说明 + 红色按钮防误操作、告知影响范围删除/下架类操作的场景
滚动体验配置Scroll + EdgeEffect.Spring + bottom padding回弹动效、内容不被遮挡长列表滚动的移动端页面
双 CTA 布局主按钮 layoutWeight(1) + 副按钮固定宽主次分明、操作优先级清晰表单提交或确认的场景

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 584.ets - EsportsArena 电竞俱乐部
// 风格:深紫+电光蓝霓虹,斜切电竞风

interface EsportPalette {
  bg: string;
  deepBg: string;
  panelBg: string;
  cardBg: string;
  line: string;
  textMain: string;
  textSub: string;
  textHint: string;
  neon: string;
  neonDeep: string;
  magenta: string;
  gold: string;
  green: string;
  red: string;
  purple: string;
  white: string;
}

const COLORS: EsportPalette = {
  bg: '#12092B',
  deepBg: '#0B0620',
  panelBg: '#1D1140',
  cardBg: '#241548',
  line: '#3A2870',
  textMain: '#F4EFFF',
  textSub: '#B9A8E8',
  textHint: '#7A68B0',
  neon: '#00E5FF',
  neonDeep: '#0091EA',
  magenta: '#FF2E97',
  gold: '#FFC24B',
  green: '#4ADE80',
  red: '#FF5C7A',
  purple: '#A78BFA',
  white: '#FFFFFF'
};

enum EsportTab {
  TEAM,
  PLAYER,
  MATCH,
  SKIN,
  FAN
}

interface TabMeta {
  label: string;
  icon: string;
  color: string;
}

const TABS: Record<string, TabMeta> = {
  'team': { label: '战队', icon: '🛡️', color: COLORS.neon },
  'player': { label: '选手', icon: '🎮', color: COLORS.magenta },
  'match': { label: '赛事', icon: '🏆', color: COLORS.gold },
  'skin': { label: '皮肤', icon: '✨', color: COLORS.purple },
  'fan': { label: '粉丝', icon: '💜', color: COLORS.neon }
};

const TAB_KEYS: string[] = ['team', 'player', 'match', 'skin', 'fan'];

interface TeamItem {
  id: number;
  name: string;
  tag: string;
  region: string;
  rank: number;
  winRate: number;
  points: number;
  color: string;
  slogan: string;
}

interface PlayerItem {
  id: number;
  name: string;
  nick: string;
  role: string;
  kda: number;
  mvp: number;
  team: string;
  country: string;
  win: number;
}

interface MatchItem {
  id: number;
  title: string;
  home: string;
  away: string;
  score: string;
  date: string;
  status: string;
  viewers: string;
}

interface SkinItem {
  id: number;
  name: string;
  hero: string;
  price: number;
  rarity: string;
  sales: number;
  color: string;
  desc: string;
}

interface FanItem {
  id: number;
  user: string;
  gift: string;
  points: number;
  msg: string;
  time: string;
}

@Observed
class EsportModel {
  teams: TeamItem[] = [
    { id: 1, name: '雷霆电竞', tag: 'TDG', region: '上海', rank: 1, winRate: 78, points: 168, color: COLORS.neon, slogan: '雷霆万钧 直指巅峰' },
    { id: 2, name: '烈焰之翼', tag: 'FWG', region: '成都', rank: 2, winRate: 72, points: 152, color: COLORS.magenta, slogan: '烈焰不熄 翼展苍穹' },
    { id: 3, name: '星海战队', tag: 'SSC', region: '北京', rank: 3, winRate: 69, points: 141, color: COLORS.purple, slogan: '以星为誓 以海为证' },
    { id: 4, name: '苍狼部落', tag: 'CLB', region: '西安', rank: 4, winRate: 65, points: 130, color: COLORS.gold, slogan: '苍狼啸月 战无不克' },
    { id: 5, name: '冰川之刃', tag: 'ICB', region: '哈尔滨', rank: 5, winRate: 61, points: 118, color: COLORS.neonDeep, slogan: '寒刃出鞘 破冰而行' },
    { id: 6, name: '疾风战队', tag: 'GSW', region: '广州', rank: 6, winRate: 57, points: 105, color: COLORS.green, slogan: '疾风知劲 快刀斩乱' }
  ];
  players: PlayerItem[] = [
    { id: 1, name: '林昊', nick: 'Hunter', role: '打野', kda: 8.6, mvp: 12, team: '雷霆电竞', country: '中国', win: 34 },
    { id: 2, name: '陈泽', nick: 'Zeus', role: '上单', kda: 7.9, mvp: 9, team: '雷霆电竞', country: '中国', win: 31 },
    { id: 3, name: '王启', nick: 'Kiwi', role: '中单', kda: 9.2, mvp: 14, team: '烈焰之翼', country: '中国', win: 33 },
    { id: 4, name: '李昂', nick: 'Aron', role: 'ADC', kda: 8.1, mvp: 11, team: '烈焰之翼', country: '韩国', win: 30 },
    { id: 5, name: '赵奕', nick: 'Yve', role: '辅助', kda: 7.2, mvp: 6, team: '星海战队', country: '中国', win: 28 },
    { id: 6, name: '孙铭', nick: 'Mint', role: '中单', kda: 8.8, mvp: 13, team: '星海战队', country: '中国', win: 29 },
    { id: 7, name: '周凯', nick: 'Kay', role: '打野', kda: 7.5, mvp: 8, team: '苍狼部落', country: '中国', win: 26 },
    { id: 8, name: '吴迪', nick: 'Dio', role: '上单', kda: 6.9, mvp: 5, team: '苍狼部落', country: '中国', win: 24 },
    { id: 9, name: '郑浩', nick: 'Hawk', role: 'ADC', kda: 8.3, mvp: 10, team: '冰川之刃', country: '中国', win: 25 },
    { id: 10, name: '冯雪', nick: 'Snow', role: '辅助', kda: 7.0, mvp: 4, team: '冰川之刃', country: '中国', win: 22 },
    { id: 11, name: '何俊', nick: 'June', role: '中单', kda: 7.7, mvp: 9, team: '疾风战队', country: '中国', win: 23 },
    { id: 12, name: '马超', nick: 'Mars', role: '打野', kda: 8.0, mvp: 11, team: '疾风战队', country: '中国', win: 25 },
    { id: 13, name: '高翔', nick: 'Sky', role: '上单', kda: 6.6, mvp: 3, team: '雷霆电竞', country: '中国', win: 20 },
    { id: 14, name: '罗阳', nick: 'Roy', role: 'ADC', kda: 7.4, mvp: 7, team: '星海战队', country: '中国', win: 22 },
    { id: 15, name: '韩冰', nick: 'Ice', role: '辅助', kda: 6.8, mvp: 3, team: '烈焰之翼', country: '中国', win: 19 },
    { id: 16, name: '邓超', nick: 'Chao', role: '中单', kda: 7.1, mvp: 6, team: '苍狼部落', country: '中国', win: 21 },
    { id: 17, name: '曾毅', nick: 'Zen', role: 'ADC', kda: 7.8, mvp: 9, team: '冰川之刃', country: '中国', win: 24 },
    { id: 18, name: '谢航', nick: 'Hang', role: '打野', kda: 6.7, mvp: 4, team: '疾风战队', country: '中国', win: 18 }
  ];
  matches: MatchItem[] = [
    { id: 1, title: '夏季赛 总决赛', home: '雷霆电竞', away: '烈焰之翼', score: '3:1', date: '08-20 19:00', status: '即将开战', viewers: '528万' },
    { id: 2, title: '夏季赛 半决赛', home: '星海战队', away: '苍狼部落', score: '3:2', date: '08-15 18:00', status: '已结束', viewers: '461万' },
    { id: 3, title: '夏季赛 半决赛', home: '烈焰之翼', away: '冰川之刃', score: '3:0', date: '08-14 18:00', status: '已结束', viewers: '389万' },
    { id: 4, title: '常规赛 第12轮', home: '雷霆电竞', away: '疾风战队', score: '2:0', date: '08-10 17:00', status: '已结束', viewers: '256万' },
    { id: 5, title: '常规赛 第11轮', home: '星海战队', away: '烈焰之翼', score: '1:2', date: '08-08 17:00', status: '已结束', viewers: '312万' },
    { id: 6, title: '常规赛 第10轮', home: '苍狼部落', away: '冰川之刃', score: '2:1', date: '08-05 17:00', status: '已结束', viewers: '198万' },
    { id: 7, title: '常规赛 第9轮', home: '疾风战队', away: '雷霆电竞', score: '0:2', date: '08-03 17:00', status: '已结束', viewers: '224万' },
    { id: 8, title: '常规赛 第8轮', home: '烈焰之翼', away: '星海战队', score: '2:0', date: '08-01 17:00', status: '已结束', viewers: '276万' },
    { id: 9, title: '常规赛 第7轮', home: '冰川之刃', away: '苍狼部落', score: '1:2', date: '07-29 17:00', status: '已结束', viewers: '167万' },
    { id: 10, title: '常规赛 第6轮', home: '雷霆电竞', away: '星海战队', score: '2:1', date: '07-26 17:00', status: '已结束', viewers: '301万' },
    { id: 11, title: '常规赛 第5轮', home: '疾风战队', away: '烈焰之翼', score: '1:2', date: '07-24 17:00', status: '已结束', viewers: '189万' },
    { id: 12, title: '常规赛 第4轮', home: '苍狼部落', away: '雷霆电竞', score: '0:2', date: '07-22 17:00', status: '已结束', viewers: '243万' },
    { id: 13, title: '常规赛 第3轮', home: '星海战队', away: '疾风战队', score: '2:0', date: '07-19 17:00', status: '已结束', viewers: '215万' },
    { id: 14, title: '常规赛 第2轮', home: '烈焰之翼', away: '苍狼部落', score: '2:1', date: '07-17 17:00', status: '已结束', viewers: '207万' },
    { id: 15, title: '常规赛 第1轮', home: '冰川之刃', away: '疾风战队', score: '2:0', date: '07-15 17:00', status: '已结束', viewers: '176万' },
    { id: 16, title: '全明星表演赛', home: '明星红队', away: '明星蓝队', score: '2:2', date: '07-12 20:00', status: '已结束', viewers: '412万' }
  ];
  skins: SkinItem[] = [
    { id: 1, name: '雷霆龙魂', hero: '猎影·雷恩', price: 168, rarity: '传说', sales: 85200, color: COLORS.neon, desc: '雷光特效 龙形回城' },
    { id: 2, name: '暗夜魅影', hero: '刺客·影', price: 128, rarity: '史诗', sales: 76400, color: COLORS.purple, desc: '隐身时留下紫影' },
    { id: 3, name: '烈焰凤凰', hero: '法师·炎姬', price: 168, rarity: '传说', sales: 73100, color: COLORS.magenta, desc: '大招化凤 焚天灼地' },
    { id: 4, name: '冰川战神', hero: '战士·冰魁', price: 118, rarity: '史诗', sales: 68900, color: COLORS.neonDeep, desc: '冻伤减速 冰甲加身' },
    { id: 5, name: '黄金圣衣', hero: '射手·金羽', price: 198, rarity: '限定', sales: 65800, color: COLORS.gold, desc: '冠军限定 金光环绕' },
    { id: 6, name: '机甲风暴', hero: '坦克·钢盾', price: 108, rarity: '史诗', sales: 62400, color: COLORS.neon, desc: '变形机甲 机械音效' },
    { id: 7, name: '星海漫游', hero: '辅助·月灵', price: 128, rarity: '史诗', sales: 59100, color: COLORS.purple, desc: '星河缠绕 治疗带星光' },
    { id: 8, name: '绯红月刃', hero: '刺客·影', price: 98, rarity: '稀有', sales: 56800, color: COLORS.red, desc: '红月特效 刃锋染血' },
    { id: 9, name: '苍狼图腾', hero: '战士·蛮王', price: 108, rarity: '史诗', sales: 53400, color: COLORS.gold, desc: '狼魂附体 嚎叫登场' },
    { id: 10, name: '极光行者', hero: '打野·风', price: 128, rarity: '史诗', sales: 51200, color: COLORS.neonDeep, desc: '极光拖尾 全图闪现' },
    { id: 11, name: '糖果甜心', hero: '辅助·兔兔', price: 88, rarity: '稀有', sales: 49800, color: COLORS.magenta, desc: '糖果弹幕 萌系特效' },
    { id: 12, name: '黑曜领主', hero: '法师·冥', price: 168, rarity: '传说', sales: 47600, color: COLORS.purple, desc: '黑曜石阵 冥火燃烧' },
    { id: 13, name: '天使降临', hero: '辅助·圣光', price: 138, rarity: '史诗', sales: 45300, color: COLORS.gold, desc: '圣光羽翼 治愈光环' },
    { id: 14, name: '赛博朋克', hero: '射手·骇客', price: 158, rarity: '传说', sales: 43100, color: COLORS.neon, desc: '赛博纹路 霓虹弹道' },
    { id: 15, name: '海龙咆哮', hero: '坦克·潮汐', price: 118, rarity: '史诗', sales: 40200, color: COLORS.neonDeep, desc: '海啸范围 浪击特效' },
    { id: 16, name: '幻影游侠', hero: '打野·风', price: 98, rarity: '稀有', sales: 38700, color: COLORS.purple, desc: '幻影分身 虚虚实实' },
    { id: 17, name: '熔岩之心', hero: '战士·炎魁', price: 128, rarity: '史诗', sales: 36500, color: COLORS.red, desc: '熔岩裂地 灼烧光环' },
    { id: 18, name: '月光女神', hero: '辅助·月灵', price: 138, rarity: '史诗', sales: 34800, color: COLORS.gold, desc: '月光洒落 静谧治愈' },
    { id: 19, name: '雷霆风暴', hero: '猎影·雷恩', price: 98, rarity: '稀有', sales: 32600, color: COLORS.neon, desc: '落雷范围 电光特效' },
    { id: 20, name: '青花瓷韵', hero: '法师·青鸾', price: 158, rarity: '限定', sales: 30100, color: COLORS.neonDeep, desc: '国风限定 青花纹理' }
  ];
  fans: FanItem[] = [
    { id: 1, user: '电竞少女小鹿', gift: '火箭×10', points: 12800, msg: '雷霆加油!冠军是我们的!', time: '08-18 09:12' },
    { id: 2, user: '夜夜观赛', gift: '应援棒×66', points: 6600, msg: 'Hunter 打野节奏太强了', time: '08-18 08:47' },
    { id: 3, user: '老周爱电竞', gift: '能量饮料×20', points: 5000, msg: '星海战队今天状态拉满', time: '08-18 08:20' },
    { id: 4, user: '小柒不吃辣', gift: '灯牌×88', points: 8800, msg: 'Kiwi 中单细节神了!', time: '08-18 07:55' },
    { id: 5, user: '电竞小钢炮', gift: '火箭×5', points: 6400, msg: '苍狼部落冲进前三!', time: '08-18 07:30' },
    { id: 6, user: '糖糖观赛日记', gift: '应援棒×30', points: 3000, msg: '现场氛围太炸裂了', time: '08-18 06:58' },
    { id: 7, user: '午夜战神', gift: '锦旗×12', points: 3600, msg: '冰川之刃雪耻之战!', time: '08-18 06:26' },
    { id: 8, user: '元气电竞团', gift: '灯牌×120', points: 12000, msg: '全场最佳 MVP 锁定', time: '08-17 23:49' },
    { id: 9, user: '追梦少年阿凯', gift: '能量饮料×40', points: 10000, msg: '决赛门票已到手!', time: '08-17 22:31' },
    { id: 10, user: '软软团子', gift: '应援棒×50', points: 5000, msg: '烈焰之翼永不熄灭', time: '08-17 21:15' },
    { id: 11, user: '峡谷侦探', gift: '锦旗×8', points: 2400, msg: '战术复盘:换线决策满分', time: '08-17 20:02' },
    { id: 12, user: '阿峰不加班', gift: '火箭×3', points: 3840, msg: '疾风战队新人很亮眼', time: '08-17 18:44' },
    { id: 13, user: '奶茶配观赛', gift: '灯牌×40', points: 4000, msg: '看完比赛来杯奶茶!', time: '08-17 17:28' },
    { id: 14, user: '电竞老炮儿', gift: '锦旗×20', points: 6000, msg: '十年老粉不请自来', time: '08-17 16:07' }
  ];
}

interface KdaMeta {
  label: string;
  value: number;
  color: string;
}

const KDA_SKILLS: KdaMeta[] = [
  { label: '击杀', value: 92, color: COLORS.neon },
  { label: '助攻', value: 85, color: COLORS.magenta },
  { label: '生存', value: 78, color: COLORS.purple },
  { label: '输出', value: 88, color: COLORS.gold },
  { label: '经济', value: 82, color: COLORS.green }
];

interface TierMeta {
  name: string;
  min: number;
  color: string;
}

const TIERS: TierMeta[] = [
  { name: '青铜', min: 0, color: '#B08D57' },
  { name: '白银', min: 20, color: '#C0C0C8' },
  { name: '黄金', min: 40, color: COLORS.gold },
  { name: '铂金', min: 60, color: COLORS.neon },
  { name: '钻石', min: 80, color: COLORS.purple },
  { name: '王者', min: 100, color: COLORS.magenta }
];

interface ChartMeta {
  label: string;
  value: number;
  color: string;
}

const SEASON_POINTS: ChartMeta[] = [
  { label: '雷霆', value: 168, color: COLORS.neon },
  { label: '烈焰', value: 152, color: COLORS.magenta },
  { label: '星海', value: 141, color: COLORS.purple },
  { label: '苍狼', value: 130, color: COLORS.gold },
  { label: '冰川', value: 118, color: COLORS.neonDeep },
  { label: '疾风', value: 105, color: COLORS.green }
];

interface NewsMeta {
  title: string;
  tag: string;
  color: string;
}

const NEWS: NewsMeta[] = [
  { title: '夏季赛总决赛门票今日开售', tag: '公告', color: COLORS.gold },
  { title: '雷霆战队锁定常规赛头名', tag: '战报', color: COLORS.neon },
  { title: '新版本补丁:打野生态大改', tag: '版本', color: COLORS.purple },
  { title: '青花瓷韵皮肤限时返场', tag: '皮肤', color: COLORS.magenta }
];

interface FanBoardMeta {
  rank: number;
  user: string;
  points: number;
}

const FAN_BOARD: FanBoardMeta[] = [
  { rank: 1, user: '电竞少女小鹿', points: 12800 },
  { rank: 2, user: '元气电竞团', points: 12000 },
  { rank: 3, user: '追梦少年阿凯', points: 10000 },
  { rank: 4, user: '小柒不吃辣', points: 8800 },
  { rank: 5, user: '夜夜观赛', points: 6600 },
  { rank: 6, user: '电竞小钢炮', points: 6400 },
  { rank: 7, user: '电竞老炮儿', points: 6000 }
];

function getTierColor(rate: number): string {
  if (rate >= 80) {
    return COLORS.magenta;
  }
  if (rate >= 60) {
    return COLORS.neon;
  }
  if (rate >= 40) {
    return COLORS.gold;
  }
  return COLORS.purple;
}

function getStatusColor(status: string): string {
  if (status === '即将开战') {
    return COLORS.neon;
  }
  return COLORS.textHint;
}

function getRarityColor(rarity: string): string {
  if (rarity === '限定') {
    return COLORS.magenta;
  }
  if (rarity === '传说') {
    return COLORS.gold;
  }
  if (rarity === '史诗') {
    return COLORS.purple;
  }
  return COLORS.neon;
}

function getRoleColor(role: string): string {
  if (role === '打野' || role === '上单') {
    return COLORS.neon;
  }
  if (role === '中单') {
    return COLORS.magenta;
  }
  if (role === 'ADC') {
    return COLORS.gold;
  }
  return COLORS.green;
}

@Entry
@Component
struct EsportApp {
  @State model: EsportModel = new EsportModel();
  @State curTab: number = 0;
  @State showAddMatch: boolean = false;
  @State showEditPlayer: boolean = false;
  @State showDeleteSkin: boolean = false;
  @State showDetail: boolean = false;
  @State delSkinName: string = '';
  @State detailTeam: string = '';
  @State neonPulse: boolean = false;

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
    .opacity(0)
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        Row() {
          Column() {
            Text('ESPORTS ARENA')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .letterSpacing(4)
            Text('职业联赛 · 数据中台')
              .fontSize(10)
              .fontColor(COLORS.textSub)
              .letterSpacing(1)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column() {
            Text('⚡')
              .fontSize(20)
              .onClick(() => {
                this.neonPulse = !this.neonPulse;
              })
              .scale({ x: this.neonPulse ? 1.3 : 1, y: this.neonPulse ? 1.3 : 1 })
              .animation({ duration: 500, curve: Curve.EaseOut })
          }
          .width(44)
          .height(44)
          .justifyContent(FlexAlign.Center)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(22)
          .border({ width: 1, color: COLORS.neon })
          .shadow({ radius: 14, color: COLORS.neon + '66', offsetY: 2 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 10 })

        if (this.curTab === EsportTab.TEAM) {
          TeamContent({ model: this.model, onDetail: (n: string) => {
            this.detailTeam = n;
            this.showDetail = true;
          } })
        } else if (this.curTab === EsportTab.PLAYER) {
          PlayerContent({ model: this.model, onEdit: () => {
            this.showEditPlayer = true;
          } })
        } else if (this.curTab === EsportTab.MATCH) {
          MatchContent({ model: this.model, onAdd: () => {
            this.showAddMatch = true;
          } })
        } else if (this.curTab === EsportTab.SKIN) {
          SkinContent({ model: this.model, onDel: (n: string) => {
            this.delSkinName = n;
            this.showDeleteSkin = true;
          } })
        } else {
          FanContent({ model: this.model })
        }
      }
      .width('100%')
      .height('100%')

      Row() {
        ForEach(TAB_KEYS, (k: string) => {
          Column() {
            Text(TABS[k].icon)
              .fontSize(19)
            Text(TABS[k].label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_KEYS.indexOf(k) ? TABS[k].color : COLORS.textHint)
              .fontWeight(this.curTab === TAB_KEYS.indexOf(k) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_KEYS.indexOf(k) ? COLORS.panelBg : COLORS.bg)
          .borderRadius(6)
          .shadow(this.curTab === TAB_KEYS.indexOf(k) ? { radius: 12, color: TABS[k].color + '44', offsetY: 2 } : { radius: 0, color: '#00000000', offsetY: 0 })
          .border(this.curTab === TAB_KEYS.indexOf(k) ? { width: 1, color: TABS[k].color + '55' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_KEYS.indexOf(k);
          })
        }, (k: string) => k)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.deepBg)
      .border({ width: 1, color: COLORS.line })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

@Component
struct NeonBadge {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor(this.color)
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .borderRadius(12)
      .border({ width: 1, color: this.color })
      .shadow({ radius: 8, color: this.color + '55', offsetY: 1 })
  }
}

@Component
struct TeamContent {
  @Prop model: EsportModel;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('赛季积分榜')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('SUMMER 2026 · 常规赛')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'LIVE', color: COLORS.magenta })
        }
        .width('100%')

        Row() {
          ForEach(SEASON_POINTS, (p: ChartMeta) => {
            Column() {
              Text(p.label)
                .fontSize(9)
                .fontColor(COLORS.textSub)
              Column()
                .width(18)
                .height(Math.round(p.value * 0.32))
                .backgroundColor(p.color)
                .borderRadius(5)
                .shadow({ radius: 6, color: p.color + '55', offsetY: 1 })
                .margin({ top: 6 })
              Text(Math.round(p.value).toString())
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(p.color)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (p: ChartMeta) => p.label)
        }
        .width('100%')
        .height(150)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Text('战队档案 · 6 支')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.teams, (t: TeamItem) => {
          Column() {
            Row() {
              Text('#' + t.rank.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(t.rank <= 2 ? COLORS.gold : COLORS.textHint)
                .width(34)
              Column() {
                Text(t.name)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text(t.tag + ' · ' + t.region)
                  .fontSize(10)
                  .fontColor(COLORS.textSub)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 8 })
              Text(t.slogan)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width(90)
              Text(t.points.toString() + '分')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(t.color)
                .margin({ left: 8 })
            }
            .width('100%')

            Row() {
              Text('胜率')
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .width(36)
              Row() {
                Column()
                  .width(Math.round(t.winRate * 2.6))
                  .height(8)
                  .backgroundColor(t.color)
                  .borderRadius(4)
                  .shadow({ radius: 4, color: t.color + '55', offsetX: 0, offsetY: 0 })
              }
              .layoutWeight(1)
              .height(8)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(4)
              Text(t.winRate.toString() + '%')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(t.color)
                .width(42)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 10 })

            Row() {
              Text('查看详情')
                .fontSize(11)
                .fontColor(t.color)
                .onClick(() => {
                  this.onDetail(t.name);
                })
              Text('→')
                .fontSize(11)
                .fontColor(t.color)
                .margin({ left: 4 })
            }
            .width('100%')
            .justifyContent(FlexAlign.End)
            .margin({ top: 8 })
          }
          .width('100%')
          .padding(14)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
          .border({ width: 1, color: COLORS.line })
          .shadow({ radius: 8, color: t.color + '22', offsetY: 2 })
          .margin({ bottom: 10 })
        }, (t: TeamItem) => t.id.toString())

        Text('段位阶梯 · 粉丝牌')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(TIERS, (g: TierMeta) => {
            Column() {
              Column()
                .width(14)
                .height(Math.round(g.min * 0.5) + 12)
                .backgroundColor(g.color)
                .borderRadius(7)
                .shadow({ radius: 6, color: g.color + '55', offsetY: 1 })
              Text(g.name)
                .fontSize(9)
                .fontColor(g.min >= 80 ? g.color : COLORS.textSub)
                .fontWeight(g.min >= 80 ? FontWeight.Bold : FontWeight.Normal)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (g: TierMeta) => g.name)
        }
        .width('100%')
        .height(90)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

@Component
struct PlayerContent {
  @Prop model: EsportModel;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('选手数据榜')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('KDA / MVP / 胜场')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'TOP 18', color: COLORS.neon })
        }
        .width('100%')

        Column() {
          Text('五维能力 · 雷达拆解')
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .alignSelf(ItemAlign.Start)
          ForEach(KDA_SKILLS, (s: KdaMeta) => {
            Row() {
              Text(s.label)
                .fontSize(10)
                .fontColor(COLORS.textSub)
                .width(40)
              Row() {
                Column()
                  .width(Math.round(s.value * 2.4))
                  .height(8)
                  .backgroundColor(s.color)
                  .borderRadius(4)
                  .shadow({ radius: 4, color: s.color + '66', offsetY: 0 })
              }
              .layoutWeight(1)
              .height(8)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(4)
              Text(s.value.toString())
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(s.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (s: KdaMeta) => s.label)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Row() {
          Text('选手名单')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
          Text('共 ' + this.model.players.length.toString() + ' 人')
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .margin({ left: 6 })
          Text('录入新选手')
            .fontSize(11)
            .fontColor(COLORS.neon)
            .layoutWeight(1)
            .textAlign(TextAlign.End)
            .onClick(() => {
              this.onEdit();
            })
        }
        .width('100%')
        .margin({ top: 16, bottom: 8 })

        ForEach(this.model.players, (p: PlayerItem) => {
          Row() {
            Text(p.nick)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .width(74)
            Column() {
              Text(p.name + ' · ' + p.country)
                .fontSize(10)
                .fontColor(COLORS.textSub)
              Text(p.team)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(p.role)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(getRoleColor(p.role))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(getRoleColor(p.role) + '22')
            }
            Column() {
              Text(p.kda.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(getTierColor(p.kda))
              Text('KDA')
                .fontSize(8)
                .fontColor(COLORS.textHint)
            }
            .alignItems(HorizontalAlign.End)
            .width(56)
            Column() {
              Text(p.mvp.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gold)
              Text('MVP')
                .fontSize(8)
                .fontColor(COLORS.textHint)
            }
            .alignItems(HorizontalAlign.End)
            .width(48)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 10, bottom: 10 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (p: PlayerItem) => p.id.toString())

        Text('胜场排行')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(this.model.players.slice(0, 6), (p: PlayerItem) => {
            Column() {
              Text(p.nick)
                .fontSize(9)
                .fontColor(COLORS.textSub)
              Column()
                .width(16)
                .height(Math.round(p.win * 1.1))
                .backgroundColor(getTierColor(p.kda))
                .borderRadius(5)
                .margin({ top: 5 })
              Text(p.win.toString())
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (p: PlayerItem) => p.id.toString())
        }
        .width('100%')
        .height(110)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

@Component
struct MatchContent {
  @Prop model: EsportModel;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('赛事中心')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('赛程 · 比分 · 观看热度')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: '+ 新赛事', color: COLORS.gold })
        }
        .width('100%')

        Row() {
          Text('发布新赛程')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.bg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .height(36)
            .backgroundColor(COLORS.gold)
            .borderRadius(10)
            .shadow({ radius: 8, color: COLORS.gold + '55', offsetY: 2 })
            .onClick(() => {
              this.onAdd();
            })
        }
        .width('100%')
        .margin({ top: 12 })

        Text('近期赛程 · 16 场')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.matches, (m: MatchItem) => {
          Column() {
            Row() {
              Text(m.title)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
                .layoutWeight(1)
              Text(m.status)
                .fontSize(9)
                .fontColor(getStatusColor(m.status))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .border({ width: 1, color: getStatusColor(m.status) })
            }
            .width('100%')

            Row() {
              Column() {
                Text(m.home)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.neon)
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.End)
              Column() {
                Text(m.score)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text(m.viewers)
                  .fontSize(9)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .padding({ left: 10, right: 10 })
              Column() {
                Text(m.away)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.magenta)
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
            }
            .width('100%')
            .margin({ top: 10 })

            Text(m.date + ' · ' + m.viewers + ' 人在线')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .width('100%')
              .textAlign(TextAlign.Center)
              .margin({ top: 8 })
          }
          .width('100%')
          .padding(14)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 10 })
        }, (m: MatchItem) => m.id.toString())

        Text('观赛热度')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(this.model.matches.slice(0, 8), (m: MatchItem) => {
            Column() {
              Text(m.home.substring(0, 2))
                .fontSize(8)
                .fontColor(COLORS.textSub)
              Column()
                .width(14)
                .height(Math.round(Number(m.viewers.substring(0, 3)) * 0.09))
                .backgroundColor(COLORS.magenta)
                .borderRadius(4)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (m: MatchItem) => m.id.toString())
        }
        .width('100%')
        .height(100)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

@Component
struct SkinContent {
  @Prop model: EsportModel;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('皮肤商城')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('限定 · 传说 · 史诗 · 稀有')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'SALE', color: COLORS.magenta })
        }
        .width('100%')

        Column() {
          Text('本周销量 TOP 5')
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .alignSelf(ItemAlign.Start)
          ForEach(this.model.skins.slice(0, 5), (s: SkinItem) => {
            Row() {
              Text(s.name)
                .fontSize(10)
                .fontColor(COLORS.textMain)
                .width(72)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Column()
                  .width(Math.round(s.sales * 0.0011))
                  .height(7)
                  .backgroundColor(s.color)
                  .borderRadius(3)
              }
              .layoutWeight(1)
              .height(7)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(3)
              Text(s.sales.toString())
                .fontSize(9)
                .fontColor(s.color)
                .width(44)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (s: SkinItem) => s.id.toString())
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Text('全部皮肤 · 20 款')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.skins, (s: SkinItem) => {
          Row() {
            Column() {
              Text(s.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
              Text(s.hero + ' · ' + s.desc)
                .fontSize(9)
                .fontColor(COLORS.textSub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(s.rarity)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(getRarityColor(s.rarity))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(getRarityColor(s.rarity) + '22')
              Text(s.price.toString() + ' 点券')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gold)
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.End)
            .margin({ left: 8 })
            Text('🗑')
              .fontSize(14)
              .margin({ left: 10 })
              .onClick(() => {
                this.onDel(s.name);
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: s.color + '44' })
          .margin({ bottom: 8 })
        }, (s: SkinItem) => s.id.toString())

        Text('销量分布')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        Row() {
          ForEach(this.model.skins.slice(0, 10), (s: SkinItem) => {
            Column() {
              Text(Math.round(s.sales * 0.0001).toString() + 'w')
                .fontSize(8)
                .fontColor(COLORS.textHint)
              Column()
                .width(12)
                .height(Math.round(s.sales * 0.0016))
                .backgroundColor(s.color)
                .borderRadius(4)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
          }, (s: SkinItem) => s.id.toString())
        }
        .width('100%')
        .height(90)
        .alignItems(VerticalAlign.Bottom)
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12, bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

@Component
struct FanContent {
  @Prop model: EsportModel;

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('粉丝应援')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
            Text('弹幕 · 礼物 · 应援榜')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          NeonBadge({ text: 'HOT', color: COLORS.magenta })
        }
        .width('100%')

        Column() {
          Text('赛事公告')
            .fontSize(11)
            .fontColor(COLORS.textSub)
            .alignSelf(ItemAlign.Start)
          ForEach(NEWS, (n: NewsMeta) => {
            Row() {
              Text(n.tag)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(n.color)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(6)
                .border({ width: 1, color: n.color })
              Text(n.title)
                .fontSize(10)
                .fontColor(COLORS.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .layoutWeight(1)
                .margin({ left: 8 })
            }
            .width('100%')
            .margin({ top: 6 })
          }, (n: NewsMeta) => n.title)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Row() {
          Text('应援榜')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
          Text('TOP 7')
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .margin({ left: 6 })
        }
        .width('100%')
        .margin({ top: 16, bottom: 8 })

        ForEach(FAN_BOARD, (f: FanBoardMeta) => {
          Row() {
            Text(f.rank.toString())
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(f.rank === 1 ? COLORS.gold : (f.rank <= 3 ? COLORS.neon : COLORS.textHint))
              .width(28)
            Column() {
              Text(f.user)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textMain)
              Row() {
                Column()
                  .width(Math.round(f.points * 0.004))
                  .height(6)
                  .backgroundColor(f.rank <= 3 ? COLORS.magenta : COLORS.purple)
                  .borderRadius(3)
              }
              .width(100)
              .height(6)
              .backgroundColor(COLORS.deepBg)
              .borderRadius(3)
              .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 6 })
            Text(f.points.toString() + ' 应援值')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 9, bottom: 9 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 7 })
        }, (f: FanBoardMeta) => f.rank.toString())

        Text('应援留言 · 14 条')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textMain)
          .margin({ top: 8, bottom: 8 })
          .alignSelf(ItemAlign.Start)

        ForEach(this.model.fans, (f: FanItem) => {
          Row() {
            Column() {
              Text(f.user)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.neon)
              Text(f.msg)
                .fontSize(11)
                .fontColor(COLORS.textMain)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(f.gift)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.magenta)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor(COLORS.magenta + '22')
              Text(f.time)
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.End)
            .margin({ left: 8 })
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (f: FanItem) => f.id.toString())
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 4, bottom: 76 })
    }
    .width('100%')
    .height('100%')
    .scrollable(ScrollDirection.Vertical)
    .edgeEffect(EdgeEffect.Spring)
  }
}

@Component
struct AddMatchModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('新增赛事')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textSub)
            .width(32)
            .height(32)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.panelBg)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Text('🏆')
          .fontSize(40)
          .width(70)
          .height(70)
          .textAlign(TextAlign.Center)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(35)
          .border({ width: 2, color: COLORS.gold })
          .shadow({ radius: 14, color: COLORS.gold + '55', offsetY: 3 })
          .margin({ top: 14 })
        Text('录入一场新对局')
          .fontSize(12)
          .fontColor(COLORS.textSub)
          .margin({ top: 8 })

        Column() {
          Text('对阵双方')
            .fontSize(11)
            .fontColor(COLORS.textSub)
          Text('雷霆电竞 VS 苍狼部落')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textMain)
            .margin({ top: 4 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .backgroundColor(COLORS.panelBg)
        .borderRadius(10)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Row() {
          Column() {
            Text('比赛时间')
              .fontSize(11)
              .fontColor(COLORS.textSub)
            Text('08-22 19:00')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textMain)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.panelBg)
          .borderRadius(10)
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 10, right: 8 })
          Column() {
            Text('赛制')
              .fontSize(11)
              .fontColor(COLORS.textSub)
            Text('BO5 三胜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
              .margin({ top: 4 })
          }
          


结尾总结

在这里插入图片描述

从架构设计维度看,本应用呈现了高度成熟的分层策略。最底层是色彩契约层(EsportPalette 接口与 COLORS 常量),通过接口约束与单例实现,保证全应用颜色来源唯一且类型安全。其上是数据契约层(七个 interface 定义业务实体形状),用结构化类型把战队、选手、赛事、皮肤、粉丝、KDA、段位、图表、新闻、粉丝榜十类数据的字段契约固化,为后续数据流转提供编译期保障。再往上是可观察数据层(@Observed EsportModel),将所有业务数据聚合到一个响应式模型中,配合 @State 实现数据变更到视图更新的自动驱动。最顶层是组件层(一个 @Entry 入口 + 五个内容页 + 四个弹窗 + 一个通用徽章),通过 @Prop 单向数据流与回调函数完成父子通信。这种"契约—数据—组件"的三层架构,清晰隔离了"形状—状态—渲染"三个关注点,是中大型声明式应用的推荐范式。

从状态管理维度看,应用采用了"状态提升 + 单向数据流"的经典模式。全局可变状态(modelcurTab、四个弹窗显隐布尔值、两个上下文字符串、一个交互态布尔值)全部集中在入口组件 EsportApp@State 中,子组件通过 @Prop 接收只读副本,保证数据自上而下流动。子组件需要触发写操作时(如打开弹窗),不直接修改自身状态,而是通过 onDetail/onEdit/onAdd/onDel 回调把意图上报给父组件,由父组件统一调度弹窗显隐与上下文参数设置。这种模式的好处是状态变更路径可预测——所有状态变更都经过入口组件,便于调试与审计;坏处是入口组件承担了较多职责,随着弹窗数量增长可能变得臃肿。在当前四个弹窗的规模下尚可接受,若弹窗继续增长,可考虑引入状态管理框架或 Provider 模式进一步解耦。neonPulse 作为纯交互态独立于业务态,体现了"交互态就近管理"的细化思路。

从视觉设计维度看,“实体自带主题色"是贯穿全应用的核心设计哲学。每支战队、每款皮肤、每个能力维度、每个段位、每条图表数据都携带一个 color 字段,该颜色同时驱动卡片阴影、进度条填充、徽章描边、图表柱体等多处视觉元素,形成"颜色—实体"的强映射。这种设计让列表中的每条数据都具备视觉个性,避免了"千篇一律灰底卡片"的廉价感。深紫底色营造赛博空间纵深感,霓虹蓝与品红作为双主色形成互补色对比,金色专司荣耀、绿色专司状态、紫色专司辅助,色彩语义高度自洽。四个工具函数把"段位—颜色”“状态—颜色”“稀有度—颜色”"位置—颜色"四类业务映射收敛为纯函数,实现了视觉逻辑与渲染逻辑的分离。五个弹窗恰好使用调色板的五个强调色作为主题色,完成色彩体系的完整展示。

从工程实践维度看,源码展现了多项值得借鉴的细节。其一,ForEach 的键值生成函数全部使用实体的 id 或唯一字段(t.id/p.id/m.id/s.id/f.id/p.label/g.name/n.title/f.rank),保证列表 diff 稳定,避免数据更新时的不必要重渲染。其二,长文本字段(口号、皮肤名、公告标题、英雄描述)统一使用 maxLines(1) + textOverflow(Ellipsis) 单行省略,保证卡片高度一致;而粉丝留言不限行,因情感内容需完整展示,体现了"按内容性质差异化处理"的细化考量。其三,滚动列表统一使用 Scroll + scrollable(ScrollDirection.Vertical) + edgeEffect(EdgeEffect.Spring) + padding({ bottom: 76 }),既启用竖向滚动与回弹动效,又为底部 Tab 栏预留空间,避免内容被遮挡。其四,弹窗统一使用 constraintSize({ maxHeight: '78%' }) 限制内容高度、#00000088 半透明遮罩、justifyContent(FlexAlign.End) 底部对齐,形成统一的模态交互模式。

从数据建模维度看,应用采用了"宽模型 + 字符串友好"的策略。MatchItemscore(“3:1”)和 viewers(“528万”)有意用字符串存储,因为渲染时本身就是文本展示,字符串避免了格式化逻辑。TeamItem/PlayerItem/SkinItemcolor 字段把视觉身份嵌入实体,让数据自带呈现信息。EsportModel 把五个数组聚合到一个 @Observed 类中,形成"数据岛"的统一治理,而非散落在多个 @State。KDA/段位/图表/新闻/粉丝榜五组配置元数据独立于主模型,是"展示配置"与"业务数据"分离的体现。当然,这种宽模型也有代价——SEASON_POINTSteams 的积分需要手动同步,FAN_BOARDfans 的应援值需要手动维护一致性,在无后端的 demo 阶段可接受,生产环境应改为从主数据派生。四个工具函数作为"业务语义到视觉颜色"的映射层,是关注点分离的典范,便于规则演进与单元测试。

从可扩展性维度看,应用的架构为未来演进留出了充足空间。新增 Tab 只需追加 EsportTab 枚举成员、TABS 配置项、TAB_KEYS 数组项与对应内容页组件,不影响既有逻辑。新增弹窗只需定义 @Component 并在入口组件追加 @State 显隐布尔值与回调绑定,复用同构弹窗布局。新增颜色只需扩展 EsportPalette 接口与 COLORS 常量,所有消费方自动获得类型提示。新增数据实体只需定义 interface 并在 EsportModel 追加数组,内容页通过 @Prop model 自动获取。这种"配置驱动 + 接口契约 + 单向数据流"的架构,让应用在功能增长时保持代码整洁,是中台型应用的理想起点。整体而言,这套源码虽为 demo 性质,但在架构分层、状态管理、视觉设计、工程细节、数据建模、可扩展性六个维度都展现了专业水准,是研究 HarmonyOS 声明式 UI 与电竞业务建模的优质学习样本。

更多推荐