一、引言

在这里插入图片描述

电竞产业近年呈井喷式增长,从职业战队入驻、赛事组织到外设零售、上网套餐,一个中型电竞馆每天都要处理海量运营数据。传统的管理后台往往采用表格堆砌的方式,信息密度虽高但视觉疲劳严重,值班经理在高峰时段难以快速捕捉异常机位、告急库存与即将开打的赛事。本项目「赛博电竞馆 · 霓虹之夜」正是针对这一痛点而设计的中台型运营面板,它把客流、机位、战队、赛事、外设、套餐六大业务域收敛到同一屏,通过霓虹色系与高频数据可视化,让管理者一屏掌控全局。

设计理念上,本应用遵循「数据即视觉、状态即色彩」的原则。每一个业务对象都拥有专属色彩语义:机位空闲呈电光青、使用中呈热浪粉、预约呈暖琥珀;战队排名前三呈金、四至六呈青、其余呈暗紫。这种颜色编码不是装饰,而是把第二维信息(状态、等级)直接编码进视觉通道,减少认知跳转。配合分区圆角卡片、交替行底色、细线分隔,整体形成「赛博朋克 + 数据驾驶舱」的复合美学,既贴合电竞用户审美,又满足运营场景的高效读图需求。

技术选型方面,本项目基于 ArkUI 声明式范式构建,全程采用 struct 组件、@State/@Prop/@ObjectLink 三层状态装饰器、@Builder 与 @Observed 类,没有引入任何第三方库。这种「零依赖」策略带来三重收益:首先是包体积极小,适合在低配收银机或值班平板上流畅运行;其次是渲染确定性强,所有数据均为静态常量或 @Observed 实例,不存在异步竞态;最后是可读性高,所有逻辑集中在单一文件内,便于运维人员快速定位问题。色彩、数据、组件分层清晰,体现了「静态常量层—可观察数据层—纯展示组件层—容器组件层」的四段式架构。

色彩体系是本应用的灵魂。NeonPalette 接口定义了 18 个语义色槽,从 primary 霓虹紫到 accent 电光青,再到 danger 热浪粉与 success 翠绿,构成一套完整的暗色主题词表。背景采用 #120A24 的深紫黑,卡片用 #1D1236#261847 双层区分,文字主色为带紫调的近白 #F2EDFF,次级文字为 #A99BD6,提示色为 #6B5C9E。所有强调色都带高饱和度与高亮度,在深底上形成「霓虹发光」的视觉错觉,呼应电竞馆的灯效氛围。

组件化策略上,应用采用了「主入口 + 内容子组件 + 弹窗子组件 + 原子展示组件」的四层划分。ArenaApp 作为 @Entry 主入口,负责顶栏、Tab 切换与弹窗调度;五个 XxxContent 组件分别承载五大业务域的可视化;四个 XxxModal 组件处理增删改查的二次确认与详情;NeonTag 则是被反复复用的原子级标签组件。这种分层使得每一类组件职责单一、可独立测试,新增业务域时只需追加一个 Content 组件并在主入口的 if-else 链中挂载即可,扩展成本极低。下面我们将逐段拆解源码,深入剖析每一处工程考量。

二、逐段代码分析

在这里插入图片描述

段 1:NeonPalette 色彩接口定义

interface NeonPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  neon: string;
  pink: string;
}

本段定义了整套应用的色彩契约接口 NeonPalette,它把所有可能用到的颜色槽位以字段形式声明出来,包括主色三档(primary/primaryLight/primaryDark)、强调色两档、背景与卡片两档、文字三档、边框与分隔线两档,以及 success/warning/danger 三种语义状态色。这种「先接口后常量」的写法是典型的契约式设计:接口本身即是文档,任何后续维护者一看便知系统支持哪些语义色,避免散落各处的魔法字符串。把 pink 与 neon 单独列出,是因为这两色在电竞场景中作为「点缀色」使用频次极高,独立命名可减少 hex 拼接的心智成本。整体 18 个槽位既覆盖了暗色主题的全部需求,又没有冗余,体现了「恰好够用」的工程克制。

段 2:COLORS 常量实例

在这里插入图片描述

const COLORS: NeonPalette = {
  primary: '#9B5DE5',
  primaryLight: '#C9A7F5',
  primaryDark: '#4A1E8A',
  accent: '#00F5D4',
  accentLight: '#B3FFF2',
  bg: '#120A24',
  cardBg: '#1D1236',
  cardAlt: '#261847',
  textPrimary: '#F2EDFF',
  textSecondary: '#A99BD6',
  textHint: '#6B5C9E',
  border: '#3A2A6B',
  line: '#3A2A6B',
  success: '#00E5A0',
  warning: '#FFD166',
  danger: '#FF5C8A',
  white: '#FFFFFF',
  neon: '#00F5D4',
  pink: '#FF5C8A'
};

COLORS 是对前述接口的具体实现,全部采用 6 位 hex 字符串。主色 #9B5DE5 是偏蓝的霓虹紫,primaryDark #4A1E8A 用于顶栏背景,营造「夜空」感;强调色 #00F5D4 是高饱和的电光青,与紫色形成互补色对比,在深底上极为抢眼。背景 #120A24 几乎是黑紫,卡片 #1D1236cardAlt #261847 仅相差一档亮度,用于行的奇偶交替,既区分又不喧宾夺主。文字三档从近白 #F2EDFF 到深紫 #6B5C9E,层次分明。把 neonaccent 设为同值、pinkdanger 设为同值,是工程上的别名手段:语义不同但视觉相同,便于后续单独调整时互不干扰。常量用 const 声明,确保运行期不可变,避免误改色值导致的主题漂移。

段 3:ArenaTab 枚举

在这里插入图片描述

enum ArenaTab {
  BOOTH = 0,
  TEAM = 1,
  MATCH = 2,
  GEAR = 3,
  COMBO = 4
}

ArenaTab 用枚举固化了五个业务域的索引值,分别是机位、战队、赛事、外设、套餐。使用枚举而非魔法数字,是工程严谨的体现:在主入口的 if (this.curTab === ArenaTab.BOOTH) 分支中,语义远比 === 0 清晰,且后续若新增业务域,只需在枚举末尾追加并保证不重复即可。显式赋值 0 到 4 而非依赖自增,是为了与 TAB_LIST 数组的下标严格对齐——Tab 项的渲染顺序与枚举值一一对应,这种「双源对齐」避免了因数组重排导致的索引错位。枚举值用数字而非字符串,是为了在 @State curTab: number 中存储时节省内存并与 indexOf 比较高效。整体设计体现了「语义清晰 + 性能友好」的双重考量。

段 4:TabMeta 接口

在这里插入图片描述

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

TabMeta 描述了底部 Tab 项的元数据结构,包含 key、icon、label、color 四个字段。key 作为 ForEach 的稳定键,icon 是 emoji 图标,label 是中文标签,color 是该 Tab 的主题色。把 Tab 的全部信息收敛到一个接口,使得新增 Tab 只需在数据层加一条记录,无需改动渲染逻辑,是典型的数据驱动 UI。color 字段的存在意味着每个 Tab 拥有独立色彩身份——选中时该色作为边框高亮,这种「色随 Tab 变」的设计让用户即便不看文字也能凭色识别当前位置。icon 用 emoji 而非矢量图标,是出于零依赖与跨平台一致性的考量,emoji 在所有设备上渲染稳定且无需打包字体文件。整体四个字段恰好覆盖了 Tab 渲染所需的全部信息,无冗余。

段 5:TAB_LIST 常量

在这里插入图片描述

const TAB_LIST: TabMeta[] = [
  { key: 'booth', icon: '🖥️', label: '机位', color: '#9B5DE5' },
  { key: 'team', icon: '🎮', label: '战队', color: '#00F5D4' },
  { key: 'match', icon: '🏆', label: '赛事', color: '#FFD166' },
  { key: 'gear', icon: '🖱️', label: '外设', color: '#FF5C8A' },
  { key: 'combo', icon: '⚡', label: '套餐', color: '#00E5A0' }
];

TAB_LIST 是 TabMeta 接口的实例化数组,五条记录对应五个业务域。注意 color 字段并没有全部复用 COLORS 常量,而是直接写了 hex——这是为了保持数据自包含,便于后续把 Tab 配置抽到后端下发时无需依赖前端常量。五条记录的颜色分别取了紫、青、金、粉、绿,恰好覆盖了 NeONPalette 中的五个强调色,使每个 Tab 都有独立的视觉身份。key 值用小写英文,是为了作为 ForEach 的稳定键时避免中文 hash 可能的边界问题。数组顺序与 ArenaTab 枚举值严格对应,这种「双源对齐」是工程纪律的体现。整体五项恰好填满底部 Tab 栏的宽度,多一项会拥挤、少一项会空旷,体现了对移动端横屏比例的精确把控。

段 6:GRID_COL 网格列常量

在这里插入图片描述

const GRID_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7];

GRID_COL 是一个简单的数字数组,用于在顶栏下方渲染一条 8 格的霓虹装饰条。把 0-7 写成数组而非用 new Array(8),是为了在 ForEach 中获得稳定的键值——每项的值本身即可作为 key。8 格的数量经过权衡:太少则装饰感不足,太多则单格过窄影响色彩呼吸。装饰条采用青粉交替的配色,呼应电竞馆的霓虹灯带,是纯视觉元素而非功能元素。把它独立成常量而非内联,是因为后续若要调整格数,只需改一处即可,且常量名 GRID_COL 自解释。这种「即使是装饰元素也数据化」的习惯,使得整个应用的每一处可视化都可被数据驱动地调整,体现了声明式范式的彻底贯彻。

段 7:BoothItem 机位数据接口

interface BoothItem {
  name: string;
  area: string;
  spec: string;
  price: number;
  status: string;
  emoji: string;
}

BoothItem 描述了单个机位的业务模型,包含编号、区域、硬件规格、时价、状态、图标六个字段。area 字段是分区键(普通区/电竞区/包厢区/手游区/直播区),后续会被 getAreaColor 映射成色彩,是「状态即色彩」理念的核心载体。spec 字段把显卡型号与分辨率刷新率合并为字符串,而非拆成 gpu/resolution/refresh 三字段,是因为运营场景下这三者总是成组展示,拆开反而增加拼接成本。price 用 number 而非 string,便于后续做价格区间统计或排序。status 用字符串枚举(空闲/使用中/预约),牺牲了类型严格性换取了与展示文案的零转换,是「展示优先」的实用主义。emoji 字段让每个机位可携带个性化图标,增强视觉辨识度。

段 8:TeamItem 战队数据接口

interface TeamItem {
  name: string;
  rank: number;
  winRate: number;
  members: number;
  sponsor: string;
  emoji: string;
}

TeamItem 定义了入驻战队的业务模型,含名称、排名、胜率、人数、赞助商、图标。rank 用 number 是为了在 getRankColor 中做区间判断(<=3 金、<=6 青、其余紫),数值比较比字符串比较更高效且语义清晰。winRate 同样是 number,便于在列表中根据是否 >=70% 切换强调色——胜率高于七成的战队用青色突出,低于的用金色,形成「精英 vs 普通」的视觉分级。members 字段虽未直接参与色彩映射,但保留了数据完整性,便于后续扩展战队规模筛选。sponsor 字段把赞助商名作为字符串展示,是运营场景下「谁在养这支队」的关键信息。emoji 让每支战队有拟人化标识,增强情感连接。整体六字段恰好满足列表展示需求,无冗余。

段 9:MatchItem 赛事数据接口

interface MatchItem {
  name: string;
  date: string;
  prize: number;
  teams: number;
  status: string;
  emoji: string;
}

MatchItem 是赛事业务模型,含名称、日期、奖金、队伍数、状态、图标。date 用字符串 ‘MM-DD’ 而非 Date 对象,是因为展示场景只需月日,且不需要做日期运算,字符串最省心。prize 用 number 便于在统计区求「最高奖金」最大值。teams 字段记录参赛队伍数,在热度榜中作为横条长度的归一化分母(除以 64)。status 字段(报名中/筹备中)会被 getMatchColor 映射为青/金,是又一处状态即色彩的应用。emoji 为每场赛事赋予主题图标(雪山、城市、夜空等),增强可识别性。整体设计把「展示什么」与「如何展示」分离——接口只管数据,色彩映射交给工具函数,职责清晰。这种分离使得同一份数据可在不同视图中以不同色彩语义呈现,复用性高。

段 10:GearItem 外设数据接口

interface GearItem {
  name: string;
  type: string;
  price: number;
  sales: number;
  stock: number;
  emoji: string;
}

GearItem 描述外设商品模型,含名称、类型、价格、销量、库存、图标。type 字段(键盘/鼠标/显示器/耳机/配件/手柄/直播/座椅)是色彩映射键,getGearColor 为每种类型分配独立色,使商品列表呈现彩虹般的类型分区。sales 与 stock 都是 number,前者用于销量榜的归一化(除以 520 最大值),后者用于库存预警——stock > 15 显示「充足」绿色、否则显示「告急」红色,是典型的阈值驱动 UI。price 字段在列表中直接展示,配合销量形成「价 + 销」双信息行。emoji 为每件商品赋予图标,弥补纯文字的枯燥。整体六字段覆盖了外设零售的全部展示需求,且每个数值字段都参与了至少一处可视化决策,数据利用率极高。

段 11:ComboItem 套餐数据接口

interface ComboItem {
  name: string;
  price: number;
  hours: number;
  games: string;
  discount: number;
  emoji: string;
}

