引言:声明式UI开发范式与HarmonyOS 6.1.1新特性

在这里插入图片描述

HarmonyOS 6.1.1作为华为分布式操作系统的最新演进版本,其ArkUI声明式UI框架已经发展到了ArkTS API 24阶段,为开发者提供了前所未有的UI构建能力。声明式UI开发范式的核心思想在于"描述UI应该是什么样子,而不是如何一步步构建UI"。在传统的命令式UI开发中,开发者需要手动调用创建控件、设置属性、添加到父容器等一系列繁琐操作;而在HarmonyOS 6.1.1的ArkUI声明式范式下,开发者只需用一组链式调用的声明式语法描述界面的结构和状态绑定关系,框架的渲染引擎会自动完成UI组件的创建、更新和销毁。这种范式转变极大地提升了开发效率,使得开发者能够将精力聚焦于业务逻辑和交互体验本身,而非繁琐的UI操作细节。ArkTS语言作为TypeScript的超集,在保留了静态类型检查优势的同时,针对HarmonyOS的声明式UI场景做了深度优化,其装饰器系统(@Entry、@Component、@State、@Builder、@Observed等)构成了声明式UI开发的核心基础设施。

HarmonyOS 6.1.1带来的新特性不仅体现在语言层面,更深入到了渲染管线的优化上。ArkUI渲染管线采用了基于VSync信号驱动的三阶段渲染机制——“布局测量、布局排列、绘制合成”,每一帧的渲染都严格遵循16.6ms的VSync周期。当组件的状态变量发生变更时,ArkUI框架会通过其观察者模式自动触发依赖收集和差分更新,只重新渲染真正发生变化的UI节点,而非整棵组件树。这种精确到节点级别的差分更新机制,使得即使是在复杂的列表滚动、动画特效叠加等高性能场景下,应用依然能够保持丝滑的60fps流畅体验。在本篇分析的"轨语·铁道模型沙盘与场景搭建俱乐部"应用中,我们可以清晰地看到这一机制的运用——每90毫秒触发一次的特效定时器驱动着信号灯闪烁和蒸汽烟团上飘动画,而框架的差分渲染确保了这些高频更新不会影响用户对页面其他区域的正常交互。

@Observed状态管理机制是HarmonyOS ArkUI声明式UI框架中最核心的特性之一。当我们在数据模型类上标注@Observed装饰器时,框架会在运行时为该类的所有可观察属性自动安装监听器。一旦这些属性的值发生变化,所有依赖于该属性的UI组件都会收到通知并触发重新渲染。在本应用中,PostItem(轨友动态)、LocoItem(机车模型)、DioramaItem(沙盘订单)、GearItem(配件零件)四个核心数据模型都使用了@Observed装饰器,这意味着当用户发布新的轨语、编辑沙盘订单状态或取消沙盘时,界面上所有展示这些数据的组件——列表、卡片、进度条、角标——都会自动同步更新,无需开发者手动调用任何刷新方法。这种数据驱动UI的编程模型,彻底消除了传统开发中"数据与界面不同步"的顽疾,让状态管理变得自然、可靠且高效。

@Builder装饰器与组件复用机制是ArkUI框架提供的另一个关键能力。@Builder装饰器允许开发者将一段UI描述逻辑封装为可复用的构建函数,这些函数可以在组件内部被多次调用,实现UI结构的模块化拆分。在本应用中,整个页面被拆分为近20个@Builder方法——从fxLayer特效层、header头部、subNav子导航,到pageFeatured、pageLocos、pageDioramas等各页面Builder,再到addModalBody、editModalBody、delModalBody三个弹框组件,以及bottomBar底部导航和mainContent主内容区。这种拆分方式不仅使得代码结构清晰可读,更重要的是让每一个UI模块都可以独立开发、测试和维护。@Builder方法虽然不是独立组件,但它们在编译时会被内联到调用位置,既保证了代码的复用性,又不会引入额外的组件层级开销,是ArkUI框架在"复用"与"性能"之间取得的精妙平衡。

Stack/Column/Row布局系统是ArkUI框架提供的三种核心容器组件,它们构成了声明式UI布局的基础原语。Stack用于层叠布局,子组件按顺序堆叠,后声明的子组件覆盖在先声明的子组件之上;Column用于垂直线性布局,子组件从上到下依次排列;Row用于水平线性布局,子组件从左到右依次排列。这三种容器通过layoutWeight(权重分配)、alignItems(交叉轴对齐)、justifyContent(主轴对齐)等属性的组合,可以表达出绝大多数常见的界面布局模式。在本应用的build()方法中,最外层使用Stack实现了四层堆叠结构——背景层、特效层、内容层、弹框层,而内容层内部又通过Column+Row的嵌套组合构建出了header头部、沙盘进度卡、数据四格等复杂的卡片式布局。值得注意的是,应用在特效层fxLayer中使用了hitTestBehavior(HitTestMode.None)来让特效层"穿透"点击事件,使得用户可以透过闪烁的信号灯和上飘的蒸汽烟团直接操作下方的内容层,这一设计充分体现了ArkUI框架对触摸事件分发机制的精细控制能力。

ArkUI渲染管线的高效运作离不开铁道蓝色彩设计系统的支撑。本应用采用了一套以铁道蓝(#3D5A80)为主色、红色机车(#C0564F)为强调色的双主题工业色彩方案,辅以铁轨色(#8B9DAF)作为特殊装饰色,构建出了一种沉稳、工业、具有铁路质感的设计调性。这种色彩选择与铁道模型这一主题的天然属性高度契合——铁道蓝色呼应了铁路信号系统、调度中心控制台、机车驾驶室面板那介于深蓝与钢灰之间的标志性色调,红色机车色则让人联想到蒸汽机车锅炉的赤红涂装、制动信号灯的警示红光以及调车信号的停车信号,而rail铁轨色更是直接致敬了镍银轨、钢轨在自然光下呈现出的冷灰金属光泽。整个色彩系统通过ColorPalette接口定义了16个语义化颜色字段,从primary(主色)到textHint(提示文字色),从success(成功色)到danger(危险色),每一个颜色都有其明确的用途和使用场景,形成了一套完整、自洽、可扩展的设计规范。

铁道模型社区交互模式是本应用在产品体验层面最突出的设计亮点。整个应用采用了"底部4主tab(首页/模型/课堂/我的)+ 首页7内容tab(描边药丸式)"的双层导航架构。首页的7个内容tab——精选、机车橱窗、我的沙盘、铺轨工坊、驾驶课堂、轨友圈、零件库——通过横向滚动的方式呈现,每个tab项采用了描边药丸式的设计语言,选中时药丸填充铁道蓝浅色背景、描边加粗为铁道蓝主色并加粗文字,未选中时药丸为白色背景、细描边为浅灰边框色,模拟了铁路信号灯罩或列车车次牌的视觉语言,引导用户按内容分区探索。同时,应用还实现了三套弹框交互——发布轨语(addModalBody)、编辑沙盘单(editModalBody)、取消沙盘/退课(delModalBody),每一套弹框都包含了遮罩层、内容卡片、表单输入和操作按钮的完整交互链路,并通过@State变量控制显示与隐藏,配合Stack的层叠能力实现了模态弹窗的效果。这种"导航+内容+弹窗"三位一体的交互架构,为铁道模型沙盘搭建这一垂直领域的用户提供了流畅、直观且功能完整的使用体验。


一、ColorPalette接口设计与颜色系统

在这里插入图片描述

应用代码的开篇即定义了ColorPalette接口,这是整个应用的色彩设计规范的根基。该接口声明了16个字符串类型的颜色字段,每一个字段都承担着明确的视觉语义。这套色彩系统以铁道蓝和红色机车为双主色调,模拟了铁路工业中调度信号与机车涂装的色彩关系,为整个铁道模型沙盘应用奠定了沉稳、工业、手作的视觉基调。

1.1 primary与primaryLight、primaryDark:铁道蓝主色三阶体系

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  ...
}

primary字段被赋值为#3D5A80,这是一个中等明度的铁道蓝色。在HSL色彩空间中,它大约对应于H(213°)、S(36%)、L(37%),这种色调介于深蓝与钢灰之间,带有明显的工业属性和低饱和度的沉稳气质。这个颜色并非鲜艳的宝蓝或天蓝,而是一种接近铁路调度中心控制台、机车驾驶室仪表盘、铁路信号灯杆在自然光下呈现的钢蓝色调——饱和度较低、明度适中,给人稳重、可靠、不浮躁的视觉感受。在应用中,primary被用于沙盘进度卡的主背景色、底部导航栏选中项的文字颜色、subNav选中项的描边和文字颜色、各页面"加入购物车"按钮背景色、柱状图高频数据条颜色、沙盘订单"铺轨中"状态色等核心视觉元素。primaryLight被赋值为#D8E2EE,是primary的浅化版本,明度更高、饱和度更低,呈现出接近雾蓝与奶白之间的一种极浅色调,主要用于subNav选中项的浅色背景填充和辅助色块装饰。primaryDark被赋值为#274060,是primary的深化版本,明度更低、色调更深沉,用于沙盘进度卡上的"查看沙盘"按钮文字色和背景对比、沙盘进度卡上的"调度5/8班"标签文字色等深色场景。这三阶颜色体系构成了一个完整的"主色梯度",使得同一种主题色可以在不同的视觉层级上使用——深色用于前景文字和强交互元素,中色用于品牌标识和主操作按钮,浅色用于背景填充和辅助装饰。这种三阶设计是现代UI设计系统的标准实践,确保了界面在色彩一致性上的高度统一。

1.2 accent与accentLight:红色机车强调色体系

在这里插入图片描述

accent: string;
accentLight: string;

accent字段被赋值为#C0564F,这是一个中等明度的红色机车色。在HSL色彩空间中大约对应于H(4°)、S(48%)、L(53%),带有明显的暖色属性和金属质感。这个颜色让人联想到蒸汽机车锅炉外板的赤红涂装、铁路停车信号的警示红光、调车手信号旗的红色布面在风中翻动的色泽——它既不是纯红也不是橙红,而是介于两者之间、带有微妙褐色调的机车红。accent作为与primary铁道蓝形成冷暖对比的强调色,在应用中被广泛用于二级操作按钮(如运转会招募横幅的"报名"按钮)、数据四格中"机车藏品"数据值颜色、周末运转占比百分比数值、柱状图中频数据条颜色、沙盘订单"布景中"状态色、人气机车"定制"等级标签色等场景。accentLight被赋值为#F2DBD8,是accent的极浅版本,几乎接近奶白色但带有一丝温暖的粉红色调,主要用于状态标签的浅色背景——例如沙盘订单的状态标签(已发货、铺轨中、布景中等)就使用了accentLight作为背景色,与深色文字形成柔和的对比。这种"冷色铁道蓝主色+暖色红色机车强调"的双色搭配方案,在视觉心理学上能够形成强烈的情绪张力——铁道蓝传达工业与秩序,红色机车传达动力与警示,恰如铁道模型本身既是精密机械装置又是承载情怀的玩具这一双重本质。

1.3 bg与cardBg:双层背景体系

在这里插入图片描述

bg: string;
cardBg: string;

bg字段被赋值为#F1F0EC,是一个极浅的暖灰色,作为整个应用的页面底色使用。这个颜色并非纯白,而是带有微妙的暖黄倾向,使得界面背景呈现出一种"沙盘底板木纹刷漆后"或"工作坊水泥地面"的质感,与铁道模型沙盘这一主题的天然属性形成呼应。cardBg被赋值为#FFFFFF(纯白色),作为所有卡片的背景色使用。当白色卡片浮在暖灰色页面底色之上时,通过borderRadius圆角和微妙的层级关系,营造出一种"机车模型摆放在沙盘工作台上"的物理隐喻,这正是Material Design和iOS设计语言中"卡片式设计"的核心表达。bg和cardBg之间约6%的明度差异,既足以让用户视觉上区分背景与内容,又不至于产生割裂感,是经过精心调校的视觉层级设计。这种底色选择也与应用的特效层产生了微妙的协同——当rail铁轨色的蒸汽烟团上飘在暖灰色背景之上时,整体画面呈现出一种"车库中蒸汽弥漫、车灯闪烁"的氛围感。

1.4 textPrimary、textSecondary、textHint:文字三阶体系

textPrimary: string;
textSecondary: string;
textHint: string;

textPrimary被赋值为#2C3440,是一个极深的蓝灰色,带有冷色倾向,用于标题、主要数据值、按钮文字等最重要的文字内容。这个颜色并非纯黑,而是接近深夜车厢内或调度室控制面板的深蓝灰色,与整体的铁道蓝工业调性保持一致。textSecondary被赋值为#66707E,是一个中等明度的蓝灰色,用于次要说明文字、列表项描述、价格信息等二级文字内容。textHint被赋值为#A4ACB6,是一个较浅的蓝灰色,用于标签、单位、时间戳、人气值说明等最弱层级的辅助文字。这三阶文字色与背景色之间形成了明确的对比度梯度——textPrimary与bg的对比度约为9.0:1,远超WCAG AA标准要求的4.5:1;textSecondary与cardBg的对比度约为5.0:1,同样满足无障碍标准;textHint则处于辅助信息的层级,其对比度虽有所降低但仍可辨认。值得注意的是,三阶文字色都带有蓝灰色调倾向——textPrimary偏深蓝灰、textSecondary偏中蓝灰、textHint偏浅蓝灰,形成了一条完整的"蓝色文字梯度",这种设计使得界面文字在保持可读性的同时,也融入了整体的铁道蓝工业调性,避免了纯黑或纯灰文字可能带来的"廉价感"。

1.5 border、success、warning、danger、white:功能色体系

border: string;
success: string;
warning: string;
danger: string;
white: string;

