在鸿蒙HarmonyOS的ArkUI声明式开发范式中,一个优秀的企业级应用不仅需要扎实的代码逻辑,更需要在视觉设计语言与交互体验之间取得精妙平衡。本文将以一款名为"FENCING LAB"的潮流击剑俱乐部应用为研究对象,从调色板体系、类型定义模型、全局纯函数、六个弹框组件、主入口组件及其六个功能页面(剑道预约、装备工坊、训练中心、积分联赛、商城、个人中心)等维度,逐段剖析其完整的代码架构与设计思路。通过深入解读每一段代码背后的工程决策与鸿蒙技术细节,读者将全面掌握ArkTS在实际项目中的最佳实践。

一、技术背景与设计哲学

1.1 应用定位与场景概述

在移动互联网与运动社交深度融合的当下,击剑这项历史悠久的贵族运动正在经历一场"潮流化"改造。FENCING LAB正是在这样的背景下诞生的一款鸿蒙原生应用——它将得物App类的潮流电商风格与击剑运动的专业特质相结合,打造出一个集剑道预约、装备管理、训练排课、积分联赛、装备商城、个人档案于一体的综合平台。

从技术架构的视角来看,这款应用采用单文件ArkTS的UI Demo形式构建,这意味着所有的数据模型、组件定义、页面逻辑都在同一个源文件中完成。这种设计虽然在工程实践中更适合原型开发与功能演示,但它能够极为清晰地展现整个应用的完整骨架,对于学习鸿蒙ArkUI的开发者而言具有极高的参考价值。

应用的核心模块被划分为六个功能页面:剑道预约负责管理八条剑道的实时状态与对决信息;装备工坊承载用户的十件装备清单与三剑种参数对比;训练中心包含教练团队展示、周课程表以及命中部位分布分析;积分联赛提供TOP10积分榜、赛程对阵与个人积分走势图;商城实现了包含分类筛选、新品横滑、商品列表的电商功能闭环;个人中心则涵盖剑手档案、段位进度、徽章墙与设置项管理。

1.2 视觉风格的设计逻辑

应用的视觉语言建立在"深海军蓝"这一主基调之上。底色#0D1B2A取自深海与夜空的交汇处,营造出一种沉静而专业的氛围——这种色值在击剑运动中具有强烈的象征意义,因为击剑馆内的灯光通常偏暗,剑手需要在这种环境中保持高度专注。卡片色#1B263B比底色稍亮,模拟金属剑库的质感,让信息卡片如同剑条般在深色背景上浮现。

鸿蒙ArkUI的色彩系统强调语义化设计。在本应用中,每一种颜色都被赋予了明确的角色定位:红色#D62828代表赛台得分灯与警示信号,金属银#C0C7D1象征剑条与文字主色,绿色#3FB68B传递空闲状态与积极信号,金色#E8C46B标示教学状态与段位成就。这种色彩语义化体系使得用户无需阅读文字即可感知信息状态。

1.3 borderRadius差异化策略

在整个应用中,一个极为显著的设计特征是borderRadius的"单侧差异化"处理。几乎每一个卡片、按钮、标签都采用了非对称的圆角值——例如borderRadius({ topLeft: 10, bottomRight: 10, topRight: 3, bottomLeft: 3 })这样的写法。这种设计并非随意为之,而是有意模拟击剑运动中剑条的斜切角观感:当两个对角圆角较大、另两个对角圆角较小时,视觉上会产生一种"剑锋斜出"的动感效果,完美呼应了击剑运动的核心意象。

1.4 动画与特效体系

应用内置了两种核心动画效果:剑尖闪击与得分灯呼吸。剑尖闪击通过bladeFlashX状态变量的translate属性往返位移实现,在aboutToAppear生命周期中使用animateTo配置iterations: -1(无限循环)和PlayMode.Alternate(双向播放模式),使一个箭头符号在水平方向上反复移动,模拟剑尖出击与回撤的节奏。得分灯呼吸则通过scoreGlow状态变量的opacity属性在0.25到1之间往复变化,营造对决进行中得分指示灯闪烁的真实感。

在鸿蒙ArkUI的动画体系中,animateTo是最核心的显式动画API。它接受一个动画参数对象和一个闭包:参数对象中duration控制毫秒级时长,iterations设为-1表示无限循环,playModePlayMode.Alternate确保动画在起始值与终止值之间往复执行而非跳跃重置,curve则定义了缓动曲线类型。这种声明式的动画配置方式让开发者能够以最少的代码实现复杂的连续视觉效果。

二、调色板与设计系统深度剖析

2.1 色彩接口定义

应用首先定义了一个FLColorPalette接口,将所有颜色按语义角色进行结构化组织:

interface FLColorPalette {
  bg: string;
  card: string;
  cardDeep: string;
  red: string;
  redDim: string;
  silver: string;
  silverDim: string;
  textMain: string;
  textSub: string;
  textHint: string;
  line: string;
  green: string;
  gold: string;
  blue: string;
  steel: string;
}

const FL_COLORS: FLColorPalette = {
  bg: '#0D1B2A',
  card: '#1B263B',
  cardDeep: '#152036',
  red: '#D62828',
  redDim: '#7A1F1F',
  silver: '#C0C7D1',
  silverDim: '#8A94A6',
  textMain: '#E8EDF4',
  textSub: '#AAB4C4',
  textHint: '#5D6A7E',
  line: '#2A3A55',
  green: '#3FB68B',
  gold: '#E8C46B',
  blue: '#4C86C6',
  steel: '#6E7B8F'
};

在这里插入图片描述

这段代码的设计思路值得深入分析。首先,使用interface定义色彩结构而非直接使用散落的常量,这确保了色彩体系的完整性——任何遗漏的颜色在TypeScript类型检查阶段就会被捕获。其次,FL_COLORS常量对象在全局作用域定义,使得所有组件都能以FL_COLORS.textMain这样的方式引用颜色值,既保证了命名的一致性,又避免了魔法字符串在代码各处散布。

具体来看每种颜色的语义角色:bg是页面最底层背景色,取值#0D1B2A是深海军蓝的标准色值;card是一级卡片背景,#1B263B比底色稍亮约15%的明度,确保卡片在背景上有足够的视觉层级区分;cardDeep是二级深色背景,用于卡片内部的嵌套区域或选中态背景,#152036比一级卡片更深,形成三级层次。红色系有两个值:red是赛台红,用于得分灯、选中态边框、确认按钮等强交互元素;redDim是暗红,用于选中态的背景填充,提供足够的对比度又不至于过于刺眼。银色系同样有两个值:silver是金属银主色,用于品牌标题、主要数值显示;silverDim是暗银,用于次级文字与图标。

文字色采用三级体系:textMain是主体文字色#E8EDF4,在深色背景上提供高对比度;textSub是辅助文字色#AAB4C4,用于次级说明文字;textHint是提示文字色#5D6A7E,用于最弱层级的标签与时间信息。这种三级文字色体系确保了信息层级的清晰传达。

2.2 底部Tab配置体系

应用采用枚举与接口配合的方式定义底部导航栏的配置:

enum FLTab {
  PISTES = 0,
  ARMORY = 1,
  TRAINING = 2,
  LEAGUE = 3,
  SHOP = 4,
  ME = 5
}

interface FLTabItem {
  tab: FLTab;
  icon: string;
  label: string;
  en: string;
}

const FL_TABS: FLTabItem[] = [
  { tab: FLTab.PISTES, icon: '⚔', label: '剑道', en: 'PISTES' },
  { tab: FLTab.ARMORY, icon: '⛨', label: '装备', en: 'ARMORY' },
  { tab: FLTab.TRAINING, icon: '◎', label: '训练', en: 'TRAINING' },
  { tab: FLTab.LEAGUE, icon: '♛', label: '联赛', en: 'LEAGUE' },
  { tab: FLTab.SHOP, icon: '▣', label: '商城', en: 'SHOP' },
  { tab: FLTab.ME, icon: '♗', label: '我的', en: 'ME' }
];

在这里插入图片描述

这里的设计有三个值得关注的细节。第一,使用enum而非普通的数字常量来定义Tab标识符,这样在if-else条件分支中可以使用this.activeTab === FLTab.PISTES这样的语义化比较,而非this.activeTab === 0这样的魔法数字,大幅提升了代码可读性。第二,每个Tab项包含了三个显示字段:icon是Unicode符号图标(使用了击剑主题相关的符号如⚔代表剑道、⛨代表装备护盾、♛代表联赛奖杯等),label是中文标签,en是英文缩写——这种三字段设计让底部Tab栏能够在有限空间内同时展示图标、中文标签和英文标识,营造出潮流品牌的双语视觉风格。第三,整个Tab配置以数组形式集中定义,底部导航栏通过ForEach遍历渲染,实现了数据与视图的完全解耦。

三、类型定义与数据模型逐段分析

3.1 剑道实时状态模型

应用定义了FLPiste接口来描述每条剑道的实时运营状态:

interface FLPiste {
  id: number;
  name: string;
  weapon: string;
  status: string;
  current: number;
  capacity: number;
  utilization: number;
}

const FL_PISTES: FLPiste[] = [
  { id: 1, name: 'A1 金属主道', weapon: '花剑', status: '对决中', current: 2, capacity: 2, utilization: 92 },
  { id: 2, name: 'A2 金属主道', weapon: '花剑', status: '空闲', current: 0, capacity: 2, utilization: 45 },
  { id: 3, name: 'B1 竞速赛道', weapon: '佩剑', status: '对决中', current: 2, capacity: 2, utilization: 88 },
  { id: 4, name: 'B2 竞速赛道', weapon: '佩剑', status: '教学', current: 6, capacity: 8, utilization: 76 },
  { id: 5, name: 'C1 重剑专区', weapon: '重剑', status: '空闲', current: 0, capacity: 2, utilization: 38 },
  { id: 6, name: 'C2 重剑专区', weapon: '重剑', status: '维护', current: 0, capacity: 2, utilization: 0 },
  { id: 7, name: 'D1 新手感应道', weapon: '花剑', status: '教学', current: 5, capacity: 6, utilization: 81 },
  { id: 8, name: 'D2 新手感应道', weapon: '花剑', status: '空闲', current: 0, capacity: 6, utilization: 52 }
];

在这里插入图片描述

这段数据模型的设计体现了对击剑运动实际运营场景的深刻理解。八条剑道按剑种分为四大区域:A区(金属主道,花剑专用)、B区(竞速赛道,佩剑专用)、C区(重剑专区)、D区(新手感应道,配备电动感应设备的花剑道)。每条剑道的status字段有四种状态值:空闲、对决中、教学、维护——这与击剑馆的实际运营完全吻合。currentcapacity字段记录当前在场人数与最大容量,对决型剑道容量为2人(一对一),教学型剑道容量更大。utilization字段是利用率百分比,用于在UI中以颜色区分繁忙程度。

在鸿蒙ArkUI的数据驱动UI范式下,数据接口的设计直接决定了UI的渲染逻辑。FLPiste接口中的status字段虽然是一个简单的字符串,但它驱动了整条剑道卡片的状态色标、背景色、边框色以及点击行为——空闲状态显示绿色边框和"立即预约"按钮,维护状态点击后弹出Toast提示而非打开预约弹框。这种"数据即界面"的设计理念是声明式UI的核心。

3.2 实时对决数据模型

interface FLDuel {
  id: number;
  redName: string;
  redScore: number;
  blueName: string;
  blueScore: number;
  piste: string;
  weapon: string;
  period: string;
  remain: string;
}

const FL_DUELS: FLDuel[] = [
  { id: 1, redName: '陆沉', redScore: 9, blueName: '程一剑', blueScore: 8, piste: 'A1 金属主道', weapon: '花剑', period: '第三局', remain: '01:24' },
  { id: 2, redName: '白鹭', redScore: 12, blueName: '关山月', blueScore: 5, piste: 'B1 竞速赛道', weapon: '佩剑', period: '决胜局', remain: '00:47' },
  { id: 3, redName: '薛无衣', redScore: 4, blueName: '林渡', blueScore: 4, piste: 'A1 金属主道', weapon: '花剑', period: '第二局', remain: '02:10' }
];

在这里插入图片描述

对决数据模型采用了红蓝双方的对称结构。这里有一个精心设计的字段:period记录当前局数(如"第三局"、“决胜局”),remain记录剩余时间(如"01:24")。这两个字段让UI能够实时展示对决的紧张感——决胜局仅剩47秒的对决信息会激发用户的观赛欲望。值得注意的是,对决列表中红蓝方的分配并非随机:红方通常是更受关注的剑手(如积分榜第一的程一剑作为蓝方出现,增加叙事张力)。

3.3 三剑种参数对比模型

interface FLWeaponSpec {
  weapon: string;
  enName: string;
  length: string;
  weight: string;
  maxPressure: string;
  target: string;
  style: string;
  color: string;
}