ComboItem 是上网套餐模型,含名称、价格、时长、适用游戏、折扣、图标。discount 用 number 表示折数(4-9 折),既是展示数据又参与了「立省百分比」的计算(100 - discount*10),一处字段两用,经济。hours 字段在列表中以 NeonTag 形式高亮展示,让用户快速比较时长性价比。games 字段是字符串描述(全站畅玩/含小食/含采集卡等),承载了套餐的差异化卖点。price 与 discount 联动决定主色:discount <= 6 用 danger 粉色突出「超低折扣」,其余用 warning 金色,形成「越便宜越抢眼」的视觉引导。emoji 为每个套餐赋予主题图标。整体设计把套餐的核心卖点(时长、折扣、价)都编码进了色彩与文案,用户扫一眼即可锁定最优选。

段 12-15:图表元数据接口群

interface WeekMeta {
  day: string;
  value: number;
}

interface RankMeta {
  name: string;
  count: number;
  color: string;
}

interface MatchHotMeta {
  name: string;
  teams: number;
  color: string;
}

interface GearTopMeta {
  name: string;
  count: number;
  color: string;
}

这四个接口定义了四类图表的元数据结构。WeekMeta 用于客流柱状图,含星期与数值;RankMeta 用于段位分布横条,含名称、队数、颜色;MatchHotMeta 用于赛事热度横条,含名称、队伍数、颜色;GearTopMeta 用于外设销量横条,含名称、销量、颜色。后三者都内嵌了 color 字段,是因为这些图表数据是静态展示用的「榜单」,色彩作为数据的一部分随数据下发,而非运行时计算——这与 BoothItem 等业务数据「运行时映射色彩」的策略不同,体现了「静态数据静态配色、动态数据动态配色」的分层设计。WeekMeta 不带 color,是因为客流图采用「值 >= 200 切换青色」的运行时阈值映射,色彩由数据驱动而非预置。四接口字段极简,仅含可视化必需字段,无冗余。

段 16:WEEK_FLOW 客流数据

const WEEK_FLOW: WeekMeta[] = [
  { day: '周一', value: 86 },
  { day: '周二', value: 92 },
  { day: '周三', value: 88 },
  { day: '周四', value: 110 },
  { day: '周五', value: 156 },
  { day: '周六', value: 238 },
  { day: '周日', value: 205 }
];

WEEK_FLOW 是一周客流人次的静态数据,周一到周日的值依次为 86、92、88、110、156、238、205。数据形态符合电竞馆真实规律:工作日低迷、周末爆发,周六峰值 238 人次。这组数据在 BoothContent 中被渲染为 7 根柱子,柱高 = value * 0.5,最大柱高约 119px 恰好容纳于 150px 的图表区。值 >= 200 的柱子(周六周日)用电光青高亮,其余用主紫色,形成「周末高峰」的视觉聚焦。把数据写成常量而非从接口拉取,是因为本应用定位为演示型中台,静态数据足以验证可视化效果。day 字段用中文「周一」而非 ‘Mon’,贴合本地化展示需求。整体七项数据形态合理,既是 UI 演示素材,也隐含了运营分析的洞察(周末是营收主峰)。

段 17:RANK_SHARE 段位分布

const RANK_SHARE: RankMeta[] = [
  { name: '钻石', count: 3, color: '#9B5DE5' },
  { name: '铂金', count: 3, color: '#00F5D4' },
  { name: '黄金', count: 2, color: '#FFD166' },
  { name: '白银', count: 2, color: '#A99BD6' },
  { name: '青铜', count: 1, color: '#FF5C8A' },
  { name: '王者', count: 1, color: '#FF9DB1' }
];

RANK_SHARE 描述常客段位分布,六档段位从钻石到王者。count 字段是队伍数,最大 3,在横条图中作为 layoutWeight(count/3) 的分子,最大占满 1/3 权重——这种「最大值归一」的画法使得最高档恰好占满前 1/3,剩余档按比例缩放,视觉层次分明。color 字段直接内嵌,每档独立色,钻石紫、铂金青、黄金金、白银暗紫、青铜粉、王者浅粉,色彩与段位语义呼应(王者虽稀少但用浅粉突出)。把王者放在最后而非按段位高低排序,是为了营造「钻石最多、王者彩蛋」的叙事节奏。count 总和 12 恰好对应战队总数 12 支,数据自洽。整体六档覆盖了主流段位体系,既是展示也是运营洞察(钻石段是核心客群)。

段 18:MATCH_HOT 赛事热度

const MATCH_HOT: MatchHotMeta[] = [
  { name: '城市争霸赛', teams: 64, color: '#9B5DE5' },
  { name: '峡谷巅峰杯', teams: 48, color: '#00F5D4' },
  { name: '电竞之夜', teams: 32, color: '#FFD166' },
  { name: '水友娱乐赛', teams: 24, color: '#FF5C8A' },
  { name: '速通挑战赛', teams: 18, color: '#00E5A0' },
  { name: '女队表演赛', teams: 12, color: '#C9A7F5' }
];

MATCH_HOT 是赛事热度榜的静态数据,按队伍数降序排列,城市争霸赛 64 队居首。teams 字段最大 64,在横条图中作为 layoutWeight(teams/64) 的分子,64 队恰好占满全宽,其余按比例缩放。color 字段六色各异,使每场赛事在榜中色彩独立可辨。这组数据在 TeamContent 中渲染为热度横条榜,前三名序号用金色突出、其余用暗紫,配合横条长度形成「双维度热度」表达。数据形态反映了电竞赛事的真实分布:大型争霸赛吸聚最多队伍,小型表演赛聚焦细分人群。把热度榜放在「战队」Tab 而非「赛事」Tab,是有意为之——运营经理在战队页看到「哪场赛事最热」时,能立刻判断是否要为入驻战队匹配参赛机会,体现了信息组织的业务考量。

段 19:GEAR_TOP 外设销量榜

const GEAR_TOP: GearTopMeta[] = [
  { name: '机械键盘', sales: 412, color: '#9B5DE5' },
  { name: '电竞鼠标', sales: 386, color: '#00F5D4' },
  { name: '高刷显示器', sales: 128, color: '#FFD166' },
  { name: '电竞耳机', sales: 264, color: '#FF5C8A' },
  { name: '鼠标垫', sales: 520, color: '#00E5A0' },
  { name: '手柄', sales: 96, color: '#C9A7F5' }
];

GEAR_TOP 是外设销量 TOP6 榜,按销量排列但鼠标垫 520 件居首。sales 字段最大 520,在横条图中作为 layoutWeight(sales/520) 的归一化分母,鼠标垫占满全宽。color 六色各异,前三名序号用粉色突出、其余暗紫,与赛事榜的金色前三形成差异化,避免用户混淆两榜。这组数据放在 GearContent 顶部,作为「爆款导览」引导用户关注热销品。鼠标垫销量夺冠符合电竞外设真实规律(低价高频消耗品)。把销量榜与商品列表同页展示,让用户「看榜单—下单」一气呵成,是电商场景的转化优化。整体六项数据形态合理,既是展示素材也隐含选品洞察。

段 20:ArenaData 类声明与 booths 数据

@Observed
export class ArenaData {
  booths: BoothItem[] = [
    { name: 'A1', area: '普通区', spec: 'RTX4060 · 2K144', price: 8, status: '空闲', emoji: '🖥️' },
    { name: 'A2', area: '普通区', spec: 'RTX4060 · 2K144', price: 8, status: '使用中', emoji: '🎮' },
    { name: 'A3', area: '普通区', spec: 'RTX4060 · 2K144', price: 8, status: '空闲', emoji: '🖥️' },
    { name: 'B1', area: '电竞区', spec: 'RTX4070 · 2K240', price: 12, status: '使用中', emoji: '🔥' },
    { name: 'B2', area: '电竞区', spec: 'RTX4070 · 2K240', price: 12, status: '空闲', emoji: '🖥️' },
    { name: 'B3', area: '电竞区', spec: 'RTX4070 · 2K240', price: 12, status: '预约', emoji: '📅' },
    { name: 'C1', area: '包厢区', spec: 'RTX4080 · 4K144', price: 20, status: '使用中', emoji: '👑' },
    { name: 'C2', area: '包厢区', spec: 'RTX4080 · 4K144', price: 20, status: '空闲', emoji: '🖥️' },
    { name: 'C3', area: '包厢区', spec: 'RTX4080 · 4K144', price: 20, status: '预约', emoji: '📅' },
    { name: 'D1', area: '手游区', spec: '骁龙8至尊', price: 6, status: '使用中', emoji: '📱' },
    { name: 'D2', area: '手游区', spec: '骁龙8至尊', price: 6, status: '空闲', emoji: '📱' },
    { name: 'E1', area: '直播区', spec: 'RTX4090 · 采集卡', price: 30, status: '空闲', emoji: '🎙️' }
  ];

ArenaData 是整个应用的核心数据容器,用 @Observed 装饰,使其成为可观察对象——任何 @ObjectLink 引用它的子组件都能在其字段变更时自动刷新。export class 使其可被其他模块引用,便于后续拆分文件。booths 数组含 12 个机位,按区域分块:A 普通区 3 个、B 电竞区 3 个、C 包厢区 3 个、D 手游区 2 个、E 直播区 1 个,覆盖了主流电竞馆的分区模型。price 从 6 到 30 元/小时,区域间价差明显,体现了「硬件决定定价」的商业逻辑。status 三态(空闲/使用中/预约)分布合理,让运营经理一眼看到「哪些区满载、哪些区有空位」。emoji 为每个机位赋予状态图标(👑 包厢王者、🔥 热门机位),增强视觉辨识度。整体 12 项数据形态真实,既是 UI 演示也是运营沙盘。

段 21:ArenaData.teams 战队数据

  teams: TeamItem[] = [
    { name: 'Nova战队', rank: 1, winRate: 86, members: 5, sponsor: '星途电竞', emoji: '🌟' },
    { name: '雷霆小队', rank: 2, winRate: 78, members: 5, sponsor: '超频科技', emoji: '⚡' },
    { name: '幻影联队', rank: 3, winRate: 72, members: 6, sponsor: '影驰外设', emoji: '👻' },
    { name: '钢铁之翼', rank: 4, winRate: 68, members: 5, sponsor: '钢盔工坊', emoji: '🦅' },
    { name: '疾风战队', rank: 5, winRate: 65, members: 5, sponsor: '疾风网吧', emoji: '💨' },
    { name: '暗夜猎手', rank: 6, winRate: 62, members: 6, sponsor: '暗夜电竞', emoji: '🦇' },
    { name: '像素联盟', rank: 7, winRate: 58, members: 5, sponsor: '像素科技', emoji: '🕹️' },
    { name: '战狼小队', rank: 8, winRate: 55, members: 5, sponsor: '战狼外设', emoji: '🐺' },
    { name: '星河舰队', rank: 9, winRate: 52, members: 6, sponsor: '星河网咖', emoji: '🌌' },
    { name: '暴风之眼', rank: 10, winRate: 52, members: 5, sponsor: '暴风电竞', emoji: '🌀' },
    { name: '赤焰军团', rank: 11, winRate: 46, members: 5, sponsor: '赤焰俱乐部', emoji: '🔥' },
    { name: '冰川猛犸', rank: 12, winRate: 43, members: 6, sponsor: '冰川工坊', emoji: '🧊' }
  ];

teams 数组含 12 支入驻战队,rank 从 1 到 12 严格递增,与段位分布榜的 count 总和 12 自洽。winRate 从 86% 递减到 43%,形态符合「排名越高胜率越高」的真实规律。members 在 5 与 6 间波动,呼应了主流电竞项目(LOL 5 人、CS:GO 5 人、VALORANT 5 人,部分项目含替补 6 人)。sponsor 字段为每支战队绑定赞助商,呈现了电竞馆的「战队—赞助」生态网络。emoji 从 🌟 到 🧊 涵盖星辰、雷霆、幻影、猛禽、疾风、暗夜、像素、战狼、星河、暴风、赤焰、冰川等意象,命名体系自洽且富有叙事感。整体数据形态既真实又富有故事性,既是 UI 演示也是电竞馆招商素材。

段 22:ArenaData.matches 赛事数据

  matches: MatchItem[] = [
    { name: '峡谷巅峰杯', date: '08-20', prize: 20000, teams: 48, status: '报名中', emoji: '🏔️' },
    { name: '城市争霸赛', date: '08-23', prize: 50000, teams: 64, status: '报名中', emoji: '🏙️' },
    { name: '电竞之夜', date: '08-25', prize: 8000, teams: 32, status: '筹备中', emoji: '🌃' },
    { name: '水友娱乐赛', date: '08-28', prize: 2000, teams: 24, status: '报名中', emoji: '🎉' },
    { name: '速通挑战赛', date: '09-01', prize: 5000, teams: 18, status: '筹备中', emoji: '⏱️' },
    { name: '女队表演赛', date: '09-05', prize: 3000, teams: 12, status: '筹备中', emoji: '👩‍🎤' },
    { name: '高校联赛', date: '09-10', prize: 15000, teams: 40, status: '报名中', emoji: '🎓' },
    { name: '网吧对抗赛', date: '09-15', prize: 6000, teams: 20, status: '筹备中', emoji: '🏪' },
    { name: '主播邀请赛', date: '09-20', prize: 12000, teams: 16, status: '报名中', emoji: '📺' },
    { name: '秋季总决赛', date: '10-01', prize: 100000, teams: 32, status: '筹备中', emoji: '🏆' },
    { name: '新秀选拔赛', date: '09-25', prize: 4000, teams: 28, status: '报名中', emoji: '🌱' },
    { name: '夜猫子杯', date: '09-28', prize: 3500, teams: 22, status: '筹备中', emoji: '🦉' }
  ];

matches 数组含 12 场赛事,date 从 08-20 到 10-01 跨越一个半月,覆盖了赛事运营的真实周期密度。prize 从 2000 到 100000 跨越 50 倍,秋季总决赛 10 万奖金作为压轴,呼应了「赛季制」的电竞规律。teams 字段从 12 到 64,与热度榜的 MATCH_HOT 数据部分对应(城市争霸赛、峡谷巅峰杯、电竞之夜、水友娱乐赛、速通挑战赛、女队表演赛六场在热度榜中也出现),数据自洽。status 报名中与筹备中各 6 场,分布均衡,让运营经理能看到「即时报」与「规划期」两类赛事。emoji 涵盖雪山、城市、夜空、派对、秒表、女歌手、学帽、便利店、电视、奖杯、嫩芽、猫头鹰,意象丰富。整体数据形态真实,既是 UI 演示也是赛事日历素材。

段 23:ArenaData.gears 外设数据