border被赋值为#E0DFD8,是一个极浅的暖灰色,用于进度条背景、分隔线、卡片边框、subNav未选中项描边等场景。它的明度介于bg和cardBg之间,能够在浅色背景上形成不突兀的边界感,同时其暖灰色调也与整体主题保持一致。success被赋值为#7BA884,是一个柔和的草绿色,用于"已发货"状态标识、"运营里程"数据值、"今日工时"数据值等成功或正面信息的标识。这个绿色比primary更浅、更明亮,带有一种"信号开通、绿灯放行"的行车许可感,与铁路信号系统中绿灯表示通行的语义高度契合。warning被赋值为#D9A54E,是一个温暖的琥珀金色,用于"轨币"数据值、"接线中"状态色、“工序"数据值、柱状图低频数据条颜色等需要提醒注意的信息。这个琥珀金色让人联想到铁路道口的黄灯警示或机车大灯的暖色光束。danger被赋值为#C76B5A,是一个柔和的红褐色,用于"取消沙盘”、“退课"等破坏性操作按钮背景、购物车角标。这个红褐色比accent红色机车色略浅且更柔和,接近制动闸瓦摩擦后的铁锈红色调。white被赋值为#FFFFFF,作为纯白色用于按钮文字、图标等需要最高对比度的场景。这五个功能色各自承担着明确的语义角色,共同构成了界面的"情绪指示系统”,恰如铁路信号系统的红黄绿三色灯对应停车、注意、通行的语义规范。

1.6 rail:铁轨色特殊装饰色

在这里插入图片描述

rail: string;

rail字段被赋值为#8B9DAF,这是整套色彩系统中唯一一个不参与常规语义角色、专门为铁道主题定制的特殊装饰色。在HSL色彩空间中,它大约对应于H(208°)、S(20%)、L(62%),是一个中等明度、低饱和度的冷灰蓝色。这个颜色直接致敬了真实铁道工程中镍银轨(Nickel Silver)、钢轨在自然光下呈现出的冷灰金属光泽——它既不是纯灰也不是纯蓝,而是介于两者之间、带有微妙蓝色倾向的"轨钢色"。在应用中,rail色被赋予了极为特殊的视觉使命:它是蒸汽烟团上飘特效粒子的唯一着色,使得fxLayer特效层中上飘的烟团呈现出与真实蒸汽机车烟囱冒出的蒸汽相似的冷灰色调;同时它也被用于沙盘进度卡的"查看沙盘"按钮背景、"调度5/8班"标签背景、“信号系统已联锁"文字色,以及SCAlE_CHART中"其他比例"数据条颜色。rail色的存在使得"铁轨"这一铁道模型最基础的物理元素被抽象为一种视觉语言渗透到界面各处,这种"色彩即主题"的设计思路,使得应用即使在没有logo和文字提示的情况下,仅凭rail色的运用就能被识别为"与铁道相关的应用”。

1.7 COLORS常量与16色全景

在这里插入图片描述

const COLORS: ColorPalette = {
  primary: '#3D5A80',
  primaryLight: '#D8E2EE',
  primaryDark: '#274060',
  accent: '#C0564F',
  accentLight: '#F2DBD8',
  bg: '#F1F0EC',
  cardBg: '#FFFFFF',
  textPrimary: '#2C3440',
  textSecondary: '#66707E',
  textHint: '#A4ACB6',
  border: '#E0DFD8',
  success: '#7BA884',
  warning: '#D9A54E',
  danger: '#C76B5A',
  white: '#FFFFFF',
  rail: '#8B9DAF'
};

COLORS常量是ColorPalette接口的唯一实例化对象,它将16个语义化颜色字段一次性定义,并作为全局常量在整个应用的所有@Builder方法中被引用。这种"接口定义契约+常量实例化"的设计模式,既获得了TypeScript静态类型检查的好处(任何拼写错误的字段名都会在编译时报错),又保证了色彩值的单一数据源(Single Source of Truth)。当未来需要切换主题色时,只需修改COLORS常量这一处定义,所有引用COLORS的UI组件都会自动应用新色彩。16个颜色字段可以归纳为五组:主色三阶(primary/primaryLight/primaryDark)、强调色二阶(accent/accentLight)、背景二阶(bg/cardBg)、文字三阶(textPrimary/textSecondary/textHint)、功能色五元(border/success/warning/danger/white)以及特殊装饰色rail。这种分组方式使得色彩系统的语义结构清晰可读,是大型UI项目色彩管理最佳实践的典型体现。


二、@Observed数据模型深度解析

在这里插入图片描述

应用定义了四个使用@Observed装饰器的数据模型类,它们构成了应用业务数据的完整描述。@Observed装饰器的核心作用是使类的实例成为"可观察对象",当其属性发生变化时,框架会自动通知所有依赖该属性的UI组件触发重新渲染。这四个模型分别对应"轨友动态"“机车模型”“沙盘订单”"配件零件"四个业务域,每个模型都遵循"全字段默认值+构造函数赋值"的统一设计规范。

2.1 PostItem:轨友动态数据模型

@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  text: string = ''
  time: string = ''
  likes: number = 0
  constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
    this.time = time; this.likes = likes
  }
}

PostItem是描述"轨友动态"的数据模型,对应社区中用户发布的短内容动态。它包含6个字段:id(number类型,动态唯一标识)、nick(string类型,发布者昵称,如"蒸汽机车迷"“沙盘搭建党"等富有铁道文化色彩的昵称)、avatar(string类型,emoji头像,如🚂🏞️🎮等与铁道主题相关的表情符号)、text(string类型,动态正文内容)、time(string类型,发布时间,如"9分钟前”“昨天"等相对时间格式)、likes(number类型,点赞数)。每个字段都在声明时赋予了默认值(0或空字符串),这种"全字段默认值"策略确保了即使通过new PostItem()无参构造也能得到一个合法实例,避免运行时undefined错误。构造函数接收6个参数并依次赋值给对应字段,构造函数内部使用了紧凑的单行多赋值语法(this.id = id; this.nick = nick; ...),这是ArkTS代码风格中常见的简洁写法。@Observed装饰器使得PostItem的属性变化时能够触发UI更新。在应用中,PostItem是可变数据模型——用户可以通过"发轨语"弹框调用doAdd()方法向postList数组头部插入新的PostItem(id固定为999,昵称"我的轨语”,头像🚂,时间为"刚刚",点赞数0),也可以通过"取消沙盘/退课"弹框调用doDel()方法删除postList首项。PostItem在应用中被多个页面引用——pageFeatured精选页展示前3条、pageCircle轨友圈页展示完整列表、pageMine我的页展示前3条摘要。

2.2 LocoItem:机车模型数据模型

在这里插入图片描述

@Observed
export class LocoItem {
  id: number = 0
  name: string = ''
  type: string = ''
  price: string = ''
  level: string = ''
  hot: number = 0
  constructor(id: number, name: string, type: string, price: string, level: string, hot: number) {
    this.id = id; this.name = name; this.type = type; this.price = price
    this.level = level; this.hot = hot
  }
}

LocoItem是描述"机车模型"的数据模型,对应俱乐部中展示和售卖的机车模型产品。它包含6个字段:id(number类型,产品唯一标识)、name(string类型,产品名称,如"蒸汽机车·人民型"“电力机车·韶山型”“动车组·复兴号"等真实机车原型名称,带·分隔符的命名风格)、type(string类型,产品特性描述,如"烟效+声音”“受电弓可动”“八编整组"等技术规格)、price(string类型,价格字符串,如"¥688/台”“¥1088/组"等带货币符号和单位的格式)、level(string类型,难度等级,取值为"入门”“进阶”“定制”“大师"四级)、hot(number类型,人气值,0-100的整数)。LocoItem的@Observed标注为未来可能加入的"收藏”"加入购物车数量变化"等交互预留了状态管理基础。LocoItem在应用中被多个页面引用——pageLocos机车橱窗页展示完整列表并带人气进度条、pageFeatured精选页以双列卡片形式展示、pageWorkshop铺轨工坊页以排行榜形式展示前5项。在pageLocos中,每个LocoItem卡片使用了locoTagColor(it.name)函数根据产品名称关键词(蒸汽/窄轨→红、电力/动车→蓝、内燃/调车→金、其他→绿)计算左竖条颜色,使用levelColor(it.level)函数计算难度标签颜色,使用it.hot值驱动人气进度条宽度(${it.hot}%)。这种"数据字段驱动视觉表现"的设计模式,使得同一个数据模型在不同页面上下文中呈现出不同的视觉形态,体现了数据与视图分离的架构思想。

2.3 DioramaItem:沙盘订单数据模型

@Observed
export class DioramaItem {
  id: number = 0
  name: string = ''
  item: string = ''
  status: string = ''
  progress: number = 0
  constructor(id: number, name: string, item: string, status: string, progress: number) {
    this.id = id; this.name = name; this.item = item; this.status = status
    this.progress = progress
  }
}

DioramaItem是描述"沙盘订单"的数据模型,对应用户定制的铁道模型沙盘场景订单。它包含5个字段:id(number类型,订单唯一标识)、name(string类型,沙盘名称,如"1:87 城市环线·2.4m"“山岳支线·1.2m"等带比例和尺寸的命名)、item(string类型,沙盘内容描述,如"双层高架+隧道”“展线+桥梁”“道岔群+货区"等场景元素列举)、status(string类型,订单状态,取值为"已发货”“铺轨中”“布景中”“接线中”“试运行”“设计稿”"待付尾款"等多种状态)、progress(number类型,搭建进度,0-100的整数)。DioramaItem是应用中交互最丰富的数据模型——用户可以通过"编辑沙盘单"弹框调用doEdit()方法修改指定索引的name、status、item三个字段(进度被重置为40),也可以通过"取消沙盘"弹框调用doDel()方法删除首项订单。@Observed装饰器确保了这些数据操作能够实时反映到所有展示DioramaItem的UI组件——pageDioramas我的沙盘页展示完整列表并带状态进度条和编辑入口、pageMine我的页展示前3条摘要、header头部沙盘进度卡展示首条沙盘的概览。在pageDioramas中,status字段通过statusColor函数映射为状态标签颜色(已发货→绿、铺轨中→蓝、布景中→红、接线中→金、试运行→rail铁轨色),通过statusProgress函数映射为进度条百分比,这种"状态字段驱动双重视觉表现"的设计使得同一状态在标签和进度条上呈现出一致的色彩语义。

2.4 GearItem:配件零件数据模型

@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

GearItem是描述"配件零件"的数据模型,对应铁道模型搭建所需的各种配件物料。它包含4个字段,是四个模型中字段最少的:id(number类型,配件唯一标识)、name(string类型,配件名称,如"柔性轨道"“道岔”“DCC解码器"等)、desc(string类型,配件描述,如"HO镍银2m”"电动左开#6"“带声音芯片"等技术规格)、icon(string类型,emoji图标,如🛤️🔀📟等与配件类型相关的表情符号)。GearItem目前是只读数据(没有编辑和删除操作),但其@Observed标注为未来可能加入的"加入购物车”"库存变化"等交互预留了状态管理基础。GearItem在应用中被两个页面引用——pageWorkshop铺轨工坊页以双列卡片形式展示完整列表并带"加购"入口、pageGear零件库页以单列列表形式展示完整列表并带"加购"按钮。在pageWorkshop中,GEAR_LIST通过idx % 2 === 0的索引判断实现了双列布局,每两个GearItem为一行,左侧和右侧各一个卡片,这种"奇偶分列"的布局算法是ArkUI中实现网格化列表的常见技巧。

2.5 图表与课程接口

interface WeekChartItem {
  label: string;
  value: number;
}

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

interface CourseItem {
  title: string;
  week: string;
  progress: number;
  color: string;
}

interface ClubTypeItem {
  name: string;
  desc: string;
  icon: string;
  hot: boolean;
}

除了四个@Observed类模型外,应用还定义了四个普通interface用于图表和课程数据。WeekChartItem(label标签+value数值)用于周运转柱状图数据;ScaleChartItem(label标签+value数值+color颜色)用于模型比例构成条形图数据,每项自带颜色字段使得不同比例可以呈现不同色彩;CourseItem(title标题+week周次+progress进度+color颜色)用于驾驶课堂课程数据,同样自带颜色字段;ClubTypeItem(name名称+desc描述+icon图标+hot是否热门)用于首页玩法四格的数据。这些interface没有使用@Observed装饰器,因为它们对应的数据是静态只读的,不需要响应属性变化。使用interface而非class的设计选择,体现了ArkTS中"静态数据用interface描述结构、用对象字面量实例化"的最佳实践——相比class,interface没有构造函数开销,对象字面量的定义更简洁直观。


三、静态数据数组深度解析

应用定义了8个顶层静态数据数组,它们为整个应用提供了种子数据。这些数组在模块加载时即被初始化,作为POST_LIST、LOCO_LIST等全局常量存在,部分数组被赋值给@State变量(postList、dioramaList)使得它们成为可变数据,其余数组则保持只读。

3.1 POST_LIST:轨友动态种子数据

const POST_LIST: PostItem[] = [
  new PostItem(1, '蒸汽机车迷', '🚂', '新到的 HO 蒸汽机车,烟效一开整个房间都是味道。', '9分钟前', 122),
  new PostItem(2, '沙盘搭建党', '🏞️', '城市环线隧道口用了石膏塑形,真实感直接拉满。', '21分钟前', 108),
  ...
  new PostItem(8, '运转会主持', '🏛️', '本周运转日 22 人到场,环线排图排到了晚上十点。', '昨天', 72)
];

POST_LIST数组包含8条轨友动态种子数据,每条通过new PostItem(...)构造。这8条动态的昵称极具铁道文化特色——“蒸汽机车迷”“沙盘搭建党”“数码控车派”“轨语主理人”“微场景手作者”“轨道打磨工”“老模修复师”“运转会主持”,涵盖了铁道模型爱好者的各种细分身份。动态正文内容也充满专业术语——“HO蒸汽机车”“烟效”“DCC声音解码器”“道岔接点氧化”“铜质机车”"环线排图"等,体现了对铁道模型文化的深度理解。头像使用了与铁道主题相关的emoji(🚂🏞️🎮🛤️🏘️🔧🛠️🏛️),点赞数从122递减到64再回升到72,模拟了真实社区动态的互动分布。这8条数据为pageFeatured精选页(前3条)、pageCircle轨友圈页(完整列表)、pageMine我的页(前3条摘要)提供了展示内容。

3.2 LOCO_LIST:机车模型种子数据

const LOCO_LIST: LocoItem[] = [
  new LocoItem(1, '蒸汽机车 · 人民型', '烟效 + 声音', '¥688/台', '进阶', 95),
  new LocoItem(2, '电力机车 · 韶山型', '受电弓可动', '¥528/台', '进阶', 92),
  ...
  new LocoItem(8, '货运敞车 · 六轴', '载重仿真', '¥168/辆', '入门', 60)
];

LOCO_LIST数组包含8条机车模型种子数据。产品命名采用了"动力类型·原型型号"的格式——“蒸汽机车·人民型”“电力机车·韶山型”“内燃机车·东风型”“动车组·复兴号”“小运转调车机”“窄轨登山机车”“客车车厢·25G”“货运敞车·六轴”,这些原型型号(人民型、韶山型、东风型、复兴号、25G)都是中国铁路真实的机车车型,体现了应用的专业性。特性描述"烟效+声音"“受电弓可动”“双马达牵引”"八编整组"等都是铁道模型爱好者关注的核心功能点。价格从¥688到¥168不等,难度等级覆盖入门、进阶、定制、大师四级,人气值从95递减到60。这8条数据为pageLocos机车橱窗页(完整列表+人气进度条)、pageFeatured精选页(双列卡片)、pageWorkshop铺轨工坊页(排行榜前5项)提供了展示内容。

3.3 DIORAMA_LIST:沙盘订单种子数据

const DIORAMA_LIST: DioramaItem[] = [
  new DioramaItem(1, '1:87 城市环线 · 2.4m', '双层高架 + 隧道', '已发货', 100),
  new DioramaItem(2, '山岳支线 · 1.2m', '展线 + 桥梁', '铺轨中', 80),
  ...
  new DioramaItem(8, '婚庆定制盘 · 1.0m', '刻字 + 场景还原', '待付尾款', 8)
];

DIORAMA_LIST数组包含8条沙盘订单种子数据。订单命名采用"比例·主题·尺寸"的格式——“1:87 城市环线·2.4m”“山岳支线·1.2m”“港口编组站·1.8m”“老街区微景·0.6m”“雪国线·1.5m”“儿童启蒙盘·0.8m”“俱乐部公共盘·3m”“婚庆定制盘·1.0m”,涵盖了城市、山岳、港口、街区、雪国、儿童、公共、婚庆等多种沙盘主题。内容描述"双层高架+隧道"“展线+桥梁”“道岔群+货区”“做旧楼房+夜灯”“造雪+冻湖”“环形+隧道山”“分段共建”"刻字+场景还原"列举了每种沙盘的核心场景元素。状态字段覆盖了"已发货→铺轨中→布景中→接线中→试运行→设计稿→待付尾款"完整的订单生命周期,进度从100递减到8。这8条数据为pageDioramas我的沙盘页(完整列表+状态进度条+编辑入口)、pageMine我的页(前3条摘要)、header头部沙盘进度卡(首条概览)提供了展示内容。

3.4 GEAR_LIST:配件零件种子数据

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '柔性轨道', 'HO 镍银 2m', '🛤️'),
  new GearItem(2, '道岔', '电动左开 #6', '🔀'),
  ...
  new GearItem(8, '夜灯灯珠', '暖光 3V 组', '💡')
];