const FL_WEAPON_SPECS: FLWeaponSpec[] = [
  { weapon: '花剑', enName: 'FOIL', length: '110cm', weight: '\u2264500g', maxPressure: '500g', target: '躯干有效', style: '技巧流派 · 点刺为主', color: '#4C86C6' },
  { weapon: '重剑', enName: 'EPEE', length: '110cm', weight: '\u2264770g', maxPressure: '750g', target: '全身有效', style: '沉稳流派 · 全身压制', color: '#C0C7D1' },
  { weapon: '佩剑', enName: 'SABRE', length: '105cm', weight: '\u2264500g', maxPressure: '可劈刺', target: '上半身', style: '速度流派 · 劈刺结合', color: '#D62828' }
];

这个模型是应用中最具专业深度的数据结构之一。它不仅包含了三剑种(花剑、重剑、佩剑)的基本物理参数——剑长、重量、判定压力、有效部位,还通过style字段为每个剑种赋予了"流派"标签,用击剑文化的语言来区分不同剑种的技战术特征。color字段直接关联调色板体系:花剑用蓝色、重剑用银色、佩剑用红色——这种"一剑种一色"的设计贯穿了整个应用的视觉体系,使得用户在任何页面看到特定颜色就能联想到对应的剑种。

3.4 装备清单与教练团队模型

interface FLGear {
  id: number;
  name: string;
  weapon: string;
  brand: string;
  pounds: number;
  size: string;
  status: string;
  bought: string;
  note: string;
}

const FL_GEARS: FLGear[] = [
  { id: 1, name: '竞赛级花剑(法式枪柄)', weapon: '花剑', brand: 'ALLSTAR', pounds: 350, size: '5号', status: '服役中', bought: '2026-03-12', note: '主力剑 · 剑尖灵敏度92%' },
  { id: 2, name: '训练花剑(直柄)', weapon: '花剑', brand: 'PBT', pounds: 420, size: '5号', status: '服役中', bought: '2025-09-02', note: '日常训练用 · 握柄缠带已换' },
  { id: 3, name: '金属衣(FIE认证)', weapon: '花剑', brand: 'UHLMANN', pounds: 800, size: 'M', status: '服役中', bought: '2026-01-20', note: '导电率100% · 赛季主力' },
  { id: 4, name: '重剑(比利时柄)', weapon: '重剑', brand: 'BLESTMANN', pounds: 700, size: '5号', status: '保养中', bought: '2025-06-18', note: '剑身微弯 · 已送工坊校直' }
];

在这里插入图片描述

装备清单模型包含了十件装备,覆盖了剑具、护具、配件三大类别。每件装备的status字段有四种状态:服役中、保养中、待更换、库存中。note字段是装备的详细备注,包含了极为专业的维护信息——例如"剑尖灵敏度92%"、“导电率100%”、"剑身微弯·已送工坊校直"等。这些数据不仅让UI展示更加真实,更重要的是它们直接驱动了装备工坊页面中的"维护提醒"模块:当装备状态为"保养中"或"待更换"时,维护提醒卡片会自动展示相应内容。

教练团队模型FLCoach包含了六位教练的完整信息:姓名、头衔、主项剑种、段位、生涯胜率、执教年限、学员数量以及教练格言。教练格言字段quote是一个巧妙的设计——它让每位教练的UI卡片不仅有数据展示,更有性格表达,如"速度是佩剑手的呼吸"、"重剑是手谈,不是角力"等充满击剑哲学的格言。

3.5 课程表、联赛与商城模型

interface FLClass {
  id: number;
  day: string;
  time: string;
  name: string;
  coach: string;
  weapon: string;
  level: string;
  duration: number;
  capacity: number;
  booked: number;
  price: number;
}

const FL_CLASSES: FLClass[] = [
  { id: 1, day: '周一', time: '19:00', name: '花剑步伐基础', coach: '苏梨', weapon: '花剑', level: '入门', duration: 90, capacity: 12, booked: 9, price: 180 },
  { id: 2, day: '周二', time: '19:30', name: '佩剑劈刺专项', coach: '顾长风', weapon: '佩剑', level: '进阶', duration: 90, capacity: 10, booked: 10, price: 240 }
];

在这里插入图片描述

课程表模型的bookedcapacity字段的比例关系驱动了UI中的进度条渲染:当booked >= capacity时进度条显示红色(已满),否则显示绿色(可报名)。这种数据到视觉的直接映射是声明式UI的核心优势。

联赛模型分为两个部分:FLLeaguePlayer描述积分榜前十名选手的排名信息,包含排名、姓名、俱乐部、剑种、积分、胜负记录与排名变化趋势(如"上2"表示上升2位);FLMatch描述赛程对阵,采用左右对称结构(leftright分别代表两位选手及其得分),这种结构直接驱动了联赛页面中"左右决斗式对称布局,中间VS徽标"的视觉设计。

商城模型FLProduct包含了十件商品,每件商品都有category分类、priceoriginPrice的现价原价对比、sold销量、tag标签(热卖、认证、镇店、新品、联名等)以及color色块(用于商品图片的占位色块)。这种设计让商城页面能够实现分类筛选、折扣展示、标签标记等完整的电商功能。

3.6 积分趋势、命中分布与徽章模型

interface FLPointNode {
  week: string;
  points: number;
  rank: number;
}

const FL_POINT_TREND: FLPointNode[] = [
  { week: 'W1', points: 1210, rank: 22 },
  { week: 'W2', points: 1335, rank: 19 },
  { week: 'W3', points: 1298, rank: 20 },
  { week: 'W4', points: 1462, rank: 16 },
  { week: 'W5', points: 1588, rank: 14 },
  { week: 'W6', points: 1660, rank: 12 },
  { week: 'W7', points: 1789, rank: 12 },
  { week: 'W8', points: 1892, rank: 10 }
];

在这里插入图片描述

积分趋势模型以周为单位记录用户的积分与排名变化,八周数据从1210分/22名增长到1892分/10名,展现了明显的上升曲线。在UI中,每个数据点渲染为一根竖线,高度通过全局函数flTrendHeight按比例计算——这种柱状图式的趋势可视化虽然简洁,但信息传达效率极高。

命中部位分布模型是一个有趣的训练分析数据结构:它记录了近30场比赛中选手命中对手各部位的次数与百分比。在UI中,这个数据被渲染为一条水平堆叠条形图,每个部位用不同颜色表示,下方附带图例列表。这种可视化让训练效果一目了然——42%的命中集中在躯干正面,说明该选手的点刺精准度较高。

徽章模型FLBadge定义了八枚成就徽章,其中六枚已获得、两枚未解锁。每枚徽章有earned布尔状态字段,直接驱动UI中已获得徽章以金色边框和金色图标展示,未解锁徽章以灰色暗淡展示——这种对比强化了成就系统的激励机制。

四、全局纯函数体系分析

4.1 状态色彩映射函数

function flStatusColor(status: string): string {
  if (status === '空闲') {
    return FL_COLORS.green;
  }
  if (status === '对决中') {
    return FL_COLORS.red;
  }
  if (status === '教学') {
    return FL_COLORS.gold;
  }
  return FL_COLORS.steel;
}

function flStatusBg(status: string): string {
  if (status === '空闲') {
    return '#12362C';
  }
  if (status === '对决中') {
    return '#3A1414';
  }
  if (status === '教学') {
    return '#3A2F14';
  }
  return '#1C2436';
}

在这里插入图片描述

这两个函数是应用中"状态-颜色"映射的核心逻辑。flStatusColor返回状态对应的主体色(用于文字与图标),flStatusBg返回状态对应的背景色(用于卡片填充)。值得注意的是,背景色并非简单的主体色加透明度,而是精心调配的暗色变体——空闲状态的背景是暗绿#12362C、对决中是暗红#3A1414、教学是暗金#3A2F14。这些暗色背景确保了文字在背景上依然保持高对比度可读性,同时通过色调暗示了状态信息。

在ArkTS中,全局纯函数是组织UI逻辑的重要手段。与组件内的方法不同,纯函数不依赖任何组件实例的this上下文,可以在任何组件中被直接调用。这种设计使得状态-颜色的映射逻辑在整个应用中保持唯一一致性——无论是剑道列表、对决卡片还是装备清单,只要是相同的状态值就一定映射到相同的颜色。这种"单一真实来源"的设计原则是大型应用可维护性的基石。

4.2 剑种色彩映射函数

function flWeaponColor(weapon: string): string {
  if (weapon === '花剑') {
    return FL_COLORS.blue;
  }
  if (weapon === '佩剑') {
    return FL_COLORS.red;
  }
  if (weapon === '重剑') {
    return FL_COLORS.silver;
  }
  return FL_COLORS.gold;
}

在这里插入图片描述

这个函数将三种剑种映射到三种语义色:花剑-蓝、佩剑-红、重剑-银。这种映射贯穿了整个应用的视觉系统——从剑道卡片的剑种标签色块,到装备清单的左侧色条,从教练卡片的头像背景色,到课程表的时间标签色,所有涉及剑种标识的地方都统一调用这个函数。这种集中化的色彩管理确保了视觉一致性,同时也使得未来如果需要调整剑种颜色方案,只需修改这一处函数即可。

4.3 图表高度计算函数

function flBarHeight(percent: number, maxPercent: number): string {
  if (percent >= maxPercent) {
    return '92vp';
  }
  return (percent / maxPercent * 92).toFixed(0) + 'vp';
}

function flTrendHeight(points: number, minPoints: number, maxPoints: number): string {
  if (maxPoints <= minPoints) {
    return '40vp';
  }
  return (20 + (points - minPoints) / (maxPoints - minPoints) * 64).toFixed(0) + 'vp';
}

这两个函数负责将数据值转换为UI中的高度值。flBarHeight用于剑道利用率柱状图——将百分比映射到0-92vp的高度范围,当数据达到或超过最大值时直接返回最大高度92vp,避免溢出。flTrendHeight用于积分趋势图——将积分值映射到20-84vp的高度范围(20为基础偏移量,64为动态范围),这种设计确保了即使最低分的数据点也有20vp的可见高度,同时最高分与最低分之间有64vp的视觉差异足以区分趋势变化。

返回值使用vp(virtual pixel)单位是鸿蒙ArkUI的推荐做法——vp是一种响应式像素单位,它会根据屏幕密度自动缩放,确保在不同分辨率的设备上视觉表现一致。

4.4 奖牌图标与颜色函数

function flMedalIcon(rank: number): string {
  if (rank === 1) {
    return '\u2460';
  }
  if (rank === 2) {
    return '\u2461';
  }
  if (rank === 3) {
    return '\u2462';
  }
  return rank.toString();
}

function flMedalColor(rank: number): string {
  if (rank === 1) {
    return '#E8C46B';
  }
  if (rank === 2) {
    return '#C0C7D1';
  }
  if (rank === 3) {
    return '#B0703C';
  }
  return FL_COLORS.textHint;
}

这两个函数配合使用,为积分榜前三名提供金牌圆圈数字+金色、银牌圆圈数字+银色、铜牌圆圈数字+铜色的视觉标识,第四名及以后使用普通数字加暗灰文字色。这种设计让积分榜的视觉层级一目了然——前三名通过圆圈数字与金/银/铜色彩与后续排名形成鲜明对比。

4.5 胜率色彩与趋势色彩函数

function flWinRateColor(rate: number): string {
  if (rate >= 60) {
    return FL_COLORS.green;
  }
  if (rate >= 45) {
    return FL_COLORS.gold;
  }
  return FL_COLORS.redDim;
}

function flTrendColor(trend: string): string {
  if (trend.startsWith('\u2191')) {
    return FL_COLORS.green;
  }
  if (trend.startsWith('\u2193')) {
    return FL_COLORS.red;
  }
  return FL_COLORS.textHint;
}

flWinRateColor将胜率数值映射为三档颜色:60%以上绿色(优秀)、45%以上金色(中等)、45%以下暗红(需提升)。flTrendColor通过字符串前缀判断趋势方向:以上升箭头开头表示排名上升用绿色、以下降箭头开头表示排名下降用红色、横线表示持平用灰色。这种基于字符串前缀的判断方式虽然不如正则表达式优雅,但在ArkTS的运行时环境中足够高效且可读性强。

五、弹框组件体系逐段分析

5.1 弹框组件架构总览

应用定义了六个独立的弹框组件,每个组件负责一个核心业务场景的表单交互。这些组件遵循统一的设计模式:使用@Component装饰器声明、通过@State管理内部表单状态、通过外部回调函数onCloseonConfirm与父组件通信、内部使用Scroll容器承载可滚动表单内容、底部固定操作按钮区域。这种统一的架构模式确保了六个弹框在交互体验上的一致性。

PISTES

ARMORY

TRAINING

LEAGUE

SHOP

ME