  gears: GearItem[] = [
    { name: '霓虹机械键盘', type: '键盘', price: 399, sales: 412, stock: 38, emoji: '⌨️' },
    { name: '光速电竞鼠标', type: '鼠标', price: 249, sales: 386, stock: 52, emoji: '🖱️' },
    { name: '幻影高刷显示器', type: '显示器', price: 1899, sales: 128, stock: 9, emoji: '🖥️' },
    { name: '环绕电竞耳机', type: '耳机', price: 329, sales: 264, stock: 41, emoji: '🎧' },
    { name: '超滑鼠标垫', type: '配件', price: 59, sales: 520, stock: 120, emoji: '🧶' },
    { name: '无线手柄', type: '手柄', price: 259, sales: 96, stock: 6, emoji: '🎮' },
    { name: '电竞座椅', type: '座椅', price: 899, sales: 64, stock: 12, emoji: '💺' },
    { name: 'RGB灯带', type: '配件', price: 89, sales: 210, stock: 66, emoji: '🌈' },
    { name: '采集卡', type: '直播', price: 499, sales: 48, stock: 15, emoji: '📷' },
    { name: '麦克风', type: '直播', price: 359, sales: 72, stock: 18, emoji: '🎤' },
    { name: '电竞桌垫', type: '配件', price: 79, sales: 188, stock: 44, emoji: '/table' },
    { name: '手柄充电座', type: '配件', price: 99, sales: 84, stock: 30, emoji: '🔋' }
  ];

gears 数组含 12 件外设商品,type 涵盖键盘、鼠标、显示器、耳机、配件、手柄、座椅、直播八类,色彩映射覆盖完整。price 从 59 到 1899 跨越 32 倍,覆盖了从低价配件到高端显示器的全价格带。sales 字段与 GEAR_TOP 榜单部分对应(机械键盘 412、电竞鼠标 386、显示器 128、电竞耳机 264、鼠标垫 520、手柄 96),数据自洽。stock 字段从 6 到 120 分布,显示器 stock 9、手柄 stock 6 触发「告急」红色预警,是库存管理的典型场景。emoji 涵盖键盘、鼠标、显示器、耳机、毛线、手柄、座椅、彩虹、相机、麦克风、电池等意象。整体数据形态既是 UI 演示也是商品管理沙盘,库存预警机制尤为实用。

段 24:ArenaData.combos 套餐数据

  combos: ComboItem[] = [
    { name: '单机畅玩', price: 30, hours: 4, games: '全站畅玩', discount: 9, emoji: '🕹️' },
    { name: '双人开黑', price: 55, hours: 4, games: '热门联机', discount: 8, emoji: '🤝' },
    { name: '五人车队', price: 120, hours: 4, games: '峡谷五排', discount: 7, emoji: '🚗' },
    { name: '通宵战神', price: 88, hours: 10, games: '全站畅玩', discount: 6, emoji: '🌙' },
    { name: '周末套餐', price: 68, hours: 8, games: '含小食', discount: 8, emoji: '🍟' },
    { name: '包厢派对', price: 280, hours: 6, games: '含饮品', discount: 7, emoji: '🎉' },
    { name: '直播套餐', price: 199, hours: 6, games: '含采集卡', discount: 8, emoji: '📺' },
    { name: '学生月卡', price: 299, hours: 60, games: '全站畅玩', discount: 5, emoji: '🎓' },
    { name: '钻石年卡', price: 2888, hours: 800, games: '全站VIP', discount: 4, emoji: '💎' },
    { name: '水友包时', price: 45, hours: 5, games: '指定游戏', discount: 8, emoji: '💧' },
    { name: '手游畅玩', price: 25, hours: 4, games: '手游专区', discount: 9, emoji: '📱' },
    { name: '战队训练包', price: 499, hours: 20, games: '含战术室', discount: 6, emoji: '🏆' }
  ];
}

combos 数组含 12 个套餐,price 从 25 到 2888 跨越 115 倍,覆盖了从单次畅玩到年度 VIP 的全付费梯度。hours 从 4 到 800 跨越 200 倍,单机畅玩 4 小时与钻石年卡 800 小时形成「短单次 vs 长包段」的双极。discount 从 4 到 9,钻石年卡 4 折最优惠,触发 danger 粉色「超低折扣」高亮,是促销视觉的核心。games 字段承载差异化卖点(含小食、含饮品、含采集卡、含战术室、全站 VIP 等),让每个套餐有独立卖点。emoji 涵盖摇杆、握手、汽车、月亮、薯条、派对、电视、学帽、钻石、水滴、手机、奖杯等意象。整体数据形态覆盖了电竞馆的全部收费模式,既是 UI 演示也是定价策略素材,discount 与 price 的联动色彩映射尤为精妙。

段 25:getAreaColor 区域配色函数

function getAreaColor(area: string): string {
  if (area === '普通区') {
    return '#9B5DE5';
  } else if (area === '直播区') {
    return '#00F5D4';
  } else if (area === '包厢区') {
    return '#FFD166';
  } else if (area === '手游区') {
    return '#FF5C8A';
  }
  return '#C9A7F5';
}

getAreaColor 是机位区域的色彩映射函数,把分区字符串映射为 hex 色值。普通区紫、直播区青、包厢区金、手游区粉,默认(直播区)浅紫。这种「一文一色」的映射是状态即色彩理念的最直接体现——运营经理看到紫色块就知道是普通区,无需读文字。用 if-else 链而非 switch 或 map,是因为分支数少(4 个),if-else 可读性最佳且无 map 查找开销。默认返回浅紫 #C9A7F5,是兜底策略,确保未列举的区域也有合理色彩而非报错。把映射逻辑独立成函数而非内联,是因为该函数在 BoothContent 的列表项中被多次调用(背景色、边框色、NeonTag 色三处),抽取后避免重复且便于统一调整。整体设计体现了「数据—映射—展示」三层分离的工程纪律。

段 26:getStatusColor 状态配色函数

function getStatusColor(status: string): string {
  if (status === '空闲') {
    return '#00E5A0';
  } else if (status === '使用中') {
    return '#FF5C8A';
  }
  return '#FFD166';
}

getStatusColor 是机位状态的色彩映射,空闲绿、使用中粉、预约(默认)金。三态映射极简,绿粉金恰好对应 success/danger/warning 三种语义色,复用了 NeonPalette 的语义槽位。把「空闲」映射为 success 绿而非 accent 青,是有意为之——绿色在运营场景中是「可用」的强语义,比青色更直白。预约态用 warning 金,提示「即将被占用但当前可调」。函数仅 7 行,但承载了机位状态可视化的全部决策,是「小函数大语义」的典范。与 getAreaColor 不同,此函数的默认分支返回金而非浅紫,是因为预约态的语义更接近 warning。两个函数协同工作:getAreaColor 决定列表项左侧色块的底色,getStatusColor 决定列表项边框与状态文字色,双维度色彩编码使机位列表信息密度极高。

段 27:getRankColor 排名配色函数

function getRankColor(rank: number): string {
  if (rank <= 3) {
    return '#FFD166';
  } else if (rank <= 6) {
    return '#00F5D4';
  }
  return '#6B5C9E';
}

getRankColor 是战队排名的色彩映射,前三金、四至六青、其余暗紫。用区间比较(<=3、<=6)而非等值比较,是因为排名是连续数值,区间映射更符合「梯队」语义——前三名是「领奖台梯队」、四至六是「挑战者梯队」、其余是「陪跑梯队」。这种梯队色彩与段位分布榜的段位色(钻石紫、铂金青、黄金金)形成呼应:前三金对应黄金段、四至六青对应铂金段,色彩语义自洽。默认返回暗紫 #6B5C9E(textHint 色),使排名靠后的战队视觉低调,自然形成「头部突出、尾部弱化」的视觉节奏。函数仅 6 行,但承载了战队列表的「梯队可视化」全部决策,是数据驱动 UI 的典范。把数值区间映射为色彩,比把排名直接展示更高效,用户扫一眼即可定位头部战队。

段 28:getGearColor 类型配色函数

function getGearColor(type: string): string {
  if (type === '键盘') {
    return '#9B5DE5';
  } else if (type === '鼠标') {
    return '#00F5D4';
  } else if (type === '显示器') {
    return '#FFD166';
  } else if (type === '耳机') {
    return '#FF5C8A';
  } else if (type === '配件') {
    return '#00E5A0';
  } else if (type === '手柄') {
    return '#C9A7F5';
  }
  return '#A99BD6';
}

getGearColor 是外设类型的色彩映射,八类商品各赋一色。键盘紫、鼠标青、显示器金、耳机粉、配件绿、手柄浅紫,默认(座椅、直播)暗紫。六加二共八类,色彩覆盖完整且无冲突。这种「一类一色」的映射使商品列表呈现彩虹般的类型分区,用户扫一眼即可定位某类商品。用 if-else 链而非 switch,与前述函数保持风格一致。默认返回 #A99BD6(textSecondary 色),使未列举的类型(座椅、直播)视觉次级,突出主流外设类型。函数在 GearContent 中被多处调用(色块底色、NeonTag 色、边框色),抽取后避免重复。整体设计体现了「类型即色彩」的电商可视化理念,把商品分类这一第二维信息直接编码进视觉通道。

段 29:getMatchColor 赛事状态配色函数

function getMatchColor(status: string): string {
  if (status === '报名中') {
    return '#00E5A0';
  } else if (status === '筹备中') {
    return '#FFD166';
  }
  return '#FF5C8A';
}

getMatchColor 是赛事状态的色彩映射,报名中绿、筹备中金、默认(已结束)粉。三态映射与 getStatusColor 形成对偶:机位状态是「空间态」(空闲/使用/预约),赛事状态是「时间态」(报名/筹备/结束),两者复用绿金粉三色但语义不同。把「报名中」映射为 success 绿,是因为报名中是「可参与」的强语义,与机位空闲同色,形成「可行动」的视觉一致性。筹备中用 warning 金,提示「即将开打但暂不可报名」。默认返回 danger 粉,对应已结束赛事的「不可参与」语义。函数仅 6 行,但承载了赛事列表的全部状态可视化。与 getAreaColor/getStatusColor/getRankColor/getGearColor 共同构成五个映射函数群,覆盖了应用全部的状态即色彩需求,是数据驱动 UI 的基础设施层。

段 30:ArenaApp 主入口声明与状态

@Entry
@Component
struct ArenaApp {
  @State curTab: number = 0;
  @State data: ArenaData = new ArenaData();
  @State showAddTeam: boolean = false;
  @State showEditBooth: boolean = false;
  @State showDeleteGear: boolean = false;
  @State showDetail: boolean = false;
  @State delGearName: string = '';
  @State detailMatchName: string = '';
  @State padShake: boolean = false;
  @State neonFlash: boolean = false;

ArenaApp 是应用的 @Entry 主入口组件,用 @Component 装饰为声明式组件。@State 装饰的状态变量有 10 个:curTab 是当前 Tab 索引,data 是 ArenaData 实例(@Observed 对象),四个 showXxx 是弹窗开关,delGearName 与 detailMatchName 是弹窗的入参,padShake 与 neonFlash 是顶栏两个图标的交互动画开关。把所有状态集中在主入口,是「单向数据流」的体现——子组件通过回调通知主入口改状态,主入口再通过条件渲染控制弹窗,避免状态散落。curTab 默认 0 即机位 Tab,是因为机位是运营最高频关注域。data 用 new ArenaData() 直接实例化,确保 @Observed 机制生效。padShake 与 neonFlash 这种「微交互」状态体现了对体验细节的重视。

段 31:modalOverlay 占位 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)
  }

modalOverlay 是一个 @Builder 函数,本质是一个几乎不可见的 1x1 像素占位元素,内含一个透明 Button 用于兜底点击关闭。这种设计在声明式 UI 中常见——某些场景下需要「点击空白处关闭弹窗」,但 ArkUI 的弹窗机制基于条件渲染而非原生 Modal,因此用一个透明占位元素模拟「遮罩点击」。Button 而非 Text 是因为 Button 自带 onClick 语义,Text 需要额外绑定。width/height 1 而非 0,是因为 0 尺寸元素在某些渲染管线下不接收点击事件。opacity 0 确保不可见。整个 Builder 接收 onClose 回调,体现了「行为参数化」的设计。虽在本应用后续未被显式调用,但保留了扩展能力,是工程上的「预留口子」。

段 32:主入口 build 顶栏左部

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('⚡ 赛博电竞馆')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('E-Sports Arena · 霓虹之夜')
                .fontSize(10)
                .fontColor('#FFFFFFAA')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