GEAR_LIST数组包含8条配件零件种子数据。配件命名覆盖了铁道模型搭建的全套物料——“柔性轨道”“道岔”“道砟颗粒”“枕木贴条”“DCC解码器”“轨道清洁车”“造型石膏”“夜灯灯珠”,从轨道到道岔、从道砟到枕木、从电控到造景、从清洁到照明,完整呈现了沙盘搭建所需的物料体系。描述字段"HO镍银2m""电动左开#6"“仿真500g”“自粘软木”“带声音芯片”“双向打磨”“塑形2kg”"暖光3V组"都包含了具体规格,为购买决策提供了技术参考。图标使用了🛤️🔀🪨🪵📟🧹🧱💡等与配件类型高度匹配的emoji。这8条数据为pageWorkshop铺轨工坊页(双列卡片+加购)、pageGear零件库页(单列列表+加购按钮)提供了展示内容。

3.5 WEEK_CHART与SCALE_CHART:图表种子数据

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 3 },
  { label: '周二', value: 5 },
  { label: '周三', value: 6 },
  { label: '周四', value: 7 },
  { label: '周五', value: 9 },
  { label: '周六', value: 13 },
  { label: '周日', value: 12 }
];

const SCALE_CHART: ScaleChartItem[] = [
  { label: 'HO 1:87', value: 38, color: '#3D5A80' },
  { label: 'N 1:160', value: 24, color: '#C0564F' },
  { label: 'O 1:48', value: 16, color: '#D9A54E' },
  { label: 'G 花园级', value: 12, color: '#7BA884' },
  { label: '其他比例', value: 10, color: '#8B9DAF' }
];

WEEK_CHART数组包含7条周运转场次数据,从周一到周日(value: 3→5→6→7→9→13→12),呈现了"工作日递增、周末高峰"的典型运转会排班规律。周六13场为峰值,体现了铁道模型俱乐部周末集中活动的特性。这组数据驱动pageFeatured精选页的柱状图,每根柱子的高度通过barH函数计算(Math.round(118 * v / max),max=13),颜色通过locoColor函数映射(v>9蓝、v>6红、否则金)。SCALE_CHART数组包含5条模型比例构成数据——HO 1:87(38%,铁道蓝)、N 1:160(24%,红色机车)、O 1:48(16%,琥珀金)、G花园级(12%,草绿)、其他比例(10%,rail铁轨色),每项自带颜色字段。HO比例38%占比最高,符合铁道模型爱好者中HO比例最流行的实际情况。这组数据驱动pageFeatured精选页的水平条形图,每条进度条宽度为${it.value}%,颜色使用各项自带的color字段。

3.6 TYPE_LIST与COURSE_LIST:类型与课程种子数据

const TYPE_LIST: ClubTypeItem[] = [
  { name: '沙盘定制', desc: '量尺设计 · 全程搭建', icon: '🏞️', hot: true },
  { name: '机车改装', desc: 'DCC · 灯光 · 做旧', icon: '🔧', hot: true },
  { name: '老模修复', desc: '齿轮 · 漆面 · 走行', icon: '🛠️', hot: false },
  { name: '运转会租线', desc: '俱乐部环线排班', icon: '🚉', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '铁道模型比例入门', week: '第 1 周', progress: 100, color: '#3D5A80' },
  { title: '路基铺设与道砟固定', week: '第 2 周', progress: 84, color: '#C0564F' },
  { title: '微场景建筑做旧', week: '第 3 周', progress: 52, color: '#D9A54E' },
  { title: 'DCC 数字控车实操', week: '第 4 周', progress: 26, color: '#7BA884' },
  { title: '沙盘电路与信号系统', week: '第 5 周', progress: 6, color: '#8B9DAF' }
];

TYPE_LIST数组包含4条俱乐部玩法类型数据——“沙盘定制”“机车改装”“老模修复”“运转会租线”,前两项标记为hot热门(红色"热门"角标),后两项不热门。每项包含name、desc、icon、hot四个字段,desc采用"关键词·关键词·关键词"的格式列举了每种玩法的核心内容。这组数据驱动pageFeatured精选页的玩法四格。COURSE_LIST数组包含5条驾驶课堂课程数据,从第1周到第5周,进度从100%递减到6%(100→84→52→26→6),每项自带颜色字段。课程标题涵盖了铁道模型从入门到进阶的完整知识体系——“铁道模型比例入门”“路基铺设与道砟固定”“微场景建筑做旧”“DCC数字控车实操”“沙盘电路与信号系统”,体现了课程设计的系统性。这组数据驱动pageCourse驾驶课堂页的课程进度列表,每条进度条宽度为${it.progress}%,颜色使用各项自带的color字段。


四、导航配置深度解析

4.1 NAV_LIST:底部4主tab导航

interface NavItem {
  icon: string;
  label: string;
}