FLApp 主入口

contentArea 内容区

activeTab 判断

剑道预约页

装备工坊页

训练中心页

联赛页

商城页

我的页

showBookModal

showChallengeModal

showStoreModal

showDeleteModal

showJoinModal

showProfileModal

FLBookPisteForm

FLChallengeForm

FLGearStoreForm

FLDeleteGearForm

FLJoinClassForm

FLEditProfileForm

5.2 FLBookPisteForm:剑道预约弹框

这是应用中最复杂的弹框组件之一,负责剑道预约的完整交互流程:

@Component
struct FLBookPisteForm {
  @State selPisteName: string = 'A2 金属主道'
  @State selDate: string = '今天 08-26'
  @State selTime: string = '19:00'
  @State selWeapon: string = '花剑'
  @State userName: string = ''
  onClose: () => void = () => {}
  onConfirm: (piste: string, date: string, time: string, weapon: string) => void = () => {}

  build() {
    Column() {
      Row() {
        Column() {
          Text('预约剑道')
            .fontSize(18).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.textMain)
          Text('PISTE RESERVATION')
            .fontSize(9).fontColor(FL_COLORS.textHint).letterSpacing(2).margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Text('X')
          .fontSize(16).fontColor(FL_COLORS.textSub).padding(8)
          .onClick(() => { this.onClose() })
      }
      .width('100%').padding({ left: 18, right: 14, top: 16, bottom: 12 })

      Column()
        .width(46).height(2).backgroundColor(FL_COLORS.red)
        .borderRadius({ topLeft: 2, bottomRight: 2 }).margin({ left: 18 })

组件首先定义了五个@State状态变量:selPisteName记录选中的剑道名称、selDate记录预约日期、selTime记录时段、selWeapon记录剑种、userName记录用户备注。两个回调函数onCloseonConfirm提供了与父组件的通信通道——关闭弹框时调用onClose,确认预约时调用onConfirm并携带所有选中的参数。

UI结构从上到下分为三个区域:头部标题栏、可滚动表单区、底部操作按钮栏。头部使用了双语标题设计——中文"预约剑道"18px加粗作为主标题,英文"PISTE RESERVATION"9px加字间距作为副标题,这种双语标题设计是整个应用一致的品牌风格。标题下方有一个46x2的红色细线条作为视觉分隔,这个设计细节模拟了击剑运动中剑条划过的痕迹。

表单区域的第一部分是剑道宫格选择。这里使用了Flex容器配合FlexWrap.Wrap实现自动换行的网格布局,每个剑道卡片宽度为31%(三列布局),通过ForEach遍历FL_PISTES数据。每张卡片显示状态指示灯(圆圈或实心圆符号加状态文字)、剑道名称、剑种与人数信息。选中态通过背景色从FL_COLORS.cardDeep变为FL_COLORS.redDim、边框色从FL_COLORS.line变为FL_COLORS.red来体现。点击时更新selPisteName状态。

在鸿蒙ArkUI中,Flex容器配合FlexWrap.Wrap是实现自适应网格布局的推荐方案。与Grid组件相比,Flex的换行布局在项数不固定或项宽需要百分比控制时更加灵活。这里使用justifyContent: FlexAlign.SpaceBetween确保三列卡片之间间距均匀分布,而每个卡片宽度设为31%(而非33.3%),留出约7%的空间作为列间距。

表单的第二、三、四部分分别是日期chips、时段chips和剑种chips。这三组选择器采用了相似的设计模式:通过ForEach渲染一组Text组件作为可点击的选择标签,选中态通过背景色和文字色的变化来体现。一个值得注意的设计细节是三组chips的圆角处理各不相同:日期chips使用斜对角圆角,时段chips使用四角统一圆角并增加了边框,剑种chips使用左大右小的斜切效果。这种差异化的圆角处理让三组选择器在视觉上形成层次区分,避免了一览无余的雷同感。

5.3 FLJoinClassForm:课程报名弹框

@Component
struct FLJoinClassForm {
  @State selCoach: string = '顾长风'
  @State selCycle: string = '4周基础班'
  @State needRent: boolean = true
  @State selCourse: string = '实战对抗夜'
  onClose: () => void = () => {}
  onConfirm: (coach: string, cycle: string, rent: boolean) => void = () => {}

  flCyclePriceText(cycle: string): string {
    if (cycle === '4周基础班') {
      return '1440'
    }
    if (cycle === '8周进阶班') {
      return '2680'
    }
    return '380/节'
  }

  flTotalText(cycle: string, rent: boolean): string {
    if (cycle === '4周基础班') {
      if (rent) {
        return '1678'
      }
      return '1296'
    }
    if (cycle === '8周进阶班') {
      if (rent) {
        return '2610'
      }
      return '2412'
    }
    if (rent) {
      return '378'
    }
    return '342'
  }
}

这个组件的亮点在于其内部的两个计算方法:flCyclePriceText根据课程周期返回对应价格文本,flTotalText根据课程周期和是否租赁装备计算总价。这种将业务逻辑封装在组件内部方法中的设计,使得价格计算逻辑与UI渲染逻辑紧密耦合但又层次分明——当用户切换周期或租赁开关时,费用明细区域会自动更新显示金额。

组件的UI结构包含四个部分:教练横滑选择器、课程周期选择、装备租赁开关、费用明细。教练选择器使用了嵌套的Scroll+Row+ForEach结构实现横向滑动列表,每个教练卡片宽度固定84vp,显示教练姓名、剑种和段位信息。装备租赁开关是一个巧妙的自定义Toggle实现——通过onClick切换needRent布尔状态,开关文字在"开"和"关"之间切换,背景色在绿色和深色之间切换。

费用明细区域是这个组件最具商业感的设计:课程费、装备租赁费、会员折扣、合计四行信息以表格化布局排列,合计行使用更大的15px字体和红色加粗强调,直接驱动用户的决策行为。

5.4 FLGearStoreForm:装备入库弹框

这个组件的特色在于其"磅数步进器"(Stepper)的实现。用户可以通过点击减号或加号按钮来调整剑尖压力值,步进为10克,范围在300g到760g之间:

          Row() {
            Text('-')
              .fontSize(18).fontColor(FL_COLORS.silver).width(40).height(40)
              .textAlign(TextAlign.Center).backgroundColor(FL_COLORS.cardDeep)
              .borderRadius({ topLeft: 20, bottomLeft: 20 })
              .border({ width: 1, color: FL_COLORS.line }).onClick(() => {
                if (this.pounds > 300) {
                  this.pounds = this.pounds - 10
                }
              })
            Column() {
              Text(this.pounds.toString() + ' g')
                .fontSize(14).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.textMain)
            }
            .layoutWeight(1).height(40).justifyContent(FlexAlign.Center)
            Text('+')
              .fontSize(18).fontColor(FL_COLORS.red).width(40).height(40)
              .textAlign(TextAlign.Center).backgroundColor(FL_COLORS.cardDeep)
              .borderRadius({ topRight: 20, bottomRight: 20 })
              .border({ width: 1, color: FL_COLORS.line }).onClick(() => {
                if (this.pounds < 760) {
                  this.pounds = this.pounds + 10
                }
              })
          }
          .width('100%')

这个步进器的设计极为精致:减号按钮使用银色文字、左半圆角和左边框;加号按钮使用红色文字、右半圆角和右边框;中间数值区域使用layoutWeight(1)撑满剩余空间并居中显示当前值。两侧按钮的圆角分别是左侧上下20和右侧上下20,配合40x40的尺寸形成完美的半圆形端帽效果。这种设计模式在应用中被多处复用——编辑档案弹框中的体重步进器、约战弹框中的赌注积分步进器都采用了相同的布局结构。

5.5 FLEditProfileForm与FLDeleteGearForm

编辑档案弹框FLEditProfileForm的UI结构与入库弹框类似,但包含了年龄组段位chips(U10到成年组六档)和持剑手chips(左/右手持剑)。持剑手的选择器设计有一个巧妙细节:使用左右箭头符号来视觉化左手和右手的持剑方向。

删除装备确认弹框FLDeleteGearForm是六个弹框中唯一不使用@State的组件——它通过外部传入的gearNamegearMeta属性来展示待删除装备的信息,本身不维护任何可变状态。这个组件的视觉风格以红色警示为主:背景色使用了更暗的色值,边框使用暗红色,内部装备信息卡片的背景是深红底色加暗红边框,“工坊提示"行使用金色文字提醒"该装备存在待处理保养单”。

5.6 FLChallengeForm:发起约战弹框

@Component
struct FLChallengeForm {
  @State selOpponent: string = '叶栖迟'
  @State selWeapon: string = '花剑'
  @State stake: number = 50
  @State msgInput: string = ''
  onClose: () => void = () => {}
  onConfirm: (opponent: string, weapon: string, stake: number) => void = () => {}
}

约战弹框是社交功能的核心载体。它从FL_OPPONENTS数据中渲染三位积分相近的对手列表,每位对手显示在线状态(绿点或灰点)、姓名、俱乐部、剑种、积分和胜率。选中态通过背景色变为暗红和右侧出现红色勾选符号来体现。

赌注积分步进器的设计有一个额外的信息展示:标签文本中动态计算并显示"胜者全取"的积分总额——通过将赌注乘以2实时显示在提示文字中。这种实时反馈让用户在调整赌注时能够立即感知到潜在收益,增强了交互的参与感。

弹框组件体系的架构设计体现了ArkTS组件通信的核心模式:父组件通过属性向子组件传递数据和回调函数,子组件通过调用回调函数向父组件传递结果。这种"属性向下、事件向上"的单向数据流模式确保了组件之间的低耦合高内聚——每个弹框组件都是自包含的,它不关心父组件是谁,只关心自己接收的输入和需要输出的结果。

六、主入口FLApp深度解析

6.1 组件声明与状态管理

@Entry
@Component
struct FLApp {
  @State activeTab: FLTab = FLTab.PISTES
  @State showBookModal: boolean = false
  @State showJoinModal: boolean = false
  @State showStoreModal: boolean = false
  @State showProfileModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showChallengeModal: boolean = false
  @State bladeFlashX: number = 0
  @State scoreGlow: number = 0.25
  @State shopCat: string = '全部'
  @State pendingDeleteName: string = '训练手套(右手)'
  @State pendingDeleteMeta: string = 'NEMEA · 9码 · 2025-05-30 入库'
  @State toastText: string = ''

主入口组件FLApp使用@Entry装饰器标记为应用的入口点,这意味着它的build()方法的返回值将作为整个页面的根节点。组件维护了十二个@State状态变量,可以分为四组:

第一组是Tab切换状态:activeTab记录当前激活的页面,初始值为FLTab.PISTES(剑道页)。

第二组是弹框显隐状态:六个布尔变量分别控制六个弹框的显示与隐藏。这种"多布尔开关"的设计虽然简单直接,但它确保了同一时间可以有多个弹框同时挂载在Stack中——虽然实际交互中用户通常只会打开一个弹框。

第三组是动画状态:bladeFlashX控制剑尖闪击的水平位移,scoreGlow控制得分灯的透明度。这两个状态在aboutToAppear生命周期中被设置为动画的驱动目标。

第四组是业务状态:shopCat记录商城当前选中的分类,pendingDeleteNamependingDeleteMeta记录待删除装备的信息,toastText记录全局Toast提示的文本内容。

6.2 生命周期与动画初始化

  aboutToAppear() {
    this.getUIContext().animateTo({ duration: 900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.bladeFlashX = 14
    })
    this.getUIContext().animateTo({ duration: 480, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseIn }, () => {
      this.scoreGlow = 1
    })
  }

aboutToAppear是ArkUI组件的生命周期回调,在组件创建后、build()方法执行前被调用。这里在生命周期中启动了两个无限循环动画:

第一个动画驱动剑尖闪击效果:持续时长900毫秒,无限循环,双向交替播放,缓动曲线为EaseInOut(开始和结束慢、中间快),目标是将bladeFlashX从0变化到14。在剑道页面的"实时对决"标题栏中,一个箭头符号的translate属性绑定了这个状态变量——当动画运行时,箭头会在0到14vp之间往复移动,模拟剑尖出击的视觉效果。

第二个动画驱动得分灯呼吸效果:持续时长480毫秒(比剑尖闪击更快,模拟心跳节奏),缓动曲线为EaseIn(开始快、结束慢),目标是将scoreGlow从0.25变化到1。在对决卡片中,红蓝双方的得分指示灯圆点的opacity属性绑定了这个变量——当动画运行时,圆点的透明度在0.25到1之间往复变化,营造得分灯闪烁的真实感。

值得注意的是,这两个动画的时长比例(900:480)经过精心设计——它们的循环周期不会同步,因此两个动画的视觉节奏始终保持错位,避免了机械性的同步闪烁。这种"异步动画"设计是提升UI生动感的高级技巧。

6.3 全屏遮罩与弹框挂载

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
      .position({ x: 0, y: 0 }).zIndex(998).onClick(onClose)
  }

  @Builder bookModalHost() {
    Column() {
      this.modalOverlay(() => { this.showBookModal = false })
      FLBookPisteForm({
        onClose: () => { this.showBookModal = false },
        onConfirm: (piste: string, date: string, time: string, weapon: string) => {
          this.toastText = '已锁定 ' + piste + ' · ' + date + ' ' + time + ' · ' + weapon
          this.showBookModal = false
        }
      })
    }
    .width('100%').height('100%').justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center).position({ x: 0, y: 0 }).zIndex(999)
  }

modalOverlay是一个可复用的@Builder方法,它生成一个全屏半透明遮罩层。遮罩使用rgba(0,0,0,0.5)的黑色半透明背景、绝对定位覆盖整个屏幕、zIndex(998)确保它浮在内容之上但弹框之下。点击遮罩时触发onClose回调关闭弹框。

bookModalHost等六个弹框挂载@Builder方法遵循统一模式:在一个Column容器中先放置遮罩层,再放置弹框组件本身,整个容器使用zIndex(999)浮在遮罩之上,通过justifyContent(FlexAlign.Center)alignItems(HorizontalAlign.Center)使弹框在屏幕中央显示。

弹框组件的实例化采用了典型的ArkUI组件传参模式:通过花括号语法传入属性和回调——onClose回调简单地关闭弹框(将对应的布尔状态设为false),onConfirm回调在关闭弹框的同时更新toastText状态以显示操作结果的Toast提示。这种"操作结果Toast化"的设计让用户在完成弹框操作后立即获得视觉反馈,提升了交互的闭环感。

6.4 内容区与Tab切换路由