build 方法是组件的渲染入口。最外层 Column 包裹整个应用,内层 Column 是内容区,再内层 Column 是顶栏。顶栏左侧用一个 Column 承载主标题「⚡ 赛博电竞馆」与副标题「E-Sports Arena · 霓虹之夜」。主标题 20px 加粗白色,副标题 10px 半透明白(#FFFFFFAA,AA 是 alpha 通道 66%),主副字号差 10px 形成强烈视觉层次。alignItems Start 使文字左对齐,layoutWeight 1 使左侧占满剩余宽度,把右侧图标栏挤到右端。这种「主副标题 + layoutWeight」是顶栏的经典布局模式。用 Column 嵌套 Row 再嵌套 Column,体现了 ArkUI 声明式的嵌套表达力——每一层容器负责一个维度的布局。整体顶栏左侧设计简洁但信息密度合理,主标题的 ⚡ emoji 呼应电竞主题。

段 33:主入口顶栏右侧交互图标

            Row() {
              Text('🎮')
                .fontSize(20)
                .onClick(() => {
                  this.padShake = !this.padShake;
                })
                .translate({ x: this.padShake ? 4 : 0 })
                .rotate({ angle: this.padShake ? -8 : 0 })
                .animation({ duration: 350, curve: Curve.EaseInOut })
              Text('⚡')
                .fontSize(18)
                .margin({ left: 10 })
                .onClick(() => {
                  this.neonFlash = !this.neonFlash;
                })
                .opacity(this.neonFlash ? 0.3 : 1)
                .animation({ duration: 300, curve: Curve.Linear })
              Text('🕹️')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF14')
            .borderRadius(6)
            .border({ width: 1, color: COLORS.accent + '66' })

顶栏右侧是一个 Row 容器,内含三个 emoji 图标,前两个可交互。🎮 手柄点击触发 padShake 状态翻转,配合 translate 与 rotate 实现「抖动 + 倾斜」的微动画,duration 350ms EaseInOut 缓动,营造「手柄震动」的拟物反馈。⚡ 闪电点击触发 neonFlash,配合 opacity 实现「闪烁」效果,duration 300ms Linear,呼应霓虹灯的频闪特性。两个动画都用 this.xxx = !this.xxx 的状态翻转驱动,是声明式动画的典型范式——状态变 → 渲染变 → 动画过渡。整体 Row 用半透明白底(#FFFFFF14,14 即 8% alpha)+ 青色边框(accent + 66 即 40% alpha)包裹,形成「胶囊状图标栏」的视觉容器。这种「微交互 + 视觉容器」的设计,让顶栏不仅有信息还有趣味,提升了用户停留时长。

段 34:GRID_COL 装饰条渲染

          Row() {
            ForEach(GRID_COL, (c: number) => {
              Column()
                .layoutWeight(1)
                .height(8)
                .backgroundColor(c % 2 === 0 ? COLORS.accent : COLORS.pink)
                .margin({ left: 1, right: 1 })
            }, (c: number) => 'grid' + c)
          }
          .width('100%')
          .height(8)
          .margin({ top: 10 })
          .opacity(0.85)

顶栏下方是一条 8 格的霓虹装饰条,用 ForEach 遍历 GRID_COL 数组渲染。每格用 Column(实为空容器)layoutWeight 1 等分宽度,高度 8px,背景色按 c % 2 === 0 交替为青色 accent 或粉色 pink,形成「青粉彩带」的视觉。margin 1px 留出格间缝隙,使每格独立可辨。整体 Row 宽 100% 高 8px,opacity 0.85 略微降低饱和度避免喧宾夺主。这条装饰条纯视觉无功能,但承载了「霓虹灯带」的主题隐喻,强化了赛博朋克美学。ForEach 的 key 用 ‘grid’ + c 拼接,确保每项有稳定键值。margin top 10 与顶栏主区拉开间距。整体设计体现了「即使是装饰元素也数据驱动」的声明式范式贯彻,且色彩复用了 COLORS 常量,无新增色值。

段 35:色彩标签行渲染

          Row() {
            Text('霓虹紫')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF1F')
              .borderRadius(4)
            Text('电光青')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF1F')
              .borderRadius(4)
            Text('热浪粉')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF1F')
              .borderRadius(4)
          }
          .width('100%')
          .margin({ top: 8 })

顶栏最底部是三个色彩标签:「霓虹紫」「电光青」「热浪粉」,分别对应 primary、accent、pink 三个主题色。每个标签 8px 字号,半透明白字(#FFFFFFCC,CC 即 80% alpha),半透明白底(#FFFFFF1F,1F 即 12% alpha)+ 4px 圆角,形成「胶囊标签」的视觉。三个标签横向排列无间距,整体 Row margin top 8 与上方装饰条拉开。这一行标签是「主题色说明书」,让用户在进入业务页面前先认知到应用的三大主色,建立色彩心智模型。字号 8px 极小,是辅助说明而非主信息,故弱化。三个标签的写法高度重复,是声明式 UI 的常见代价——虽可抽 Builder 复用,但三处内联的可读性更佳,工程上是一种「重复换可读」的权衡。

段 36:顶栏容器收束

        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

这是顶栏最外层 Column 的属性收束,宽 100%,padding 16/12,背景 primaryDark 深紫 #4A1E8A。padding 16 横向 + 12 纵向,使顶栏内容与屏幕边缘有合理留白,避免贴边压迫感。背景用 primaryDark 而非 bg,是因为顶栏作为「应用标题区」需要比内容区更深的底色,形成「标题深底 + 内容浅底」的层次区分。primaryDark #4A1E8A 是深紫,与下方内容区 bg #120A24 几乎黑紫形成亮度梯度,但同色系保持视觉统一。这一行属性收束看似简单,但承载了顶栏的全部容器决策——宽度撑满、内边距合理、背景层次分明。声明式 UI 的特点在此体现:容器的样式属性写在闭合括号前,与子元素并列,结构清晰。

段 37:Tab 内容区条件渲染

        if (this.curTab === ArenaTab.BOOTH) {
          BoothContent({ data: this.data, onEdit: () => {
            this.showEditBooth = true;
          } })
        } else if (this.curTab === ArenaTab.TEAM) {
          TeamContent({ data: this.data, onAdd: () => {
            this.showAddTeam = true;
          } })
        } else if (this.curTab === ArenaTab.MATCH) {
          MatchContent({ data: this.data, onDetail: (n: string) => {
            this.detailMatchName = n;
            this.showDetail = true;
          } })
        } else if (this.curTab === ArenaTab.GEAR) {
          GearContent({ data: this.data, onDel: (n: string) => {
            this.delGearName = n;
            this.showDeleteGear = true;
          } })
        } else {
          ComboContent({ data: this.data })
        }

这是 Tab 内容区的条件渲染核心,根据 curTab 当前值挂载对应的 Content 子组件。每个 Content 组件接收 data: this.data(@Observed 对象引用,自动建立响应式链路)与一个回调函数。回调函数的模式统一:子组件触发动作 → 主入口改 showXxx 状态 → 条件渲染弹窗。这种「子组件发事件、主入口改状态」是单向数据流的典型实现,避免子组件直接操作弹窗,职责清晰。MatchContent 与 GearContent 的回调接收参数 n(名称),把详情/删除的对象名回传主入口,主入口再作为 prop 传给弹窗——数据在「子→主→弹窗」间流转,主入口是调度中心。ComboContent 无回调,是因为套餐页只展示不交互。整体 if-else 链虽冗长但可读性最佳,且新增 Tab 时追加 else if 即可,扩展友好。

段 38:底部 Tab 栏渲染

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primaryDark : COLORS.cardBg)
          .borderRadius(6)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + 'CC' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_LIST.indexOf(t);
          })
        }, (t: TabMeta) => t.key)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.line })

底部 Tab 栏用 ForEach 遍历 TAB_LIST 渲染五个 Tab 项。每项是 Column 容器,含 emoji 图标(19px)与中文标签(10px),标签的色与粗细随 curTab 是否等于该项索引而切换:选中白粗、未选中 hint 普通粗。容器背景选中 primaryDark、未选中 cardBg,边框选中 t.color + CC(80% alpha 主题色)、未选中 line。这种「四态联动」(字色 + 字粗 + 底色 + 边框色)使选中态视觉极为突出。onClick 改 curTab 为当前项索引,触发整条 Tab 栏重渲染。ForEach 的 key 用 t.key 稳定。整体 Row 高 60px、padding 8,背景 cardBg、边框 line,是「固定底栏」的标准容器。TAB_LIST.indexOf(t) 在每处都计算一次,虽可优化为先算 indexOf 缓存,但 ForEach 项数仅 5,性能损耗可忽略,可读性优先。

段 39:弹窗条件渲染群

      if (this.showAddTeam) {
        AddTeamModal({
          onClose: () => {
            this.showAddTeam = false;
          }
        })
      }
      if (this.showEditBooth) {
        EditBoothModal({
          onClose: () => {
            this.showEditBooth = false;
          }
        })
      }
      if (this.showDeleteGear) {
        DeleteGearModal({
          title: this.delGearName, onClose: () => {
            this.showDeleteGear = false;
          }
        })
      }
      if (this.showDetail) {
        DetailMatchModal({
          name: this.detailMatchName, onClose: () => {
            this.showDetail = false;
          }
        })
      }
    }
  }
}

这是主入口 build 的尾部,四个弹窗的条件渲染。每个弹窗由对应的 showXxx 状态控制,true 则渲染、false 则销毁。弹窗的 onClose 回调统一把 showXxx 置 false,实现「点击关闭即销毁」。DeleteGearModal 与 DetailMatchModal 额外接收 title/name 参数,把主入口缓存的 delGearName/detailMatchName 传入弹窗展示。这种「主入口缓存参数 + 弹窗读参数」的模式,使弹窗无需直接访问触发它的子组件,解耦彻底。四个 if 平行而非 if-else,是因为理论上不会同时触发多个弹窗,但平行写法更清晰且无副作用。整体设计体现了「状态驱动渲染」的声明式范式——UI 是状态的函数,状态变则 UI 变。主入口作为状态中枢,统一调度所有弹窗,是「集中状态管理」的典型实现。

段 40:NeonTag 原子组件

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

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '22')
      .borderRadius(3)
      .border({ width: 1, color: this.color + '88' })
  }
}

NeonTag 是被反复复用的原子级标签组件,用 @Prop 接收 text 与 color 两个只读属性。@Prop 而非 @State,是因为标签是纯展示组件,无需内部状态,父组件传入什么就展示什么。fontSize 9px、padding 6/2、borderRadius 3,形成紧凑的胶囊标签。色彩的精妙在于三处复用同色但不同透明度:文字用全色、底色用 color + 22(约 13% alpha)、边框用 color + 88(约 53% alpha),使标签的「字—底—框」三层同色但层次分明,形成「霓虹发光」的视觉。这种「一色三透明度」的用法是暗色主题标签的经典手法,比纯色标签更有质感。NeonTag 在 BoothContent、TeamContent、MatchContent、GearContent、ComboContent 中被高频复用,是「原子组件」理念的典范——一次定义,处处复用,统一了全应用的标签视觉语言。

段 41:BoothContent 客流柱状图

@Component
struct BoothContent {
  @ObjectLink data: ArenaData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 周客流人次')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六238人次')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_FLOW, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 200 ? COLORS.accent : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 0.5)
                  .backgroundColor(w.value >= 200 ? COLORS.accent : COLORS.primary)
                  .borderRadius(2)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }

BoothContent 是机位 Tab 的内容组件,用 @ObjectLink 引用 ArenaData,建立响应式链路。onEdit 回调用于触发编辑机位弹窗。第一张图是周客流柱状图,标题「📈 周客流人次」+ 副标「周六 238 人次」用青色突出峰值。图表用 ForEach 遍历 WEEK_FLOW,每项一个 Column 容器:顶部数值(>=200 青色、其余次色)、中部柱子(width 20、height = value * 0.5、色 >=200 青、其余紫)、底部星期。柱子高度 = value * 0.5,最大 238 * 0.5 = 119px 恰好容纳于 150px 图表区。Row 用 alignItems Bottom 使所有柱子底对齐,形成标准柱状图。>=200 的双高亮(数值色 + 柱色)使周末高峰一眼可辨。ForEach 的 key 用 w.day 稳定。整体设计用纯 Column + 高度模拟柱状图,无图表库依赖,体现了「零依赖」的工程纪律。