const NAV_LIST: NavItem[] = [
  { icon: '🚂', label: '首页' },
  { icon: '🛤️', label: '模型' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

NAV_LIST定义了应用底部导航栏的4个主tab配置,每个NavItem包含icon(emoji图标)和label(文字标签)两个字段。4个tab的命名——首页/模型/课堂/我的——采用了垂直领域应用的经典导航架构。首页承载了内容聚合和社区动态功能;模型tab直接展示机车模型橱窗;课堂tab提供驾驶课程;我的tab展示个人档案和订单。图标选择上,首页用🚂机车、模型用🛤️轨道、课堂用📚书本、我的用👤人像,4个emoji都与各自tab的功能语义高度匹配,且整体呈现出"机车→轨道→学习→用户"的叙事递进。在bottomBar Builder中,NAV_LIST通过ForEach遍历渲染4个导航项,选中项的图标opacity为1、标签字体加粗、颜色为primary铁道蓝,未选中项的图标opacity为0.55、标签字体正常、颜色为textHint浅灰色。通过mainTab @State变量(0-3)控制选中状态,点击导航项时更新mainTab值,mainContent Builder根据mainTab值路由到对应页面内容。

4.2 SUB_NAV_LIST:首页7内容tab(描边药丸式)

const SUB_NAV_LIST: string[] = ['精选', '机车橱窗', '我的沙盘', '铺轨工坊', '驾驶课堂', '轨友圈', '零件库'];

SUB_NAV_LIST定义了首页内容区7个子tab的标签文字数组,是一个纯字符串数组。7个tab——精选、机车橱窗、我的沙盘、铺轨工坊、驾驶课堂、轨友圈、零件库——覆盖了铁道模型俱乐部应用的核心功能分区。精选是内容聚合页(玩法四格+柱状图+比例构成+双列机车+轨友动态),机车橱窗展示机车模型列表,我的沙盘展示沙盘订单列表,铺轨工坊展示工位信息和排行榜,驾驶课堂展示课程进度,轨友圈展示社区动态流,零件库展示配件物料。subNav Builder采用了描边药丸式的设计语言——每个tab项是一个带圆角的药丸形容器(borderRadius 16),选中时药丸填充primaryLight浅蓝背景、描边加粗为1.5宽的primary铁道蓝、文字加粗为13字号primary色,未选中时药丸为cardBg白色背景、描边为1宽的border浅灰色、文字正常为11字号textSecondary色。整个subNav通过Scroll容器实现横向滚动(scrollable Horizontal、scrollBar Off隐藏滚动条),使得7个tab项在窄屏设备上也能完整呈现。描边药丸式的设计语言模拟了铁路信号灯罩或列车车次牌的视觉形态,与整体铁道主题形成呼应。


五、纯函数工具集深度解析

应用定义了12个顶层纯函数,它们是无副作用的状态/数据到视觉表现的映射工具。这些函数接收原始数据作为输入,返回颜色、坐标、尺寸等视觉参数,被各@Builder方法调用以驱动UI渲染。纯函数的设计使得映射逻辑与UI组件解耦,便于独立测试和未来调整。

5.1 barH:柱状图高度计算函数

function barH(v: number, max: number): number {
  return Math.round(118 * v / max);
}

barH函数接收当前值v和最大值max两个参数,返回柱状图的高度(像素)。计算公式为Math.round(118 * v / max),即按比例将v映射到0-118像素的高度区间。Math.round确保返回整数像素值,避免亚像素渲染导致的边缘模糊。在应用中,barH被pageFeatured精选页的周运转柱状图调用,传入WEEK_CHART的各项value和最大值13,计算每根柱子的高度。例如周一value=3时返回Math.round(118*3/13)=27像素,周六value=13时返回Math.round(118*13/13)=118像素。这种"按最大值归一化"的设计使得柱状图能够自适应数据范围的变化,即使未来数据值增大,柱状图也不会溢出预设的140像素高度区域。

5.2 locoColor:热度颜色映射函数

function locoColor(v: number): string {
  if (v > 9) {
    return '#3D5A80';
  } else if (v > 6) {
    return '#C0564F';
  }
  return '#D9A54E';
}

locoColor函数接收数值v,按三档阈值返回不同颜色:v>9返回铁道蓝#3D5A80(高频/热门),v>6返回红色机车#C0564F(中频),否则返回琥珀金#D9A54E(低频)。这种"阈值分色"的策略函数将数值热度映射为视觉热度,使得柱状图能够通过颜色直观区分运转场次的冷热程度。在应用中,locoColor被pageFeatured精选页的周运转柱状图调用,传入WEEK_CHART的各项value(3-13)。例如周六value=13>9返回蓝色(高频热门),周五value=9不大于9返回金色(中低频),周一value=3返回金色(低频)。值得注意的是,阈值判断使用了严格的>而非>=,使得value=9时落入中频红色档而非高频蓝色档,这种边界处理体现了设计者对"周末高峰"与"工作日低谷"的色彩区分意图。

5.3 signalA/X/Y/S/Color:信号灯闪烁特效函数组

function signalA(tick: number, i: number): number {
  return 0.1 + ((tick + i) % 4) * 0.05;
}

function signalX(tick: number, i: number): number {
  return 50 + ((tick * 3 + i * 193) % 610);
}

function signalY(tick: number, i: number): number {
  return 60 + ((tick * 5 + i * 167) % 620);
}

function signalS(tick: number, i: number): number {
  return 4 + ((tick + i * 2) % 3) * 2;
}

function signalColor(tick: number, i: number): string {
  if ((tick + i) % 2 === 0) {
    return '#C0564F';
  }
  return '#7BA884';
}

这是信号灯闪烁特效的核心函数组,共5个函数,全部接收tick(时间帧计数器)和i(粒子索引)两个参数,分别返回透明度、X坐标、Y坐标、尺寸、颜色五个视觉属性。signalA返回透明度0.1-0.25之间,通过(tick+i) % 4实现4帧一周期的呼吸式透明度变化;signalX返回50-660之间的X坐标,通过tick*3 + i*193的复合运算实现每个粒子独特的水平运动轨迹;signalY返回60-680之间的Y坐标,通过tick*5 + i*167实现垂直运动轨迹;signalS返回4-8之间的尺寸,通过(tick + i*2) % 3实现3帧一周期的尺寸脉动;signalColor返回红色机车#C0564F或草绿#7BA884,通过(tick+i) % 2实现2帧一周期的红绿闪烁。这5个函数共同驱动fxLayer特效层中5个信号灯粒子的闪烁动画,模拟了铁路信号灯红绿交替闪烁的视觉效果。函数中使用的193、167等质数系数是为了确保不同粒子(i=0到4)的运动轨迹互不相同且均匀分布,避免粒子重叠或同步运动。(tick + i) % 4(tick + i) % 2的取模运算实现了循环动画,每4帧或2帧一个周期,配合90ms的定时器,形成了约360ms或180ms的闪烁周期,接近真实铁路信号灯的闪烁频率。

5.4 smokeA/X/Y/S:蒸汽烟团上飘特效函数组

function smokeA(tick: number, i: number): number {
  return 0.06 + ((tick + i) % 5) * 0.03;
}

function smokeX(tick: number, i: number): number {
  return 60 + ((tick * 4 + i * 199) % 600);
}

function smokeY(tick: number, i: number): number {
  return 70 + ((tick * 6 + i * 173) % 610);
}

function smokeS(tick: number, i: number): number {
  return 6 + ((tick + i) % 4) * 3;
}

这是蒸汽烟团上飘特效的核心函数组,共4个函数(无颜色函数,烟团颜色固定为COLORS.rail铁轨色#8B9DAF),同样接收tick和i两个参数,返回透明度、X坐标、Y坐标、尺寸四个视觉属性。smokeA返回透明度0.06-0.18之间,通过(tick+i) % 5实现5帧一周期的透明度变化,比信号灯更缓慢更柔和;smokeX返回60-660之间的X坐标,通过tick*4 + i*199实现水平漂移;smokeY返回70-680之间的Y坐标,通过tick*6 + i*173实现垂直运动,注意smokeY的tick系数6大于smokeX的tick系数4,使得烟团的垂直运动速度大于水平运动,模拟了蒸汽受热上升的物理特性;smokeS返回6-15之间的尺寸,通过(tick+i) % 4实现4帧一周期的尺寸脉动,比信号灯的尺寸范围更大,使得烟团呈现出"扩散膨胀"的视觉效果。这4个函数共同驱动fxLayer特效层中5个蒸汽烟团粒子的上飘动画,配合rail铁轨色的着色,模拟了蒸汽机车烟囱冒出的冷灰蒸汽在空中飘散的视觉效果。199、173等质数系数同样用于确保粒子轨迹的多样性。

5.5 statusColor与statusProgress:沙盘订单状态映射函数

function statusColor(s: string): string {
  if (s === '已发货') { return '#7BA884'; }
  else if (s === '铺轨中') { return '#3D5A80'; }
  else if (s === '布景中') { return '#C0564F'; }
  else if (s === '接线中') { return '#D9A54E'; }
  else if (s === '试运行') { return '#8B9DAF'; }
  return '#A4ACB6';
}

function statusProgress(s: string): number {
  if (s === '已发货') { return 100; }
  else if (s === '铺轨中') { return 80; }
  else if (s === '布景中') { return 62; }
  else if (s === '接线中') { return 46; }
  else if (s === '试运行') { return 32; }
  return 12;
}

statusColor和statusProgress是一对状态映射函数,都接收status字符串,分别返回对应的颜色和进度百分比。statusColor将6种状态映射为语义化颜色——已发货→success草绿#7BA884(完成)、铺轨中→primary铁道蓝#3D5A80(进行中主体工序)、布景中→accent红色机车#C0564F(视觉重点工序)、接线中→warning琥珀金#D9A54E(电气工序警示)、试运行→rail铁轨色#8B9DAF(调试阶段)、其他→textHint浅灰#A4ACB6(待启动)。statusProgress将同样6种状态映射为进度百分比——100/80/62/46/32/12,呈递减序列。这两个函数的映射逻辑保持一致——同一状态在颜色和进度上呈现相同的语义层级,使得沙盘订单的状态标签颜色和进度条颜色形成统一的视觉表达。在pageDioramas我的沙盘页中,这两个函数被同时调用,分别驱动状态标签的fontColor和backgroundColor、进度条的width和backgroundColor,确保了状态信息的双重视觉强化。

5.6 locoTagColor与levelColor:标签着色函数

function locoTagColor(g: string): string {
  if (g.indexOf('蒸汽') >= 0 || g.indexOf('窄轨') >= 0) { return '#C0564F'; }
  else if (g.indexOf('电力') >= 0 || g.indexOf('动车') >= 0) { return '#3D5A80'; }
  else if (g.indexOf('内燃') >= 0 || g.indexOf('调车') >= 0) { return '#D9A54E'; }
  return '#7BA884';
}

function levelColor(s: string): string {
  if (s === '入门') { return '#7BA884'; }
  else if (s === '进阶') { return '#3D5A80'; }
  else if (s === '定制') { return '#D9A54E'; }
  return '#C0564F';
}

locoTagColor函数接收机车名称字符串g,通过indexOf关键词匹配返回对应颜色:包含"蒸汽"或"窄轨"返回红色机车#C0564F(蒸汽动力系)、包含"电力"或"动车"返回铁道蓝#3D5A80(电力动力系)、包含"内燃"或"调车"返回琥珀金#D9A54E(内燃动力系)、其他返回草绿#7BA884。这种基于关键词的着色策略将机车按动力类型分类,使得用户可以通过颜色快速识别机车的动力系属。levelColor函数接收难度等级字符串s,返回对应颜色:入门→草绿#7BA884(容易)、进阶→铁道蓝#3D5A80(标准)、定制→琥珀金#D9A54E(特殊)、大师→红色机车#C0564F(顶级)。这两个函数在pageLocos机车橱窗页中被同时调用——locoTagColor驱动机车卡片的左竖条颜色和人气进度条颜色,levelColor驱动难度标签的文字颜色,形成了"动力类型+难度等级"的双重色彩分类体系。


六、组件状态管理与生命周期

6.1 @State状态变量

@Entry
@Component
struct PageRailModelClub {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'diorama'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State dioramaList: DioramaItem[] = DIORAMA_LIST
  @State fxTimer: number = -1
  ...
}

PageRailModelClub组件定义了16个@State状态变量,它们按功能可分为四组。导航状态组:mainTab(0-3控制底部4主tab选中)、subTab(0-6控制首页7内容tab选中)。特效状态组:tick(时间帧计数器,每90ms递增1,驱动fxLayer动画)、glow(0/1交替值,预留的发光特效开关)。弹框状态组:addOpen/editOpen/delOpen(三个布尔值分别控制三个弹框的显示)、editIdx(当前编辑的沙盘索引)、delTarget(删除目标,‘diorama’或’course’)、editName/editStyle/editNote(编辑沙盘单的三个表单字段)、addTitle/addContent(发布轨语的两个表单字段)。数据状态组:postList(轨友动态数组,初始为POST_LIST)、dioramaList(沙盘订单数组,初始为DIORAMA_LIST)。资源状态组:fxTimer(定时器ID,初始-1)。@State装饰器使得这些变量的变化能够自动触发UI重新渲染,每个变量的职责单一明确。postList和dioramaList作为@State数组,其unshift/splice操作会触发列表的差分更新,确保新增和删除项的动画流畅。

6.2 aboutToAppear与aboutToDisappear:生命周期管理

aboutToAppear(): void {
  this.fxTimer = setInterval(() => {
    this.tick = this.tick + 1;
    this.glow = (this.glow + 1) % 2;
  }, 90);
}

aboutToDisappear(): void {
  if (this.fxTimer > 0) {
    clearInterval(this.fxTimer);
    this.fxTimer = -1;
  }
}

aboutToAppear是组件生命周期的"将出现"回调,在组件实例创建后、build()方法执行前被调用。在这里,应用通过setInterval创建了一个90毫秒间隔的定时器,将返回的定时器ID保存到fxTimer变量。定时器回调中执行两个操作:tick递增1(驱动fxLayer的信号灯闪烁和蒸汽烟团上飘动画)、glow在0/1之间交替(预留的发光特效)。90ms的间隔意味着约11fps的动画帧率,虽然低于60fps的流畅标准,但对于"闪烁"和"上飘"这种缓慢、柔和的特效而言完全足够,且较低的帧率降低了CPU占用。aboutToDisappear是组件生命周期的"将消失"回调,在组件即将被销毁前被调用。在这里,应用检查fxTimer是否大于0(确保定时器存在),如果是则调用clearInterval清除定时器并将fxTimer重置为-1。这种"成对的资源获取与释放"模式是防止内存泄漏和定时器泄漏的最佳实践——如果不清除定时器,即使组件被销毁,定时器仍会持续运行,导致tick持续递增但UI已不存在,造成无效计算和潜在的内存泄漏。

6.3 弹框操作方法

openAdd(): void {
  this.addTitle = '';
  this.addContent = '';
  this.addOpen = true;
}

openEdit(index: number): void {
  if (index >= 0 && index < this.dioramaList.length) {
    this.editIdx = index;
    this.editName = this.dioramaList[index].name;
    this.editStyle = this.dioramaList[index].status;
    this.editNote = this.dioramaList[index].item;
  }
  this.editOpen = true;
}

openDelA(): void {
  this.delTarget = 'diorama';
  this.delOpen = true;
}

openDelB(): void {
  this.delTarget = 'course';
  this.delOpen = true;
}

四个open方法分别打开四个弹框场景。openAdd清空addTitle和addContent表单字段后设置addOpen为true,打开发布轨语弹框。openEdit接收index参数,进行边界检查后(确保index在0到dioramaList.length范围内),将指定索引沙盘的name、status、item三个字段预填到editName、editStyle、editNote表单变量,然后设置editOpen为true,打开编辑沙盘单弹框。这种"预填表单"的设计使得用户在编辑时能看到当前值而非空白,提升了编辑体验。openDelA和openDelB分别设置delTarget为’diorama’和’course’后打开删除确认弹框——delTarget为’diorama’时弹框标题为"取消沙盘订单"、文案为"将取消首笔沙盘订单并按工序退费";delTarget为’course’时弹框标题为"退出驾驶课堂"、文案为"将退出首门进行中的课程"。一个delModalBody弹框通过delTarget变量的值动态切换文案,服务于两种删除场景,这是模板方法模式的典型运用。

6.4 数据操作方法

doAdd(): void {
  if (this.addTitle.length > 0) {
    this.postList.unshift(new PostItem(999, '我的轨语', '🚂', this.addTitle, '刚刚', 0));
  }
  this.addOpen = false;
}

doEdit(): void {
  if (this.editIdx >= 0 && this.editIdx < this.dioramaList.length) {
    this.dioramaList.splice(this.editIdx, 1, new DioramaItem(this.dioramaList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
  }
  this.editOpen = false;
}

doDel(): void {
  if (this.delTarget === 'diorama' && this.dioramaList.length > 0) {
    this.dioramaList.splice(0, 1);
  } else if (this.delTarget === 'course' && this.postList.length > 0) {
    this.postList.splice(0, 1);
  }
  this.delOpen = false;
}

三个do方法分别执行三种数据操作。doAdd检查addTitle非空后,调用postList.unshift()在数组头部插入一个新的PostItem(id固定999,昵称"我的轨语",头像🚂,正文为addTitle,时间"刚刚",点赞0),然后关闭弹框。unshift使得新动态出现在列表最上方,符合社交媒体"最新内容置顶"的交互习惯。doEdit进行边界检查后,调用dioramaList.splice(editIdx, 1, newItem)用新构造的DioramaItem替换指定索引的旧项——新项保留了原id,但name、item、status使用了表单字段的值,progress被硬编码为40。splice的"替换"用法(第二个参数1表示删除1项,第三个参数是新项)实现了"原地编辑"的语义。doDel根据delTarget的值执行不同删除——'diorama’时调用dioramaList.splice(0, 1)删除首项沙盘,'course’时调用postList.splice(0, 1)删除首条轨语。值得注意的是,delTarget为’course’时实际删除的是postList而非COURSE_LIST,这在语义上存在偏差("退课"应删除课程而非轨语),可能是演示代码的简化处理,在真实应用中应修正为操作COURSE_LIST。


七、fxLayer特效层:信号灯闪烁与蒸汽烟团上飘

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(signalS(this.tick, i))
        .height(signalS(this.tick, i))
        .borderRadius(signalS(this.tick, i) / 2)
        .backgroundColor(signalColor(this.tick, i))
        .opacity(signalA(this.tick, i))
        .translate({ x: signalX(this.tick, i), y: signalY(this.tick, i) })
    }, (i: number) => i.toString())
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(smokeS(this.tick, i))
        .height(smokeS(this.tick, i))
        .borderRadius(smokeS(this.tick, i) / 2)
        .backgroundColor(COLORS.rail)
        .opacity(smokeA(this.tick, i))
        .translate({ x: smokeX(this.tick, i), y: smokeY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer是应用最富视觉表现力的@Builder方法,它构建了一个覆盖全屏的特效层,包含两组共10个动画粒子——5个信号灯闪烁粒子和5个蒸汽烟团上飘粒子。整个特效层使用Stack容器(alignContent TopStart)作为粒子堆叠的容器,两组ForEach各遍历[0,1,2,3,4]生成5个粒子。每个粒子是一个Column组件,通过width/height/borderRadius/backgroundColor/opacity/translate六个属性的组合呈现圆形粒子的视觉表现。信号灯粒子使用signalColor函数返回的红绿交替颜色(#C0564F或#7BA884),borderRadius为signalS/2形成圆形;蒸汽烟团粒子使用固定的COLORS.rail铁轨色#8B9DAF,同样通过borderRadius形成圆形。所有粒子的视觉属性都绑定到this.tick状态变量,当tick每90ms递增1时,所有粒子的透明度、坐标、尺寸、颜色都会重新计算并触发重新渲染,形成连续的闪烁和上飘动画。fxLayer最关键的设计是.hitTestBehavior(HitTestMode.None)——这一设置使得整个特效层"穿透"触摸事件,用户可以透过闪烁的信号灯和上飘的蒸汽烟团直接操作下方的内容层。HitTestMode.None表示该组件及其子组件都不参与命中测试,触摸事件会自动传递给z序更低的组件。这种设计让"视觉丰富度"和"交互可用性"不再是一对矛盾——特效层可以尽情展示动画而不阻挡用户的任何点击操作。


八、header头部Builder

8.1 品牌行与购物车

header头部Builder的第一部分是品牌行,采用Row水平布局。左侧是🚂emoji品牌标识(fontSize 24),中间是双行文案Column——标题"轨语铁模俱乐部"(15字号、Bold、textPrimary深色)和副标题"城市环线·本周日运转会"(10字号、primary铁道蓝色)。中间Column使用layoutWeight(1)占据剩余宽度,实现了弹性布局。右侧是购物车图标——使用Stack层叠实现"🧺篮子emoji+红色数字角标3"的组合,角标通过translate({ x: 10, y: -10 })偏移到右上角,模拟了电商应用中常见的"购物车未读数量"视觉语言。购物车角标的danger红褐色#C76B5A与白色文字形成高对比度,确保角标数字清晰可读。

8.2 沙盘进度卡

Row({ space: 12 }) {
  Column({ space: 4 }) {
    Text('1:87 城市环线 · 试运行中')
      .fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
    Text('2.4m 双层高架 · 三列车同时在跑')
      .fontSize(10).fontColor('#D7E0EC')
    Row({ space: 6 }) {
      Text('调度 5/8 班')
        .fontSize(9).fontWeight(FontWeight.Bold).fontColor(COLORS.primaryDark)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .backgroundColor(COLORS.rail).borderRadius(6)
      Text('信号系统已联锁')
        .fontSize(9).fontColor('#D7E0EC')
    }
  }
  .alignItems(HorizontalAlign.Start).layoutWeight(1)
  Text('查看沙盘')
    .fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.primaryDark)
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(COLORS.rail).borderRadius(14)
    .onClick(() => { this.openEdit(0); })
}
.width('100%').padding(14).backgroundColor(COLORS.primary).borderRadius(16)

沙盘进度卡是header的视觉焦点,使用primary铁道蓝#3D5A80作为卡片背景色,形成整个头部最强烈的视觉锚点。卡片左侧Column展示沙盘的运行状态——标题"1:87 城市环线·试运行中"(白色15字号加粗)、副标题"2.4m 双层高架·三列车同时在跑"(浅蓝灰色#D7E0EC 10字号),下方是两个标签——"调度5/8班"使用rail铁轨色背景+primaryDark深色文字+"信号系统已联锁"使用浅蓝灰色文字。右侧"查看沙盘"按钮使用rail铁轨色背景+primaryDark深色文字+14圆角,点击触发openEdit(0)打开编辑沙盘单弹框并预填首条沙盘数据。这个按钮的交互设计将沙盘概览卡与编辑弹框直接关联,形成了"查看→编辑"的快捷操作路径。

8.3 运转会招募横幅与数据四格

运转会招募横幅使用白色卡片背景(cardBg),左侧🚉emoji(22字号),中间双行文案——标题"周日运转会·招募跑班车长"(13字号、Bold、textPrimary深色)和副标题"公共盘3m环线·免台费·送道砟包"(10字号、textSecondary灰蓝色)。右侧"报名"按钮使用accent红色机车色背景+白色文字+14圆角,点击触发openAdd()打开发布轨语弹框。这里的交互映射与新人礼横幅类似——"报名"按钮实际打开的是"发布轨语"弹框,在真实应用中应路由到报名流程。数据四格使用Row水平布局展示4个数据卡片——“在制沙盘2座”(primary铁道蓝)、“机车藏品23台”(accent红色机车)、“运营里程412m”(success草绿)、“轨币¥84”(warning琥珀金),每格使用layoutWeight(1)等分宽度,8padding+10圆角+白色背景+居中对齐。四个数据值分别使用了不同的语义颜色,使得用户能通过颜色快速区分数据类型。


九、subNav子导航(描边药丸式)

@Builder
subNav() {
  Scroll() {
    Row({ space: 10 }) {
      ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
        Text(item)
          .fontSize(this.subTab === idx ? 13 : 11)
          .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.subTab === idx ? COLORS.primary : COLORS.textSecondary)
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .backgroundColor(this.subTab === idx ? COLORS.primaryLight : COLORS.cardBg)
          .borderRadius(16)
          .border({
            width: this.subTab === idx ? 1.5 : 1,
            color: this.subTab === idx ? COLORS.primary : COLORS.border,
            radius: 16
          })
          .onClick(() => { this.subTab = idx; })
      }, (item: string) => item)
    }
    .width('100%').alignItems(VerticalAlign.Center)
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .padding({ top: 6, bottom: 10 })
}

subNav Builder实现了描边药丸式子导航,这是本应用在交互设计上最具辨识度的创新点。整个导航通过Scroll容器实现横向滚动,内部Row以10间距水平排列7个tab项。每个tab项是一个Text组件,通过条件三元运算符(this.subTab === idx ? A : B)实现选中/未选中两种视觉状态的双值切换。选中态:13字号、Bold加粗、primary铁道蓝文字、primaryLight浅蓝背景、1.5宽primary铁道蓝描边、16圆角,整体呈现为一个填充浅蓝、描边深蓝的药丸形状。未选中态:11字号、Normal正常、textSecondary灰蓝文字、cardBg白色背景、1宽border浅灰描边、16圆角,整体呈现为一个白色背景、细灰描边的药丸形状。选中与未选中之间的字号差异(13 vs 11)、字重差异(Bold vs Normal)、颜色差异(primary vs textSecondary)、背景差异(primaryLight vs cardBg)、描边差异(1.5宽primary vs 1宽border)五重视觉对比,使得选中项在横向滚动列表中具有极高的视觉辨识度。描边药丸式的设计语言模拟了铁路信号灯罩或列车车次牌的视觉形态——药丸的圆角形态呼应了信号灯的圆形轮廓,描边的粗细变化呼应了信号灯的亮灭状态,整体形成了一种"铁道信号系统"的视觉隐喻。Scroll容器设置了scrollBar(BarState.Off)隐藏滚动条,确保7个tab项在横向滚动时不会出现底部滚动条的视觉干扰。


十、各页面Builder深度解析

10.1 pageFeatured:精选页

pageFeatured是首页精选tab的内容页,是信息密度最高的页面,包含五个内容模块。玩法四格模块使用TYPE_LIST数据,每格展示icon(24字号emoji)、name(11字号Bold)、desc(9字号textHint,maxLines 1+Ellipsis单行省略)、hot标签(热门时显示红色"热门",否则显示空格)。周运转柱状图模块使用WEEK_CHART数据,每根柱子通过barH函数计算高度、locoColor函数计算颜色,柱状图区域高度140像素、底部对齐,下方显示"周末运转占比59%"(accent红色机车色14字号加粗)。比例偏好构成模块使用SCALE_CHART数据,每行通过Stack层叠实现进度条(底层border浅灰全宽+上层it.value%宽it.color色),右侧显示百分比数值。人气机车双列模块使用LOCO_LIST数据,通过idx % 2 === 0实现双列布局,每张卡片展示level标签(levelColor着色)、name、price和hot信息。轨友动态模块使用postList.slice(0,3)展示前3条,每条展示avatar、nick、time、text(maxLines 2省略)、likes,右上角"发轨语"入口触发openAdd。

10.2 pageLocos:机车橱窗页

pageLocos是机车橱窗tab的内容页,展示完整的机车模型列表。页面顶部标题行右侧"开始定制"入口触发openAdd。每张机车卡片采用"左竖条+内容+进度条+操作行"的四段式布局:左竖条(4宽38高2圆角,locoTagColor着色)通过颜色标识机车动力类型;内容区展示name(14字号Bold)、level标签(levelColor着色+bg背景6圆角)、price和type信息;进度条通过Stack层叠实现(底层border浅灰全宽+上层hot%宽locoTagColor色,6高3圆角);操作行展示"人气值hot"(textHint 10字号)和"加入购物车"按钮(primary铁道蓝背景+白色文字+8圆角)。locoTagColor函数同时驱动左竖条颜色和进度条颜色,形成了机车动力类型的双重色彩标识。

10.3 pageDioramas:我的沙盘页

pageDioramas是我的沙盘tab的内容页,展示用户的沙盘订单列表。每张沙盘卡片采用"图标+内容+进度条+操作行"的四段式布局:左侧🏞️emoji图标(24字号);内容区展示name(14字号Bold)、status标签(statusColor着色+accentLight背景6圆角)、item内容描述(带🧾前缀);进度条通过Stack层叠实现(底层border浅灰全宽+上层statusProgress%宽statusColor色);操作行展示"搭建进度statusProgress%"(textHint 10字号)和"编辑沙盘单"入口(primary铁道蓝11字号)触发openEdit(idx)。statusColor和statusProgress两个函数同时驱动状态标签和进度条,确保了状态信息的双重视觉强化。卡片通过ForEach遍历this.dioramaList(可变@State数组),使得编辑和删除操作能实时反映到列表。

10.4 pageWorkshop:铺轨工坊页

pageWorkshop是铺轨工坊tab的内容页,包含三个内容模块。工位四格模块展示当前工位信息——“当前工位 搭建台D2”(primary蓝)、“沙盘规格 港口编组”(accent红)、“工序 铺道砟”(warning金)、“今日工时 5.0h”(success绿),每格使用layoutWeight(1)等分宽度。机车排行榜模块使用LOCO_LIST.slice(0,5)展示前5项,每行展示排名序号(前三名primary蓝、后两名textHint灰)、name、type和price、hot值(locoTagColor着色)。零件速购模块使用GEAR_LIST数据,通过idx % 2 === 0实现双列布局,每张卡片展示icon(26字号emoji)、name(14字号Bold)、desc(11字号)、"加购"入口(primary蓝11字号)。

10.5 pageCourse:驾驶课堂页

pageCourse是驾驶课堂tab的内容页,展示课程进度列表。每张课程卡片展示title(14字号Bold)、week(11字号textHint)、进度条(Stack层叠,底层border全宽+上层progress%宽it.color色,8高4圆角)、"已学"标签和progress百分比(primary蓝加粗)。COURSE_LIST的5项课程通过ForEach遍历渲染,每项自带color字段驱动进度条颜色,形成了"第1周蓝→第2周红→第3周金→第4周绿→第5周rail铁轨色"的色彩梯度,暗示了课程从基础到进阶的难度递进。页面底部是一个danger红褐色的"退课"按钮,通过Button()+Text()的叠加实现(Text使用margin偏移覆盖在Button上),点击触发openDelB打开退课确认弹框。

10.6 pageCircle:轨友圈页

pageCircle是轨友圈tab的内容页,展示完整的社区动态流。页面顶部标题行右侧"发轨语"入口触发openAdd。每条动态采用"头像+内容"的横向布局:左侧avatar(26字号emoji),右侧Column展示nick(14字号Bold)、time(10字号textHint)、text正文(13字号textSecondary)、互动指标"🚉likes·💬回复"(11字号textHint)。postList通过ForEach遍历渲染完整列表,用户新发布的轨语通过unshift出现在列表最上方。

10.7 pageGear:零件库页

pageGear是零件库tab的内容页,包含三个内容模块。分类三格模块展示"轨道区"(🛤️ 28字号,“柔性轨·道岔”)、“电控区”(📟,“DCC·信号”)、“造景区”(🧱,“石膏·道砟”),每格使用layoutWeight(1)等分宽度。配件列表模块使用GEAR_LIST数据,每行展示icon(20字号emoji)、name(13字号Bold)、desc(10字号)、"加购"按钮(primary蓝背景+白色文字+8圆角)。页面底部是一个danger红褐色的"取消沙盘"按钮,同样通过Button()+Text()叠加实现,点击触发openDelA打开取消沙盘确认弹框。

10.8 pageMine:我的页

pageMine是我的tab的内容页,展示用户个人档案。顶部用户卡片展示🚂emoji(40字号)和用户信息——昵称"轨语调度长"(18字号Bold)、身份描述"金扳手会员·藏车23台·运转412m·打卡90天"(11字号textSecondary)。我的沙盘档案卡片展示dioramaList.length数量(primary蓝12字号)和前3条沙盘摘要(name+status,statusColor着色)。我的轨语卡片展示postList.length数量(primary蓝12字号)和前3条轨语摘要(nick+time)。两个档案卡片都通过slice(0,3)只展示前3条摘要,形成了"概览+详情入口"的信息层级。


十一、弹框组件深度解析

11.1 modalOverlay:遮罩层

@Builder
modalOverlay() {
  Stack() {
    Column()
      .width('100%').height('100%')
      .backgroundColor('#000000').opacity(0.6)
      .onClick(() => {
        this.addOpen = false;
        this.editOpen = false;
        this.delOpen = false;
      })
  }
  .width('100%').height('100%')
}

modalOverlay是三个弹框共享的遮罩层Builder,使用纯黑色#000000背景+0.6透明度实现半透明遮罩效果。遮罩层的onClick回调同时将addOpen、editOpen、delOpen三个布尔值设为false,实现了"点击遮罩关闭任何弹框"的统一交互。这种"共享遮罩+多弹框复用"的设计减少了重复代码,确保了三个弹框的遮罩行为完全一致。modalOverlay在build()中被三个弹框的条件渲染块各自调用,每个弹框的Stack结构为"modalOverlay遮罩层+modalBody内容卡片"。

11.2 addModalBody:发布轨语弹框

addModalBody是发布轨语弹框的内容卡片,包含标题"发布轨语"、TextInput单行输入框(placeholder"一句话工坊日常…“,绑定addTitle)、TextArea多行输入框(placeholder"补充细节:机车、沙盘、运转心得…”,绑定addContent,高度90)、双按钮行("取消"灰色bg背景+关闭弹框/"发布"primary蓝背景+调用doAdd)。TextInput和TextArea都通过onChange回调将输入值同步到@State变量,确保表单数据与UI输入实时同步。按钮行采用layoutWeight(1)等分宽度+8圆角+38高度的设计,"取消"和"发布"两个按钮通过Button()+Text()的叠加实现(Text使用margin负值偏移覆盖在Button上),这是ArkUI中实现"带文字按钮"的一种技巧。

11.3 editModalBody:编辑沙盘单弹框

editModalBody是编辑沙盘单弹框的内容卡片,结构与addModalBody类似但表单字段不同。包含标题"编辑沙盘单"、三个输入框——TextInput"沙盘名称"(绑定editName)、TextInput"状态(如铺轨中)“(绑定editStyle)、TextArea"沙盘内容与备注说明”(绑定editNote,高度70)、双按钮行(“取消”/“保存”,保存按钮调用doEdit)。三个表单字段在openEdit时被预填为当前编辑沙盘的name、status、item值,使得用户在编辑时能看到当前值。doEdit调用splice替换指定索引的沙盘项,新项保留了原id但使用了表单字段的值,progress被硬编码为40。

11.4 delModalBody:删除确认弹框

delModalBody是删除确认弹框的内容卡片,通过delTarget变量的值动态切换文案。标题根据delTarget显示"取消沙盘订单"或"退出驾驶课堂",说明文案根据delTarget显示"将取消首笔沙盘订单并按工序退费,确认执行?“或"将退出首门进行中的课程,确认执行?”。双按钮行中"确认"按钮使用danger红褐色背景(区别于addModalBody和editModalBody的primary蓝背景),调用doDel执行删除。这种"单弹框双语义"的设计通过delTarget变量实现了模板方法模式——弹框的结构模板固定,文案内容根据变量动态切换,一个组件服务于两种删除场景。


十二、bottomBar底部导航栏

@Builder
bottomBar() {
  Row() {
    ForEach(NAV_LIST, (it: NavItem, idx: number) => {
      Column({ space: 2 }) {
        Text(it.icon)
          .fontSize(20)
          .opacity(this.mainTab === idx ? 1 : 0.55)
        Text(it.label)
          .fontSize(10)
          .fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.mainTab = idx; })
    }, (it: NavItem) => it.label)
  }
  .width('100%').height(56)
  .backgroundColor(COLORS.cardBg)
  .borderRadius({ topLeft: 16, topRight: 16 })
}

bottomBar Builder实现了底部4主tab导航栏。NAV_LIST通过ForEach遍历渲染4个导航项,每项是一个Column(图标+标签,space 2间距)。选中项的图标opacity为1(完全不透明)、标签Bold加粗、primary铁道蓝色;未选中项的图标opacity为0.55(半透明,视觉弱化)、标签Normal正常、textHint浅灰色。每个导航项使用layoutWeight(1)等分宽度,点击时更新mainTab值触发页面切换。整个bottomBar使用cardBg白色背景、56高度、顶部16圆角(topLeft和topRight),形成了"浮在内容上方的圆角底栏"的视觉效果。顶部圆角设计使得bottomBar与内容区之间形成了柔和的视觉过渡,避免了直角带来的生硬感。


十三、mainContent主内容区与build()入口

13.1 mainContent:内容路由

@Builder
mainContent() {
  Column() {
    this.header()
    if (this.mainTab === 0) {
      this.subNav()
      Scroll() {
        Column() {
          if (this.subTab === 0) { this.pageFeatured() }
          else if (this.subTab === 1) { this.pageLocos() }
          else if (this.subTab === 2) { this.pageDioramas() }
          else if (this.subTab === 3) { this.pageWorkshop() }
          else if (this.subTab === 4) { this.pageCourse() }
          else if (this.subTab === 5) { this.pageCircle() }
          else { this.pageGear() }
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    } else if (this.mainTab === 1) {
      Scroll() { Column() { this.pageLocos() } ... }
    } else if (this.mainTab === 2) {
      Scroll() { Column() { this.pageCourse() } ... }
    } else {
      Scroll() { Column() { this.pageMine() } ... }
    }
  }
  .width('100%').height('100%')
}

mainContent Builder实现了"header+tab内容"的内容路由架构。首先调用this.header()渲染头部(品牌行+沙盘进度卡+横幅+数据四格),然后根据mainTab值路由到4个主tab内容。mainTab=0(首页)时,先调用this.subNav()渲染描边药丸式子导航,再通过Scroll容器包裹嵌套的条件链——subTab 0-6分别路由到pageFeatured/pageLocos/pageDioramas/pageWorkshop/pageCourse/pageCircle/pageGear 7个页面Builder,else分支默认渲染pageGear(对应subTab=6零件库)。mainTab=1(模型)时直接渲染pageLocos,mainTab=2(课堂)时直接渲染pageCourse,mainTab=3(我的)时渲染pageMine。所有非首页tab的内容都通过Scroll容器(scrollable Vertical、scrollBar Off隐藏滚动条、layoutWeight 1占据header下方的全部剩余空间、padding left/right 14左右边距+bottom 20底部留白)实现了垂直滚动能力。这种"每个tab独立Scroll"的架构确保了各tab内容的滚动位置独立维护,切换tab时不会互相影响。首页tab的嵌套条件链通过if-else if-else结构实现了7路分支,确保了所有subTab值都有对应的页面内容。

13.2 build():应用UI入口

build() {
  Stack() {
    Column()
      .width('100%').height('100%')
      .backgroundColor(COLORS.bg)
    this.fxLayer()
    Column() {
      this.mainContent()
      this.bottomBar()
    }
    .width('100%').height('100%')
    if (this.addOpen) {
      Stack() {
        this.modalOverlay()
        Column() { this.addModalBody() }
          .width('88%').constraintSize({ maxHeight: '80%' })
          .borderRadius(16).zIndex(999)
      }
      .width('100%').height('100%')
    }
    if (this.editOpen) { ... }
    if (this.delOpen) { ... }
  }
  .width('100%').height('100%')
}

build()方法是整个应用的UI入口,它采用Stack分层架构组织了四层视觉内容。第一层是背景层——一个全屏Column使用COLORS.bg暖灰色#F1F0EC填充,为整个应用提供统一的底色。第二层是特效层——this.fxLayer()渲染信号灯闪烁和蒸汽烟团上飘动画粒子,通过hitTestBehavior(HitTestMode.None)穿透触摸事件。第三层是内容层——一个全屏Column包含mainContent(header+各页面内容)和bottomBar(底部导航),是用户交互的主要区域。第四层是弹框层——三个条件渲染块(addOpen/editOpen/delOpen为true时渲染),每个弹框是一个Stack包含modalOverlay遮罩层和modalBody内容卡片,内容卡片使用88%宽度、80%最大高度约束、16圆角、zIndex 999确保浮于所有内容之上。这四层从下到上依次堆叠,形成了一个"背景→氛围→内容→弹窗"的视觉层级体系。Stack的层叠特性使得后声明的子元素自动覆盖先声明的子元素,而hitTestBehavior的设置确保了各层之间的触摸事件不会互相干扰。这种架构模式是ArkUI声明式UI中处理"复杂层叠界面"的经典范式。


十四、应用架构流程图

渲染错误: Mermaid 渲染失败: Parse error on line 37: ...List.splice删除] L[@Observed数据模型] --> ---------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

十五、结尾总结:设计模式与架构分析

15.1 设计模式总结

纵观整个应用代码,我们可以识别出多种经典设计模式的运用。首先是观察者模式——@Observed装饰器和@State变量的组合本质上就是观察者模式的框架级实现。数据模型是被观察者(Subject),UI组件是观察者(Observer),当被观察者的属性变化时,框架自动通知所有依赖该属性的观察者进行更新。这种模式使得数据与UI之间保持了单向数据流——数据变化驱动UI更新,而UI交互(如点击按钮)通过修改数据来间接触发UI更新,形成了一个清晰的闭环。

其次是策略模式——statusColor、statusProgress、locoTagColor、levelColor、locoColor、barH等12个纯函数实质上是一组策略函数,它们将"状态/产品名/等级到视觉表现"的映射逻辑从UI组件中解耦出来,封装为独立的可替换策略。如果未来需要调整颜色映射规则,只需修改函数实现而无需触碰UI代码。例如,要将"铺轨中"状态的颜色从铁道蓝改为紫色,只需在statusColor函数中修改一个条件分支的返回值,所有引用该函数的UI组件都会自动应用新颜色。

第三是建造者模式——@Builder装饰器将复杂的UI结构拆分为多个可复用的构建方法,每个方法负责构建UI的一个局部。build()方法作为总建造者,通过调用各@Builder方法组装出完整的界面。这种模式使得UI代码的复杂度得到了有效控制——近1883行代码被拆分为近20个职责单一的Builder方法,每个方法的代码量在30-200行之间,可读性和可维护性都得到了保障。

第四是模板方法模式——三个弹框(addModalBody、editModalBody、delModalBody)虽然内容不同,但都遵循"标题+表单字段+双按钮行"的统一结构模板。它们共享modalOverlay遮罩层和build()中的条件渲染+Stack包装逻辑,形成了弹框组件的统一模板。delModalBody更是通过delTarget变量的值动态切换文案,一个组件服务于两种删除场景。这种模式确保了弹框交互体验的一致性——用户在任何弹框中都能通过相同的视觉布局和操作路径(点击遮罩关闭/点击按钮执行)完成任务。

15.2 铁道模型沙盘UI设计分析

从产品设计角度审视,这套UI设计在"垂直领域专业性"与"大众易用性"之间取得了精妙的平衡。一方面,应用深入铁道模型这一小众工业爱好领域的肌理——从蒸汽机车、电力机车、内燃机车、动车组的车型区分到HO/N/O/G比例的模型规格,从柔性轨道、道岔、道砟、枕木的物料体系到DCC解码器、轨道清洁车、造型石膏、夜灯灯珠的配件清单,从"设计稿→铺轨→布景→接线→试运行→已发货"的沙盘订单流转到"比例入门→路基铺设→微场景做旧→DCC控车→电路信号"的课程体系,每一个数据字段和文案都体现了对铁道模型文化的深度理解。另一方面,应用的交互模式借鉴了成熟的电商和社区应用范式——底部4主tab导航、首页描边药丸式子导航、机车橱窗卡片、订单进度追踪、购物车角标、运转会招募横幅、社区动态流、个人数据档案,这些都是用户已经熟悉的交互模式,使得即使是第一次接触铁道模型的用户也能快速上手。

铁道蓝+红色机车的色彩系统是这套UI设计中最具辨识度的视觉特征。铁道蓝色调直接呼应了铁路调度信号、机车驾驶室仪表盘、铁路控制中心的钢蓝色调,让数字界面在视觉上与铁路工业产生了通感连接。红色机车作为冷暖对比的强调色,不仅提供了视觉层次,更隐喻了机车锅炉涂装、制动信号红光、调车停车信号的警示属性。而rail铁轨色作为特殊装饰色,被用于蒸汽烟团特效粒子、沙盘进度卡按钮、调度标签背景、“试运行"状态标签,在细节处不断强化"铁轨"这一核心意象。这种"色彩即品牌"的设计思路,使得应用即使在没有logo和文字提示的情况下,仅凭色彩调性就能被识别为"与铁道相关的应用”。

15.3 架构优点分析

应用的架构设计具有四个显著优点。第一是状态管理清晰——16个@State变量按功能分组(导航、特效、弹框、数据),每个变量的职责单一明确,不会出现一个变量同时控制多个不相关UI区域的情况。第二是组件复用充分——@Builder方法的拆分使得header、subNav、bottomBar等公共组件可以在多个页面间复用,modalOverlay遮罩层被三个弹框共享,纯函数被多个页面Builder调用,LOCO_LIST数据在pageLocos、pageFeatured、pageWorkshop三个页面中以不同形态呈现。第三是特效与交互解耦——fxLayer特效层通过hitTestBehavior(HitTestMode.None)实现了与内容层的完全解耦,特效的运行不会影响用户的任何交互操作,这种设计让"视觉丰富度"和"交互可用性"不再是一对矛盾。第四是数据驱动UI——所有页面内容都直接绑定到@Observed数据模型或@State数组,UI的变化完全由数据变化驱动,不存在任何"手动调用刷新"的命令式代码。

15.4 可扩展性分析

从可扩展性角度评估,这套架构为未来的功能扩展预留了充足的空间。新增一个底部tab只需要在NAV_LIST数组中添加一项,并在mainContent的条件链中添加一个分支。新增一个首页内容tab只需要在SUB_NAV_LIST数组中添加一项,并在mainContent的嵌套条件链中添加对应的页面Builder调用。新增一个数据模型只需要定义一个@Observed类并创建对应的静态数据数组。新增一种沙盘状态只需要在statusColor和statusProgress两个函数中各添加一个条件分支。新增一种弹框只需要定义一个@State布尔变量、一个modalBody Builder和在build()的条件渲染中添加一个分支。这种"数组驱动+条件路由"的架构模式,使得功能扩展的成本与功能数量呈线性关系而非指数关系,具备良好的可维护性和可演化性。

当然,当前架构也存在一些可以优化的方向。随着功能增长,单文件的结构可能会导致代码量过大(当前已近1883行),可以考虑将各页面Builder拆分为独立的子组件文件。纯函数可以进一步抽象为工具类或服务模块,便于单元测试。特效层的粒子数量和运动参数可以通过配置对象外部化,支持运行时调整特效强度。弹框系统可以抽象为通用的Modal组件,减少重复代码。doDel方法中delTarget为’course’时实际删除postList而非COURSE_LIST的语义偏差应当在真实应用中修正。这些优化方向并不影响当前架构的正确性和可用性,而是在应用规模增长到一定程度后的自然演进路径。


十六、数据模型对比表

维度PostItem 轨友动态LocoItem 机车模型DioramaItem 沙盘订单GearItem 配件零件
装饰器@Observed@Observed@Observed@Observed
字段数量6654
idnumber 唯一标识number 唯一标识number 唯一标识number 唯一标识
名称字段nick 昵称name 机车名name 沙盘名name 配件名
内容字段text 动态正文type 特性描述item 沙盘内容desc 配件描述
视觉字段avatar emoji头像level 难度等级status 状态icon emoji图标
数值字段likes 点赞数hot 人气值progress 进度
价格字段price 价格字符串
默认值策略全字段默认值全字段默认值全字段默认值全字段默认值
静态数据POST_LIST 8条LOCO_LIST 8条DIORAMA_LIST 8条GEAR_LIST 8条
可编辑性可新增/可删除只读可编辑/可删除只读
关联纯函数locoTagColor/levelColorstatusColor/statusProgress
使用页面pageFeatured/pageCircle/pageMinepageLocos/pageFeatured/pageWorkshoppageDioramas/pageMine/headerpageWorkshop/pageGear
ForEach键生成id.toString()id.toString()id.toString()id.toString()
交互入口发轨语弹框加入购物车编辑沙盘单弹框加购
进度条关联hot%人气进度statusProgress%搭建进度
颜色关联avatar独立emojilocoTag/level双标签status单标签icon独立emoji
图表关联SCALE_CHART比例构成
特效关联
动力类型分类蒸汽/电力/内燃三系
状态生命周期设计稿→铺轨→布景→接线→试运行→已发货

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 场景:铁道模型机车市集 / 沙盘场景定制搭建 / 铺轨驾驶课程 / 模型铁道社区
// 底部4主tab(首页/模型/课堂/我的)+ 首页7内容tab(描边药丸式)
// 特效:信号灯闪烁 + 蒸汽烟团上飘(特效层位于底层,不遮挡点击)
// 弹框:新增(发轨语) / 编辑(编辑沙盘单) / 删除(取消沙盘·退课)

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  rail: string;
}

const COLORS: ColorPalette = {
  primary: '#3D5A80',
  primaryLight: '#D8E2EE',
  primaryDark: '#274060',
  accent: '#C0564F',
  accentLight: '#F2DBD8',
  bg: '#F1F0EC',
  cardBg: '#FFFFFF',
  textPrimary: '#2C3440',
  textSecondary: '#66707E',
  textHint: '#A4ACB6',
  border: '#E0DFD8',
  success: '#7BA884',
  warning: '#D9A54E',
  danger: '#C76B5A',
  white: '#FFFFFF',
  rail: '#8B9DAF'
};

// ============ 轨友动态数据模型 ============
@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  text: string = ''
  time: string = ''
  likes: number = 0
  constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
    this.time = time; this.likes = likes
  }
}