  @Builder contentArea() {
    Stack() {
      if (this.activeTab === FLTab.PISTES) {
        this.pisteTab()
      } else if (this.activeTab === FLTab.ARMORY) {
        this.armoryTab()
      } else if (this.activeTab === FLTab.TRAINING) {
        this.trainingTab()
      } else if (this.activeTab === FLTab.LEAGUE) {
        this.leagueTab()
      } else if (this.activeTab === FLTab.SHOP) {
        this.shopTab()
      } else {
        this.meTab()
      }
      if (this.toastText !== '') {
        Column() {
          Text(this.toastText)
            .fontSize(11).fontColor('#FFFFFF')
            .padding({ left: 18, right: 18, top: 9, bottom: 9 }).backgroundColor(FL_COLORS.red)
            .borderRadius({ topLeft: 16, bottomRight: 16 })
        }
        .width('100%').justifyContent(FlexAlign.Center).position({ x: 0, y: '88%' })
        .zIndex(600).onClick(() => { this.toastText = '' })
      }
      if (this.showBookModal) {
        this.bookModalHost()
      }
      if (this.showJoinModal) {
        this.joinModalHost()
      }
      if (this.showStoreModal) {
        this.storeModalHost()
      }
      if (this.showProfileModal) {
        this.profileModalHost()
      }
      if (this.showDeleteModal) {
        this.deleteModalHost()
      }
      if (this.showChallengeModal) {
        this.challengeModalHost()
      }
    }
    .width('100%').layoutWeight(1).backgroundColor(FL_COLORS.bg)
  }

内容区使用Stack作为根容器——这是整个应用的布局核心。Stack是一种层叠布局容器,它允许子元素在Z轴上层叠排列。在这个Stack中,最底层是当前激活的Tab页面(通过if-else条件分支选择渲染),中间层是Toast提示(当toastText非空时渲染),最顶层是弹框(当对应的布尔状态为true时渲染)。

这种"Stack层叠+条件渲染"的设计模式是ArkUI中实现复杂页面层级的标准方案。它的优势在于:Tab页面切换时不需要销毁和重建整个页面结构,只是条件渲染的分支发生了变化;Toast和弹框可以在任何Tab页面上叠加显示,不受当前页面内容的影响。

Toast提示的设计也值得注意:它使用position({ x: 0, y: '88%' })定位在屏幕底部偏上位置,zIndex(600)确保它浮在Tab内容之上但低于弹框。点击Toast会清空toastText从而使其消失——这种"点击即消失"的交互模式比传统的自动消失Toast更加灵活,允许用户在充分阅读后再关闭提示。

6.5 底部TabBar构建