段 42:BoothContent 段位分布横条

        Column() {
          Row() {
            Text('🎮 常客段位分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('钻石段最多')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(RANK_SHARE, (n: RankMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(48)
              Row() {
                Row()
                  .layoutWeight(n.count / 3)
                  .height(8)
                  .backgroundColor(n.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(1 - n.count / 3)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(8)
              Text(n.count + '队')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: RankMeta) => n.name)
        }

第二张图是段位分布横条,标题「🎮 常客段位分布」+ 副标「钻石段最多」。每项一个 Row:左 48px 段位名、中横条、右 32px 队数。横条用双 Row 拼接——前段 layoutWeight(count/3) + 该段位色,后段 layoutWeight(1 - count/3) + bg 色,形成「填充 + 空白」的进度条效果。count 最大 3,3/3=1 占满全宽,其余按比例缩放。这种「双 Row 拼进度条」是声明式 UI 的经典手法,比真进度条组件更轻量且可定制。队数文字色用段位色,使横条与文字色彩呼应。整体六项垂直堆叠,margin top 8 间距合理。ForEach key 用 n.name 稳定。这张图与客流柱状图形成「时间维度 + 人群维度」的双图表组合,让运营经理一屏看到「何时来人 + 来的是什么段位」。

段 43:BoothContent 机位列表

        Column() {
          Row() {
            Text('🖥️ 机位总览')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️调整')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('✏️')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.booths, (b: BoothItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getAreaColor(b.area) + '22')
                .borderRadius(6)
                .border({ width: 1, color: getAreaColor(b.area) + '66' })
              Text(b.emoji)
                .fontSize(17)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(b.name + ' 机位')
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  NeonTag({ text: b.area, color: getAreaColor(b.area) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(b.spec + ' · ¥' + b.price + '/小时')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(b.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(getStatusColor(b.status))
                Text(b.price + '元/h')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(8)
            .border({ width: 1, color: getStatusColor(b.status) + '44' })
            .margin({ bottom: 8 })
          }, (b: BoothItem, i: number) => b.name + i)
        }

机位总览列表是 BoothContent 的核心,标题区含 ✏️ 编辑图标触发 onEdit 回调。列表用 ForEach 遍历 this.data.booths,每项一个 Row:左 40x40 色块(getAreaColor + 22 底、+ 66 边)+ emoji 图标、中间信息列(机位名 + NeonTag 区域 + 规格 + 价格)、右状态列(状态文字 + 价格提示)。色块用区域色三透明度(22 底、66 边、NeonTag 内的 22/88),形成「区域色彩标识」。状态文字用 getStatusColor 映射色,使「空闲绿、使用中粉、预约金」一眼可辨。列表项背景按 i % 2 交替 cardBg/cardAlt,形成斑马纹。边框用状态色 + 44(27% alpha),使状态色渗透到边框,强化状态识别。ForEach key 用 b.name + i 避免重名冲突。整体列表项信息密度极高:区域色块 + emoji + 名称 + 区域标签 + 规格 + 价格 + 状态 + 价格提示,八维信息一屏呈现。

段 44:TeamContent 赛事热度榜

@Component
struct TeamContent {
  @ObjectLink data: ArenaData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆 赛事热度榜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('争霸赛最热')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(MATCH_HOT, (m: MatchHotMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.warning : COLORS.textHint)
                .width(22)
              Text(m.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(80)
              Row() {
                Row()
                  .layoutWeight(m.teams / 64)
                  .height(8)
                  .backgroundColor(m.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(1 - m.teams / 64)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(8)
              Text(m.teams + '队')
                .fontSize(9)
                .fontColor(m.color)
                .width(38)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (m: MatchHotMeta, i: number) => m.name + i)
        }

TeamContent 是战队 Tab 的内容组件,@ObjectLink 引用 data,onAdd 回调触发战队入驻弹窗。第一张图是赛事热度榜,标题「🏆 赛事热度榜」+ 副标「争霸赛最热」青色。每项一个 Row:左 22px 序号(前三金色、其余暗紫)、80px 赛事名、横条(teams/64 填充 + 1-teams/64 空白)、38px 队数。横条用双 Row 拼接,teams 最大 64 占满全宽,其余按比例缩放。序号色与段位榜的 getRankColor 不同——这里前三用 warning 金而非 getRankColor 的前三金,是另一处「同色不同源」的工程细节。队数文字用 m.color 赛事色,使横条与文字色彩呼应。整体六项垂直堆叠,margin top 7 间距合理。把赛事热度榜放在战队 Tab,是有意为之——运营经理在战队页看到「哪场赛事最热」时,能立刻判断是否要为入驻战队匹配参赛机会,体现了信息组织的业务考量。

段 45:TeamContent 战队列表

        Column() {
          Row() {
            Text('🎮 入驻战队')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕入驻')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.teams, (t: TeamItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getRankColor(t.rank) + '22')
                .borderRadius(20)
              Text(t.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(t.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  NeonTag({ text: 'NO.' + t.rank, color: getRankColor(t.rank) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('胜率' + t.winRate + '% · ' + t.members + '人 · ' + t.sponsor)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(t.winRate + '%')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(t.winRate >= 70 ? COLORS.accent : COLORS.warning)
                Text('胜率')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(8)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (t: TeamItem, i: number) => t.name + i)
        }

战队列表是 TeamContent 的核心,标题区含 ➕ 入驻图标触发 onAdd。每项一个 Row:左 40x40 圆形色块(getRankColor + 22 底,borderRadius 20 即圆形)+ emoji、中间信息列(战队名 + NeonTag NO.排名 + 胜率/人数/赞助商)、右胜率列(胜率值 + 胜率标签)。色块用 borderRadius 20 形成圆形,与机位列表的 6px 圆角矩形形成差异,使两类列表视觉区分。胜率值用 winRate >= 70 ? accent : warning 双色映射,>=70% 青色突出精英战队、<70% 金色普通,形成「精英 vs 普通」的视觉分级。NeonTag 显示 NO.排名,用 getRankColor 色,使排名梯队可视化。列表项背景斑马纹 + line 边框,与机位列表统一。ForEach key 用 t.name + i。整体列表项信息密度高:排名色块 + emoji + 名称 + 排名标签 + 胜率/人数/赞助 + 胜率值,多维信息一屏呈现。

段 46:MatchContent 统计卡区

@Component
struct MatchContent {
  @ObjectLink data: ArenaData;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🏆 近期赛事')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('点击查看详情')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        Row() {
          Column() {
            Text('12')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('年内赛事')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.primary + '88' })
          Column() {
            Text('10万')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.warning)
            Text('最高奖金')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.warning + '88' })
          .margin({ left: 10 })
          Column() {
            Text('64')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('最大队伍数')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.accent + '88' })
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })

MatchContent 是赛事 Tab 的内容组件,onDetail 回调接收赛事名触发详情弹窗。顶部是三连统计卡:年内赛事 12(紫)、最高奖金 10 万(金)、最大队伍数 64(青)。每卡用 Column 居中布局,大数字 26px 加粗主题色 + 小标签 9px 次色,padding 14 纵向 + cardBg 底 + 8 圆角 + 主题色 88 边框。三卡 layoutWeight 1 等分宽度,中间卡 margin left 10 拉开间距。这种「三连统计卡」是运营驾驶舱的经典布局,让关键 KPI 一屏可读。三卡分别用 primary/warning/accent 三色,与 TAB_LIST 的前三个 Tab 色呼应,形成色彩心智模型的一致性。大数字用主题色而非白色,使每卡有独立色彩身份。整体统计卡区虽是静态数据,但承载了「赛事规模」的全局认知,是详情列表的导览。

段 47:MatchContent 赛事列表

        ForEach(this.data.matches, (m: MatchItem, i: number) => {
          Row() {
            Column()
              .width(42)
              .height(42)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(getMatchColor(m.status) + '22')
              .borderRadius(21)
            Text(m.emoji)
              .fontSize(18)
              .width(30)
              .textAlign(TextAlign.Center)
            Column() {
              Row() {
                Text(m.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                NeonTag({ text: m.status, color: getMatchColor(m.status) })
                  .margin({ left: 6 })
              }
              .width('100%')
              Text(m.date + ' · ' + m.teams + '支队伍')
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 8 })
            Column() {
              Text('¥' + m.prize)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.warning)
              Text('总奖金')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
              Text('👁️')
                .fontSize(12)
                .margin({ top: 3 })
                .onClick(() => {
                  this.onDetail(m.name);
                })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 10, right: 12, top: 9, bottom: 9 })
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (m: MatchItem, i: number) => m.name + i)

赛事列表用 ForEach 遍历 this.data.matches,每项一个 Row:左 42x42 圆形色块(getMatchColor + 22 底,borderRadius 21 即圆形)+ emoji、中间信息列(赛事名 + NeonTag 状态 + 日期/队伍数)、右奖金列(奖金值 + 总奖金标签 + 👁️ 详情图标)。色块用 getMatchColor 映射状态色,使「报名中绿、筹备中金」一眼可辨,圆形与战队列表的圆形色块保持一致。奖金值用 warning 金色突出,是赛事运营的核心吸引点。👁️ 图标点击触发 onDetail(m.name),把赛事名回传主入口。列表项背景斑马纹 + line 边框,与前述列表统一。ForEach key 用 m.name + i。整体列表项信息密度高:状态色块 + emoji + 名称 + 状态标签 + 日期/队伍 + 奖金 + 详情入口,多维信息一屏呈现,且状态色与奖金色双维突出。

段 48:GearContent 销量榜

@Component
struct GearContent {
  @ObjectLink data: ArenaData;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🖱️ 外设销量TOP6')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('鼠标垫夺冠')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(GEAR_TOP, (g: GearTopMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.pink : COLORS.textHint)
                .width(22)
              Text(g.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(80)
              Row() {
                Row()
                  .layoutWeight(g.sales / 520)
                  .height(8)
                  .backgroundColor(g.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(1 - g.sales / 520)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(8)
              Text(g.sales + '件')
                .fontSize(9)
                .fontColor(g.color)
                .width(44)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (g: GearTopMeta, i: number) => g.name + i)
        }

GearContent 是外设 Tab 的内容组件,onDel 回调接收商品名触发下架弹窗。第一张图是销量 TOP6 榜,标题「🖱️ 外设销量 TOP6」+ 副标「鼠标垫夺冠」青色。每项一个 Row:左 22px 序号(前三粉色、其余暗紫)、80px 商品名、横条(sales/520 填充 + 1-sales/520 空白)、44px 销量。横条用双 Row 拼接,sales 最大 520(鼠标垫)占满全宽,其余按比例缩放。序号前三用 pink 而非 warning 金,与赛事热度榜前三的金色形成差异化,避免用户混淆两榜——这是「同结构不同色」的细节考量。销量文字用 g.color 商品色,使横条与文字色彩呼应。整体六项垂直堆叠,margin top 7 间距合理。把销量榜放在商品列表上方,是电商「爆款导览」的转化优化,让用户先看到热销品再浏览全量。

段 49:GearContent 商品列表

        Column() {
          Row() {
            Text('🛒 外设商城')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击🗑️下架')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.gears, (g: GearItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getGearColor(g.type) + '22')
                .borderRadius(8)
              Text(g.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(g.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  NeonTag({ text: g.type, color: getGearColor(g.type) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('¥' + g.price + ' · 已售' + g.sales + '件')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(g.stock > 15 ? '充足' : '告急')
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(g.stock > 15 ? COLORS.success : COLORS.danger)
                Text('库存' + g.stock)
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
                Text('🗑️')
                  .fontSize(12)
                  .margin({ top: 4 })
                  .onClick(() => {
                    this.onDel(g.name);
                  })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(8)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (g: GearItem, i: number) => g.name + i)
        }

商品列表是 GearContent 的核心,标题「🛒 外设商城」+ 副标「点击🗑️下架」。每项一个 Row:左 40x40 圆角矩形色块(getGearColor + 22 底)+ emoji、中间信息列(商品名 + NeonTag 类型 + 价格/销量)、右库存列(库存状态 + 库存值 + 🗑️ 下架图标)。色块用 getGearColor 映射类型色,使「键盘紫、鼠标青、显示器金」一眼可辨。库存状态用 stock > 15 ? '充足' : '告急' 双态映射,>=15 绿色充足、<15 红色告急,是阈值驱动 UI 的典型应用——运营经理扫一眼即可定位低库存商品。🗑️ 图标点击触发 onDel(g.name),把商品名回传主入口。列表项背景斑马纹 + line 边框,与前述列表统一。ForEach key 用 g.name + i。整体列表项信息密度高:类型色块 + emoji + 名称 + 类型标签 + 价格/销量 + 库存状态 + 库存值 + 下架入口,多维信息一屏呈现,且库存预警双态突出。

段 50:ComboContent 统计卡区

@Component
struct ComboContent {
  @ObjectLink data: ArenaData;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('⚡ 上网套餐')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('学生月卡最划算')
            .fontSize(9)
            .fontColor(COLORS.accent)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        Row() {
          Column() {
            Text('12')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('在售套餐')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.primary + '88' })
          Column() {
            Text('4折')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.warning)
            Text('最低折扣')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.warning + '88' })
          .margin({ left: 10 })
          Column() {
            Text('¥25')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('最低单价')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.accent + '88' })
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })

ComboContent 是套餐 Tab 的内容组件,仅 @ObjectLink data 无回调,因为套餐页只展示不交互。顶部是三连统计卡:在售套餐 12(紫)、最低折扣 4 折(金)、最低单价 ¥25(青)。布局与 MatchContent 的统计卡完全一致——三卡 layoutWeight 1 等分、中间卡 margin left 10、26px 大数字 + 9px 小标签 + cardBg 底 + 8 圆角 + 主题色 88 边框。这种「跨页统计卡布局统一」是设计系统的体现,用户在不同 Tab 间切换时,统计卡的位置与形态稳定,降低认知成本。三卡分别用 primary/warning/accent 三色,与 MatchContent 统计卡色一致,形成色彩语义的跨页一致性。整体统计卡区虽是静态数据,但承载了「套餐规模与价格带」的全局认知,是套餐列表的导览。

段 51:ComboContent 套餐列表

        ForEach(this.data.combos, (c: ComboItem, i: number) => {
          Row() {
            Column()
              .width(42)
              .height(42)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(COLORS.neon + '22')
              .borderRadius(21)
            Text(c.emoji)
              .fontSize(18)
              .width(30)
              .textAlign(TextAlign.Center)
            Column() {
              Row() {
                Text(c.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                NeonTag({ text: c.hours + '小时', color: COLORS.neon })
                  .margin({ left: 6 })
              }
              .width('100%')
              Text(c.games + ' · ' + c.discount + '折优惠')
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 8 })
            Column() {
              Text('¥' + c.price)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(c.discount <= 6 ? COLORS.danger : COLORS.warning)
              Text('立省' + (100 - c.discount * 10) + '%')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 10, right: 12, top: 9, bottom: 9 })
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (c: ComboItem, i: number) => c.name + i)

套餐列表用 ForEach 遍历 this.data.combos,每项一个 Row:左 42x42 圆形色块(neon + 22 底,borderRadius 21 即圆形)+ emoji、中间信息列(套餐名 + NeonTag 时长 + 游戏/折扣)、右价格列(价格 + 立省百分比)。色块统一用 neon 青色,不区分类型——因为套餐无类型分区,统一色块降低视觉噪音。NeonTag 显示时长「X 小时」用 neon 色,使时长这一核心卖点高亮。价格用 discount <= 6 ? danger : warning 双色映射,<=6 折粉色突出「超低折扣」、其余金色,形成「越便宜越抢眼」的视觉引导。立省百分比用 100 - discount*10 计算,是 discount 字段的两用——既是展示数据又是计算数据,经济。列表项背景斑马纹 + line 边框,与前述列表统一。ForEach key 用 c.name + i。整体列表项信息密度高:色块 + emoji + 名称 + 时长标签 + 游戏/折扣 + 价格 + 立省,多维信息一屏呈现,且折扣双态突出。

段 52:AddTeamModal 战队入驻弹窗

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('🎮')
            .fontSize(26)
          Column() {
            Text('战队入驻')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('录入新战队资料')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('战队名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('星辉战队')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 14 })

        Row() {
          Text('主力项目')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('英雄联盟 · 5人')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('赞助商')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('星辉电竞 · 训练费已缴')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('训练时段')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('周二至周四 20:00-23:00')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Text('✅ 战队入驻需缴纳训练场地费,包厢训练室需提前一天预约。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
          Text('确认入驻')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.primary)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
      .border({ width: 1, color: COLORS.primary + 'AA' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

AddTeamModal 是战队入驻弹窗,仅 onClose 回调无入参。弹窗分四区:标题区(🎮 + 战队入驻 + 录入新战队资料 + ✕ 关闭)、表单区(四行 Row:战队名称、主力项目、赞助商、训练时段,每行左 70px 标签 + 右 layoutWeight 1 值)、提示文案(✅ 战队入驻需缴纳训练场地费)、按钮区(取消 + 确认入驻,双 Text 按钮 layoutWeight 1 等分)。表单值用不同色:主力项目用 accent 青、训练时段用 warning 金,使关键信息色彩突出。按钮区取消用 cardAlt 底 + 次色字,确认入驻用 primary 紫底 + 白字加粗,形成「次级 + 主级」的按钮层级。弹窗外层 Column justifyContent End + constraintSize maxHeight 78%,使弹窗贴底展示且最大占屏 78%,是「底部弹层」的典型布局。整体设计虽是静态表单(无真实输入),但承载了战队入驻的全部信息结构,是弹窗布局的范本。

段 53:EditBoothModal 编辑机位弹窗

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('🖥️')
            .fontSize(26)
          Column() {
            Text('编辑机位')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('调整配置与价格')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('机位编号')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('C1 · 包厢区')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 14 })

        Row() {
          Text('硬件配置')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('RTX4080 · 4K144')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('价格调整')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('¥20/小时 → ¥22/小时')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('维护状态')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('正常 · 无报修记录')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Text('⚠️ 改价后同步更新小程序展示价,会员价另行按折扣计算。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
          Text('保存修改')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.accent)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
      .border({ width: 1, color: COLORS.accent + 'AA' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

EditBoothModal 是编辑机位弹窗,结构与 AddTeamModal 几乎一致:标题区(🖥️ + 编辑机位 + 调整配置与价格 + ✕)、表单区(四行:机位编号、硬件配置、价格调整、维护状态)、提示文案(⚠️ 改价同步)、按钮区(取消 + 保存修改)。差异在于:表单值用色不同——硬件配置用 accent 青、价格调整用 warning 金(含 → 箭头展示前后价)、维护状态用 success 绿,使每行的色彩语义贴合内容。按钮主级用 accent 青底而非 primary 紫,与 AddTeamModal 的紫底形成差异,使两个弹窗的「主操作色」不同,便于用户凭色识别当前弹窗类型。弹窗外层边框用 accent + AA,与主按钮色呼应。整体布局与 AddTeamModal 高度同构,是「弹窗模板」的体现——同一套布局骨架承载不同业务,是组件化设计的深层价值。

段 54:DeleteGearModal 下架确认弹窗

@Component
struct DeleteGearModal {
  @Prop title: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Column() {
          Text('🗑️')
            .fontSize(34)
          Text('确认下架外设')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 10 })
          Text('「' + this.title + '」将从商城下架,已售订单不受影响。')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .textAlign(TextAlign.Center)
            .width('100%')
            .margin({ top: 8 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
          Text('确认下架')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.danger)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
      .border({ width: 1, color: COLORS.danger + 'AA' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

DeleteGearModal 是下架确认弹窗,@Prop 接收 title 商品名。弹窗分两区:内容区(🗑️ 大图标 + 确认下架外设标题 + 含 title 的说明文案)、按钮区(取消 + 确认下架)。与 AddTeam/EditBooth 不同,此弹窗无表单,是「确认型弹窗」——内容居中布局,alignItems Center,强调「确认动作」而非「填写信息」。说明文案用 + title + 」将从商城下架,已售订单不受影响,把 @Prop title 嵌入文案,是「参数化文案」的典型应用。按钮主级用 danger 粉底,是「危险动作」的语义色——下架是不可逆操作,粉红色警示用户三思。弹窗外层边框用 danger + AA,与主按钮色呼应。整体设计简洁但承载了「危险确认」的全部语义:大图标警示 + 标题 + 说明 + 双按钮(次级取消 + 主级危险),是确认型弹窗的范本。

段 55:DetailMatchModal 赛事详情弹窗

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('🏆')
            .fontSize(26)
          Column() {
            Text('赛事详情')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('赛事「' + this.name + '」')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('报名状态')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('报名中 · 截止08-19')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 14 })

        Row() {
          Text('赛制安排')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('小组赛 → 淘汰赛 → 决赛')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('奖金池')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('冠军5万 · 亚军2万 · 季军1万')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Column() {
          Text('赛事说明')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('本赛事面向全网战队开放,参赛需实名认证,比赛全程直播,最终解释权归电竞馆所有。')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .lineHeight(20)
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .alignItems(HorizontalAlign.Start)
        .margin({ top: 8 })

        Text('🎮 参赛队伍将获得电竞馆提供的免费训练场地。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Text('知道了')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .margin({ top: 16 })
          .onClick(() => {
            this.onClose();
          })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
      .border({ width: 1, color: COLORS.warning + 'AA' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

DetailMatchModal 是赛事详情弹窗,@Prop 接收 name 赛事名。弹窗分四区:标题区(🏆 + 赛事详情 + 赛事「name」+ ✕)、表单区(四行:报名状态、赛制安排、奖金池、赛事说明)、提示文案(🎮 参赛队伍免费训练)、按钮区(单按钮「知道了」)。表单值用色丰富:报名状态 success 绿、赛制安排 textPrimary 白、奖金池 warning 金,使每行色彩语义贴合内容。赛事说明是唯一的多行文本,用 Column 容器 + lineHeight 20 撑高,alignItems Start 左对齐,与其它行的 Row 布局形成差异,使「说明文」与「键值对」视觉区分。按钮区仅一个「知道了」primary 紫底白字,是「详情型弹窗」的典型——详情无需双按钮确认,单按钮关闭即可。弹窗外层边框用 warning + AA 金色,呼应赛事主题色。整体布局承载了赛事详情的全部信息结构,是详情型弹窗的范本。

三、组件关系与数据流向图

为更直观地呈现整个应用的架构,下面用 Mermaid 流程图展示组件层级、数据流向与状态管理关系。

原子组件层

弹窗组件层

内容组件层

主入口

数据层

工具函数层

静态层

data 引用

data 引用

data 引用

data 引用

data 引用

onEdit 回调

onAdd 回调

onDetail 回调

onDel 回调

showAddTeam

showEditBooth

showDeleteGear+delGearName

showDetail+detailMatchName

onClose

onClose

onClose

onClose

color 来自

color 来自

color 来自

color 来自

COLORS 色彩常量

TAB_LIST Tab 元数据

WEEK_FLOW 客流数据

RANK_SHARE 段位数据

MATCH_HOT 热度数据

GEAR_TOP 销量数据

getAreaColor 区域配色

getStatusColor 状态配色

getRankColor 排名配色

getGearColor 类型配色

getMatchColor 赛事配色

ArenaData @Observed 类
booths/teams/matches/gears/combos

ArenaApp @Entry
curTab/data/showXxx 状态

BoothContent @ObjectLink

TeamContent @ObjectLink

MatchContent @ObjectLink

GearContent @ObjectLink

ComboContent @ObjectLink

AddTeamModal

EditBoothModal

DeleteGearModal

DetailMatchModal

NeonTag @Prop

上图清晰呈现了「静态层—工具函数层—数据层—主入口—内容组件层—弹窗组件层—原子组件层」的七层架构。数据从 ArenaData 单向流向各 Content 组件,回调从 Content 反向流向 ArenaApp,ArenaApp 再通过 showXxx 状态驱动弹窗渲染,形成完整的单向数据流闭环。NeonTag 作为原子组件被五个 Content 复用,色彩来自工具函数层,体现了「原子复用 + 色彩映射」的设计。

五、技术对比表

技术要点 实现方式 优势 适用场景
色彩体系 NeonPalette 接口 + COLORS 常量 语义清晰、可维护、别名复用 暗色主题、多状态可视化
状态管理 @State/@Prop/@ObjectLink 三层 数据流向清晰、响应式链路自动 中型应用、单数据源场景
可观察数据 @Observed class ArenaData 字段级细粒度刷新、跨组件同步 共享业务对象、多视图同源
Tab 切换 枚举 + if-else 条件渲染 语义清晰、扩展友好、零开销 五以内 Tab、内容区整替
弹窗调度 showXxx 状态 + 条件渲染 单向数据流、主入口集中控制 确认型/表单型/详情型弹窗
原子组件 NeonTag @Prop 复用 统一视觉语言、一次定义处处用 标签、徽章、胶囊类高频元素
色彩映射 五个 getXxxColor 工具函数 数据—映射—展示三层分离 状态即色彩、动态配色
柱状图 Column + height = value * 0.5 零依赖、可定制、性能优 轻量图表、低端设备
横条图 双 Row 拼 layoutWeight 纯声明式、进度条效果 排行榜、占比可视化
统计卡 三连 Column + 大数字小标签 KPI 一屏可读、跨页布局统一 驾驶舱、运营概览
斑马列表 i % 2 交替 cardBg/cardAlt 行区分、视觉节奏 长列表、商品/机位/战队
阈值驱动 UI stock > 15 切色、winRate >= 70 切色 关键状态高亮、扫一眼定位 库存预警、精英识别
微交互 padShake/neonFlash + translate/opacity 顶栏趣味、状态变→动画过渡 标题区、图标按钮
一色三透明度 color + 22/66/88 hex 拼接 暗色主题层次感、节省色值 标签、色块、边框
ForEach 稳定键 b.name + i、t.key、w.day 避免重名冲突、渲染稳定 列表渲染、动态数据
底部弹层 justifyContent End + maxHeight 78% 贴底展示、不遮挡内容 确认弹窗、表单弹窗
参数化文案 「 + this.title + 」将从商城下架 弹窗复用、文案动态 确认型弹窗、详情弹窗
危险动作色 danger 粉底主按钮 语义警示、不可逆操作提示 下架、删除、注销
双源对齐 ArenaTab 枚举值 = TAB_LIST 下标 索引严格对应、避免错位 枚举与数组并存的场景
跨页布局统一 统计卡三色 primary/warning/accent 降低认知成本、设计系统 多 Tab 同构页面

通过以上 20 项技术要点的对比,可以看出本应用在色彩、状态、可视化、组件化、工程纪律五个维度都形成了可复用的方法论。这些要点并非孤立存在,而是相互交织——色彩映射依赖工具函数层、原子组件复用色彩常量、弹窗调度依赖状态管理、可视化依赖 layoutWeight 与 backgroundColor。正是这种「多维交织」的体系性设计,使一个单文件应用呈现出中型运营中台的完整架构形态,值得每一位声明式 UI 开发者细细品味与借鉴。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface NeonPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  neon: string;
  pink: string;
}

const COLORS: NeonPalette = {
  primary: '#9B5DE5',
  primaryLight: '#C9A7F5',
  primaryDark: '#4A1E8A',
  accent: '#00F5D4',
  accentLight: '#B3FFF2',
  bg: '#120A24',
  cardBg: '#1D1236',
  cardAlt: '#261847',
  textPrimary: '#F2EDFF',
  textSecondary: '#A99BD6',
  textHint: '#6B5C9E',
  border: '#3A2A6B',
  line: '#3A2A6B',
  success: '#00E5A0',
  warning: '#FFD166',
  danger: '#FF5C8A',
  white: '#FFFFFF',
  neon: '#00F5D4',
  pink: '#FF5C8A'
};

enum ArenaTab {
  BOOTH = 0,
  TEAM = 1,
  MATCH = 2,
  GEAR = 3,
  COMBO = 4
}

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

const TAB_LIST: TabMeta[] = [
  { key: 'booth', icon: '🖥️', label: '机位', color: '#9B5DE5' },
  { key: 'team', icon: '🎮', label: '战队', color: '#00F5D4' },
  { key: 'match', icon: '🏆', label: '赛事', color: '#FFD166' },
  { key: 'gear', icon: '🖱️', label: '外设', color: '#FF5C8A' },
  { key: 'combo', icon: '⚡', label: '套餐', color: '#00E5A0' }
];

const GRID_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7];

interface BoothItem {
  name: string;
  area: string;
  spec: string;
  price: number;
  status: string;
  emoji: string;
}

interface TeamItem {
  name: string;
  rank: number;
  winRate: number;
  members: number;
  sponsor: string;
  emoji: string;
}

interface MatchItem {
  name: string;
  date: string;
  prize: number;
  teams: number;
  status: string;
  emoji: string;
}

interface GearItem {
  name: string;
  type: string;
  price: number;
  sales: number;
  stock: number;
  emoji: string;
}

interface ComboItem {
  name: string;
  price: number;
  hours: number;
  games: string;
  discount: number;
  emoji: string;
}

interface WeekMeta {
  day: string;
  value: number;
}

interface RankMeta {
  name: string;
  count: number;
  color: string;
}

interface MatchHotMeta {
  name: string;
  teams: number;
  color: string;
}

interface GearTopMeta {
  name: string;
  sales: number;
  color: string;
}

const WEEK_FLOW: WeekMeta[] = [
  { day: '周一', value: 86 },
  { day: '周二', value: 92 },
  { day: '周三', value: 88 },
  { day: '周四', value: 110 },
  { day: '周五', value: 156 },
  { day: '周六', value: 238 },
  { day: '周日', value: 205 }
];

const RANK_SHARE: RankMeta[] = [
  { name: '钻石', count: 3, color: '#9B5DE5' },
  { name: '铂金', count: 3, color: '#00F5D4' },
  { name: '黄金', count: 2, color: '#FFD166' },
  { name: '白银', count: 2, color: '#A99BD6' },
  { name: '青铜', count: 1, color: '#FF5C8A' },
  { name: '王者', count: 1, color: '#FF9DB1' }
];

const MATCH_HOT: MatchHotMeta[] = [
  { name: '城市争霸赛', teams: 64, color: '#9B5DE5' },
  { name: '峡谷巅峰杯', teams: 48, color: '#00F5D4' },
  { name: '电竞之夜', teams: 32, color: '#FFD166' },
  { name: '水友娱乐赛', teams: 24, color: '#FF5C8A' },
  { name: '速通挑战赛', teams: 18, color: '#00E5A0' },
  { name: '女队表演赛', teams: 12, color: '#C9A7F5' }
];

const GEAR_TOP: GearTopMeta[] = [
  { name: '机械键盘', sales: 412, color: '#9B5DE5' },
  { name: '电竞鼠标', sales: 386, color: '#00F5D4' },
  { name: '高刷显示器', sales: 128, color: '#FFD166' },
  { name: '电竞耳机', sales: 264, color: '#FF5C8A' },
  { name: '鼠标垫', sales: 520, color: '#00E5A0' },
  { name: '手柄', sales: 96, color: '#C9A7F5' }
];

@Observed
export class ArenaData {
  booths: BoothItem[] = [
    { name: 'A1', area: '普通区', spec: 'RTX4060 · 2K144', price: 8, status: '空闲', emoji: '🖥️' },
    { name: 'A2', area: '普通区', spec: 'RTX4060 · 2K144', price: 8, status: '使用中', emoji: '🎮' },
    { name: 'A3', area: '普通区', spec: 'RTX4060 · 2K144', price: 8, status: '空闲', emoji: '🖥️' },
    { name: 'B1', area: '电竞区', spec: 'RTX4070 · 2K240', price: 12, status: '使用中', emoji: '🔥' },
    { name: 'B2', area: '电竞区', spec: 'RTX4070 · 2K240', price: 12, status: '空闲', emoji: '🖥️' },
    { name: 'B3', area: '电竞区', spec: 'RTX4070 · 2K240', price: 12, status: '预约', emoji: '📅' },
    { name: 'C1', area: '包厢区', spec: 'RTX4080 · 4K144', price: 20, status: '使用中', emoji: '👑' },
    { name: 'C2', area: '包厢区', spec: 'RTX4080 · 4K144', price: 20, status: '空闲', emoji: '🖥️' },
    { name: 'C3', area: '包厢区', spec: 'RTX4080 · 4K144', price: 20, status: '预约', emoji: '📅' },
    { name: 'D1', area: '手游区', spec: '骁龙8至尊', price: 6, status: '使用中', emoji: '📱' },
    { name: 'D2', area: '手游区', spec: '骁龙8至尊', price: 6, status: '空闲', emoji: '📱' },
    { name: 'E1', area: '直播区', spec: 'RTX4090 · 采集卡', price: 30, status: '空闲', emoji: '🎙️' }
  ];

  teams: TeamItem[] = [
    { name: 'Nova战队', rank: 1, winRate: 86, members: 5, sponsor: '星途电竞', emoji: '🌟' },
    { name: '雷霆小队', rank: 2, winRate: 78, members: 5, sponsor: '超频科技', emoji: '⚡' },
    { name: '幻影联队', rank: 3, winRate: 72, members: 6, sponsor: '影驰外设', emoji: '👻' },
    { name: '钢铁之翼', rank: 4, winRate: 68, members: 5, sponsor: '钢盔工坊', emoji: '🦅' },
    { name: '疾风战队', rank: 5, winRate: 65, members: 5, sponsor: '疾风网吧', emoji: '💨' },
    { name: '暗夜猎手', rank: 6, winRate: 62, members: 6, sponsor: '暗夜电竞', emoji: '🦇' },
    { name: '像素联盟', rank: 7, winRate: 58, members: 5, sponsor: '像素科技', emoji: '🕹️' },
    { name: '战狼小队', rank: 8, winRate: 55, members: 5, sponsor: '战狼外设', emoji: '🐺' },
    { name: '星河舰队', rank: 9, winRate: 52, members: 6, sponsor: '星河网咖', emoji: '🌌' },
    { name: '暴风之眼', rank: 10, winRate: 49, members: 5, sponsor: '暴风电竞', emoji: '🌀' },
    { name: '赤焰军团', rank: 11, winRate: 46, members: 5, sponsor: '赤焰俱乐部', emoji: '🔥' },
    { name: '冰川猛犸', rank: 12, winRate: 43, members: 6, sponsor: '冰川工坊', emoji: '🧊' }
  ];

  matches: MatchItem[] = [
    { name: '峡谷巅峰杯', date: '08-20', prize: 20000, teams: 48, status: '报名中', emoji: '🏔️' },
    { name: '城市争霸赛', date: '08-23', prize: 50000, teams: 64, status: '报名中', emoji: '🏙️' },
    { name: '电竞之夜', date: '08-25', prize: 8000, teams: 32, status: '筹备中', emoji: '🌃' },
    { name: '水友娱乐赛', date: '08-28', prize: 2000, teams: 24, status: '报名中', emoji: '🎉' },
    { name: '速通挑战赛', date: '09-01', prize: 5000, teams: 18, status: '筹备中', emoji: '⏱️' },
    { name: '女队表演赛', date: '09-05', prize: 3000, teams: 12, status: '筹备中', emoji: '👩‍🎤' },
    { name: '高校联赛', date: '09-10', prize: 15000, teams: 40, status: '报名中', emoji: '🎓' },
    { name: '网吧对抗赛', date: '09-15', prize: 6000, teams: 20, status: '筹备中', emoji: '🏪' },
    { name: '主播邀请赛', date: '09-20', prize: 12000, teams: 16, status: '报名中', emoji: '📺' },
    { name: '秋季总决赛', date: '10-01', prize: 100000, teams: 32, status: '筹备中', emoji: '🏆' },
    { name: '新秀选拔赛', date: '09-25', prize: 4000, teams: 28, status: '报名中', emoji: '🌱' },
    { name: '夜猫子杯', date: '09-28', prize: 3500, teams: 22, status: '筹备中', emoji: '🦉' }
  ];

  gears: GearItem[] = [
    { name: '霓虹机械键盘', type: '键盘', price: 399, sales: 412, stock: 38, emoji: '⌨️' },
    { name: '光速电竞鼠标', type: '鼠标', price: 249, sales: 386, stock: 52, emoji: '🖱️' },
    { name: '幻影高刷显示器', type: '显示器', price: 1899, sales: 128, stock: 9, emoji: '🖥️' },
    { name: '环绕电竞耳机', type: '耳机', price: 329, sales: 264, stock: 41, emoji: '🎧' },
    { name: '超滑鼠标垫', type: '配件', price: 59, sales: 520, stock: 120, emoji: '🧶' },
    { name: '无线手柄', type: '手柄', price: 259, sales: 96, stock: 6, emoji: '🎮' },
    { name: '电竞座椅', type: '座椅', price: 899, sales: 64, stock: 12, emoji: '💺' },
    { name: 'RGB灯带', type: '配件', price: 89, sales: 210, stock: 66, emoji: '🌈' },
    { name: '采集卡', type: '直播', price: 499, sales: 48, stock: 15, emoji: '📷' },
    { name: '麦克风', type: '直播', price: 359, sales: 72, stock: 18, emoji: '🎤' },
    { name: '电竞桌垫', type: '配件', price: 79, sales: 188, stock: 44, emoji: '🗔' },
    { name: '手柄充电座', type: '配件', price: 99, sales: 84, stock: 30, emoji: '🔋' }
  ];

  combos: ComboItem[] = [
    { name: '单机畅玩', price: 30, hours: 4, games: '全站畅玩', discount: 9, emoji: '🕹️' },
    { name: '双人开黑', price: 55, hours: 4, games: '热门联机', discount: 8, emoji: '🤝' },
    { name: '五人车队', price: 120, hours: 4, games: '峡谷五排', discount: 7, emoji: '🚗' },
    { name: '通宵战神', price: 88, hours: 10, games: '全站畅玩', discount: 6, emoji: '🌙' },
    { name: '周末套餐', price: 68, hours: 8, games: '含小食', discount: 8, emoji: '🍟' },
    { name: '包厢派对', price: 280, hours: 6, games: '含饮品', discount: 7, emoji: '🎉' },
    { name: '直播套餐', price: 199, hours: 6, games: '含采集卡', discount: 8, emoji: '📺' },
    { name: '学生月卡', price: 299, hours: 60, games: '全站畅玩', discount: 5, emoji: '🎓' },
    { name: '钻石年卡', price: 2888, hours: 800, games: '全站VIP', discount: 4, emoji: '💎' },
    { name: '水友包时', price: 45, hours: 5, games: '指定游戏', discount: 8, emoji: '💧' },
    { name: '手游畅玩', price: 25, hours: 4, games: '手游专区', discount: 9, emoji: '📱' },
    { name: '战队训练包', price: 499, hours: 20, games: '含战术室', discount: 6, emoji: '🏆' }
  ];
}

function getAreaColor(area: string): string {
  if (area === '普通区') {
    return '#9B5DE5';
  } else if (area === '电竞区') {
    return '#00F5D4';
  } else if (area === '包厢区') {
    return '#FFD166';
  } else if (area === '手游区') {
    return '#FF5C8A';
  }
  return '#C9A7F5';
}

function getStatusColor(status: string): string {
  if (status === '空闲') {
    return '#00E5A0';
  } else if (status === '使用中') {
    return '#FF5C8A';
  }
  return '#FFD166';
}

function getRankColor(rank: number): string {
  if (rank <= 3) {
    return '#FFD166';
  } else if (rank <= 6) {
    return '#00F5D4';
  }
  return '#6B5C9E';
}

function getGearColor(type: string): string {
  if (type === '键盘') {
    return '#9B5DE5';
  } else if (type === '鼠标') {
    return '#00F5D4';
  } else if (type === '显示器') {
    return '#FFD166';
  } else if (type === '耳机') {
    return '#FF5C8A';
  } else if (type === '配件') {
    return '#00E5A0';
  } else if (type === '手柄') {
    return '#C9A7F5';
  }
  return '#A99BD6';
}

function getMatchColor(status: string): string {
  if (status === '报名中') {
    return '#00E5A0';
  } else if (status === '筹备中') {
    return '#FFD166';
  }
  return '#FF5C8A';
}

@Entry
@Component
struct ArenaApp {
  @State curTab: number = 0;
  @State data: ArenaData = new ArenaData();
  @State showAddTeam: boolean = false;
  @State showEditBooth: boolean = false;
  @State showDeleteGear: boolean = false;
  @State showDetail: boolean = false;
  @State delGearName: string = '';
  @State detailMatchName: string = '';
  @State padShake: boolean = false;
  @State neonFlash: 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)
  }

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('⚡ 赛博电竞馆')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('E-Sports Arena · 霓虹之夜')
                .fontSize(10)
                .fontColor('#FFFFFFAA')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🎮')
                .fontSize(20)
                .onClick(() => {
                  this.padShake = !this.padShake;
                })
                .translate({ x: this.padShake ? 4 : 0 })
                .rotate({ angle: this.padShake ? -8 : 0 })
                .animation({ duration: 350, curve: Curve.EaseInOut })
              Text('⚡')
                .fontSize(18)
                .margin({ left: 10 })
                .onClick(() => {
                  this.neonFlash = !this.neonFlash;
                })
                .opacity(this.neonFlash ? 0.3 : 1)
                .animation({ duration: 300, curve: Curve.Linear })
              Text('🕹️')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF14')
            .borderRadius(6)
            .border({ width: 1, color: COLORS.accent + '66' })
          }
          .width('100%')

          Row() {
            ForEach(GRID_COL, (c: number) => {
              Column()
                .layoutWeight(1)
                .height(8)
                .backgroundColor(c % 2 === 0 ? COLORS.accent : COLORS.pink)
                .margin({ left: 1, right: 1 })
            }, (c: number) => 'grid' + c)
          }
          .width('100%')
          .height(8)
          .margin({ top: 10 })
          .opacity(0.85)

          Row() {
            Text('霓虹紫')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF1F')
              .borderRadius(4)
            Text('电光青')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF1F')
              .borderRadius(4)
            Text('热浪粉')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF1F')
              .borderRadius(4)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

        if (this.curTab === ArenaTab.BOOTH) {
          BoothContent({ data: this.data, onEdit: () => {
            this.showEditBooth = true;
          } })
        } else if (this.curTab === ArenaTab.TEAM) {
          TeamContent({ data: this.data, onAdd: () => {
            this.showAddTeam = true;
          } })
        } else if (this.curTab === ArenaTab.MATCH) {
          MatchContent({ data: this.data, onDetail: (n: string) => {
            this.detailMatchName = n;
            this.showDetail = true;
          } })
        } else if (this.curTab === ArenaTab.GEAR) {
          GearContent({ data: this.data, onDel: (n: string) => {
            this.delGearName = n;
            this.showDeleteGear = true;
          } })
        } else {
          ComboContent({ data: this.data })
        }
      }
      .width('100%')
      .height('100%')

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primaryDark : COLORS.cardBg)
          .borderRadius(6)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + 'CC' } : { width: 1, color: COLORS.line })
          .onClick(() => {
            this.curTab = TAB_LIST.indexOf(t);
          })
        }, (t: TabMeta) => t.key)
      }
      .width('100%')
      .height(60)
      .padding({ left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .border({ width: 1, color: COLORS.line })

      if (this.showAddTeam) {
        AddTeamModal({
          onClose: () => {
            this.showAddTeam = false;
          }
        })
      }
      if (this.showEditBooth) {
        EditBoothModal({
          onClose: () => {
            this.showEditBooth = false;
          }
        })
      }
      if (this.showDeleteGear) {
        DeleteGearModal({
          title: this.delGearName, onClose: () => {
            this.showDeleteGear = false;
          }
        })
      }
      if (this.showDetail) {
        DetailMatchModal({
          name: this.detailMatchName, onClose: () => {
            this.showDetail = false;
          }
        })
      }
    }
  }
}

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

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '22')
      .borderRadius(3)
      .border({ width: 1, color: this.color + '88' })
  }
}

@Component
struct BoothContent {
  @ObjectLink data: ArenaData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 周客流人次')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六238人次')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_FLOW, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 200 ? COLORS.accent : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 0.5)
                  .backgroundColor(w.value >= 200 ? COLORS.accent : COLORS.primary)
                  .borderRadius(2)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.primary + 'AA' })

        Column() {
          Row() {
            Text('🎮 常客段位分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('钻石段最多')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(RANK_SHARE, (n: RankMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(48)
              Row() {
                Row()
                  .layoutWeight(n.count / 3)
                  .height(8)
                  .backgroundColor(n.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(1 - n.count / 3)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(8)
              Text(n.count + '队')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: RankMeta) => n.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('🖥️ 机位总览')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️调整')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('✏️')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.booths, (b: BoothItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getAreaColor(b.area) + '22')
                .borderRadius(6)
                .border({ width: 1, color: getAreaColor(b.area) + '66' })
              Text(b.emoji)
                .fontSize(17)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(b.name + ' 机位')
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  NeonTag({ text: b.area, color: getAreaColor(b.area) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(b.spec + ' · ¥' + b.price + '/小时')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(b.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(getStatusColor(b.status))
                Text(b.price + '元/h')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(8)
            .border({ width: 1, color: getStatusColor(b.status) + '44' })
            .margin({ bottom: 8 })
          }, (b: BoothItem, i: number) => b.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.primary + '88' })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct TeamContent {
  @ObjectLink data: ArenaData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆 赛事热度榜')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('争霸赛最热')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(MATCH_HOT, (m: MatchHotMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.warning : COLORS.textHint)
                .width(22)
              Text(m.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(80)
              Row() {
                Row()
                  .layoutWeight(m.teams / 64)
                  .height(8)
                  .backgroundColor(m.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(1 - m.teams / 64)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(8)
              Text(m.teams + '队')
                .fontSize(9)
                .fontColor(m.color)
                .width(38)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (m: MatchHotMeta, i: number) => m.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.warning + '88' })

        Column() {
          Row() {
            Text('🎮 入驻战队')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕入驻')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.teams, (t: TeamItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getRankColor(t.rank) + '22')
                .borderRadius(20)
              Text(t.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(t.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  NeonTag({ text: 'NO.' + t.rank, color: getRankColor(t.rank) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('胜率' + t.winRate + '% · ' + t.members + '人 · ' + t.sponsor)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(t.winRate + '%')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(t.winRate >= 70 ? COLORS.accent : COLORS.warning)
                Text('胜率')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(8)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (t: TeamItem, i: number) => t.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct MatchContent {
  @ObjectLink data: ArenaData;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🏆 近期赛事')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('点击查看详情')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        Row() {
          Column() {
            Text('12')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('年内赛事')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.primary + '88' })
          Column() {
            Text('10万')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.warning)
            Text('最高奖金')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.warning + '88' })
          .margin({ left: 10 })
          Column() {
            Text('64')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('最大队伍数')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.accent + '88' })
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })

        ForEach(this.data.matches, (m: MatchItem, i: number) => {
          Row() {
            Column()
              .width(42)
              .height(42)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(getMatchColor(m.status) + '22')
              .borderRadius(21)
            Text(m.emoji)
              .fontSize(18)
              .width(30)
              .textAlign(TextAlign.Center)
            Column() {
              Row() {
                Text(m.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                NeonTag({ text: m.status, color: getMatchColor(m.status) })
                  .margin({ left: 6 })
              }
              .width('100%')
              Text(m.date + ' · ' + m.teams + '支队伍')
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 8 })
            Column() {
              Text('¥' + m.prize)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.warning)
              Text('总奖金')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
              Text('👁️')
                .fontSize(12)
                .margin({ top: 3 })
                .onClick(() => {
                  this.onDetail(m.name);
                })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 10, right: 12, top: 9, bottom: 9 })
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (m: MatchItem, i: number) => m.name + i)
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct GearContent {
  @ObjectLink data: ArenaData;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🖱️ 外设销量TOP6')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('鼠标垫夺冠')
              .fontSize(9)
              .fontColor(COLORS.accent)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(GEAR_TOP, (g: GearTopMeta, i: number) => {
            Row() {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? COLORS.pink : COLORS.textHint)
                .width(22)
              Text(g.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(80)
              Row() {
                Row()
                  .layoutWeight(g.sales / 520)
                  .height(8)
                  .backgroundColor(g.color)
                  .borderRadius(2)
                Row()
                  .layoutWeight(1 - g.sales / 520)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(2)
              }
              .layoutWeight(1)
              .height(8)
              Text(g.sales + '件')
                .fontSize(9)
                .fontColor(g.color)
                .width(44)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 7 })
          }, (g: GearTopMeta, i: number) => g.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.pink + '88' })

        Column() {
          Row() {
            Text('🛒 外设商城')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击🗑️下架')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.gears, (g: GearItem, i: number) => {
            Row() {
              Column()
                .width(40)
                .height(40)
                .justifyContent(FlexAlign.Center)
                .backgroundColor(getGearColor(g.type) + '22')
                .borderRadius(8)
              Text(g.emoji)
                .fontSize(18)
                .width(28)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(g.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  NeonTag({ text: g.type, color: getGearColor(g.type) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('¥' + g.price + ' · 已售' + g.sales + '件')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(g.stock > 15 ? '充足' : '告急')
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(g.stock > 15 ? COLORS.success : COLORS.danger)
                Text('库存' + g.stock)
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
                Text('🗑️')
                  .fontSize(12)
                  .margin({ top: 4 })
                  .onClick(() => {
                    this.onDel(g.name);
                  })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius(8)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (g: GearItem, i: number) => g.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct ComboContent {
  @ObjectLink data: ArenaData;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('⚡ 上网套餐')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('学生月卡最划算')
            .fontSize(9)
            .fontColor(COLORS.accent)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 10 })

        Row() {
          Column() {
            Text('12')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('在售套餐')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.primary + '88' })
          Column() {
            Text('4折')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.warning)
            Text('最低折扣')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.warning + '88' })
          .margin({ left: 10 })
          Column() {
            Text('¥25')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('最低单价')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor(COLORS.cardBg)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.accent + '88' })
          .margin({ left: 10 })
        }
        .width('100%')
        .margin({ bottom: 12 })

        ForEach(this.data.combos, (c: ComboItem, i: number) => {
          Row() {
            Column()
              .width(42)
              .height(42)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(COLORS.neon + '22')
              .borderRadius(21)
            Text(c.emoji)
              .fontSize(18)
              .width(30)
              .textAlign(TextAlign.Center)
            Column() {
              Row() {
                Text(c.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                NeonTag({ text: c.hours + '小时', color: COLORS.neon })
                  .margin({ left: 6 })
              }
              .width('100%')
              Text(c.games + ' · ' + c.discount + '折优惠')
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 8 })
            Column() {
              Text('¥' + c.price)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(c.discount <= 6 ? COLORS.danger : COLORS.warning)
              Text('立省' + (100 - c.discount * 10) + '%')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding({ left: 10, right: 12, top: 9, bottom: 9 })
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
          .borderRadius(8)
          .border({ width: 1, color: COLORS.line })
          .margin({ bottom: 8 })
        }, (c: ComboItem, i: number) => c.name + i)
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('🎮')
            .fontSize(26)
          Column() {
            Text('战队入驻')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('录入新战队资料')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')

        Row() {
          Text('战队名称')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('星辉战队')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 14 })

        Row() {
          Text('主力项目')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('英雄联盟 · 5人')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('赞助商')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('星辉电竞 · 训练费已缴')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Row() {
          Text('训练时段')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('周二至周四 20:00-23:00')
            .fontSize(12)
            .fontColor(COLORS.warning)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(8)
        .margin({ top: 8 })

        Text('✅ 战队入驻需缴纳训练场地费,包厢训练室需提前一天预约。')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .width('100%')
          .margin({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(COLORS.cardAlt)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
          Text('确认入驻')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .margin({ left: 10 })
            .backgroundColor(COLORS.primary)
            .borderRadius(8)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
      .border({ width: 1, color: COLORS.primary + 'AA' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

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

  build() {
    Column() {
      Column() {
        Row() {
          Text('🖥️')
            .fontSize(26)
          Column() {
            Text('编辑机位')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('调整配置与价格')
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          .layoutWeight(1)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
       
}


四、结尾总结

在这里插入图片描述

纵观全篇,本应用虽是单文件实现,却折射出一套成熟的电竞场馆运营中台架构哲学。从架构维度看,「静态常量层—@Observed 数据层—工具函数层—内容组件层—弹窗组件层—原子组件层」的六层划分,使每一层职责单一、可独立演进——新增业务域只需追加 Content 组件与一条 if-else 分支,新增弹窗只需追加 Modal 组件与一个 showXxx 状态,扩展成本极低且不污染既有逻辑。这种「分层 + 数据驱动」的架构,是声明式 UI 范式的最佳实践,也是中型应用避免「面条代码」的关键。

从色彩工程维度看,NeonPalette 接口 + COLORS 常量 + 五个 getXxxColor 工具函数,构成了一套完整的「状态即色彩」基础设施。每一个业务对象(机位、战队、赛事、外设、套餐)都拥有专属色彩语义,且色彩不是装饰而是第二维信息的编码——运营经理扫一眼色彩即可判断状态、区域、排名、类型、折扣,无需读文字。一色三透明度(22 底、66 边、88 框)的标签手法,使暗色主题下的元素既有层次又节省色值。色彩复用与别名(neon=accent、pink=danger)体现了工程上的克制与可维护性考量。

从状态管理维度看,@State/@Prop/@ObjectLink 三层装饰器各司其职:@State 管理主入口的本地状态(curTab、showXxx、padShake 等),@Prop 管理原子组件的只读属性(NeonTag 的 text/color),@ObjectLink 管理内容组件对 @Observed 对象的响应式引用。这种「三层状态分层」使数据流向清晰:数据从 ArenaData 单向流向 Content,回调从 Content 反向流向 ArenaApp,ArenaApp 再通过状态驱动弹窗,形成闭环。modalOverlay 的预留、onClose 回调的统一模式、参数化文案(「「 + title + 」」)等细节,都体现了对扩展性与可维护性的深思熟虑。

从可视化维度看,应用用纯声明式组件(Column + layoutWeight + height + backgroundColor)模拟了柱状图、横条图、进度条、统计卡、斑马列表五种可视化形态,零图表库依赖。客流柱状图用「value * 0.5」做高度归一化,横条图用「双 Row 拼 layoutWeight」做进度条,统计卡用「26px 大数字 + 9px 小标签」做 KPI 展示,斑马列表用「i % 2 交替底色」做行区分。这些「轻量可视化」手法虽不如专业图表库精美,但胜在零依赖、可定制、性能优,适合在中低端设备上流畅运行。

从工程纪律维度看,全程零第三方依赖、单文件集中、常量优先、函数抽取复用、ForEach 稳定键、if-else 链可读性优先等习惯,使应用包体积极小、渲染确定性强、可读性高。虽是演示型中台,但静态数据的形态(客流周末峰值、段位钻石最多、销量鼠标垫夺冠、折扣年卡最低)都隐含了真实运营洞察,既是 UI 演示也是业务沙盘。整体设计在「视觉表现力」与「工程严谨性」间取得了精妙平衡,是声明式 UI 中型应用的优秀范本,值得电商、运营、驾驶舱类项目借鉴。

Logo

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

更多推荐