// ============ 机车模型数据模型 ============
@Observed
export class LocoItem {
  id: number = 0
  name: string = ''
  type: string = ''
  price: string = ''
  level: string = ''
  hot: number = 0
  constructor(id: number, name: string, type: string, price: string, level: string, hot: number) {
    this.id = id; this.name = name; this.type = type; this.price = price
    this.level = level; this.hot = hot
  }
}

// ============ 沙盘订单数据模型 ============
@Observed
export class DioramaItem {
  id: number = 0
  name: string = ''
  item: string = ''
  status: string = ''
  progress: number = 0
  constructor(id: number, name: string, item: string, status: string, progress: number) {
    this.id = id; this.name = name; this.item = item; this.status = status
    this.progress = progress
  }
}

// ============ 零件数据模型 ============
@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

// ============ 图表与课程接口 ============
interface WeekChartItem {
  label: string;
  value: number;
}

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

interface CourseItem {
  title: string;
  week: string;
  progress: number;
  color: string;
}

interface ClubTypeItem {
  name: string;
  desc: string;
  icon: string;
  hot: boolean;
}

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '蒸汽机车迷', '🚂', '新到的 HO 蒸汽机车,烟效一开整个房间都是味道。', '9分钟前', 122),
  new PostItem(2, '沙盘搭建党', '🏞️', '城市环线隧道口用了石膏塑形,真实感直接拉满。', '21分钟前', 108),
  new PostItem(3, '数码控车派', '🎮', 'DCC 声音解码器刷了第三方音效,气笛声太还原了。', '37分钟前', 96),
  new PostItem(4, '轨语主理人', '🛤️', '周末运转会招募中,带上你的机车来环线跑班。', '1小时前', 88),
  new PostItem(5, '微场景手作者', '🏘️', '一排纸板小楼刷了做旧漆,像 80 年代的老街区。', '2小时前', 75),
  new PostItem(6, '轨道打磨工', '🔧', '道岔接点氧化会导致跳车,定期清洁真的重要。', '3小时前', 69),
  new PostItem(7, '老模修复师', '🛠️', '修复一台 1978 年的铜质机车,齿轮居然还能用。', '5小时前', 64),
  new PostItem(8, '运转会主持', '🏛️', '本周运转日 22 人到场,环线排图排到了晚上十点。', '昨天', 72)
];