  @Builder bottomTabBar() {
    Row() {
      ForEach(FL_TABS, (t: FLTabItem) => {
        Column() {
          Text(t.icon)
            .fontSize(17)
            .fontColor(this.activeTab === t.tab ? FL_COLORS.red : FL_COLORS.textHint)
          Text(t.label)
            .fontSize(8)
            .fontColor(this.activeTab === t.tab ? FL_COLORS.silver : FL_COLORS.textHint)
            .fontWeight(this.activeTab === t.tab ? FontWeight.Bold : FontWeight.Normal)
            .margin({ top: 2 })
          Text(t.en)
            .fontSize(6).letterSpacing(1)
            .fontColor(this.activeTab === t.tab ? FL_COLORS.red : FL_COLORS.card)
            .margin({ top: 1 })
          if (this.activeTab === t.tab) {
            Column()
              .width(26).height(2).borderRadius({ topLeft: 1, bottomRight: 1 })
              .backgroundColor(FL_COLORS.red).margin({ top: 3 })
          } else {
            Column()
              .width(26).height(2).margin({ top: 3 })
          }
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        .padding({ top: 7, bottom: 6 }).onClick(() => { this.activeTab = t.tab })
      })
    }
    .width('100%').backgroundColor(FL_COLORS.card).border({ width: 1, color: FL_COLORS.line })
  }

底部Tab栏通过ForEach遍历FL_TABS配置数组渲染六个Tab项,每个项使用layoutWeight(1)等分宽度。每个Tab项包含四行内容:Unicode图标符号(17px)、中文标签(8px)、英文缩写(6px加字间距)、以及底部的指示条。选中态通过多项视觉变化来体现:图标变红色、中文标签变银色加粗、英文缩写变红色、底部出现26x2的红色指示条。非选中态所有文字变为暗灰色,底部指示条为透明。

这种"多维度视觉反馈"的设计确保了选中态与未选中态之间有强烈的视觉对比——用户无需仔细辨认,仅凭色彩感知就能定位当前所在页面。底部Tab栏整体使用卡片背景色加边框线,与上方内容区形成清晰的视觉分隔。

未选中态视觉

选中态视觉

TabBar底部导航

剑道

装备

训练

联赛

商城

我的

图标变红色

标签变银色加粗

英文变红色

指示条红色显示

图标变暗灰

标签变暗灰常规

英文变深色隐藏

指示条透明隐藏

七、六个功能页面逐段分析

7.1 剑道预约页

剑道页面是应用的默认首页,它承载了三个核心功能模块:剑道利用率柱状图、剑道状态宫格、实时对决列表。

页面顶部是一个标题栏,左侧显示"剑道大厅"标题和"8条剑道·3场对决进行中"的副标题,右侧是一个红色的"发起约战"按钮。这个按钮点击后会设置showChallengeModal为true,打开约战弹框。

利用率柱状图是一个精巧的数据可视化实现:

          Row() {
            ForEach(FL_PISTE_USAGE, (u: FLPisteUsage) => {
              Column() {
                Column()
                  .width(12).height(flBarHeight(u.percent, 95))
                  .backgroundColor(u.percent >= 78 ? FL_COLORS.red : FL_COLORS.steel)
                  .borderRadius({ topLeft: 3, topRight: 3 })
                Text(u.hour)
                  .fontSize(7).fontColor(FL_COLORS.textHint).margin({ top: 4 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.End)
            })
          }
          .width('100%').height(120).alignItems(VerticalAlign.Bottom).margin({ top: 10 })

九根柱子通过ForEach渲染,每根柱子的宽度固定为12vp,高度通过全局函数flBarHeight按百分比比例计算。当利用率达到或超过78%时柱子使用红色,否则使用钢色——这种"阈值变色"设计让用户一眼就能识别出哪些时段接近满载。整个柱状图容器高度为120vp,子元素使用justifyContent(FlexAlign.End)alignItems(VerticalAlign.Bottom)确保所有柱子从底部对齐生长。

剑道状态宫格使用Flex+ForEach渲染八张剑道卡片,每张卡片宽度48.5%(双列布局)。卡片背景色通过flStatusBg函数根据状态映射,空闲状态还会额外显示绿色边框。每张卡片包含状态指示灯、利用率、剑道名称、剑种标签、人数标签和操作按钮(“立即预约"或"查看详情”)。点击空闲剑道会打开预约弹框,点击维护中的剑道会弹出Toast提示。

实时对决列表通过ForEach渲染三场正在进行的对决卡片。每张对决卡片采用左右对称布局:左侧是红方选手(红色圆点+姓名+红方标签),中间是比分显示(红色分数比蓝色分数,26px超大字号),右侧是蓝方选手。红蓝双方的得分指示灯圆点都绑定了scoreGlow动画状态,实现呼吸闪烁效果。

7.2 装备工坊页

装备页面包含三个模块:三剑种参数对比卡、装备清单列表、维护提醒卡片。

三剑种参数对比卡使用Row+ForEach渲染三个等宽的卡片,每张卡片展示一种剑种的完整参数。卡片顶部的3px色条通过w.color关联剑种色彩体系,下方依次展示剑种中文名、英文名、剑长、重量、判定压力、有效部位和流派风格。这种"参数对比表"的设计让用户能够快速了解三种剑种的核心差异。

装备清单列表是这个页面的主体。每件装备渲染为一个横向卡片,左侧是4px宽的剑种色条(通过flWeaponColor函数映射颜色),中间是装备名称、品牌参数和备注信息,右侧是"移除"按钮。点击移除按钮会设置pendingDeleteNamependingDeleteMeta状态并打开删除确认弹框。

维护提醒卡片是一个特殊样式的卡片:背景色是暗金底色,边框使用金色,内部展示具体的维护建议(如"重剑剑身微弯·工坊校直预计2个工作日"、“训练手套掌心磨损已达更换阈值·商城可8折换新”)。右侧"去处理"按钮点击后会切换到商城Tab页——这种跨Tab导航通过this.activeTab = FLTab.SHOP一行代码实现,体现了ArkUI状态驱动路由的简洁性。

7.3 训练中心页

训练页面包含四个模块:教练团队列表、周课程表网格、课程详情列表、命中部位分布图。

教练团队列表通过ForEach渲染六位教练的卡片。每张卡片左侧是44x44的圆形头像(以教练姓氏首字为头像文字,背景色用剑种色映射),中间是教练姓名+段位、头衔和格言,右侧是胜率或执教年限数据。这里有一个条件渲染逻辑:当教练的winRate > 0时显示胜率百分比,否则显示执教年限——这是因为体能教练霍峻的winRate为0(他不参与比赛指导),所以显示执教年限"10年"更合理。

周课程表网格是一个精巧的矩阵布局:第一行是周一到周日的星期标题,第二行通过ForEach渲染九节课的缩略信息(时间、课程名、教练),每节课用剑种色作为时间标签颜色。下方还有一个更详细的课程列表,每节课显示为一个横向卡片:左侧是星期+时间,中间是课程名+教练+时长+剩余名额进度条,右侧是价格+报名按钮。进度条的设计尤其值得注意——通过计算已报名人数与总容量的比例来动态设置进度条宽度,颜色根据是否已满在红色和绿色之间切换。

              Row() {
                Column()
                  .width((cl.booked / cl.capacity * 60).toFixed(0) + 'vp').height(3)
                  .borderRadius(2)
                  .backgroundColor(cl.booked >= cl.capacity ? FL_COLORS.red : FL_COLORS.green)
                Column().layoutWeight(1)
              }
              .width('86%').height(3).backgroundColor(FL_COLORS.cardDeep).borderRadius(2)
              .margin({ top: 5 })

命中部位分布图分为两个部分:上方是一条水平堆叠条形图,通过ForEach渲染五个部位的色块,每个色块宽度为对应的百分比宽度,使用clip(true)裁剪圆角;下方是图例列表,每个部位显示色块+名称+命中次数和百分比。

7.4 积分联赛页

联赛页面包含四个模块:积分榜TOP10、赛程对阵、个人积分走势图。

积分榜通过ForEach渲染十名选手的排名行。每行包含:排名图标(通过flMedalIcon函数,前三名使用圆圈数字,其余使用普通数字)、排名颜色(通过flMedalColor函数,前三名金/银/铜色)、选手姓名+俱乐部+剑种、积分+胜负记录、排名趋势(通过flTrendColor函数着色)。当前用户"我(段青岚)"的行使用特殊样式:背景色为深色、边框为金色、文字为金色加粗——这种"高亮当前用户"的设计让用户在排名表中快速定位自己。

赛程对阵列表是这个页面的视觉亮点。每场比赛渲染为一个左右对称的"决斗式"卡片:

            Row() {
              Column() {
                Text(m.left)
                  .fontSize(12).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.red)
                  .textAlign(TextAlign.End)
                Text(m.leftScore.toString() + ' 剑')
                  .fontSize(16).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.textMain)
                  .margin({ top: 4 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.End)

              Column() {
                Text('VS')
                  .fontSize(12).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.gold)
                Text(m.venue)
                  .fontSize(7).fontColor(FL_COLORS.textHint).margin({ top: 2 })
              }
              .width(64).alignItems(HorizontalAlign.Center).margin({ left: 8, right: 8 })

              Column() {
                Text(m.right)
                  .fontSize(12).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.blue)
                  .textAlign(TextAlign.Start)
                Text(m.rightScore.toString() + ' 剑')
                  .fontSize(16).fontWeight(FontWeight.Bold).fontColor(FL_COLORS.textMain)
                  .margin({ top: 4 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
            }

左侧选手使用红色文字、右对齐布局;中间是金色"VS"徽标加场地信息;右侧选手使用蓝色文字、左对齐布局。这种"红-金-蓝"的左右对称结构完美模拟了击剑对决中两位剑手隔剑相对的视觉意象。直播中的比赛还会使用红色边框突出显示。

7.5 商城页

商城页面包含四个模块:分类筛选条、新品横滑区、精选商品列表。

分类筛选条通过ForEach渲染六个分类标签(全部、剑具、护具、配件、鞋履、潮服),选中态使用红色背景和白色文字,未选中态使用卡片背景和灰色文字。点击分类标签会更新shopCat状态,下方的商品列表会根据这个状态进行条件渲染——只有分类匹配的商品才会渲染。

新品横滑区使用Scroll+Row+ForEach实现横向滑动列表,但这里有一个巧妙的条件过滤:只有标签为"新品"或"联名"的商品才会出现在横滑区。这种"同一数据源、不同过滤条件"的设计让商品数据只需维护一份,不同展示区域通过条件筛选呈现不同的子集。

精选商品列表是商城的主体,每件商品渲染为一个横向卡片:左侧是74x74的色块(用商品自身的color属性作为背景色,模拟商品图片),中间是标签+分类、商品名、简介、价格(现价红色加粗+原价灰色删除线)和销量,右侧是"加购"按钮。当原价大于现价时,会额外渲染原价文本并添加删除线效果——这种价格对比设计是电商应用的标准模式,能够有效刺激购买决策。

7.6 个人中心页

个人中心页面包含五个模块:剑手档案卡、段位进度条、徽章墙、设置项列表。

剑手档案卡是这个页面的视觉焦点。卡片左侧是56x56的圆形头像(以"段"字为头像文字,银色背景),中间是用户姓名、段位+持剑手+体重信息、俱乐部名称。下方是一个三列数据展示区:总场次、胜场、胜率——使用1px宽的竖线分隔。胜率区域还额外渲染了三个圆点,分别用绿色、金色(绑定scoreGlow动画)和暗红色,视觉化地暗示了胜率的构成。

段位进度条是一个简洁但信息丰富的模块:

          Row() {
            Column()
              .width(FL_PROFILE.danProgress.toString() + '%').height(8)
              .borderRadius({ topLeft: 4, bottomLeft: 4 }).backgroundColor(FL_COLORS.gold)
            Column()
              .layoutWeight(1).height(8).borderRadius({ topRight: 4, bottomRight: 4 })
              .backgroundColor(FL_COLORS.cardDeep)
          }
          .width('100%').margin({ top: 10 })
          Text('再拿 ' + (2500 - FL_PROFILE.points).toString() + ' 分晋升 ' + FL_PROFILE.nextDan)
            .fontSize(9).fontColor(FL_COLORS.textSub).margin({ top: 8 })

进度条使用两段Column实现:已完成部分宽度为danProgress百分比、金色背景、左侧圆角;未完成部分使用layoutWeight(1)撑满剩余空间、深色背景、右侧圆角。下方还有一行文字动态计算"再拿610分晋升金剑士"的提示——这种实时计算的信息让用户清楚地知道距离下一个段位还差多少。

徽章墙使用Flex+ForEach渲染八枚徽章,每枚徽章宽度31%(三列布局)。已获得的徽章使用金色边框、金色图标和卡片背景;未解锁的徽章使用灰色暗淡样式。这种对比强化了"已获得"的成就感和"未解锁"的期待感。

设置项列表通过ForEach渲染六项设置(我的剑档、预约记录、联赛报名、消息通知、装备保险、关于),每项显示图标、标签、当前值和右箭头。整个列表使用统一的卡片容器和边框样式,点击后显示Toast提示。

八、主构建方法与整体布局

  build() {
    Column() {
      this.header()
      this.contentArea()
      this.bottomTabBar()
    }
    .width('100%').height('100%').backgroundColor(FL_COLORS.bg)
  }

主构建方法build()是整个应用的布局骨架。它使用一个Column容器从上到下排列三个@Builder方法生成的区域:固定头部header()、弹性内容区contentArea()、固定底部Tab栏bottomTabBar()。整个容器宽高均为100%,背景色为深海军蓝。

头部header()是一个52px高的固定区域,包含品牌标题、搜索栏、优惠券入口和段位徽章。内容区contentArea()使用layoutWeight(1)撑满头部和底部之间的剩余空间,内部使用Stack层叠布局管理Tab页面、Toast和弹框。底部Tab栏bottomTabBar()是一个固定高度的区域,包含六个Tab入口。

这种"头部固定+中间弹性+底部固定"的三段式布局是移动端应用最经典的结构模式。在ArkUI中,通过Column+layoutWeight的组合实现这种布局极为简洁——layoutWeight(1)让内容区自动占据剩余空间,无需手动计算高度。

build 主构建方法

header 头部 52px固定

contentArea 内容区 layoutWeight弹性

bottomTabBar 底部Tab栏 固定

品牌标题 FENCING LAB

搜索栏

优惠券入口

段位徽章

Stack层叠容器

Tab页面 条件渲染

Toast提示 条件渲染

弹框层 条件渲染

剑道页

装备页

训练页

联赛页

商城页

我的页

六个Tab入口 ForEach渲染

九、鸿蒙技术点深度解读

9.1 声明式UI与状态驱动

整个应用是鸿蒙ArkUI声明式UI范式的完整实践。声明式UI的核心思想是"状态驱动视图"——开发者只需描述UI在什么状态下应该呈现什么样子,而不需要手动操作DOM或视图树来更新界面。在这个应用中,当activeTab状态发生变化时,contentArea中的if-else分支会自动重新执行,渲染对应的Tab页面;当toastText从空字符串变为非空时,Toast组件会自动出现在Stack中;当弹框的布尔状态从false变为true时,弹框组件会自动挂载并显示。

鸿蒙ArkUI的@State装饰器是实现状态驱动视图的关键。被@State标记的变量在其值发生变化时,会自动触发依赖该变量的UI部分的重新渲染。这种"响应式"机制类似于Vue.js的响应式系统或React的useState Hook,但ArkUI在编译期就能确定状态与UI之间的依赖关系,因此能够实现更精确的局部更新——只有真正依赖变化状态的UI片段才会被重新渲染,而非整个组件树。

9.2 @Builder方法与组件复用

@Builder装饰器用于定义可复用的UI构建方法。在这个应用中,modalOverlayheaderbottomTabBarcontentArea以及六个ModalHost方法和六个Tab页面方法都是使用@Builder定义的。@Builder方法与@Component组件的区别在于:@Builder方法不需要独立的状态管理,它是父组件UI的一部分,可以直接访问父组件的this上下文(包括@State变量和其他@Builder方法);而@Component组件是独立的可复用单元,拥有自己的状态和生命周期。

在弹框的设计中,两种复用方式被结合使用:弹框组件本身使用@Component定义(如FLBookPisteForm),而弹框的挂载容器使用@Builder定义(如bookModalHost)。@Builder方法内部实例化@Component组件并传入属性和回调——这种"Builder包装Component"的模式是ArkUI中实现弹框系统的标准方案。

9.3 Stack层叠布局与Z轴管理

Stack是ArkUI中实现层叠效果的核心容器。在这个应用中,contentArea使用Stack作为根容器,通过zIndex属性管理三个层级的Z轴顺序:Tab页面(默认zIndex)、Toast提示(zIndex 600)、弹框遮罩(zIndex 998)、弹框主体(zIndex 999)。

zIndex的工作原理是:在同一个Stack容器中,zIndex值越大的子元素越靠近用户的视线。当多个弹框同时打开时,它们的遮罩和主体都使用相同的zIndex值,后渲染的会覆盖先渲染的——但由于实际交互中用户通常只打开一个弹框,这种潜在的重叠问题不会产生实际影响。

9.4 animateTo动画系统

animateTo是ArkUI提供的显式动画API,它接受一个动画参数对象和一个闭包。在闭包中修改状态变量的值,ArkUI会自动在当前值和目标值之间插入过渡帧,实现平滑的动画效果。

动画参数对象支持以下关键属性:duration(动画时长,毫秒)、iterations(迭代次数,-1为无限循环)、playMode(播放模式,PlayMode.Alternate为双向交替)、curve(缓动曲线,Curve.EaseInOut为缓入缓出、Curve.EaseIn为缓入)、delay(延迟开始的毫秒数)。

在这个应用中,两个动画都使用了iterations: -1PlayMode.Alternate的组合——这确保了动画在起始值和目标值之间无限往复执行。如果使用默认的PlayMode.Normal,动画到达目标值后会跳回起始值重新开始,造成视觉上的"跳跃感";而Alternate模式则让动画先从起始值变化到目标值,再从目标值变化回起始值,形成平滑的往复运动。

9.5 ForEach与列表渲染

ForEach是ArkUI中实现列表渲染的核心组件。它接受三个参数:数据源数组、项渲染函数、以及可选的键生成器(key generator)。在这个应用中,几乎所有的列表区域都使用了ForEach来渲染数据驱动的列表项。

键生成器的作用是为每个列表项提供一个唯一标识。在这个应用中,剑道列表使用id作为键,课程列表省略了键生成器。在ArkUI中,如果省略键生成器,框架会使用默认的索引作为键——这在静态列表中是可行的,但在动态列表(增删排序)中可能导致渲染性能问题或状态错位。

9.6 条件渲染与if-else分支

ArkUI的if-else条件渲染允许在build()方法或@Builder方法中使用条件分支来决定渲染哪些UI组件。在这个应用中,contentArea使用if-else链来选择渲染当前激活的Tab页面,商城列表使用if条件来过滤商品,底部Tab栏使用if-else来决定是否显示选中指示条。

条件渲染的一个重要特性是:当条件从true变为false时,对应的UI子树会被销毁,其状态也会被重置;当条件从false变为true时,UI子树会被重新创建。这意味着如果用户在Tab A中输入了一些数据然后切换到Tab B再切回来,Tab A中的非@State数据会丢失。在这个应用中,所有需要持久化的数据都存储在父组件FLApp@State中,而非各Tab页面的局部变量中,因此Tab切换不会导致数据丢失。

9.7 layoutWeight弹性布局

layoutWeight是ArkUI中实现弹性布局的重要属性。当一个容器内的多个子元素中有一个使用了layoutWeight(1)时,该子元素会占据容器中所有剩余的空间。在这个应用中,这个属性被大量使用:contentArea使用layoutWeight(1)占据头部和底部之间的空间,Column().layoutWeight(1)作为弹性占位符用于在Row中实现"左对齐+右对齐"的两端布局。

9.8 position绝对定位

position属性用于将组件从正常文档流中脱离出来,相对于父容器进行绝对定位。在这个应用中,modalOverlay使用position({ x: 0, y: 0 })确保遮罩覆盖整个父容器区域,Toast使用position({ x: 0, y: '88%' })定位在底部偏上位置。

position属性接受Position类型参数,支持xy两个轴,值可以是数字(默认vp单位)或字符串(如’88%'表示父容器高度的88%)。这种灵活的定位方式使得精确的UI布局成为可能。

十、弹框交互模式与组件通信

10.1 弹框系统的完整架构

整个弹框系统由三层构成:状态控制层、挂载容器层、弹框组件层。

状态控制层由六个布尔@State变量组成,分别控制六个弹框的显隐。当某个布尔变量为true时,对应的弹框挂载@Builder方法会在contentAreaStack中被渲染。这种"布尔开关+条件渲染"的模式确保了弹框只在需要时才被创建和销毁,避免了不必要的内存占用。

挂载容器层由六个@Builder方法组成(如bookModalHost),每个方法生成一个包含遮罩和弹框组件的Column容器。这个容器使用zIndex(999)浮在所有内容之上,通过justifyContent(FlexAlign.Center)alignItems(HorizontalAlign.Center)使弹框在屏幕中央显示。

弹框组件层由六个@Component组件组成(如FLBookPisteForm),每个组件负责自己的表单UI和内部状态管理。组件通过onCloseonConfirm回调与父组件通信。

10.2 Toast提示系统

      if (this.toastText !== '') {
        Column() {
          Text(this.toastText)
            .fontSize(11).fontColor('#FFFFFF')
            .padding({ left: 18, right: 18, top: 9, bottom: 9 }).backgroundColor(FL_COLORS.red)
            .borderRadius({ topLeft: 16, bottomRight: 16 })
        }
        .width('100%').justifyContent(FlexAlign.Center).position({ x: 0, y: '88%' })
        .zIndex(600).onClick(() => { this.toastText = '' })
      }

Toast提示系统是一个精巧的设计。它只有一个toastText字符串状态变量——当这个变量为空字符串时,Toast不渲染;当它被设置为非空字符串时(如"已锁定A2金属主道·今天08-26 19:00·花剑"),Toast立即出现在屏幕88%高度位置。用户点击Toast后,toastText被清空,Toast消失。

这个Toast系统被用于整个应用的操作反馈:预约剑道成功、报名课程成功、装备入库成功、档案更新成功、发起约战成功、装备移除成功等操作都会通过设置toastText来显示反馈信息。这种统一的反馈机制简化了交互逻辑——开发者无需为每种操作编写不同的反馈UI,只需更新一个字符串状态即可。

Toast提示系统的设计体现了"单一状态驱动单一UI"的极简理念。在传统的Android开发中,Toast通常是一个全局服务调用;而在ArkUI中,通过将Toast集成到组件状态中,开发者获得了更灵活的控制——Toast的样式、位置、消失时机都可以由组件状态精确控制,且不依赖任何全局服务或单例。

十一、综合对比与技术总结

11.1 核心模块对比表

模块核心数据模型关键交互鸿蒙技术要点视觉特色
剑道预约FLPiste/FLDuel/FLPisteUsage剑道宫格选择、实时对决查看、发起约战Flex+Wrap网格布局、animateTo剑尖闪击动画、柱状图数据可视化状态色标系统、红蓝对决对称布局
装备工坊FLGear/FLWeaponSpec装备列表浏览、入库新增、移除确认、维护提醒条件渲染(winRate判断)、跨Tab导航、删除确认弹框三剑种参数对比卡、剑种色条标识
训练中心FLCoach/FLClass/FLHitZone教练选择、课程报名、命中分布查看横向滑动列表、进度条动态宽度计算、堆叠条形图教练格言展示、课程矩阵网格
积分联赛FLLeaguePlayer/FLMatch/FLPointNode积分榜浏览、赛程对阵查看、积分趋势查看排名图标函数映射、左右决斗式对称布局、柱状图趋势可视化金银铜奖牌色系、红-金-蓝对决布局
商城FLProduct分类筛选、新品横滑、商品列表浏览条件过滤渲染、Scroll横向滑动、TextDecorationType删除线价格对比展示、标签色块系统
个人中心FLProfile/FLBadge/FLSettingItem档案编辑、段位进度查看、徽章墙浏览、设置项管理动态进度条宽度、Flex徽章网格、胜率圆点可视化段位进度金色条、已获/未获徽章对比

11.2 弹框组件对比表

弹框组件核心状态交互模式特色设计
FLBookPisteFormselPisteName/selDate/selTime/selWeapon宫格单选+chips多组选择剑道宫格状态色标、三组chips差异化圆角
FLJoinClassFormselCoach/selCycle/needRent横滑选择+开关+费用明细教练横滑卡片、自定义Toggle开关、动态价格计算
FLGearStoreFormselWeapon/selSize/pounds/brandInput三剑种大宫格+步进器+输入框磅数Stepper半圆端帽设计、三剑种参数卡片
FLEditProfileFormselDan/selHand/weightNum/clubInputchips选择+步进器+输入框段位chips金色选中态、持剑手箭头方向符号
FLDeleteGearFormgearName/gearMeta(外部传入)确认/取消双按钮红色警示风格、深红信息卡片、维护提示金色文字
FLChallengeFormselOpponent/selWeapon/stake/msgInput对手列表选择+步进器+输入框在线状态指示灯、赌注积分动态总额显示

11.3 全局纯函数对比表

函数名输入输出使用场景
flStatusColorstatus字符串颜色值剑道状态文字与图标着色
flStatusBgstatus字符串背景色值剑道卡片背景填充
flWeaponColorweapon字符串颜色值所有剑种标识着色
flBarHeightpercent/maxPercentvp高度字符串利用率柱状图高度计算
flTrendHeightpoints/min/maxvp高度字符串积分趋势柱状图高度计算
flMedalIconrank数字符号字符串积分榜排名图标
flMedalColorrank数字颜色值积分榜排名着色
flWinRateColorrate数字颜色值胜率数值着色
flTrendColortrend字符串颜色值排名趋势箭头着色
flGearStatusColorstatus字符串颜色值装备状态着色
flPriceTextprice数字价格字符串商城价格格式化

十二、总结与架构反思

12.1 架构设计的核心优势

纵观整个FENCING LAB应用的代码架构,其最显著的优势在于"数据-逻辑-视图"三层分离的清晰度。数据层由十余个interface定义和对应的const数据数组构成,这些数据模型不仅定义了字段结构,更通过精心设计的字段值(如装备的维护备注、教练的格言、课程的报名进度)为UI提供了丰富的展示内容。逻辑层由十二个全局纯函数构成,负责处理状态-颜色映射、数值-高度转换、排名-图标映射等纯逻辑计算,这些函数不依赖任何组件实例,可以在任何位置被调用。视图层由六个弹框组件和一个主入口组件构成,通过@State状态管理、@Builder方法复用、ForEach列表渲染、if-else条件渲染等ArkUI核心机制,将数据和逻辑转化为可视化的交互界面。

在鸿蒙ArkUI的开发实践中,"interface先行"是一种值得推荐的设计方法论。先定义数据接口再编写UI组件,能够确保UI的每个部分都有明确的数据来源,避免在开发过程中临时添加字段导致的数据结构不一致问题。FENCING LAB应用的数据接口设计覆盖了击剑俱乐部运营的方方面面——从剑道状态到对决信息、从装备参数到教练档案、从课程排期到联赛积分——每个业务领域都有对应的interface和const数据,这种"业务领域模型化"的设计思路使得应用具有极强的可扩展性。

12.2 视觉设计语言的一致性

应用的视觉设计语言在整个代码中保持了高度的一致性。borderRadius的单侧差异化策略贯穿了几乎所有卡片和按钮;剑种-颜色映射(花剑-蓝、佩剑-红、重剑-银)在所有涉及剑种标识的地方统一使用flWeaponColor函数;状态-颜色映射(空闲-绿、对决中-红、教学-金、维护-灰)在所有状态展示处统一使用flStatusColorflStatusBg函数;双语标题(中文主标题+英文副标题+字间距)在所有页面头部和弹框头部统一使用。这种设计语言的一致性使得应用在整体视觉上呈现出统一而专业的品牌风格。

12.3 动画系统的精巧设计

两个核心动画(剑尖闪击与得分灯呼吸)虽然实现简单,但它们的设计考虑了多个维度的精巧性。时长比例(900:480)的异步设计避免了同步闪烁;缓动曲线的选择(EaseInOut与EaseIn)符合各自的物理意象——剑尖闪击是双向运动所以缓入缓出,得分灯呼吸是单向渐亮所以缓入;PlayMode.Alternate确保了往复运动而非跳跃重置。这些细节虽然微小,但它们共同构成了应用"活着"的动态感。

12.4 弹框系统的架构启示

六个弹框组件的统一架构模式(@Component+@State+onClose/onConfirm+Scroll+底部按钮栏)提供了一套可复用的弹框设计模板。这种模式的优点在于:弹框组件自包含,不需要父组件提供任何额外的上下文;通过回调函数的参数类型约束,确保了弹框输出的数据类型安全;布尔状态开关的显隐控制简单直接,不需要复杂的路由管理。当然,这种模式在弹框数量进一步增加时可能会面临状态变量膨胀的问题——六个布尔变量已经占据了主组件状态定义的一半,如果未来需要支持十几个弹框,可能需要考虑使用枚举状态来替代多个布尔变量。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================================
// FENCING LAB 潮流击剑俱乐部 — 单文件 ArkTS UI Demo
// ----------------------------------------------------------------------------
// 场景:得物App类潮流电商风格 · 击剑贵族运动的潮流化改造
// 核心模块:剑道预约(Pistes)/ 装备工坊(Armory)/ 训练(Training)
//           积分联赛(League)/ 商城(Shop)/ 我的(Me)
// 风格说明:
//   底色深海军蓝 #0D1B2A,卡片 #1B263B(金属剑库质感)
//   赛台红点缀 #D62828(得分灯/警示/选中态),金属银 #C0C7D1(剑条/文字主色)
//   卡片多为细长窄条(模拟剑条),borderRadius 单侧差异化制造斜切角观感
//   联赛对阵采用左右决斗式对称布局,中间 VS 徽标
// 特效:剑尖闪击(bladeFlashX translate 往返)/ 得分灯呼吸闪烁(scoreGlow opacity)
// ============================================================================

// ============ 调色板 ============
interface FLColorPalette {
  bg: string;
  card: string;
  cardDeep: string;
  red: string;
  redDim: string;
  silver: string;
  silverDim: string;
  textMain: string;
  textSub: string;
  textHint: string;
  line: string;
  green: string;
  gold: string;
  blue: string;
  steel: string;
}

const FL_COLORS: FLColorPalette = {
  bg: '#0D1B2A',
  card: '#1B263B',
  cardDeep: '#152036',
  red: '#D62828',
  redDim: '#7A1F1F',
  silver: '#C0C7D1',
  silverDim: '#8A94A6',
  textMain: '#E8EDF4',
  textSub: '#AAB4C4',
  textHint: '#5D6A7E',
  line: '#2A3A55',
  green: '#3FB68B',
  gold: '#E8C46B',
  blue: '#4C86C6',
  steel: '#6E7B8F'
};

// ============ 底部 Tab 配置 ============
enum FLTab {
  PISTES = 0,
  ARMORY = 1,
  TRAINING = 2,
  LEAGUE = 3,
  SHOP = 4,
  ME = 5
}

interface FLTabItem {
  tab: FLTab;
  icon: string;
  label: string;
  en: string;
}

const FL_TABS: FLTabItem[] = [
  { tab: FLTab.PISTES, icon: '⚔', label: '剑道', en: 'PISTES' },
  { tab: FLTab.ARMORY, icon: '⛨', label: '装备', en: 'ARMORY' },
  { tab: FLTab.TRAINING, icon: '◎', label: '训练', en: 'TRAINING' },
  { tab: FLTab.LEAGUE, icon: '♛', label: '联赛', en: 'LEAGUE' },
  { tab: FLTab.SHOP, icon: '▣', label: '商城', en: 'SHOP' },
  { tab: FLTab.ME, icon: '♗', label: '我的', en: 'ME' }
];

// ============ 数据接口:剑道实时状态 ============
interface FLPiste {
  id: number;
  name: string;
  weapon: string;
  status: string;
  current: number;
  capacity: number;
  utilization: number;
}

const FL_PISTES: FLPiste[] = [
  { id: 1, name: 'A1 金属主道', weapon: '花剑', status: '对决中', current: 2, capacity: 2, utilization: 92 },
  { id: 2, name: 'A2 金属主道', weapon: '花剑', status: '空闲', current: 0, capacity: 2, utilization: 45 },
  { id: 3, name: 'B1 竞速赛道', weapon: '佩剑', status: '对决中', current: 2, capacity: 2, utilization: 88 },
  { id: 4, name: 'B2 竞速赛道', weapon: '佩剑', status: '教学', current: 6, capacity: 8, utilization: 76 },
  { id: 5, name: 'C1 重剑专区', weapon: '重剑', status: '空闲', current: 0, capacity: 2, utilization: 38 },
  { id: 6, name: 'C2 重剑专区', weapon: '重剑', status: '维护', current: 0, capacity: 2, utilization: 0 },
  { id: 7, name: 'D1 新手感应道', weapon: '花剑', status: '教学', current: 5, capacity: 6, utilization: 81 },
  { id: 8, name: 'D2 新手感应道', weapon: '花剑', status: '空闲', current: 0, capacity: 6, utilization: 52 }
];

// ============ 数据接口:实时对决 ============
interface FLDuel {
  id: number;
  redName: string;
  redScore: number;
  blueName: string;
  blueScore: number;
  piste: string;
  weapon: string;
  period: string;
  remain: string;
}

const FL_DUELS: FLDuel[] = [
  { id: 1, redName: '陆沉', redScore: 9, blueName: '程一剑', blueScore: 8, piste: 'A1 金属主道', weapon: '花剑', period: '第三局', remain: '01:24' },
  { id: 2, redName: '白鹭', redScore: 12, blueName: '关山月', blueScore: 5, piste: 'B1 竞速赛道', weapon: '佩剑', period: '决胜局', remain: '00:47' },
  { id: 3, redName: '薛无衣', redScore: 4, blueName: '林渡', blueScore: 4, piste: 'A1 金属主道', weapon: '花剑', period: '第二局', remain: '02:10' }
];

// ============ 数据接口:三剑种参数对比 ============
interface FLWeaponSpec {
  weapon: string;
  enName: string;
  length: string;
  weight: string;
  maxPressure: string;
  target: string;
  style: string;
  color: string;
}

const FL_WEAPON_SPECS: FLWeaponSpec[] = [
  { weapon: '花剑', enName: 'FOIL', length: '110cm', weight: '≤500g', maxPressure: '500g', target: '躯干有效', style: '技巧流派 · 点刺为主', color: '#4C86C6' },
  { weapon: '重剑', enName: 'EPEE', length: '110cm', weight: '≤770g', maxPressure: '750g', target: '全身有效', style: '沉稳流派 · 全身压制', color: '#C0C7D1' },
  { weapon: '佩剑', enName: 'SABRE', length: '105cm', weight: '≤500g', maxPressure: '可劈刺', target: '上半身', style: '速度流派 · 劈刺结合', color: '#D62828' }
];

// ============ 数据接口:我的装备清单 ============
interface FLGear {
  id: number;
  name: string;
  weapon: string;
  brand: string;
  pounds: number;
  size: string;
  status: string;
  bought: string;
  note: string;
}

const FL_GEARS: FLGear[] = [
  { id: 1, name: '竞赛级花剑(法式枪柄)', weapon: '花剑', brand: 'ALLSTAR', pounds: 350, size: '5号', status: '服役中', bought: '2026-03-12', note: '主力剑 · 剑尖灵敏度92%' },
  { id: 2, name: '训练花剑(直柄)', weapon: '花剑', brand: 'PBT', pounds: 420, size: '5号', status: '服役中', bought: '2025-09-02', note: '日常训练用 · 握柄缠带已换' },
  { id: 3, name: '金属衣(FIE认证)', weapon: '花剑', brand: 'UHLMANN', pounds: 800, size: 'M', status: '服役中', bought: '2026-01-20', note: '导电率100% · 赛季主力' },
  { id: 4, name: '重剑(比利时柄)', weapon: '重剑', brand: 'BLESTMANN', pounds: 700, size: '5号', status: '保养中', bought: '2025-06-18', note: '剑身微弯 · 已送工坊校直' },
  { id: 5, name: '佩剑(竞赛级)', weapon: '佩剑', brand: 'ALLSTAR', pounds: 480, size: '5号', status: '服役中', bought: '2026-04-05', note: '护手有磕痕 · 不影响使用' },
  { id: 6, name: '击剑面罩(1600N)', weapon: '通用', brand: 'PBT', pounds: 0, size: 'L', status: '服役中', bought: '2025-11-11', note: '内衬已更换 · 下次年检2027' },
  { id: 7, name: '训练手套(右手)', weapon: '通用', brand: 'NEMEA', pounds: 0, size: '9码', status: '待更换', bought: '2025-05-30', note: '掌心磨损明显 · 建议更换' },
  { id: 8, name: '护胸板(女款)', weapon: '通用', brand: 'UHLMANN', pounds: 0, size: 'M', status: '服役中', bought: '2026-02-14', note: '无变形 · 状态良好' },
  { id: 9, name: '剑袋(三剑位)', weapon: '通用', brand: 'FENCE-LAB', pounds: 0, size: '130cm', status: '服役中', bought: '2025-08-08', note: '拉链顺滑 · 肩带加固过' },
  { id: 10, name: '备用花剑条(马兰钢)', weapon: '花剑', brand: 'MARAGING', pounds: 0, size: '5号', status: '库存中', bought: '2026-06-01', note: '未开刃 · 工坊封存' }
];

// ============ 数据接口:教练团队 ============
interface FLCoach {
  id: number;
  name: string;
  title: string;
  weapon: string;
  dan: string;
  winRate: number;
  years: number;
  students: number;
  quote: string;
}

const FL_COACHES: FLCoach[] = [
  { id: 1, name: '顾长风', title: '总教练 · 前国家队佩剑主力', weapon: '佩剑', dan: '国家级健将', winRate: 78, years: 16, students: 320, quote: '速度是佩剑手的呼吸' },
  { id: 2, name: '沈青梧', title: '花剑教研组长 · 亚锦赛铜牌', weapon: '花剑', dan: '国家级健将', winRate: 74, years: 12, students: 260, quote: '距离感比剑更快' },
  { id: 3, name: '裴照', title: '重剑导师 · 全运会八强', weapon: '重剑', dan: '一级运动员', winRate: 69, years: 14, students: 190, quote: '重剑是手谈,不是角力' },
  { id: 4, name: '苏梨', title: '青少年梯队教练', weapon: '花剑', dan: '一级运动员', winRate: 71, years: 8, students: 150, quote: '把基本功练成肌肉记忆' },
  { id: 5, name: '霍峻', title: '体能与步伐教练', weapon: '通用', dan: '高级体能师', winRate: 0, years: 10, students: 400, quote: '脚下没根,手上没准' },
  { id: 6, name: '温以宁', title: '女子佩剑主管 · 全国青年赛冠军', weapon: '佩剑', dan: '一级运动员', winRate: 73, years: 9, students: 120, quote: '剑出如风,心静如水' }
];

// ============ 数据接口:周课程表 ============
interface FLClass {
  id: number;
  day: string;
  time: string;
  name: string;
  coach: string;
  weapon: string;
  level: string;
  duration: number;
  capacity: number;
  booked: number;
  price: number;
}

const FL_CLASSES: FLClass[] = [
  { id: 1, day: '周一', time: '19:00', name: '花剑步伐基础', coach: '苏梨', weapon: '花剑', level: '入门', duration: 90, capacity: 12, booked: 9, price: 180 },
  { id: 2, day: '周二', time: '19:30', name: '佩剑劈刺专项', coach: '顾长风', weapon: '佩剑', level: '进阶', duration: 90, capacity: 10, booked: 10, price: 240 },
  { id: 3, day: '周三', time: '20:00', name: '重剑手感课', coach: '裴照', weapon: '重剑', level: '进阶', duration: 90, capacity: 10, booked: 7, price: 240 },
  { id: 4, day: '周四', time: '19:00', name: '青少年花剑梯队', coach: '苏梨', weapon: '花剑', level: 'U14', duration: 120, capacity: 14, booked: 12, price: 150 },
  { id: 5, day: '周五', time: '20:00', name: '实战对抗夜', coach: '顾长风', weapon: '混合', level: '全部', duration: 120, capacity: 16, booked: 14, price: 200 },
  { id: 6, day: '周六', time: '10:00', name: '新手体验课', coach: '温以宁', weapon: '花剑', level: '体验', duration: 60, capacity: 20, booked: 16, price: 99 },
  { id: 7, day: '周六', time: '14:00', name: '体能与步伐强化', coach: '霍峻', weapon: '通用', level: '全部', duration: 75, capacity: 18, booked: 11, price: 160 },
  { id: 8, day: '周日', time: '10:00', name: '女子佩剑训练营', coach: '温以宁', weapon: '佩剑', level: '进阶', duration: 90, capacity: 10, booked: 8, price: 220 },
  { id: 9, day: '周日', time: '15:00', name: '裁判规则讲堂', coach: '沈青梧', weapon: '通用', level: '全部', duration: 60, capacity: 30, booked: 19, price: 80 }
];

// ============ 数据接口:积分联赛榜 ============
interface FLLeaguePlayer {
  rank: number;
  name: string;
  club: string;
  weapon: string;
  points: number;
  wins: number;
  losses: number;
  trend: string;
}

const FL_LEAGUE: FLLeaguePlayer[] = [
  { rank: 1, name: '程一剑', club: 'FENCING LAB 一队', weapon: '花剑', points: 2860, wins: 41, losses: 9, trend: '↑2' },
  { rank: 2, name: '白鹭', club: 'FENCING LAB 二队', weapon: '佩剑', points: 2745, wins: 38, losses: 11, trend: '↑1' },
  { rank: 3, name: '薛无衣', club: 'FENCING LAB 一队', weapon: '花剑', points: 2610, wins: 36, losses: 13, trend: '-' },
  { rank: 4, name: '关山月', club: '破晓击剑会', weapon: '佩剑', points: 2488, wins: 33, losses: 15, trend: '↓2' },
  { rank: 5, name: '林渡', club: 'FENCING LAB 二队', weapon: '重剑', points: 2370, wins: 30, losses: 16, trend: '↑3' },
  { rank: 6, name: '陆沉', club: 'FENCING LAB 一队', weapon: '花剑', points: 2284, wins: 29, losses: 18, trend: '-' },
  { rank: 7, name: '姜别鹤', club: '半岛剑馆', weapon: '重剑', points: 2196, wins: 27, losses: 19, trend: '↓1' },
  { rank: 8, name: '闻人夜', club: 'FENCING LAB 二队', weapon: '佩剑', points: 2087, wins: 25, losses: 20, trend: '↑1' },
  { rank: 9, name: '叶栖迟', club: '破晓击剑会', weapon: '花剑', points: 1975, wins: 23, losses: 22, trend: '↓1' },
  { rank: 10, name: '我(段青岚)', club: 'FENCING LAB 一队', weapon: '花剑', points: 1892, wins: 24, losses: 21, trend: '↑4' }
];

// ============ 数据接口:联赛赛程对阵 ============
interface FLMatch {
  id: number;
  round: string;
  date: string;
  time: string;
  left: string;
  right: string;
  leftScore: number;
  rightScore: number;
  venue: string;
  status: string;
}

const FL_MATCHES: FLMatch[] = [
  { id: 1, round: '第11轮', date: '08-24', time: '19:00', left: '程一剑', right: '陆沉', leftScore: 15, rightScore: 12, venue: 'A1 金属主道', status: '已结束' },
  { id: 2, round: '第11轮', date: '08-24', time: '20:10', left: '白鹭', right: '闻人夜', leftScore: 15, rightScore: 9, venue: 'B1 竞速赛道', status: '已结束' },
  { id: 3, round: '第12轮', date: '08-27', time: '19:00', left: '我(段青岚)', right: '叶栖迟', leftScore: 8, rightScore: 6, venue: 'A1 金属主道', status: '直播中' },
  { id: 4, round: '第12轮', date: '08-27', time: '20:10', left: '林渡', right: '姜别鹤', leftScore: 0, rightScore: 0, venue: 'C1 重剑专区', status: '未开始' },
  { id: 5, round: '第12轮', date: '08-28', time: '19:00', left: '薛无衣', right: '程一剑', leftScore: 0, rightScore: 0, venue: 'A1 金属主道', status: '未开始' },
  { id: 6, round: '第13轮', date: '08-31', time: '18:30', left: '关山月', right: '我(段青岚)', leftScore: 0, rightScore: 0, venue: 'B1 竞速赛道', status: '未开始' }
];

// ============ 数据接口:商城商品 ============
interface FLProduct {
  id: number;
  name: string;
  category: string;
  price: number;
  originPrice: number;
  sold: number;
  tag: string;
  color: string;
  blurb: string;
}

const FL_PRODUCTS: FLProduct[] = [
  { id: 1, name: 'ALLSTAR 竞赛级花剑(法式柄)', category: '剑具', price: 1480, originPrice: 1780, sold: 326, tag: '热卖', color: '#4C86C6', blurb: 'FIE认证马兰钢剑条 · 剑尖灵敏度可调' },
  { id: 2, name: 'UHLMANN 金属衣(FIE 800N)', category: '护具', price: 2260, originPrice: 2680, sold: 158, tag: '认证', color: '#C0C7D1', blurb: '世青赛同款 · 导电率100%出厂检测' },
  { id: 3, name: 'PBT 1600N 击剑面罩', category: '护具', price: 1180, originPrice: 1180, sold: 412, tag: '镇店', color: '#6E7B8F', blurb: ' bib 可拆洗 · 内衬抗菌处理' },
  { id: 4, name: 'FENCE-LAB 联名剑鞋 · 银锋', category: '鞋履', price: 899, originPrice: 1099, sold: 893, tag: '新品', color: '#D62828', blurb: '侧向支撑加固 · 剑步专用大底' },
  { id: 5, name: 'MARAGING 备用剑条(5号)', category: '剑具', price: 620, originPrice: 720, sold: 267, tag: '热卖', color: '#3FB68B', blurb: '马兰钢 · 断剑率低于0.3%' },
  { id: 6, name: 'NEMEA 训练手套(左右可选)', category: '配件', price: 268, originPrice: 328, sold: 538, tag: '回购王', color: '#E8C46B', blurb: '掌心加厚 · 透气网眼拼接' },
  { id: 7, name: '击剑主题连帽卫衣 · EN GARDE', category: '潮服', price: 459, originPrice: 559, sold: 1266, tag: '联名', color: '#1B263B', blurb: '剑条刺绣 · 重磅纯棉抓绒' },
  { id: 8, name: '佩剑护手加固贴片', category: '配件', price: 89, originPrice: 119, sold: 742, tag: '小物', color: '#8A94A6', blurb: '3M背胶 · 延长护手寿命一赛季' },
  { id: 9, name: 'BLESTMANN 重剑(比利时柄)', category: '剑具', price: 1680, originPrice: 1880, sold: 194, tag: '认证', color: '#4C86C6', blurb: '重心后置 · 压剑手感更稳' },
  { id: 10, name: 'FENCING LAB 剑袋(三剑位)', category: '配件', price: 399, originPrice: 459, sold: 615, tag: '热卖', color: '#6E7B8F', blurb: '碳纤纹理 · 肩带蜂窝减压垫' }
];

// ============ 数据接口:我的积分趋势(折线) ============
interface FLPointNode {
  week: string;
  points: number;
  rank: number;
}

const FL_POINT_TREND: FLPointNode[] = [
  { week: 'W1', points: 1210, rank: 22 },
  { week: 'W2', points: 1335, rank: 19 },
  { week: 'W3', points: 1298, rank: 20 },
  { week: 'W4', points: 1462, rank: 16 },
  { week: 'W5', points: 1588, rank: 14 },
  { week: 'W6', points: 1660, rank: 12 },
  { week: 'W7', points: 1789, rank: 12 },
  { week: 'W8', points: 1892, rank: 10 }
];

// ============ 数据接口:命中部位分布 ============
interface FLHitZone {
  zone: string;
  percent: number;
  color: string;
  count: number;
}

const FL_HIT_ZONES: FLHitZone[] = [
  { zone: '躯干正面', percent: 42, color: '#D62828', count: 118 },
  { zone: '腰侧转身', percent: 23, color: '#E8C46B', count: 65 },
  { zone: '肩部斜线', percent: 16, color: '#4C86C6', count: 45 },
  { zone: '背部追击', percent: 11, color: '#3FB68B', count: 31 },
  { zone: '无效部位', percent: 8, color: '#5D6A7E', count: 22 }
];

// ============ 数据接口:徽章墙 ============
interface FLBadge {
  name: string;
  icon: string;
  earned: boolean;
  desc: string;
}

const FL_BADGES: FLBadge[] = [
  { name: '首胜之剑', icon: '⚔', earned: true, desc: '赢得第一场正式对决' },
  { name: '百剑归鞘', icon: '⛨', earned: true, desc: '累计完成100场对决' },
  { name: '五连绝尘', icon: '⚡', earned: true, desc: '联赛五连胜' },
  { name: '剑尖艺术家', icon: '◎', earned: true, desc: '单场命中率≥70%' },
  { name: '晨锋者', icon: '☀', earned: true, desc: '连续30天晨训打卡' },
  { name: '逆转之王', icon: '♛', earned: false, desc: '比分落后5剑翻盘' },
  { name: '三剑客', icon: '⚜', earned: false, desc: '三种剑种均达银牌段位' },
  { name: '联赛之巅', icon: '▲', earned: false, desc: '赛季积分榜登顶' }
];

// ============ 数据接口:剑道利用率(柱状图) ============
interface FLPisteUsage {
  hour: string;
  percent: number;
}

const FL_PISTE_USAGE: FLPisteUsage[] = [
  { hour: '10点', percent: 22 },
  { hour: '11点', percent: 35 },
  { hour: '12点', percent: 18 },
  { hour: '14点', percent: 48 },
  { hour: '15点', percent: 56 },
  { hour: '16点', percent: 63 },
  { hour: '18点', percent: 78 },
  { hour: '19点', percent: 95 },
  { hour: '20点', percent: 88 }
];

// ============ 数据接口:约战对手 ============
interface FLOpponent {
  id: number;
  name: string;
  club: string;
  points: number;
  weapon: string;
  winRate: number;
  online: boolean;
}

const FL_OPPONENTS: FLOpponent[] = [
  { id: 1, name: '叶栖迟', club: '破晓击剑会', points: 1975, weapon: '花剑', winRate: 51, online: true },
  { id: 2, name: '闻人夜', club: 'FENCING LAB 二队', points: 2087, weapon: '佩剑', winRate: 55, online: true },
  { id: 3, name: '江枕流', club: '半岛剑馆', points: 1820, weapon: '花剑', winRate: 47, online: false }
];

// ============ 数据接口:我的档案 / 设置项 ============
interface FLProfile {
  name: string;
  dan: string;
  hand: string;
  weight: number;
  club: string;
  matches: number;
  wins: number;
  points: number;
  winRate: number;
  nextDan: string;
  danProgress: number;
}

const FL_PROFILE: FLProfile = {
  name: '段青岚',
  dan: 'U17 花剑 · 银剑士',
  hand: '右手持剑',
  weight: 62,
  club: 'FENCING LAB 一队',
  matches: 45,
  wins: 24,
  points: 1892,
  winRate: 53,
  nextDan: '金剑士',
  danProgress: 68
};

interface FLSettingItem {
  icon: string;
  label: string;
  value: string;
}

const FL_SETTINGS: FLSettingItem[] = [
  { icon: '⚔', label: '我的剑档', value: '3件在役' },
  { icon: '▣', label: '预约记录', value: '本周2场' },
  { icon: '♛', label: '联赛报名', value: '秋季赛已报' },
  { icon: '✉', label: '消息通知', value: '已开启' },
  { icon: '⛨', label: '装备保险', value: '生效中' },
  { icon: '◎', label: '关于 FENCING LAB', value: 'v3.2.0' }
];

// ============ 弹框 chips 选项 ============
const FL_DATE_CHIPS: string[] = ['今天 08-26', '周四 08-27', '周五 08-28', '周六 08-29', '周日 08-30'];
const FL_TIME_CHIPS: string[] = ['10:00', '14:00', '16:00', '18:00', '19:00', '20:00'];
const FL_WEAPON_CHIPS: string[] = ['花剑', '重剑', '佩剑'];
const FL_CYCLE_CHIPS: string[] = ['4周基础班', '8周进阶班', '私教1对1'];
const FL_SIZE_CHIPS: string[] = ['3号', '4号', '5号', '0号'];
const FL_DAN_CHIPS: string[] = ['U10', 'U12', 'U14', 'U17', '青年组', '成年组'];
const FL_HAND_CHIPS: string[] = ['左手持剑', '右手持剑'];
const FL_SHOP_CATS: string[] = ['全部', '剑具', '护具', '配件', '鞋履', '潮服'];
const FL_WEEK_DAYS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];

// ============ 全局纯函数 ============
function flStatusColor(status: string): string {
  if (status === '空闲') {
    return FL_COLORS.green;
  }
  if (status === '对决中') {
    return FL_COLORS.red;
  }
  if (status === '教学') {
    return FL_COLORS.gold;
  }
  return FL_COLORS.steel;
}

function flStatusBg(status: string): string {
  if (status === '空闲') {
    return '#12362C';
  }
  if (status === '对决中') {
    return '#3A1414';
  }
  if (status === '教学') {
    return '#3A2F14';
  }
  return '#1C2436';
}

function flWeaponColor(weapon: string): string {
  if (weapon === '花剑') {
    return FL_COLORS.blue;
  }
  if (weapon === '佩剑') {
    return FL_COLORS.red;
  }
  if (weapon === '重剑') {
    return FL_COLORS.silver;
  }
  return FL_COLORS.gold;
}

function flBarHeight(percent: number, maxPercent: number): string {
  if (percent >= maxPercent) {
    return '92vp';
  }
  return (percent / maxPercent * 92).toFixed(0) + 'vp';
}

function flTrendHeight(points: number, minPoints: number, maxPoints: number): string {
  if (maxPoints <= minPoints) {
    return '40vp';
  }
  return (20 + (points - minPoints) / (maxPoints - minPoints) * 64).toFixed(0) + 'vp';
}

function flMedalIcon(rank: number): string {
  if (rank === 1) {
    return '①';
  }
  if (rank === 2) {
    return '②';
  }
  if (rank === 3) {
    return '③';
  }
  return rank.toString();
}

function flMedalColor(rank: number): string {
  if (rank === 1) {
    return '#E8C46B';
  }
  if (rank === 2) {
    return '#C0C7D1';
  }
  if (rank === 3) {
    return '#B0703C';
  }
  return FL_COLORS.textHint;
}

function flWinRateColor(rate: number): string {
  if (rate >= 60) {
    return FL_COLORS.green;
  }
  if (rate >= 45) {
    return FL_COLORS.gold;
  }
  return FL_COLORS.redDim;
}

function flPriceText(price: number): string {
  return '¥' + price.toString();
}

function flTrendColor(trend: string): string {
  if (trend.startsWith('↑')) {
    return FL_COLORS.green;
  }
  if (trend.startsWith('↓')) {
    return FL_COLORS.red;
  }
  return FL_COLORS.textHint;
}

function flGearStatusColor(status: string): string {
  if (status === '服役中') {
    return FL_COLORS.green;
  }
  if (status === '保养中') {
    return FL_COLORS.gold;
  }
  if (status === '待更换') {
    return FL_COLORS.red;
  }
  return FL_COLORS.steel;
}

// ============================================================================
// 弹框组件 1:FLBookPisteForm 预约剑道
// 剑道宫格单选(状态色标)+ 日期 chips + 时段 chips + 剑种 chips
// ============================================================================
@Component
struct FLBookPisteForm {
  @State selPisteName: string = 'A2 金属主道'
  @State selDate: string = '今天 08-26'
  @State selTime: string = '19:00'
  @State selWeapon: string = '花剑'
  @State userName: string = ''
  onClose: () => void = () => {}
  onConfirm: (piste: string, date: string, time: string, weapon: string) => void = () => {}

  build() {
    Column() {
      Row() {
        Column() {
          Text('⚔ 预约剑道')
            .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(FL_COLORS.textMain)
          Text('PISTE RESERVATION')
            .fontSize(9) .fontColor(FL_COLORS.textHint) .letterSpacing(2) .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Text('✕')
          .fontSize(16) .fontColor(FL_COLORS.textSub) .padding(8)
          .onClick(() => { this.onClose() })
      }
      .width('100%') .padding({ left: 18, right: 14, top: 16, bottom: 12 })

      Column()
        .width(46) .height(2) .backgroundColor(FL_COLORS.red)
        .borderRadius({ topLeft: 2, bottomRight: 2 }) .margin({ left: 18 })

      Scroll() {
        Column() {
          Text('选择剑道')
            .fontSize(12) .fontColor(FL_COLORS.textSub) .width('100%')
            .margin({ top: 14, bottom: 8 })
          Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
            ForEach(FL_PISTES, (p: FLPiste) => {
              Column() {
                Row() {
                  Text(flStatusColor(p.status) === FL_COLORS.green ? '○' : '●')
                    .fontSize(9) .fontColor(flStatusColor(p.status))
                  Text(p.status)
                    .fontSize(9) .fontColor(flStatusColor(p.status)) .margin({ left: 3 })
                }
       
    }
    .width('100%') .height('100%') .scrollBar(BarState.Off)
  }

  // ================= 主构建 =================
  build() {
    Column() {
      this.header()
      this.contentArea()
      this.bottomTabBar()
    }
    .width('100%') .height('100%') .backgroundColor(FL_COLORS.bg)
  }
}


在这里插入图片描述

12.5 总结:

FENCING LAB应用的代码为鸿蒙ArkUI开发者提供了多个层面的实践参考。在数据建模方面,它展示了如何使用interface+const的模式构建领域模型;在组件设计方面,它展示了@Component与@Builder的协作模式;在状态管理方面,它展示了@State如何驱动UI的局部更新;在布局技巧方面,它展示了Column+layoutWeight的三段式布局、Flex+Wrap的自适应网格、Stack+zIndex的层叠管理;在动画方面,它展示了animateTo+PlayMode.Alternate的无限循环动画模式;在交互设计方面,它展示了Toast反馈系统和弹框回调通信的标准方案。

设计模式和代码结构可以迁移到任何需要"多Tab页面+多弹框表单+数据可视化"的鸿蒙原生应用中。从潮流电商到运动社交,从在线教育到本地生活,ArkUI的声明式开发范式在这些场景中都能发挥出高效的生产力——而这正是鸿蒙HarmonyOS为下一代全场景智能设备提供的UI开发基石。

Logo

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

更多推荐