const LOCO_LIST: LocoItem[] = [
  new LocoItem(1, '蒸汽机车 · 人民型', '烟效 + 声音', '¥688/台', '进阶', 95),
  new LocoItem(2, '电力机车 · 韶山型', '受电弓可动', '¥528/台', '进阶', 92),
  new LocoItem(3, '内燃机车 · 东风型', '双马达牵引', '¥598/台', '进阶', 87),
  new LocoItem(4, '动车组 · 复兴号', '八编整组', '¥1088/组', '大师', 84),
  new LocoItem(5, '小运转调车机', '轨道车作业款', '¥368/台', '入门', 78),
  new LocoItem(6, '窄轨登山机车', '齿轨轮款', '¥488/台', '定制', 73),
  new LocoItem(7, '客车车厢 · 25G', '三节套装', '¥288/套', '入门', 66),
  new LocoItem(8, '货运敞车 · 六轴', '载重仿真', '¥168/辆', '入门', 60)
];

const DIORAMA_LIST: DioramaItem[] = [
  new DioramaItem(1, '1:87 城市环线 · 2.4m', '双层高架 + 隧道', '已发货', 100),
  new DioramaItem(2, '山岳支线 · 1.2m', '展线 + 桥梁', '铺轨中', 80),
  new DioramaItem(3, '港口编组站 · 1.8m', '道岔群 + 货区', '布景中', 62),
  new DioramaItem(4, '老街区微景 · 0.6m', '做旧楼房 + 夜灯', '接线中', 48),
  new DioramaItem(5, '雪国线 · 1.5m', '造雪 + 冻湖', '试运行', 34),
  new DioramaItem(6, '儿童启蒙盘 · 0.8m', '环形 + 隧道山', '布景中', 24),
  new DioramaItem(7, '俱乐部公共盘 · 3m', '分段共建', '设计稿', 16),
  new DioramaItem(8, '婚庆定制盘 · 1.0m', '刻字 + 场景还原', '待付尾款', 8)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '柔性轨道', 'HO 镍银 2m', '🛤️'),
  new GearItem(2, '道岔', '电动左开 #6', '🔀'),
  new GearItem(3, '道砟颗粒', '仿真 500g', '🪨'),
  new GearItem(4, '枕木贴条', '自粘软木', '🪵'),
  new GearItem(5, 'DCC 解码器', '带声音芯片', '📟'),
  new GearItem(6, '轨道清洁车', '双向打磨', '🧹'),
  new GearItem(7, '造型石膏', '塑形 2kg', '🧱'),
  new GearItem(8, '夜灯灯珠', '暖光 3V 组', '💡')
];

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 3 },
  { label: '周二', value: 5 },
  { label: '周三', value: 6 },
  { label: '周四', value: 7 },
  { label: '周五', value: 9 },
  { label: '周六', value: 13 },
  { label: '周日', value: 12 }
];

const SCALE_CHART: ScaleChartItem[] = [
  { label: 'HO 1:87', value: 38, color: '#3D5A80' },
  { label: 'N 1:160', value: 24, color: '#C0564F' },
  { label: 'O 1:48', value: 16, color: '#D9A54E' },
  { label: 'G 花园级', value: 12, color: '#7BA884' },
  { label: '其他比例', value: 10, color: '#8B9DAF' }
];

const TYPE_LIST: ClubTypeItem[] = [
  { name: '沙盘定制', desc: '量尺设计 · 全程搭建', icon: '🏞️', hot: true },
  { name: '机车改装', desc: 'DCC · 灯光 · 做旧', icon: '🔧', hot: true },
  { name: '老模修复', desc: '齿轮 · 漆面 · 走行', icon: '🛠️', hot: false },
  { name: '运转会租线', desc: '俱乐部环线排班', icon: '🚉', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '铁道模型比例入门', week: '第 1 周', progress: 100, color: '#3D5A80' },
  { title: '路基铺设与道砟固定', week: '第 2 周', progress: 84, color: '#C0564F' },
  { title: '微场景建筑做旧', week: '第 3 周', progress: 52, color: '#D9A54E' },
  { title: 'DCC 数字控车实操', week: '第 4 周', progress: 26, color: '#7BA884' },
  { title: '沙盘电路与信号系统', week: '第 5 周', progress: 6, color: '#8B9DAF' }
];

// ============ 导航配置 ============
interface NavItem {
  icon: string;
  label: string;
}

const NAV_LIST: NavItem[] = [
  { icon: '🚂', label: '首页' },
  { icon: '🛤️', label: '模型' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '机车橱窗', '我的沙盘', '铺轨工坊', '驾驶课堂', '轨友圈', '零件库'];

// ============ 顶层纯函数 ============
function barH(v: number, max: number): number {
  return Math.round(118 * v / max);
}

function locoColor(v: number): string {
  if (v > 9) {
    return '#3D5A80';
  } else if (v > 6) {
    return '#C0564F';
  }
  return '#D9A54E';
}

function signalA(tick: number, i: number): number {
  return 0.1 + ((tick + i) % 4) * 0.05;
}

function signalX(tick: number, i: number): number {
  return 50 + ((tick * 3 + i * 193) % 610);
}

function signalY(tick: number, i: number): number {
  return 60 + ((tick * 5 + i * 167) % 620);
}

function signalS(tick: number, i: number): number {
  return 4 + ((tick + i * 2) % 3) * 2;
}

function signalColor(tick: number, i: number): string {
  if ((tick + i) % 2 === 0) {
    return '#C0564F';
  }
  return '#7BA884';
}

function smokeA(tick: number, i: number): number {
  return 0.06 + ((tick + i) % 5) * 0.03;
}

function smokeX(tick: number, i: number): number {
  return 60 + ((tick * 4 + i * 199) % 600);
}

function smokeY(tick: number, i: number): number {
  return 70 + ((tick * 6 + i * 173) % 610);
}

function smokeS(tick: number, i: number): number {
  return 6 + ((tick + i) % 4) * 3;
}

function statusColor(s: string): string {
  if (s === '已发货') {
    return '#7BA884';
  } else if (s === '铺轨中') {
    return '#3D5A80';
  } else if (s === '布景中') {
    return '#C0564F';
  } else if (s === '接线中') {
    return '#D9A54E';
  } else if (s === '试运行') {
    return '#8B9DAF';
  }
  return '#A4ACB6';
}

function statusProgress(s: string): number {
  if (s === '已发货') {
    return 100;
  } else if (s === '铺轨中') {
    return 80;
  } else if (s === '布景中') {
    return 62;
  } else if (s === '接线中') {
    return 46;
  } else if (s === '试运行') {
    return 32;
  }
  return 12;
}

function locoTagColor(g: string): string {
  if (g.indexOf('蒸汽') >= 0 || g.indexOf('窄轨') >= 0) {
    return '#C0564F';
  } else if (g.indexOf('电力') >= 0 || g.indexOf('动车') >= 0) {
    return '#3D5A80';
  } else if (g.indexOf('内燃') >= 0 || g.indexOf('调车') >= 0) {
    return '#D9A54E';
  }
  return '#7BA884';
}

function levelColor(s: string): string {
  if (s === '入门') {
    return '#7BA884';
  } else if (s === '进阶') {
    return '#3D5A80';
  } else if (s === '定制') {
    return '#D9A54E';
  }
  return '#C0564F';
}

@Entry
@Component
struct PageRailModelClub {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'diorama'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State dioramaList: DioramaItem[] = DIORAMA_LIST
  @State fxTimer: number = -1

  aboutToAppear(): void {
    this.fxTimer = setInterval(() => {
      this.tick = this.tick + 1;
      this.glow = (this.glow + 1) % 2;
    }, 90);
  }

  aboutToDisappear(): void {
    if (this.fxTimer > 0) {
      clearInterval(this.fxTimer);
      this.fxTimer = -1;
    }
  }

  openAdd(): void {
    this.addTitle = '';
    this.addContent = '';
    this.addOpen = true;
  }

  openEdit(index: number): void {
    if (index >= 0 && index < this.dioramaList.length) {
      this.editIdx = index;
      this.editName = this.dioramaList[index].name;
      this.editStyle = this.dioramaList[index].status;
      this.editNote = this.dioramaList[index].item;
    }
    this.editOpen = true;
  }

  openDelA(): void {
    this.delTarget = 'diorama';
    this.delOpen = true;
  }

  openDelB(): void {
    this.delTarget = 'course';
    this.delOpen = true;
  }

  doAdd(): void {
    if (this.addTitle.length > 0) {
      this.postList.unshift(new PostItem(999, '我的轨语', '🚂', this.addTitle, '刚刚', 0));
    }
    this.addOpen = false;
  }

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.dioramaList.length) {
      this.dioramaList.splice(this.editIdx, 1, new DioramaItem(this.dioramaList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
    }
    this.editOpen = false;
  }

  doDel(): void {
    if (this.delTarget === 'diorama' && this.dioramaList.length > 0) {
      this.dioramaList.splice(0, 1);
    } else if (this.delTarget === 'course' && this.postList.length > 0) {
      this.postList.splice(0, 1);
    }
    this.delOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(signalS(this.tick, i))
          .height(signalS(this.tick, i))
          .borderRadius(signalS(this.tick, i) / 2)
          .backgroundColor(signalColor(this.tick, i))
          .opacity(signalA(this.tick, i))
          .translate({ x: signalX(this.tick, i), y: signalY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(smokeS(this.tick, i))
          .height(smokeS(this.tick, i))
          .borderRadius(smokeS(this.tick, i) / 2)
          .backgroundColor(COLORS.rail)
          .opacity(smokeA(this.tick, i))
          .translate({ x: smokeX(this.tick, i), y: smokeY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      // 品牌行 + 购物车
      Row({ space: 10 }) {
        Text('🚂')
          .fontSize(24)
        Column({ space: 1 }) {
          Text('轨语铁模俱乐部')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('城市环线 · 本周日运转会')
            .fontSize(10)
            .fontColor(COLORS.primary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Stack() {
          Text('🧺')
            .fontSize(20)
          Text('3')
            .fontSize(8)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 4, right: 4, top: 1, bottom: 1 })
            .backgroundColor(COLORS.danger)
            .borderRadius(8)
            .translate({ x: 10, y: -10 })
        }
        .width(30)
        .height(30)
      }
      .width('100%')

      // 沙盘进度卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('1:87 城市环线 · 试运行中')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('2.4m 双层高架 · 三列车同时在跑')
            .fontSize(10)
            .fontColor('#D7E0EC')
          Row({ space: 6 }) {
            Text('调度 5/8 班')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.rail)
              .borderRadius(6)
            Text('信号系统已联锁')
              .fontSize(9)
              .fontColor('#D7E0EC')
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('查看沙盘')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDark)
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .backgroundColor(COLORS.rail)
          .borderRadius(14)
          .onClick(() => {
            this.openEdit(0);
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.primary)
      .borderRadius(16)

      // 运转会招募横幅
      Row({ space: 10 }) {
        Text('🚉')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('周日运转会 · 招募跑班车长')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('公共盘 3m 环线 · 免台费 · 送道砟包')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('报名')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('在制沙盘')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('2 座')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('机车藏品')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('23 台')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('运营里程')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('412 m')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('轨币')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('¥84')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 10, bottom: 4 })
  }

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 10 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Text(item)
            .fontSize(this.subTab === idx ? 13 : 11)
            .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx ? COLORS.primary : COLORS.textSecondary)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(this.subTab === idx ? COLORS.primaryLight : COLORS.cardBg)
            .borderRadius(16)
            .border({
              width: this.subTab === idx ? 1.5 : 1,
              color: this.subTab === idx ? COLORS.primary : COLORS.border,
              radius: 16
            })
            .onClick(() => {
              this.subTab = idx;
            })
        }, (item: string) => item)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .padding({ top: 6, bottom: 10 })
  }

  @Builder
  pageFeatured() {
    Column({ space: 12 }) {
      // 玩法四格
      Column() {
        Text('在轨语能玩什么')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
          .margin({ bottom: 10 })
        Row({ space: 8 }) {
          ForEach(TYPE_LIST, (it: ClubTypeItem) => {
            Column({ space: 4 }) {
              Text(it.icon)
                .fontSize(24)
              Text(it.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.desc)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(it.hot ? '热门' : ' ')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(it.hot ? COLORS.danger : COLORS.border)
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 8, left: 4, right: 4 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
            .alignItems(HorizontalAlign.Center)
          }, (it: ClubTypeItem) => it.name)
        }
        .width('100%')
      }
      .width('100%')

      // 周运转柱状图
      Column() {
        Row() {
          Text('俱乐部本周运转场次')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('单位 场')
            .fontSize(10)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        Row({ space: 10 }) {
          ForEach(WEEK_CHART, (it: WeekChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(20)
                .height(barH(it.value, 13))
                .borderRadius(5)
                .backgroundColor(locoColor(it.value))
              Text(`${it.value}`)
                .fontSize(9)
                .fontColor(COLORS.textSecondary)
              Text(it.label)
                .fontSize(9)
                .fontColor(COLORS.textHint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }, (it: WeekChartItem) => it.label + it.value.toString())
        }
        .width('100%')
        .alignItems(VerticalAlign.Bottom)
        .height(140)
        .margin({ top: 12 })
        Row({ space: 6 }) {
          Text('周末运转占比')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('59%')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 比例偏好构成
      Column() {
        Text('会员模型比例构成')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
        ForEach(SCALE_CHART, (it: ScaleChartItem) => {
          Row({ space: 8 }) {
            Text(it.label)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(70)
            Stack({ alignContent: Alignment.Start }) {
              Row()
                .width('100%')
                .height(8)
                .borderRadius(4)
                .backgroundColor(COLORS.border)
              Row()
                .width(`${it.value}%`)
                .height(8)
                .borderRadius(4)
                .backgroundColor(it.color)
            }
            .layoutWeight(1)
            Text(`${it.value}%`)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .width(44)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
        }, (it: ScaleChartItem) => it.label)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 人气机车双列
      Column() {
        Row() {
          Text('人气机车模型')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('查看全部')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .margin({ bottom: 10 })
        ForEach(LOCO_LIST, (it: LocoItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < LOCO_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🚂')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(LOCO_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(LOCO_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(LOCO_LIST[idx].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${LOCO_LIST[idx].price} · 热度 ${LOCO_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < LOCO_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🚃')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(LOCO_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(LOCO_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(LOCO_LIST[idx + 1].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${LOCO_LIST[idx + 1].price} · 热度 ${LOCO_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: LocoItem) => it.id.toString())
      }
      .width('100%')

      // 轨友动态
      Column({ space: 10 }) {
        Row() {
          Text('轨友动态')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('发轨语')
            .fontSize(12)
            .fontColor(COLORS.primary)
            .onClick(() => {
              this.openAdd();
            })
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row({ space: 10 }) {
            Text(it.avatar)
              .fontSize(22)
            Column({ space: 3 }) {
              Row() {
                Text(it.nick)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.time)
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
              Text(it.text)
                .fontSize(12)
                .fontColor(COLORS.textSecondary)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(`🚉 ${it.likes}`)
                .fontSize(11)
                .fontColor(COLORS.textHint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  pageLocos() {
    Column({ space: 10 }) {
      Row() {
        Text('机车橱窗')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('开始定制')
          .fontSize(12)
          .fontColor(COLORS.primary)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      ForEach(LOCO_LIST, (it: LocoItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(locoTagColor(it.name))
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.level)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(levelColor(it.level))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(6)
              }
              .width('100%')
              Row({ space: 8 }) {
                Text(`💰 ${it.price}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text(`⚙️ ${it.type}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${it.hot}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(locoTagColor(it.name))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`人气值 ${it.hot}`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('加入购物车')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(8)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: LocoItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageDioramas() {
    Column({ space: 10 }) {
      Text('我的沙盘')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.dioramaList, (it: DioramaItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text('🏞️')
              .fontSize(24)
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(statusColor(it.status))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.accentLight)
                  .borderRadius(6)
              }
              .width('100%')
              Text(`🧾 ${it.item}`)
                .fontSize(11)
                .fontColor(COLORS.textSecondary)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${statusProgress(it.status)}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(statusColor(it.status))
          }
          .width('100%')
          Row() {
            Text(`搭建进度 ${statusProgress(it.status)}%`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('编辑沙盘单')
              .fontSize(11)
              .fontColor(COLORS.primary)
              .onClick(() => {
                this.openEdit(idx);
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: DioramaItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageWorkshop() {
    Column({ space: 10 }) {
      Text('铺轨工坊')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('当前工位')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('搭建台 D2')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('沙盘规格')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('港口编组')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('工序')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('铺道砟')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('今日工时')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('5.0 h')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(LOCO_LIST.slice(0, 5), (it: LocoItem, idx: number) => {
        Row({ space: 10 }) {
          Text(`${idx + 1}`)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
            .width(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(`${it.type} · ${it.price}`)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text(`人气 ${it.hot}`)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(locoTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: LocoItem) => it.id.toString())
      Text('零件速购')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 4 })
      ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
        if (idx % 2 === 0) {
          Row({ space: 10 }) {
            if (idx < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('加购')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding(12)
              .backgroundColor(COLORS.cardBg)
              .borderRadius(12)
            }
            if (idx + 1 < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx + 1].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx + 1].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx + 1].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('加购')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding(12)
              .backgroundColor(COLORS.cardBg)
              .borderRadius(12)
            }
          }
          .width('100%')
        }
      }, (it: GearItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageCourse() {
    Column({ space: 10 }) {
      Text('驾驶课堂')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(COURSE_LIST, (it: CourseItem) => {
        Column({ space: 8 }) {
          Row() {
            Text(it.title)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
            Text(it.week)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(8)
              .borderRadius(4)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${it.progress}%`)
              .height(8)
              .borderRadius(4)
              .backgroundColor(it.color)
          }
          .width('100%')
          Row() {
            Text('已学')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(`${it.progress}%`)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: CourseItem) => it.title)
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelB();
        })
      Text('退课')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -28, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageCircle() {
    Column({ space: 10 }) {
      Row() {
        Text('轨友圈')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('发轨语')
          .fontSize(12)
          .fontColor(COLORS.primary)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      ForEach(this.postList, (it: PostItem) => {
        Row({ space: 10 }) {
          Text(it.avatar)
            .fontSize(26)
          Column({ space: 4 }) {
            Row() {
              Text(it.nick)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text(it.time)
                .fontSize(10)
                .fontColor(COLORS.textHint)
            }
            .width('100%')
            Text(it.text)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
            Text(`🚉 ${it.likes} · 💬 回复`)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: PostItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageGear() {
    Column({ space: 10 }) {
      Text('零件库')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 4 }) {
          Text('🛤️')
            .fontSize(28)
          Text('轨道区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('柔性轨 · 道岔')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('📟')
            .fontSize(28)
          Text('电控区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('DCC · 信号')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('🧱')
            .fontSize(28)
          Text('造景区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('石膏 · 道砟')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(GEAR_LIST, (it: GearItem) => {
        Row({ space: 10 }) {
          Text(it.icon)
            .fontSize(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.desc)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text('加购')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.primary)
            .borderRadius(8)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: GearItem) => it.id.toString())
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelA();
        })
      Text('取消沙盘')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -56, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageMine() {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Text('🚂')
          .fontSize(40)
        Column({ space: 4 }) {
          Text('轨语调度长')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('金扳手会员 · 藏车 23 台 · 运转 412m · 打卡 90 天')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的沙盘档案')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.dioramaList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.dioramaList.slice(0, 3), (it: DioramaItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: DioramaItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的轨语')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

  @Builder
  modalOverlay() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(0.6)
        .onClick(() => {
          this.addOpen = false;
          this.editOpen = false;
          this.delOpen = false;
        })
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('发布轨语')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '一句话工坊日常…', text: this.addTitle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.addTitle = v;
        })
      TextArea({ placeholder: '补充细节:机车、沙盘、运转心得…', text: this.addContent })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(90)
        .onChange((v: string) => {
          this.addContent = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.addOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doAdd();
          })
        Text('发布')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('编辑沙盘单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '沙盘名称', text: this.editName })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editName = v;
        })
      TextInput({ placeholder: '状态(如 铺轨中)', text: this.editStyle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editStyle = v;
        })
      TextArea({ placeholder: '沙盘内容与备注说明', text: this.editNote })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(70)
        .onChange((v: string) => {
          this.editNote = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.editOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doEdit();
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text(this.delTarget === 'diorama' ? '取消沙盘订单' : '退出驾驶课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'diorama' ? '将取消首笔沙盘订单并按工序退费,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.delOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.danger)
          .borderRadius(8)
          .onClick(() => {
            this.doDel();
          })
        Text('确认')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  bottomBar() {
    Row() {
      ForEach(NAV_LIST, (it: NavItem, idx: number) => {
        Column({ space: 2 }) {
          Text(it.icon)
            .fontSize(20)
            .opacity(this.mainTab === idx ? 1 : 0.55)
          Text(it.label)
            .fontSize(10)
            .fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          this.mainTab = idx;
        })
      }, (it: NavItem) => it.label)
    }
    .width('100%')
    .height(56)
    .backgroundColor(COLORS.cardBg)
    .borderRadius({ topLeft: 16, topRight: 16 })
  }

  @Builder
  mainContent() {
    Column() {
      this.header()
      if (this.mainTab === 0) {
        this.subNav()
        Scroll() {
          Column() {
            if (this.subTab === 0) {
              this.pageFeatured()
            } else if (this.subTab === 1) {
              this.pageLocos()
            } else if (this.subTab === 2) {
              this.pageDioramas()
            } else if (this.subTab === 3) {
              this.pageWorkshop()
            } else if (this.subTab === 4) {
              this.pageCourse()
            } else if (this.subTab === 5) {
              this.pageCircle()
            } else {
              this.pageGear()
            }
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 1) {
        Scroll() {
          Column() {
            this.pageLocos()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 2) {
        Scroll() {
          Column() {
            this.pageCourse()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else {
        Scroll() {
          Column() {
            this.pageMine()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      }
    }
    .width('100%')
    .height('100%')
  }

  build() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(COLORS.bg)
      this.fxLayer()
      Column() {
        this.mainContent()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      if (this.addOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.addModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
      if (this.editOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.editModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
      if (this.delOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.delModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
  }
}


在这里插入图片描述

从ColorPalette色彩系统的16个语义化字段,到四个@Observed数据模型的状态管理机制;从12个纯函数的计算逻辑,到近20个@Builder方法的UI构建能力;从fxLayer特效层的信号灯闪烁与蒸汽烟团上飘的hitTestBehavior穿透设计,到build()方法的四层Stack堆叠架构——每一个技术决策都体现了HarmonyOS 6.1.1 ArkUI声明式UI框架的能力边界与设计哲学。这套以铁道蓝+红色机车为色彩基调、以铁道模型沙盘搭建为核心业务、以信号灯闪烁和蒸汽烟团上飘为视觉氛围的应用代码,不仅是一个功能完整的产品实现,更是一份可供学习的ArkUI声明式UI开发实践范本。描边药丸式子导航的设计创新、rail铁轨色特殊装饰色的语义运用、signal与smoke双特效函数组的工业氛围模拟——这些细节共同构成了一个既专业又工业、既功能完整又视觉精良的垂直领域应用范本。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