蒸汽火车博物馆应用:HarmonyOS ArkTS API 24架构深度解析,使用基础 Row、Column 布局组件和 layoutWeight 属性来实现条形图、排行榜、统计卡片等多种数据展示形式
引言

在移动应用开发领域,以铁路文化为主题的展示型应用一直是技术与艺术交汇的绝佳载体。本文将深入剖析一款基于 ArkTS 声明式 UI 框架构建的"蒸汽火车博物馆"应用,该应用以蒸汽时代的工业美学为视觉基调,融合了博物馆馆藏展示、线路运营管理、纪念品销售和游客互动评论等多种业务场景,完整地呈现了一个具有丰富交互的垂直领域应用的全貌。
从应用背景来看,蒸汽火车博物馆应用面向铁路爱好者、历史研究者、亲子游客和文化旅行者等多类用户群体。它不仅需要展示机车、车厢、线路等静态馆藏信息,还需要提供周乘务量统计、机车馆藏分布、热门线路排行、纪念品销量等多维度的数据可视化展示。同时,应用还内置了入库新机车、编辑线路信息、下架纪念品、查看车厢详情等管理操作弹窗,形成了一个兼具展示与管理功能的完整业务闭环。这种"展示+管理"的双层架构在博物馆类应用中非常常见,使得前端界面既具有信息呈现的丰富性,又具备业务操作的实用性。
在设计理念方面,该应用采用了"蒸汽朋克"与"工业怀旧"相结合的视觉风格。整个应用的色彩体系以深褐色为背景基调,模拟老式蒸汽机车的铸铁外壳质感;以铜金色作为主要强调色,呼应蒸汽时代黄铜配件的温暖光泽;以深蓝灰色作为辅助色调,象征铁轨延伸的冷峻工业感。这种色彩搭配不仅具有强烈的时代沉浸感,还能在深色主题下保证文字内容的良好可读性。此外,应用大量使用 Emoji 作为视觉符号,既节省了图片资源的加载开销,又增添了趣味性和辨识度,是轻量级移动应用中常见的图标策略。
在技术选型上,该应用基于华为 HarmonyOS 的 ArkTS 声明式 UI 框架进行开发。ArkTS 是 TypeScript 的超集,它在 TypeScript 的类型系统基础上增加了 @Entry、@Component、@State、@Prop、@ObjectLink、@Builder、@Observed 等装饰器,为声明式 UI 编程提供了完整的语言级支持。与传统的命令式 UI 编程不同,ArkTS 采用类似 Flutter 和 SwiftUI 的声明式范式,开发者只需描述界面的"当前状态",框架会自动处理状态变化时的 UI 更新。这种编程模型极大地简化了复杂界面的开发流程,使得代码更加简洁、可维护性更强。
在状态管理策略上,该应用采用了分层状态管理体系。最顶层是 @Observed 标记的 SteamData 类,它承载了所有业务数据(机车列表、车厢列表、线路列表、纪念品列表、评论列表),作为一个可观察的数据源。主入口组件通过 @State 持有该数据实例的引用,并通过 @ObjectLink 将其传递给各子组件。这种设计使得数据修改能够自动触发 UI 更新,无需手动调用刷新方法。同时,各弹窗的显示状态、动画状态等局部状态则使用 @State 在各自组件内部管理,实现了状态职责的合理划分。
在组件化策略上,该应用遵循"单一职责"原则,将界面拆分为一个主入口组件、五个内容页面组件、四个弹窗组件和一个标签组件。每个组件负责一个明确的业务功能域,组件之间通过属性传递和回调函数进行通信。这种松耦合的组件架构使得每个组件可以独立开发、测试和维护,也方便后续的功能扩展和重构。同时,通过 @Builder 修饰器定义可复用的 UI 构建方法,进一步提高了代码的复用率。
在数据可视化方面,该应用没有依赖任何第三方图表库,而是完全使用基础的 Row、Column 布局组件和 layoutWeight 属性来实现条形图、排行榜、统计卡片等多种数据展示形式。这种"原生图表"的实现方式不仅减少了应用的体积依赖,还保证了图表样式与整体 UI 风格的高度一致性。通过灵活运用 layoutWeight 实现按比例分配宽度,开发者可以精确地控制条形图的填充比例,模拟出专业图表库的数据可视化效果。
在色彩体系设计上,该应用定义了一个完整的 SteamPalette 接口,包含 19 个语义化命名的颜色字段,涵盖了主色、辅助色、背景色、卡片色、文字色、边框色、状态色等全链路的设计令牌。这些颜色值以十六进制字符串常量的形式集中管理,遵循"单一数据源"原则,使得主题修改只需修改一处即可全局生效。这种设计令牌(Design Token)的思路在现代前端工程中已被广泛采用,它将视觉设计与代码实现解耦,为未来的主题切换和设计系统升级奠定了基础。
组件关系与数据流总览

在深入代码之前,我们先通过流程图来理解整个应用的组件关系和数据流向。
组件架构关系图
状态管理数据流

###标签页切换状态机
逐段代码分析

第一段:SteamPalette 色彩接口定义
interface SteamPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
steamGold: string;
ironGray: string;
}
这段代码定义了一个名为 SteamPalette 的 TypeScript 接口,它构成了整个应用色彩体系的基础架构。接口中声明了 19 个字符串类型的字段,每一个字段代表一个语义化的设计令牌,而非简单的颜色名称。这种命名方式遵循了现代设计系统的最佳实践,通过"语义"而非"色值"来组织颜色,使得颜色与用途之间建立了明确的映射关系。
从架构设计角度来看,将色彩体系抽象为接口有三个显著优势。首先,它提供了编译时的类型安全保证,任何使用 COLORS 常量的地方都能获得 TypeScript 的智能提示和类型检查。其次,如果未来需要支持多主题或暗黑模式切换,只需实现一个新的符合该接口的对象即可,无需修改任何业务代码。第三,接口定义本身就是一份设计文档,开发者通过阅读接口就能快速理解应用使用了哪些语义化的颜色类别。
值得注意的是,该接口将颜色分为了几个清晰的层次:主色系列(primary/primaryLight/primaryDark)用于品牌识别和主要操作按钮;辅助色系列(accent/accentLight)用于次级交互元素;背景与卡片色(bg/cardBg/cardAlt)构成多层次的背景体系;文字色分为三级(Primary/Secondary/Hint)确保信息层级的清晰传达;状态色(success/warning/danger)用于反馈用户操作结果。此外还有 steamGold 和 ironGray 两个主题特色色,直接呼应蒸汽火车的金属质感。
第二段:COLORS 色彩常量实现

const COLORS: SteamPalette = {
primary: '#B0602A',
primaryLight: '#D89A66',
primaryDark: '#5C3A1E',
accent: '#3E5C76',
accentLight: '#9FB6C9',
bg: '#23201D',
cardBg: '#332E29',
cardAlt: '#2B2723',
textPrimary: '#F2E8DC',
textSecondary: '#C4B7A6',
textHint: '#8A7D6E',
border: '#4A423A',
line: '#403A33',
success: '#7FA65A',
warning: '#D9A441',
danger: '#C05B3C',
white: '#FFFFFF',
steamGold: '#D9A441',
ironGray: '#8B8B8B'
};
这段代码将前面定义的 SteamPalette 接口实例化为一个全局常量 COLORS,赋予了每个设计令牌具体的十六进制颜色值。这个常量在整个应用中作为唯一的色彩数据源,所有组件都通过引用 COLORS 来获取颜色值,实现了"单一数据源"的设计原则。
深入分析这些色值的选择逻辑,可以发现一套精心设计的蒸汽朋克色板。主色 #B0602A 是一种偏暗的铜橙色,它模拟了黄铜在氧化后的色调,既温暖又沉稳。主色浅版 #D89A66 在原色基础上提高了明度,用于悬浮态和次要文字。主色深版 #5C3A1E 则用于页头背景,营造出铸铁机车的厚重感。辅助色 #3E5C76 是一种深蓝灰色,象征铁轨和钢铁结构的冷峻质感。
背景体系采用了三层深色叠加的设计策略。bg #23201D 作为最深层的应用背景,几乎是深褐色到黑色的过渡。cardBg #332E29 作为卡片背景,比应用背景稍亮一个层次,使卡片内容能够从背景中浮出。cardAlt #2B2723 则用于卡片内的交替行背景,创造出类似斑马纹的视觉区分效果。这种三层次背景设计在深色主题应用中非常关键,它通过微小的明度差异来建立信息层次,避免了大量使用边框造成的视觉割裂感。
文字颜色采用了暖灰系列。textPrimary #F2E8DC 是一种温暖的米白色,在深色背景上具有极高的对比度,保证了主要信息的可读性。textSecondary #C4B7A6 是一种浅褐色,用于次级描述文字。textHint #8A7D6E 是更深一层的灰色,用于提示和辅助信息。这三级文字色搭配三层背景色,构成了 9 种可能的组合,为信息展示提供了丰富的层级表达空间。
第三段:SteamTab 枚举定义

enum SteamTab {
TRAIN = 0,
COACH = 1,
LINE = 2,
GIFT = 3,
REVIEW = 4
}
这段代码定义了一个名为 SteamTab 的枚举类型,用于管理应用的五个标签页。枚举值从 0 开始递增,分别对应机车、车厢、线路、纪念品和留言五个功能模块。使用枚举而非魔法数字来管理标签页索引,是工程化前端开发中的基本实践。
从架构设计的角度来看,枚举的使用带来了三重好处。首先是可读性提升,在条件判断中使用 SteamTab.TRAIN 比使用 0 更加语义化,代码读者无需查阅注释就能理解判断的含义。其次是可维护性增强,如果未来需要调整标签页顺序或新增标签,只需修改枚举定义,所有引用该枚举的代码都会自动更新。第三是类型安全性,TypeScript 的枚举类型检查可以在编译阶段捕获无效的标签页值,避免运行时错误。
该枚举的五个值与下方 TAB_LIST 数组的索引一一对应,这种隐含的映射关系使得标签页的渲染和切换逻辑非常简洁。主组件中的 curTab 状态变量就使用这个枚举进行初始化和比较,通过 if-else 条件判断来决定渲染哪个内容组件。虽然使用 switch 语句可能更加规范,但考虑到只有五个分支,if-else 链式判断的可读性同样良好。
第四段:TabMeta 接口与标签列表

interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'train', icon: '🚂', label: '机车', color: '#B0602A' },
{ key: 'coach', icon: '🚃', label: '车厢', color: '#3E5C76' },
{ key: 'line', icon: '🛤️', label: '线路', color: '#D9A441' },
{ key: 'gift', icon: '🎁', label: '纪念品', color: '#7FA65A' },
{ key: 'review', icon: '📜', label: '留言', color: '#C05B3C' }
];
这段代码首先定义了 TabMeta 接口,用于描述标签页的元数据结构,然后基于该接口创建了一个包含五个标签页配置的常量数组 TAB_LIST。这种"数据驱动"的标签页配置方式是现代前端开发中非常推荐的模式。
TabMeta 接口包含四个字段:key 是标签的唯一标识符,用于 ForEach 的键值生成;icon 使用 Emoji 字符作为标签图标,避免了图片资源的加载开销;label 是标签的中文名称,直接显示在界面上;color 是每个标签的主题色,用于选中状态的视觉强调。每个标签都分配了不同的主题色,与对应内容页面的视觉风格相呼应,这种色彩关联设计增强了用户对标签切换的感知。
从工程角度来看,将标签页配置提取为数据数组而非硬编码在渲染逻辑中,是一种典型的"配置即数据"的设计模式。这种模式的优势在于扩展性极强:如果需要新增一个标签页,只需在 TAB_LIST 中添加一个配置对象,然后在 build 方法中添加对应的条件分支即可,无需修改标签栏的渲染逻辑。ForEach 组件会自动遍历 TAB_LIST 数组并渲染每个标签,实现了数据与视图的分离。
值得一提的是,TAB_LIST 中的 color 值与前面 COLORS 常量中的值存在重复(例如 ‘#B0602A’ 同时出现在 COLORS.primary 和 TAB_LIST[0].color 中)。虽然这种重复在小型应用中影响不大,但在大型项目中,更优的做法是直接引用 COLORS 常量,如 color: COLORS.primary,以消除数据冗余。
第五段:铆钉列常量
const RIVET_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
这行代码定义了一个包含 12 个数字的数组常量 RIVET_COL,用于在页头区域渲染一排铆钉装饰。这个数组本质上是一个简单的索引序列,从 0 到 11,代表 12 颗铆钉。
在界面渲染时,ForEach 组件会遍历这个数组,为每个数字生成一个 4x4 像素的小方块,通过 c % 2 === 0 的判断实现金色与灰色交替的视觉效果,模拟蒸汽机车上铆钉排列的工业质感。这种用纯代码生成装饰性视觉元素的做法,避免了使用图片资源,既减小了应用体积,又保证了装饰元素可以随主题色彩的变化而动态调整。
从设计模式的角度来看,使用一个数字数组而非直接使用 ForEach 的索引参数来控制渲染数量,虽然看起来多了一步,但实际上提供了更大的灵活性。如果未来需要调整铆钉数量,只需修改数组长度即可。同时,数组中的数字本身也可以被赋予更多含义,例如将来可以通过不同的数字值来控制铆钉的大小或颜色变化,而不需要重构渲染逻辑。这是一种预留扩展空间的小型设计决策。
第六段:TrainItem 机车数据模型
interface TrainItem {
name: string;
era: string;
year: number;
power: number;
speed: number;
emoji: string;
}
这段代码定义了机车数据的接口 TrainItem,它是应用中五种核心数据模型之一。接口包含六个字段:name 是机车名称,era 是时代分类(如"蒸汽时代"、"内燃时代"等),year 是出厂年份,power 是功率值(以马力为单位),speed 是最高时速(以 km/h 为单位),emoji 是机车的 Emoji 图标。
从数据建模的角度来看,这个接口的设计体现了"刚好够用"的原则。每个字段都对应一个在界面上需要展示的属性,没有冗余字段,也没有缺少必要的字段。era 字段使用字符串而非枚举,虽然牺牲了一些类型安全性,但提供了更好的灵活性,使得未来可以轻松添加新的时代分类而无需修改接口定义。
在类型设计上,year、power、speed 都使用 number 类型,这是合理的选择,因为这些值在界面上需要进行数值比较和数学运算(例如条形图的宽度计算需要根据 speed 值来按比例分配)。emoji 字段使用 string 类型存储 Emoji 字符,在 ArkTS 中 Emoji 字符可以直接作为 Text 组件的内容进行渲染,不需要额外的字体或图片资源。
第七段:CoachItem 车厢数据模型
interface CoachItem {
name: string;
type: string;
seats: number;
year: number;
level: string;
emoji: string;
}
这段代码定义了车厢数据的接口 CoachItem,包含六个字段:name 是车厢名称,type 是车厢类型(如"卧铺"、“餐车”、“观光"等),seats 是座位数量,year 是出厂年份,level 是车厢等级(如"特等”、“一等”、“普通”),emoji 是车厢的 Emoji 图标。
值得注意的是 seats 字段的设计。对于邮务车厢、行李车厢等货运类型的车厢,座位数为 0,这在界面上会被特殊处理为显示"无座位"文本。这种通过 0 值来表示特殊语义的设计虽然简单直接,但在更复杂的业务场景中,可能需要引入一个可选字段或枚举来明确区分"有座位但数量为 0"和"无座位概念"两种情况,以避免语义歧义。
level 字段的设计与 era 字段类似,使用字符串而非枚举来表示等级。在后续的 getLevelColor 函数中,会根据 level 的值返回不同的颜色,用于标签和强调色的渲染。这种将业务语义与视觉表现分离的设计,使得等级体系的变化不会影响数据结构,只需修改颜色映射函数即可。
第八段:LineItem 线路数据模型
interface LineItem {
name: string;
distance: number;
stations: number;
fare: number;
hours: number;
emoji: string;
}
这段代码定义了线路数据的接口 LineItem,包含六个字段:name 是线路名称,distance 是线路距离(以公里为单位),stations 是途经站点数量,fare 是票价(以元为单位),hours 是全程耗时(以小时为单位),emoji 是线路的 Emoji 图标。
这个接口的字段设计紧密围绕"铁路线路运营"这一业务场景。distance 和 hours 字段共同描述了线路的物理特征,在界面上会被组合显示为"XX公里 · 全程X小时"的格式。fare 字段在界面上不仅用于显示票价,还会通过数值比较来判断是否显示为高价(fare >= 100 时使用 warning 色),实现了数据驱动的视觉差异化。
stations 字段在界面渲染时会被封装为 SteamTag 标签组件的内容,显示为"X站"的格式,并使用 accent 颜色进行强调。这种将数据字段转化为标签组件内容的设计,使得每个数据项都有独立的视觉标识,提升了信息扫描的效率。整体来看,LineItem 的字段设计简洁而完整,覆盖了线路展示所需的所有核心信息。
第九段:GiftItem 纪念品数据模型
interface GiftItem {
name: string;
type: string;
price: number;
sales: number;
stock: number;
emoji: string;
}
这段代码定义了纪念品数据的接口 GiftItem,包含六个字段:name 是商品名称,type 是商品类型(如"模型"、“文创”、"复古"等),price 是商品价格,sales 是已售数量,stock 是库存数量,emoji 是商品的 Emoji 图标。
这个接口的字段设计体现了电商场景的核心数据需求。price 和 sales 字段在界面上会被组合显示为"¥XX · 已售XX件"的格式,让用户同时了解商品的价格和热销程度。stock 字段则被用于库存状态判断:当 stock > 50 时显示"充足"并使用 success 绿色,否则显示"告急"并使用 danger 红色,这种基于阈值的视觉反馈在电商应用中非常常见。
type 字段的设计与前面的 era、level 字段一脉相承,使用字符串类型并通过 getGiftTypeColor 函数进行颜色映射。这种统一的"字符串类型+颜色映射函数"模式贯穿了整个应用的数据展示逻辑,形成了一套一致的数据-视觉转换体系。emoji 字段为每种商品提供了直观的视觉标识,在列表渲染时作为左侧图标显示。
第十段:ReviewItem 评论数据模型
interface ReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
这段代码定义了评论数据的接口 ReviewItem,包含六个字段:name 是评论者昵称,score 是评分(1-5 的整数),date 是评论日期,content 是评论内容文本,tag 是评论标签(如"体验极佳"、"馆藏丰富"等),emoji 是评论的 Emoji 图标。
score 字段使用 number 类型存储评分数值,在界面上通过 '⭐'.repeat(Math.round(r.score)) 的方式将数值转换为对应数量的星号 Emoji,这是一种巧妙的评分可视化方案。相比使用图片星星或自定义星形组件,直接使用 Emoji 星号字符不仅实现简单,还能保证在不同设备上的渲染一致性。
date 字段使用 string 类型存储日期,格式为"MM-DD"(如"08-15"),省略了年份信息。这种简化在展示近期评论的场景中是合理的,避免了完整日期字符串在列表项中占用过多空间。tag 字段是该接口的设计亮点,它将评论的核心评价提炼为一个短语标签,通过 getTagColor 函数映射为不同的颜色,使得用户在快速浏览评论列表时能够通过标签颜色快速定位感兴趣的评论类型。
第十一段:图表元数据接口定义
interface WeekMeta {
day: string;
value: number;
}
interface EngineMeta {
name: string;
count: number;
color: string;
}
这段代码定义了两个用于数据可视化的元数据接口。WeekMeta 接口包含 day(星期几)和 value(乘务量数值)两个字段,用于周乘务量条形图的数据展示。EngineMeta 接口包含 name(机车类型名称)、count(数量)和 color(条形颜色)三个字段,用于机车馆藏分布图的数据展示。
这两个接口的设计体现了"展示数据"与"业务数据"分离的架构理念。WeekMeta 和 EngineMeta 不属于核心业务数据(那些由 SteamData 管理),而是专门为图表展示而构造的派生数据。这种分离使得图表数据的结构可以独立于业务数据结构进行优化,例如 EngineMeta 中直接包含了 color 字段,这样在渲染图表时无需再通过函数计算颜色,提高了渲染效率。
WeekMeta 接口的简洁性值得关注。它只有两个字段,day 和 value,完全对应条形图的两个维度。在渲染时,value 值会被除以 6 来计算条形高度(最大值 620/6 ≈ 103px),day 值直接作为 X 轴标签显示。这种极简的数据结构使得图表渲染逻辑非常清晰,易于理解和维护。
第十二段:排行榜元数据接口定义
interface LineHotMeta {
name: string;
riders: number;
color: string;
}
interface GiftTopMeta {
name: string;
sales: number;
color: string;
}
这段代码定义了另外两个用于排行榜展示的元数据接口。LineHotMeta 接口包含 name(线路名称)、riders( riders 客流量)和 color(条形颜色),用于热门线路排行榜。GiftTopMeta 接口包含 name(商品名称)、sales(销量)和 color(条形颜色),用于纪念品销量排行榜。
这两个接口与前面的 EngineMeta 接口结构高度相似,都遵循"name + 数值 + color"的三字段模式。这种一致的结构设计使得四个排行榜/分布图的渲染逻辑可以高度复用,只是数值字段名和单位不同。在更理想的架构中,可以定义一个泛型的 ChartBarMeta 接口来统一这些结构,但考虑到 ArkTS 对泛型的支持限制和代码简洁性的考量,当前的设计在可读性和可维护性之间取得了合理的平衡。
color 字段在这些接口中都是必填的,这意味着每个数据项在定义时就必须指定其展示颜色。这种"数据即配色"的设计使得图表渲染时无需额外的颜色计算逻辑,直接使用数据中携带的颜色值即可。虽然这增加了数据定义的复杂度,但换来了渲染逻辑的简化和颜色的一致性控制。
第十三段:周乘务量数据
const WEEK_RIDE: WeekMeta[] = [
{ day: '周一', value: 220 },
{ day: '周二', value: 186 },
{ day: '周三', value: 240 },
{ day: '周四', value: 268 },
{ day: '周五', value: 356 },
{ day: '周六', value: 620 },
{ day: '周日', value: 548 }
];
这段代码定义了周乘务量的静态数据数组 WEEK_RIDE,包含一周七天的乘务量数值。从数据分布来看,工作日(周一至周五)的乘务量在 186-356 之间波动,而周末(周六和周日)的乘务量显著上升,分别达到 620 和 548,呈现出典型的"周末高峰"模式。
这组数据在界面渲染时会被用于绘制一个垂直条形图。每个条形的高度通过 w.value / 6 计算得出(以最大值 620 为基准,约 103px 为最高条形高度),数值文本则通过 Math.round(w.value / 10) 进行缩放显示,避免在条形上方显示过长的数字。当 value >= 500 时,条形颜色使用主色 primary,否则使用辅助色 accent,这种基于阈值的颜色区分使得高峰日(周末)在视觉上更加突出。
从数据架构的角度来看,这组数据被定义为全局常量而非放入 SteamData 类中,表明它属于"展示型静态数据"而非"业务可变数据"。这种区分是有意义的:周乘务量是一个统计汇总值,在应用运行期间不会发生变化,因此不需要被纳入可观察的数据管理体系。如果未来需要支持实时数据更新,可以将它迁移到 SteamData 中并标记为 @Observed。
第十四段:机车馆藏分布数据
const ENGINE_SHARE: EngineMeta[] = [
{ name: '蒸汽机车', count: 6, color: '#B0602A' },
{ name: '内燃机车', count: 3, color: '#3E5C76' },
{ name: '电力机车', count: 2, color: '#7FA65A' },
{ name: '动车组', count: 1, color: '#D9A441' }
];
这段代码定义了机车馆藏分布的静态数据数组 ENGINE_SHARE,包含四种机车类型的数量和对应颜色。从数据分布来看,蒸汽机车以 6 台的数量占据馆藏主体,内燃机车 3 台次之,电力机车 2 台,动车组仅 1 台,呈现出从蒸汽到动车的递减趋势,这与博物馆"以蒸汽时代为核心"的定位相吻合。
在界面渲染时,每种机车类型会显示为一个水平进度条。进度条的填充比例通过 n.count / 6 计算(以最大值 6 为基准),未填充部分使用 cardAlt 背景色填充。这种"填充条+背景条"的设计通过 layoutWeight 属性实现了按比例分配宽度的效果,是 ArkTS 中实现进度条和条形图的经典手法。
color 字段的值与 COLORS 常量中对应的颜色一致(#B0602A = COLORS.primary,#3E5C76 = COLORS.accent 等),但这里选择直接使用色值字符串而非引用常量。这种设计选择可能是为了数据的自包含性——每个数据项都携带了完整的展示信息,不需要依赖外部常量即可渲染。在数据可能被导出或传输的场景中,这种自包含性是有价值的。
第十五段:热门线路排行数据
const LINE_HOT: LineHotMeta[] = [
{ name: '绿皮慢车线', riders: 4820, color: '#B0602A' },
{ name: '峡谷观光线', riders: 4360, color: '#3E5C76' },
{ name: '滨海蒸汽线', riders: 3980, color: '#7FA65A' },
{ name: '森林小火车', riders: 3520, color: '#D9A441' },
{ name: '古镇怀旧线', riders: 2860, color: '#C05B3C' },
{ name: '雪山观光带', riders: 2140, color: '#9FB6C9' }
];
这段代码定义了热门线路排行的静态数据数组 LINE_HOT,包含六条热门线路及其客流量数据。数据按客流量从高到低排列,绿皮慢车线以 4820 人次位居榜首,雪山观光带以 2140 人次排在末位。每条线路都分配了不同的颜色,使得排行榜中的每个条目在视觉上具有辨识度。
在界面渲染时,这个排行榜使用了"序号+名称+进度条+数值"的四列布局。前三名的序号使用 primary 主色高亮显示,后面的序号则使用 textHint 灰色弱化。进度条通过 h.riders / 5000 计算填充比例(以接近最大值的 5000 为基准),确保所有条形都能在视觉上有明显的长度差异。
这个排行榜数据的设计体现了"排行榜"这一常见 UI 模式的数据需求:名称、数值和排序。通过在数据定义阶段就完成排序,渲染时只需按数组顺序遍历即可,无需在渲染逻辑中进行排序计算,这是一种"数据预处理"的性能优化策略。同时,riders 字段在界面显示时附加了"人"的单位后缀,使得数值的含义更加明确。
第十六段:纪念品销量排行数据
const GIFT_TOP: GiftTopMeta[] = [
{ name: '蒸汽机车模型', sales: 860, color: '#B0602A' },
{ name: '铁路怀旧明信片', sales: 720, color: '#3E5C76' },
{ name: '铜制怀表', sales: 640, color: '#D9A441' },
{ name: '火车头徽章', sales: 580, color: '#7FA65A' },
{ name: '复古车票夹', sales: 460, color: '#C05B3C' },
{ name: '铁轨镇纸', sales: 380, color: '#9FB6C9' }
];
这段代码定义了纪念品销量排行榜的静态数据数组 GIFT_TOP,包含六种最畅销纪念品的销量数据。蒸汽机车模型以 860 件的销量夺得冠军,铁轨镇纸以 380 件排在末位。与热门线路排行类似,每个商品都分配了不同的展示颜色。
这个排行榜在界面渲染时与热门线路排行采用了相同的布局模式:序号+名称+进度条+数值。进度条通过 p.sales / 900 计算填充比例(以 900 为基准,略大于最大值 860),使得最高销量的条形接近满格但不完全填满,在视觉上留有呼吸空间。数值显示时附加了"件"的单位后缀。
将 GIFT_TOP 和 LINE_HOT 两个排行榜的数据结构进行对比,可以发现它们在字段设计上完全同构(name + 数值 + color),只是数值字段的语义不同(sales vs riders)。如果在一个更大的项目中,可以考虑定义一个统一的 RankItemMeta 接口来消除这种结构冗余。但在当前应用规模下,保持独立的接口定义反而更利于代码的可读性和类型安全性。
第十七段:SteamData 可观察数据类——机车数据
@Observed
export class SteamData {
trains: TrainItem[] = [
{ name: '黑金刚', era: '蒸汽时代', year: 1920, power: 2100, speed: 80, emoji: '🚂' },
{ name: '巨灵神', era: '蒸汽时代', year: 1932, power: 2600, speed: 90, emoji: '🏭' },
{ name: '太行号', era: '蒸汽时代', year: 1948, power: 2800, speed: 95, emoji: '⛰️' },
{ name: '东风一号', era: '内燃时代', year: 1958, power: 3200, speed: 110, emoji: '🚆' },
{ name: '草原骄子', era: '内燃时代', year: 1964, power: 3500, speed: 120, emoji: '🌾' },
{ name: '韶山一号', era: '电力时代', year: 1972, power: 4200, speed: 140, emoji: '🌟' },
];
这段代码定义了应用的核心数据类 SteamData 的开头部分,使用了 @Observed 装饰器进行标记。@Observed 是 ArkTS 框架提供的一个类级别装饰器,它使得被标记的类的属性变化能够被框架追踪和响应。当一个 @Observed 类的实例被 @State 或 @ObjectLink 引用时,任何对该实例属性的修改都会自动触发关联组件的重新渲染。
trains 数组包含了 12 条机车数据(此处展示前 6 条),涵盖了从 1920 年的"黑金刚"到 2008 年的"和谐号",时间跨度近 90 年。每条数据都包含了完整的 TrainItem 字段。值得注意的是,这些数据涵盖了蒸汽时代、内燃时代、电力时代和动车时代四个历史时期,与 ENGINE_SHARE 中的四种机车类型分类完全对应。
从数据质量的角度来看,这些机车的功率和速度数据呈现出随时代递增的趋势:蒸汽时代机车功率在 2100-2800 马力、速度 80-95 km/h;内燃时代提升至 3100-3500 马力、105-120 km/h;电力时代进一步达到 4000-4600 马力、125-140 km/h;动车时代则飙升至 5800-7200 马力、200-250 km/h。这种符合历史事实的数据设计增强了应用的科普价值,使得应用不仅是一个展示界面,更是一部浓缩的机车发展史。
第十八段:SteamData 可观察数据类——车厢数据
coaches: CoachItem[] = [
{ name: '绿皮软卧车', type: '卧铺', seats: 36, year: 1958, level: '一等', emoji: '🛏️' },
{ name: '红木餐车', type: '餐车', seats: 48, year: 1932, level: '特等', emoji: '🍽️' },
{ name: '蒸汽观景车', type: '观光', seats: 60, year: 1928, level: '一等', emoji: '🔭' },
{ name: '邮务车厢', type: '货运', seats: 0, year: 1946, level: '普通', emoji: '📮' },
{ name: '硬座怀旧车', type: '硬座', seats: 118, year: 1962, level: '普通', emoji: '🪑' },
{ name: '贵宾沙龙车', type: '沙龙', seats: 24, year: 1938, level: '特等', emoji: '🍷' },
];
这段代码是 SteamData 类中 coaches 属性的定义,包含了 12 条车厢数据(此处展示前 6 条)。车厢类型涵盖了卧铺、餐车、观光、货运、硬座、沙龙、守车、展览等多种类别,展现了铁路客运的历史多样性。
从数据设计来看,seats 字段的分布范围从 0(货运车厢)到 118(硬座车厢),跨越了两个数量级。在界面渲染时,seats 为 0 的车厢会被特殊处理,显示"无座位"文本而非"0座",这种基于数据值的条件渲染逻辑在 getLevelColor 等函数中有对应的体现。level 字段的三个值(特等、一等、普通)在界面中分别映射为不同的颜色,使得车厢列表中每条记录都有明确的视觉等级标识。
将这些数据放在 @Observed 类中而非全局常量中,意味着车厢列表是"可变业务数据"。虽然当前应用中车厢数据的增删功能并未完全实现(DetailCoachModal 仅展示详情),但数据架构已经为此预留了扩展空间。如果未来需要添加"新增车厢"功能,只需在 SteamData 中添加相应方法并修改 coaches 数组,UI 会自动更新。
第十九段:SteamData 可观察数据类——线路数据
lines: LineItem[] = [
{ name: '绿皮慢车线', distance: 86, stations: 12, fare: 28, hours: 4, emoji: '🌳' },
{ name: '峡谷观光线', distance: 120, stations: 8, fare: 68, hours: 5, emoji: '🏞️' },
{ name: '滨海蒸汽线', distance: 96, stations: 6, fare: 88, hours: 3, emoji: '🌊' },
{ name: '森林小火车', distance: 42, stations: 9, fare: 58, hours: 2, emoji: '🌲' },
{ name: '古镇怀旧线', distance: 64, stations: 7, fare: 48, hours: 3, emoji: '🏮' },
{ name: '雪山观光带', distance: 150, stations: 5, fare: 128, hours: 6, emoji: '🏔️' },
];
这段代码是 SteamData 类中 lines 属性的定义,包含了 12 条线路数据(此处展示前 6 条)。每条线路都有独特的主题名称和对应的 Emoji 图标,如"绿皮慢车线"配以树木图标、"滨海蒸汽线"配以海浪图标、"雪山观光带"配以雪山图标,使得线路在视觉上具有强烈的场景联想。
从数据关联的角度来看,lines 数组中的线路名称与前面定义的 LINE_HOT 排行榜数据存在交叉引用关系。例如"绿皮慢车线"在两个数据源中都有出现,但它们服务于不同的展示目的:lines 中的数据用于线路列表的详细展示,而 LINE_HOT 中的数据用于排行榜的简化展示。这种"同一实体、不同视图"的数据架构在实际应用中非常常见,但需要注意数据一致性维护的潜在风险。
fare 字段的分布从 28 元(绿皮慢车线)到 128 元(雪山观光带),在界面渲染时会根据 fare >= 100 的阈值条件使用不同的颜色显示。distance 和 hours 字段的组合使得用户能够评估线路的时间成本和空间跨度,为出行决策提供参考。stations 字段则通过 SteamTag 标签组件显示为"X站"的格式,提供了线路复杂度的直观参考。
第二十段:SteamData 可观察数据类——纪念品数据
gifts: GiftItem[] = [
{ name: '蒸汽机车模型', sales: 860, stock: 46, price: 298, emoji: '🚂' },
{ name: '铁路怀旧明信片', type: '文创', price: 18, sales: 720, stock: 200, emoji: '💌' },
{ name: '铜制怀表', type: '复古', price: 388, sales: 640, stock: 32, emoji: '⌚' },
{ name: '火车头徽章', type: '文创', price: 28, sales: 580, stock: 150, emoji: '🪙' },
{ name: '复古车票夹', type: '皮具', price: 68, sales: 460, stock: 88, emoji: '🎫' },
{ name: '铁轨镇纸', type: '摆件', price: 88, sales: 380, stock: 64, emoji: '⚙️' },
];
这段代码是 SteamData 类中 gifts 属性的定义,包含了 12 条纪念品数据(此处展示前 6 条)。纪念品涵盖了模型、文创、复古、皮具、摆件、服饰、玩偶等多种类型,体现了博物馆商店商品品类的丰富性。
stock 字段的设计在界面渲染中起到了关键的视觉反馈作用。通过 g.stock > 50 的阈值判断,库存充足的商品显示绿色"充足"标签,库存紧张的商品显示红色"告急"标签。这种基于库存阈值的视觉预警机制是电商管理界面的标准实践,帮助管理者快速识别需要补货的商品。
price 和 sales 字段的组合在界面上呈现为"¥XX · 已售XX件"的格式,让用户同时获取价格和热销度两个维度的信息。值得注意的是,纪念品的价格跨度非常大,从 18 元的明信片到 388 元的铜制怀表,覆盖了从 impulse purchase(冲动消费)到 gift purchase(礼品消费)的不同消费层级。这种价格梯度的设计在博物馆商店的文创产品中非常典型。
第二十一段:SteamData 可观察数据类——评论数据
reviews: ReviewItem[] = [
{ name: '铁轨迷', score: 5, date: '08-15', content: '绿皮慢车线太有味道了,全程窗外都是田园风光!', tag: '体验极佳', emoji: '🌳' },
{ name: '老站长', score: 5, date: '08-14', content: '博物馆里的蒸汽机车保养得真好,看得出用心。', tag: '馆藏丰富', emoji: '🚂' },
{ name: '小火车迷', score: 4, date: '08-13', content: '儿童乐园车厢娃玩疯了,就是周末排队久。', tag: '排队较长', emoji: '🎠' },
{ name: '摄影爱好者', score: 5, date: '08-12', content: '滨海蒸汽线配落日,随手一拍都是大片。', tag: '风景绝美', emoji: '🌊' },
{ name: '历史控', score: 5, date: '08-11', content: '守车和邮务车厢还原度极高,历史细节拉满。', tag: '馆藏丰富', emoji: '📮' },
{ name: '咖啡客', score: 4, date: '08-10', content: '红木餐车里的咖啡不错,环境复古有格调。', tag: '环境氛围', emoji: '🍽️' },
];
}
这段代码是 SteamData 类中 reviews 属性的定义,也是该类的最后一个属性,包含了 12 条游客评论数据。评论数据按日期从近到远排列,每条评论包含了评论者昵称、评分、日期、内容、标签和 Emoji 图标。
tag 字段的设计是评论数据模型的亮点。它将每条评论的核心评价提炼为一个短语标签,如"体验极佳"、“馆藏丰富”、“风景绝美”、"排队较长"等。这些标签在界面渲染时通过 getTagColor 函数映射为不同的颜色,使得用户在快速浏览评论列表时能够通过标签颜色快速筛选感兴趣的评价类型。
content 字段的评论文本设计也值得称道。每条评论都紧密围绕蒸汽火车博物馆的特定场景展开,有的评价线路体验(绿皮慢车线的田园风光),有的评价馆藏质量(蒸汽机车的保养状况),有的评价服务设施(儿童乐园车厢、红木餐车咖啡),还有的表达情怀(老铁路人的回忆)。这种内容设计使得评论列表不仅提供了用户反馈信息,还充当了应用功能的"口碑导览",引导新用户探索不同板块。
第二十二段:getEraColor 时代颜色映射函数
function getEraColor(era: string): string {
if (era === '蒸汽时代') {
return '#B0602A';
} else if (era === '内燃时代') {
return '#3E5C76';
} else if (era === '电力时代') {
return '#7FA65A';
}
return '#D9A441';
}
这段代码定义了 getEraColor 函数,它根据机车时代字符串返回对应的主题颜色。蒸汽时代返回铜橙色 #B0602A,内燃时代返回深蓝灰 #3E5C76,电力时代返回绿色 #7FA65A,其他时代(如动车时代)默认返回金色 #D9A441。
从设计模式的角度来看,这是一个典型的"策略映射"函数,将业务语义(时代名称)映射为视觉属性(颜色值)。这种映射函数在数据驱动的 UI 开发中非常常见,它将颜色决策逻辑集中在一处,使得颜色策略的修改只需调整函数实现即可全局生效。
该函数使用 if-else 链式判断而非 switch 语句或对象映射表,在分支数量较少(4 个)的情况下是合理的选择。if-else 链的可读性在少量分支时优于 switch,且不需要额外的数据结构。返回值直接使用色值字符串而非引用 COLORS 常量,这与前面 ENGINE_SHARE 等数据的设计一致,保证了函数的自包含性。该函数在 TrainContent 组件中被多次调用,用于机车列表项的图标背景色、边框色和标签色。
第二十三段:getLevelColor 等级颜色映射函数
function getLevelColor(level: string): string {
if (level === '特等') {
return '#C05B3C';
} else if (level === '一等') {
return '#D9A441';
}
return '#8B8B8B';
}
这段代码定义了 getLevelColor 函数,根据车厢等级返回对应的颜色。特等车厢返回红色 #C05B3C,一等车厢返回金色 #D9A441,普通车厢返回灰色 #8B8B8B。
颜色选择的设计逻辑与等级语义高度匹配。特等作为最高等级使用红色,传达尊贵和独特的感觉;一等使用金色,暗示品质和价值;普通则使用中性的灰色,表示标准配置。这种"语义-色彩"的映射关系在酒店、航空等分级行业中有广泛的应用先例。
该函数在 CoachContent 组件中被调用,用于车厢列表项的图标背景色、年份文字颜色和标签颜色的渲染。虽然函数实现非常简单,但它在数据与视觉之间架起了桥梁,使得车厢等级这一业务概念能够在界面上以色彩的形式被感知。如果未来需要调整等级颜色策略(例如增加"豪华"等级),只需修改此函数即可。
第二十四段:getTagColor 标签颜色映射函数
function getTagColor(tag: string): string {
if (tag === '体验极佳' || tag === '馆藏丰富' || tag === '风景绝美' || tag === '情怀满分') {
return '#B0602A';
} else if (tag === '环境氛围' || tag === '氛围满分' || tag === '服务贴心') {
return '#3E5C76';
} else if (tag === '商品满意' || tag === '价格实惠') {
return '#7FA65A';
} else if (tag === '排队较长') {
return '#C05B3C';
}
return '#8B8B8B';
}
这段代码定义了 getTagColor 函数,根据评论标签返回对应的颜色。该函数将评论标签分为四组并映射为不同的颜色:正面体验类标签(体验极佳、馆藏丰富、风景绝美、情怀满分)返回主色铜橙色;氛围服务类标签(环境氛围、氛围满分、服务贴心)返回辅助色深蓝灰;商品价值类标签(商品满意、价格实惠)返回成功色绿色;负面标签(排队较长)返回危险色红色;默认返回灰色。
这个函数的分组逻辑体现了评论情感分析的简化版本。虽然没有使用 NLP 技术进行自动情感分类,但通过人工预设的标签-颜色映射表,实现了类似的效果:用户在浏览评论列表时,可以通过标签颜色快速区分正面、中性和负面评价。这种"人工分类+颜色编码"的方案在数据量不大的场景中比机器学习方案更加可靠和可控。
从代码风格来看,多个条件使用 || 运算符连接在同一个 if 分支中,虽然代码行数较多,但可读性良好。如果标签数量进一步增加,可以考虑改用 Set 或 Map 数据结构来优化查找效率,但在当前 10 个标签的规模下,if-else 链的性能差异可以忽略不计。
第二十五段:getGiftTypeColor 商品类型颜色映射函数
function getGiftTypeColor(type: string): string {
if (type === '模型' || type === '摆件') {
return '#B0602A';
} else if (type === '文创') {
return '#3E5C76';
} else if (type === '皮具' || type === '服饰') {
return '#7FA65A';
}
return '#D9A441';
}
这段代码定义了 getGiftTypeColor 函数,根据纪念品类型返回对应的颜色。模型和摆件类返回主色铜橙色,文创类返回辅助色深蓝灰,皮具和服饰类返回成功色绿色,其他类型(复古、玩偶等)默认返回金色。
这个函数与前三个颜色映射函数共同构成了应用的颜色映射体系。四个函数分别处理机车时代、车厢等级、评论标签和商品类型四种业务维度的颜色映射,它们的实现模式完全一致:接收一个字符串参数,通过条件判断返回对应的颜色值。这种一致的模式使得开发者可以轻松理解和使用这些函数。
从架构设计的角度来看,这四个函数可以被统一为一个通用的颜色映射管理器,例如使用 Map<string, string> 来存储映射关系。但当前的函数式实现方式在可读性和调试便利性上有优势——开发者可以通过函数名直接定位到颜色决策逻辑,而不需要查找映射表数据。在应用规模不大的情况下,这种实现方式是合理的。
第二十六段:SteamApp 主入口组件声明与状态定义
@Entry
@Component
struct SteamApp {
@State curTab: number = 0;
@State data: SteamData = new SteamData();
@State showAddTrain: boolean = false;
@State showEditLine: boolean = false;
@State showDeleteGift: boolean = false;
@State showDetail: boolean = false;
@State delGiftName: string = '';
@State detailCoachName: string = '';
@State trainRun: boolean = false;
@State gearSpin: boolean = false;
这段代码声明了应用的主入口组件 SteamApp,使用了 @Entry 和 @Component 两个装饰器。@Entry 标记该组件为应用的入口页面,@Component 声明这是一个自定义组件。组件内部定义了 9 个 @State 状态变量,构成了整个应用的状态管理中心。
状态变量可以分为四组。第一组是核心状态:curTab 管理当前激活的标签页索引,data 持有 SteamData 可观察数据实例。第二组是弹窗控制状态:showAddTrain、showEditLine、showDeleteGift、showDetail 分别控制四个弹窗的显示与隐藏。第三组是弹窗参数状态:delGiftName 和 detailCoachName 用于向弹窗传递上下文信息。第四组是动画状态:trainRun 和 gearSpin 控制页头动画效果的触发。
这种集中式的状态管理设计在中小型应用中是合理的选择。所有跨组件共享的状态都提升到顶层组件中管理,子组件通过属性传递和回调函数与顶层组件通信。@State 装饰器确保了当这些变量的值发生变化时,引用该变量的 UI 部分会自动重新渲染。data 状态变量持有 @Observed 类的实例,当子组件通过 @ObjectLink 引用该实例时,对实例属性的修改也会触发子组件的更新。
第二十七段:modalOverlay 通用遮罩构建器
@Builder modalOverlay(onClose: () => void) {
Column() {
Text('')
.width(0)
.height(0)
.opacity(0)
Button('')
.width(1)
.height(1)
.opacity(0)
.onClick(() => {
onClose();
})
}
.width(1)
.height(1)
}
这段代码定义了一个名为 modalOverlay 的 @Builder 方法,用于构建弹窗的遮罩层。@Builder 是 ArkTS 提供的装饰器,用于声明可复用的 UI 构建函数。该构建器接收一个 onClose 回调函数作为参数,构建了一个包含一个透明 Text 和一个近乎不可见的 Button 的 Column 容器。
从设计意图来看,这个遮罩层的实现方式比较特殊。它没有使用全屏的半透明背景来覆盖底层内容,而是创建了一个 1x1 像素大小的透明容器,内部放置了一个同样几乎不可见的按钮。当用户点击这个按钮时,会触发 onClose 回调。这种设计可能用于处理弹窗显示时的焦点管理或点击区域控制,但其实现方式在实际使用中可能存在可点击区域过小的问题。
从代码复用的角度来看,将遮罩层抽象为 @Builder 方法是正确的做法。如果未来需要修改遮罩层的实现(例如添加半透明背景或动画效果),只需修改这一处即可。@Builder 方法可以接受参数,使得它能够根据调用者的需求动态生成不同的 UI 结构,比 @Component 更加轻量灵活。
第二十八段:build 方法——页头标题区域
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🚂 蒸汽火车博物馆')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Steam Train Museum · 铁轨上的时光')
.fontSize(10)
.fontColor('#FFFFFFB3')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
这段代码是 SteamApp 组件 build 方法的开头部分,构建了页头的标题区域。build 方法是 ArkTS 组件的核心方法,它以声明式的方式描述了组件的 UI 结构。整个页头被包裹在三层嵌套的 Column 中,最外层 Column 是页头容器,中间层 Column 用于设置背景色和内边距,最内层 Column 包含具体的标题内容。
标题区域使用 Row 进行水平布局,左侧是一个 Column 包含主标题和副标题。主标题"🚂 蒸汽火车博物馆"使用 20 号字体和粗体字重,颜色为白色。副标题"Steam Train Museum · 铁轨上的时光"使用 10 号小字体,颜色为半透明白色(#FFFFFFB3,B3 是十六进制的 179,约 70% 不透明度),营造出英文副标题的辅助信息效果。
左侧 Column 使用 layoutWeight(1) 占据剩余空间,确保右侧的交互按钮区域有固定的宽度。alignItems(HorizontalAlign.Start) 使得文字内容左对齐。这种"左侧弹性内容+右侧固定操作区"的布局模式在移动应用页头中非常常见,保证了标题内容在不同屏幕宽度下都能合理展示。三层 Column 嵌套虽然看起来有些冗余,但每一层都有明确的职责:最外层管布局约束,中间层管背景和间距,最内层管内容排列。
第二十九段:build 方法——页头交互按钮
Row() {
Text('🚂')
.fontSize(20)
.onClick(() => {
this.trainRun = !this.trainRun;
})
.translate({ x: this.trainRun ? 12 : 0 })
.animation({ duration: 800, curve: Curve.EaseInOut })
Text('⚙️')
.fontSize(18)
.margin({ left: 10 })
.onClick(() => {
this.gearSpin = !this.gearSpin;
})
.rotate({ angle: this.gearSpin ? 360 : 0 })
.animation({ duration: 1500, curve: Curve.Linear })
Text('🛤️')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#FFFFFF26')
.borderRadius(8)
.border({ width: 1, color: '#FFFFFF40' })
这段代码构建了页头右侧的交互按钮区域,包含三个 Emoji 图标,其中前两个具有动画效果。第一个火车 Emoji 在点击时触发 trainRun 状态切换,通过 translate 属性实现水平位移动画,使用 EaseInOut 缓动曲线在 800ms 内完成。第二个齿轮 Emoji 在点击时触发 gearSpin 状态切换,通过 rotate 属性实现 360 度旋转动画,使用 Linear 线性曲线在 1500ms 内完成。
这两个动画效果的设计体现了应用"蒸汽朋克"主题的交互特色。火车图标点击后会向右"行驶"一小段距离,模拟机车启动的效果;齿轮图标点击后会持续旋转,模拟蒸汽机的齿轮运转。这些微交互虽然不承载业务功能,但极大地增强了应用的趣味性和主题沉浸感,是优秀移动应用中常见的"惊喜元素"。
从技术实现的角度来看,animation 属性与状态驱动的 transform 属性配合使用,是 ArkTS 中实现动画的标准模式。开发者只需声明目标状态值和动画参数,框架会自动处理中间帧的计算和渲染。两个动画使用了不同的缓动曲线:EaseInOut 适合位移动画(启动慢-中间快-停止慢,模拟物理惯性),Linear 适合旋转动画(匀速旋转更符合机械齿轮的运转特征)。整个按钮区域使用半透明白色背景和圆角边框,形成了一个视觉上独立的操作组。
第三十段:build 方法——铆钉装饰行
Row() {
ForEach(RIVET_COL, (c: number) => {
Column()
.width(4)
.height(4)
.margin({ left: 2, right: 2 })
.backgroundColor(c % 2 === 0 ? COLORS.steamGold : COLORS.ironGray)
.borderRadius(2)
}, (c: number) => 'rivet' + c)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 10 })
这段代码构建了页头中的铆钉装饰行,使用 ForEach 组件遍历 RIVET_COL 数组(12 个元素),为每个元素生成一个 4x4 像素的小方块作为铆钉。铆钉颜色通过 c % 2 === 0 的判断在 steamGold(金色)和 ironGray(灰色)之间交替,模拟蒸汽机车上铆钉排列的视觉效果。
ForEach 的第三个参数是键值生成函数 (c: number) => 'rivet' + c,它为每个铆钉生成唯一的键值。这个键值在 ArkTS 的 diff 算法中用于识别列表项的唯一性,确保数组变化时只有变化的项被更新,而非整个列表重新渲染。虽然铆钉数组是静态的不会变化,但提供键值函数是 ForEach 的最佳实践。
Row 容器使用 justifyContent(FlexAlign.SpaceBetween) 使得 12 个铆钉在水平方向上均匀分布,第一个铆钉贴左、最后一个贴右,中间的铆钉等间距排列。这种空间分配方式在视觉上模拟了铁轨上等距排列的铆钉或枕木的效果。margin({ top: 10 }) 在铆钉行与上方标题行之间留出了呼吸空间。整体来看,这个装饰行虽然是纯视觉元素,不承载任何业务功能,但它为应用赋予了强烈的工业主题氛围。
第三十一段:build 方法——装饰分隔线
Row() {
Column()
.layoutWeight(1)
.height(4)
.backgroundColor(COLORS.steamGold)
.borderRadius(2)
Text('⚙️ 蒸汽动力 · 百年铁轨 ⚙️')
.fontSize(9)
.fontColor('#FFFFFFCC')
.margin({ left: 8, right: 8 })
Column()
.layoutWeight(1)
.height(4)
.backgroundColor(COLORS.steamGold)
.borderRadius(2)
}
.width('100%')
.margin({ top: 8 })
这段代码构建了页头底部的装饰分隔线,由左右两条金色横线和中间的标语文字组成。左右两条横线使用 Column 组件并通过 layoutWeight(1) 实现弹性宽度,使得中间的文字始终居中显示。横线高度为 4 像素,使用 steamGold 金色背景和 2 像素圆角。
标语文字"⚙️ 蒸汽动力 · 百年铁轨 ⚙️"使用 9 号小字体和 80% 不透明度的白色,两侧以齿轮 Emoji 作为装饰。这段标语是对应用主题的高度概括,既传达了"蒸汽动力"的技术核心,又表达了"百年铁轨"的历史纵深感。
这种"弹性线条+居中文字"的分隔线设计在 UI 设计中被称为"ornamental divider"(装饰性分隔符),它在功能上分隔上下内容区域,在视觉上增加设计的精致感。通过 layoutWeight 实现的弹性布局确保了分隔线在不同屏幕宽度下都能保持正确的比例,文字始终居中。这与前面铆钉装饰行一起,构成了页头区域完整的"工业主题"装饰体系,使得整个页头不仅具有信息传达功能,更具有视觉品牌识别价值。
第三十二段:build 方法——标签内容切换
if (this.curTab === SteamTab.TRAIN) {
TrainContent({ data: this.data, onAdd: () => {
this.showAddTrain = true;
} })
} else if (this.curTab === SteamTab.COACH) {
CoachContent({ data: this.data, onDetail: (n: string) => {
this.detailCoachName = n;
this.showDetail = true;
} })
} else if (this.curTab === SteamTab.LINE) {
LineContent({ data: this.data, onEdit: () => {
this.showEditLine = true;
} })
} else if (this.curTab === SteamTab.GIFT) {
GiftContent({ data: this.data, onDel: (n: string) => {
this.delGiftName = n;
this.showDeleteGift = true;
} })
} else {
ReviewContent({ data: this.data })
}
这段代码是 build 方法中标签内容切换的核心逻辑,通过 if-else 条件链根据 curTab 的值渲染对应的内容组件。每个内容组件都通过属性传递接收 data 参数(SteamData 实例)和回调函数参数。
从组件通信的角度来看,这里采用了"属性向下传递,事件向上回调"的单向数据流模式。父组件 SteamApp 通过 data 属性将 SteamData 实例传递给子组件,子组件通过 @ObjectLink 建立对数据的响应式引用。当子组件需要触发弹窗时,不是直接修改父组件的状态,而是调用通过属性传递的回调函数(如 onAdd、onDetail、onEdit、onDel),由父组件决定如何响应。
这种设计模式确保了状态修改的集中管理——所有状态变更都发生在父组件中,子组件只负责触发事件。回调函数中不仅设置弹窗的显示状态,还设置了弹窗所需的参数(如 detailCoachName 和 delGiftName),确保弹窗打开时能展示正确的内容。ReviewContent 是唯一不需要回调函数的内容组件,因为留言页面只展示数据,不涉及管理操作。整体来看,这段代码清晰展示了 ArkTS 中父子组件通信的标准范式。
第三十三段:build 方法——底部标签栏
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
.fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primaryDark : COLORS.cardBg)
.borderRadius(10)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '88' } : { width: 1, color: COLORS.line })
.onClick(() => {
this.curTab = TAB_LIST.indexOf(t);
})
}, (t: TabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
.border({ width: 1, color: COLORS.line })
这段代码构建了应用底部的标签栏,使用 ForEach 遍历 TAB_LIST 数组渲染五个标签项。每个标签项是一个 Column,包含图标和文字两行内容。通过 this.curTab === TAB_LIST.indexOf(t) 的判断,选中状态和非选中状态在多个视觉属性上产生差异。
选中态的视觉表现包括:文字颜色为白色、字重为粗体、背景色为 primaryDark 深褐色、边框颜色为标签主题色加 88 透明度后缀。非选中态则使用 textHint 灰色文字、Normal 字重、cardBg 卡片背景色和 line 线条色边框。这种多维度差异化设计使得选中状态在视觉上非常突出,用户可以明确感知当前所在的标签页。
标签项的 onClick 事件直接修改 curTab 状态,触发条件渲染逻辑切换内容组件。layoutWeight(1) 使得五个标签项等宽分布,padding 控制内部间距。整个标签栏高度为 60 像素,使用 cardBg 背景色和 line 边框色,与应用整体的深色主题保持一致。边框颜色 t.color + '88' 的设计巧妙地将每个标签的主题色融入选中状态的视觉表现中,使得不同标签在选中时呈现出各自的主题色调。
第三十四段:build 方法——弹窗条件渲染
if (this.showAddTrain) {
AddTrainModal({
onClose: () => {
this.showAddTrain = false;
}
})
}
if (this.showEditLine) {
EditLineModal({
onClose: () => {
this.showEditLine = false;
}
})
}
if (this.showDeleteGift) {
DeleteGiftModal({
title: this.delGiftName, onClose: () => {
this.showDeleteGift = false;
}
})
}
if (this.showDetail) {
DetailCoachModal({
name: this.detailCoachName, onClose: () => {
this.showDetail = false;
}
})
}
这段代码是 build 方法的最后部分,通过四个独立的 if 条件语句控制四个弹窗组件的渲染。每个弹窗组件都接收一个 onClose 回调函数,用于在关闭时将对应的显示状态重置为 false。DeleteGiftModal 和 DetailCoachModal 额外接收 title 和 name 参数,用于展示上下文信息。
从渲染机制的角度来看,ArkTS 的条件渲染使用 if 语句而非 display:none 样式。当条件为 false 时,组件不会被创建和渲染,不存在于组件树中;当条件变为 true 时,组件才会被实例化和渲染。这种方式比"始终渲染但隐藏"的方式更加高效,因为不需要为不可见的组件分配内存和计算资源。
四个弹窗使用独立的布尔状态变量而非一个枚举变量来控制显示,这种设计允许(在理论上)多个弹窗同时显示。虽然在当前业务逻辑中不会出现这种情况,但这种独立控制的设计为未来扩展提供了灵活性。例如,如果需要在 DetailCoachModal 中再触发一个确认弹窗,现有的状态架构可以支持这种嵌套弹窗场景。每个弹窗组件的 onClose 回调都是简单的状态重置操作,保持了关闭逻辑的统一性和可预测性。
第三十五段:SteamTag 标签组件
@Component
struct SteamTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(4)
.border({ width: 1, color: this.color + '40' })
}
}
这段代码定义了 SteamTag 标签组件,它是整个应用中复用频率最高的 UI 组件之一。该组件接收两个 @Prop 参数:text 是标签文本,color 是标签颜色。@Prop 装饰器表示该属性由父组件传递,在子组件内部是只读的。
SteamTag 的视觉设计非常精巧。文字使用传入的 color 作为字体颜色,背景使用 color + '1F'(1F 是十六进制的 31,约 12% 不透明度)作为淡色背景,边框使用 color + '40'(40 是十六进制的 64,约 25% 不透明度)作为中等透明度的边框色。这种"同色系三层次透明度"的设计使得标签在视觉上既有色彩辨识度,又不会过于突兀,与深色背景和谐融合。
SteamTag 组件在 TrainContent、CoachContent、LineContent、GiftContent 和 ReviewContent 五个内容组件中都被使用,用于展示机车时代、车厢等级、线路站点数、商品类型和评论标签等信息。这种高频复用正是组件化开发的典型价值:一次定义,多处使用,保证了视觉风格的一致性和代码的可维护性。如果需要调整标签的样式(如字号、圆角或间距),只需修改 SteamTag 组件的定义即可全局生效。
第三十六段:TrainContent——周乘务量条形图
@Component
struct TrainContent {
@ObjectLink data: SteamData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 周乘务量')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六620人次')
.fontSize(9)
.fontColor(COLORS.danger)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_RIDE, (w: WeekMeta) => {
Column() {
Text(Math.round(w.value / 10) + '')
.fontSize(8)
.fontColor(w.value >= 500 ? COLORS.primaryLight : COLORS.textSecondary)
Column()
.width(20)
.height(w.value / 6)
.backgroundColor(w.value >= 500 ? COLORS.primary : COLORS.accent)
.borderRadius(3)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekMeta) => w.day)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
这段代码是 TrainContent 组件的开头部分,定义了组件的结构和数据引用方式。@ObjectLink data 表示该组件通过对象链接引用 SteamData 实例,当 SteamData 中的 trains 数组发生变化时,TrainContent 会自动重新渲染。onAdd 回调函数默认为空函数,由父组件传递具体实现。
周乘务量条形图的实现是这段代码的核心亮点。使用 ForEach 遍历 WEEK_RIDE 数组,为每个数据项生成一个 Column,包含数值文本、条形柱和星期标签三部分。条形柱的高度通过 w.value / 6 计算,最大值 620/6 ≈ 103px。数值文本通过 Math.round(w.value / 10) 缩放显示,避免数字过长。
条形颜色的差异化设计值得注意:当 value >= 500 时(即周六和周日的数据),条形使用 primary 主色,数值文本使用 primaryLight 浅色;否则使用 accent 辅助色和 textSecondary 次级文字色。这种基于阈值的颜色区分使得周末高峰在视觉上自然突出,无需额外的标注。整个 Row 高度为 140px,使用 VerticalAlign.Bottom 对齐方式确保所有条形从底部向上生长,模拟传统条形图的视觉效果。
第三十七段:TrainContent——机车馆藏分布图
Column() {
Row() {
Text('🔥 机车馆藏分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('蒸汽机车最多')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(ENGINE_SHARE, (n: EngineMeta) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(76)
Row() {
Row()
.layoutWeight(n.count / 6)
.height(8)
.backgroundColor(n.color)
.borderRadius(3)
Row()
.layoutWeight(1 - n.count / 6)
.height(8)
.backgroundColor(COLORS.cardAlt)
.borderRadius(3)
}
.layoutWeight(1)
.height(8)
Text(n.count + '台')
.fontSize(9)
.fontColor(n.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (n: EngineMeta) => n.name)
}
这段代码构建了机车馆藏分布图,使用 ForEach 遍历 ENGINE_SHARE 数组,为每种机车类型渲染一个水平进度条。进度条的实现采用了"双 Row + layoutWeight"的经典模式:一个 Row 代表已填充部分,使用 layoutWeight(n.count / 6) 按比例分配宽度;另一个 Row 代表未填充部分,使用 layoutWeight(1 - n.count / 6) 占据剩余宽度。
这种进度条实现方式是 ArkTS 中利用 layoutWeight 实现按比例布局的典型应用。layoutWeight 的值代表权重而非百分比,框架会根据所有子组件的权重总和来分配可用空间。当两个 Row 的权重之和为 1 时(n.count/6 + (1 - n.count/6) = 1),它们会精确地按照比例分割父容器的宽度,实现了与百分比布局相同的效果。
每行的布局采用"名称+进度条+数值"的三列结构:名称固定宽度 76px 左对齐,进度条使用 layoutWeight(1) 弹性占据中间空间,数值固定宽度 32px 右对齐。这种固定+弹性+固定的三列布局在数据展示中非常常见,保证了不同行之间名称、进度条和数值的对齐。数值文本"X台"使用与条形相同的颜色,强化了数据与视觉的关联。
第三十八段:TrainContent——机车列表
Column() {
Row() {
Text('🚂 镇馆机车')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击➕入库')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('➕')
.fontSize(13)
.margin({ left: 8 })
.onClick(() => {
this.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.trains, (t: TrainItem, i: number) => {
Row() {
Column()
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(getEraColor(t.era) + '22')
.borderRadius(6)
.border({ width: 2, color: getEraColor(t.era) + '55' })
Text(t.emoji)
.fontSize(19)
.width(30)
.textAlign(TextAlign.Center)
这段代码构建了机车列表的标题行和列表项的开头部分。标题行包含"镇馆机车"标题、"点击➕入库"提示和"➕"添加按钮,添加按钮的 onClick 事件调用 this.onAdd() 回调,触发父组件显示入库弹窗。标题行使用 SpaceBetween 布局使标题和操作区域分列两端。
机车列表使用 ForEach 遍历 this.data.trains 数组。注意这里使用的是 this.data.trains 而非一个全局常量,因为 trains 是 SteamData 可观察数据的属性,通过 @ObjectLink 引用,当数据变化时列表会自动更新。ForEach 的键值函数使用 t.name + i(名称加索引),确保即使有同名机车也能正确识别和更新。
每个列表项是一个 Row,采用"图标+内容+数据"的三列布局。图标区域是一个 44x44 的 Column,背景色使用 getEraColor(t.era) + '22'(12% 不透明度),边框使用 getEraColor(t.era) + '55'(33% 不透明度),通过同色系不同透明度营造出柔和的图标容器效果。Emoji 图标使用 19 号字体居中显示在 30px 宽的区域中,紧贴图标容器右侧。这种布局设计在列表项中非常标准,保证了视觉一致性和信息可读性。
第三十九段:TrainContent——机车列表项内容与数据
Column() {
Row() {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: t.era, color: getEraColor(t.era) })
.margin({ left: 6 })
}
.width('100%')
Text(t.year + '年 · 功率' + t.power + '马力')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(t.speed + 'km/h')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(getEraColor(t.era))
Text('最高时速')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (t: TrainItem, i: number) => t.name + i)
这段代码是机车列表项的内容区域和数据区域的实现。内容区域是一个使用 layoutWeight(1) 的弹性 Column,包含机车名称(带时代标签)和参数描述两行。机车名称使用 13 号粗体白色文字,时代标签使用 SteamTag 组件,颜色由 getEraColor 函数根据时代名称动态确定。参数描述行将年份和功率信息组合为"XXXX年 · 功率XXXX马力"的格式,使用 10 号次级文字色。
数据区域是一个右对齐的 Column,显示最高时速数值和"最高时速"标签。时速数值使用 13 号粗体,颜色同样由 getEraColor 函数确定,使得时速数值的颜色与时代标签和图标背景色保持一致,形成统一的视觉主题。这种"颜色一致性"设计让用户在浏览列表时,能够通过颜色快速识别机车的时代归属。
列表项的背景色使用 i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt 实现斑马纹效果,偶数行使用 cardBg、奇数行使用 cardAlt,通过微小的色差提升列表的可读性。每项使用 10px 圆角、line 色边框和 8px 底部间距,形成了卡片式列表的视觉风格。整体来看,机车列表项的布局结构清晰、信息层次分明,是数据展示型列表的良好范例。
第四十段:CoachContent——热门线路排行榜
@Component
struct CoachContent {
@ObjectLink data: SteamData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🏆 热门线路榜')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('绿皮慢车线居首')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(LINE_HOT, (h: LineHotMeta, i: number) => {
Row() {
Text((i + 1) + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
.width(22)
Text(h.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(88)
Row() {
Row()
.layoutWeight(h.riders / 5000)
.height(8)
.backgroundColor(h.color)
.borderRadius(3)
Row()
.layoutWeight(1 - h.riders / 5000)
.height(8)
.backgroundColor(COLORS.cardAlt)
.borderRadius(3)
}
.layoutWeight(1)
.height(8)
Text(h.riders + '人')
.fontSize(9)
.fontColor(h.color)
.width(48)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7 })
}, (h: LineHotMeta, i: number) => h.name + i)
}
这段代码是 CoachContent 组件的热门线路排行榜部分。该组件通过 @ObjectLink 引用 SteamData,通过 onDetail 回调向父组件传递车厢名称以触发详情弹窗。排行榜使用 ForEach 遍历 LINE_HOT 数组,为每条线路生成一个"序号+名称+进度条+数值"的四列行。
排行榜的序号设计使用了排名高亮策略:前三名(i < 3)使用 primary 主色粗体显示,后面的排名使用 textHint 灰色弱化。这种"Top 3 高亮"的设计在排行榜 UI 中非常常见,使得用户能快速识别头部排名。线路名称固定宽度 88px,进度条使用 layoutWeight(1) 弹性占据中间空间,客流量数值固定宽度 48px 右对齐显示,附加"人"的单位后缀。
进度条的填充比例使用 h.riders / 5000 计算,以 5000 为基准值(略大于最大值 4820),确保最高排名的条形接近满格但不完全填满。每个条形使用数据中携带的 color 值作为背景色,数值文本也使用相同的颜色,实现了数据与视觉的一致性。整个排行榜的布局结构与机车馆藏分布图高度一致,体现了应用内数据可视化组件的统一设计规范。
第四十一段:CoachContent——车厢列表
Column() {
Row() {
Text('🚃 特色车厢')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击👁️查看')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.coaches, (c: CoachItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getLevelColor(c.level) + '22')
.borderRadius(6)
Text(c.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: c.level, color: getLevelColor(c.level) })
.margin({ left: 6 })
}
.width('100%')
Text(c.type + ' · ' + (c.seats === 0 ? '无座位' : c.seats + '座'))
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
这段代码是 CoachContent 组件的车厢列表部分。列表使用 ForEach 遍历 this.data.coaches 数组,每个列表项采用与机车列表相同的"图标+内容+数据"三列布局结构,但图标容器尺寸略小(40x40 vs 44x44),体现了不同列表之间的微妙差异化设计。
图标区域的背景色使用 getLevelColor(c.level) + '22'(12% 不透明度),根据车厢等级动态着色。内容区域的标题行包含车厢名称和等级标签(SteamTag),标签颜色由 getLevelColor 函数确定。描述行将车厢类型和座位信息组合显示,其中座位信息使用了条件表达式:c.seats === 0 ? '无座位' : c.seats + '座',当座位数为 0 时显示"无座位",否则显示"X座"。
这种基于数据值的条件渲染逻辑是 ArkTS 声明式 UI 的典型应用。开发者不需要编写命令式的 if-else 代码来控制文本内容,而是直接在属性值中使用三元表达式,框架会自动根据数据变化更新显示内容。车厢列表与机车列表在布局结构上保持一致,但通过不同的颜色映射函数(getLevelColor vs getEraColor)和不同的数据字段实现了各自的内容展示,体现了组件设计中的"结构复用、数据差异"原则。
第四十二段:CoachContent——车厢列表项数据区与详情入口
Column() {
Text(c.year + '')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(getLevelColor(c.level))
Text('出厂年份')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('👁️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onDetail(c.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (c: CoachItem, i: number) => c.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
这段代码是车厢列表项的数据区域和详情入口的实现。数据区域是一个右对齐的 Column,包含出厂年份数值、"出厂年份"标签和"👁️"查看按钮三部分。年份数值使用 13 号粗体,颜色由 getLevelColor 函数确定,与图标背景色和等级标签色保持一致。
"👁️"查看按钮是车厢列表项的交互核心,其 onClick 事件调用 this.onDetail(c.name) 回调,将车厢名称传递给父组件 SteamApp,由父组件设置 detailCoachName 和 showDetail 状态,触发 DetailCoachModal 弹窗的显示。这种"子组件触发事件→父组件处理状态→弹窗组件展示"的事件传递链是 ArkTS 中处理列表项交互的标准模式。
列表项的样式与机车列表保持一致:斑马纹背景、10px 圆角、line 色边框、8px 底部间距。整个车厢列表面板使用 cardAlt 背景色、12px 圆角和 line 色边框,与上方的排行榜面板形成视觉区分。值得注意的是,车厢列表面板的边框色使用 COLORS.line(中性色),而排行榜面板使用 COLORS.accent + '66'(辅助色加透明度),通过边框颜色的差异暗示了两个面板的不同功能属性。
第四十三段:LineContent——统计卡片
@Component
struct LineContent {
@ObjectLink data: SteamData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🛤️ 运行线路')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击✏️调整')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('运营线路')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.primary + '66' })
这段代码是 LineContent 组件的标题行和统计卡片的开头部分。该组件通过 @ObjectLink 引用 SteamData,通过 onEdit 回调触发线路编辑弹窗。标题行使用 15 号字体(比其他内容组件的 13 号更大),使得线路页面的标题更加醒目。
统计卡片区域使用 Row 包含三个等宽的统计卡片,每个卡片使用 layoutWeight(1) 实现等分布局。第一个统计卡片展示"12"条运营线路,数值使用 26 号大字体和 primary 主色粗体显示,标签"运营线路"使用 9 号次级文字色。卡片使用 cardBg 背景色、12px 圆角和 primary + '66'(40% 透明度主色)边框。
统计卡片的设计是数据概览型 UI 的经典模式:大号数值+小号标签的组合使得关键数据一目了然。每个卡片的边框颜色与数值颜色保持同色系(primary 主色边框配 primary 数值),通过透明度差异营造出柔和的边框效果。这种设计在仪表盘和数据概览页面中非常常见,能够在有限的空间内高效传达核心指标。三个卡片分别展示运营线路数、最长线路里程和最低票价,从不同维度概括了线路运营的整体状况。
第四十四段:LineContent——线路列表
ForEach(this.data.lines, (l: LineItem, i: number) => {
Row() {
Column()
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.steamGold + '22')
.borderRadius(6)
Text(l.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(l.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: l.stations + '站', color: COLORS.accent })
.margin({ left: 6 })
}
.width('100%')
Text(l.distance + '公里 · 全程' + l.hours + '小时')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text('¥' + l.fare)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(l.fare >= 100 ? COLORS.warning : COLORS.primaryLight)
Text('票价')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('✏️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onEdit();
})
}
.alignItems(HorizontalAlign.End)
}
这段代码是 LineContent 组件的线路列表实现。列表使用 ForEach 遍历 this.data.lines 数组,每个列表项的图标区域使用 steamGold 金色作为背景色(steamGold + '22',12% 透明度),与机车列表(按时代着色)和车厢列表(按等级着色)形成了视觉差异化。
内容区域的标题行包含线路名称和站点数标签,标签文本通过 l.stations + '站' 动态拼接,颜色使用 accent 辅助色。描述行将距离和耗时组合为"XX公里 · 全程X小时"的格式。数据区域显示票价和"✏️"编辑按钮,票价颜色的设计使用了阈值判断:l.fare >= 100 ? COLORS.warning : COLORS.primaryLight,票价大于等于 100 元时使用 warning 金色,否则使用 primaryLight 浅铜色。
这种基于票价阈值的颜色差异化设计使得高价线路在列表中自然突出,帮助用户快速识别价格较高的线路。"✏️"编辑按钮的 onClick 事件调用 this.onEdit() 回调,触发父组件显示 EditLineModal 编辑弹窗。整体来看,线路列表项的布局结构与机车列表和车厢列表保持一致,但通过不同的图标颜色、标签内容和数据展示实现了线路特有的信息展示。
第四十五段:GiftContent——纪念品销量排行
@Component
struct GiftContent {
@ObjectLink data: SteamData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🎁 纪念品销量TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('蒸汽机车模型夺冠')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(GIFT_TOP, (p: GiftTopMeta, i: number) => {
Row() {
Text((i + 1) + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
.width(22)
Text(p.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(110)
Row() {
Row()
.layoutWeight(p.sales / 900)
.height(8)
.backgroundColor(p.color)
.borderRadius(3)
Row()
.layoutWeight(1 - p.sales / 900)
.height(8)
.backgroundColor(COLORS.cardAlt)
.borderRadius(3)
}
.layoutWeight(1)
.height(8)
Text(p.sales + '件')
.fontSize(9)
.fontColor(p.color)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7 })
}, (p: GiftTopMeta, i: number) => p.name + i)
}
这段代码是 GiftContent 组件的纪念品销量排行榜部分。该组件通过 @ObjectLink 引用 SteamData,通过 onDel 回调向父组件传递商品名称以触发下架弹窗。排行榜的布局结构与 CoachContent 中的热门线路排行榜几乎完全一致,使用"序号+名称+进度条+数值"的四列布局。
两者的细微差异在于列宽分配:纪念品排行榜中名称列宽度为 110px(线路排行榜为 88px),因为纪念品名称通常更长;数值列宽度为 44px(线路排行榜为 48px),因为"件"的单位比"人"短一个字符。进度条填充比例使用 p.sales / 900(以 900 为基准,略大于最大值 860),与线路排行榜的 / 5000 基准类似,都选择了略大于最大值的整数作为分母。
这种"同一布局结构、不同数据维度"的设计模式在数据展示型应用中非常高效。开发者可以复用同一段布局代码,只需替换数据源和单位文本即可创建新的排行榜。虽然当前实现中两个排行榜的代码是独立的(没有抽象为通用组件),但在实际项目中,如果排行榜数量进一步增加,可以考虑将其抽象为一个通用的 RankListComponent,通过属性传递数据源、列宽和单位等配置参数。
第四十六段:GiftContent——纪念品列表
Column() {
Row() {
Text('🛍️ 车站商店')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击🗑️下架')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.gifts, (g: GiftItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getGiftTypeColor(g.type) + '22')
.borderRadius(6)
Text(g.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: g.type, color: getGiftTypeColor(g.type) })
.margin({ left: 6 })
}
.width('100%')
Text('¥' + g.price + ' · 已售' + g.sales + '件')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(g.stock > 50 ? '充足' : '告急')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(g.stock > 50 ? COLORS.success : COLORS.danger)
Text('库存' + g.stock)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('🗑️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onDel(g.name);
})
}
.alignItems(HorizontalAlign.End)
}
这段代码是 GiftContent 组件的纪念品列表实现。列表使用 ForEach 遍历 this.data.gifts 数组,每个列表项的布局与机车、车厢列表一致,采用"图标+内容+数据"三列结构。图标区域背景色使用 getGiftTypeColor(g.type) + '22',根据商品类型动态着色。
数据区域的设计是纪念品列表的亮点。库存状态使用了双阈值视觉反馈:当 g.stock > 50 时显示"充足"标签和 success 绿色,否则显示"告急"标签和 danger 红色。这种基于库存阈值的视觉预警机制使得管理者在浏览列表时能快速识别需要补货的商品,是电商管理界面的标准实践。
"🗑️"下架按钮的 onClick 事件调用 this.onDel(g.name) 回调,将商品名称传递给父组件,触发 DeleteGiftModal 弹窗的显示。描述行将价格和销量组合为"¥XX · 已售XX件"的格式,让用户同时获取价格和热销度两个维度的信息。整体来看,纪念品列表项在保持与其他列表一致布局结构的同时,通过库存状态的颜色编码实现了额外的数据维度展示,是列表项信息密度设计的良好范例。
第四十七段:ReviewContent——评论列表
@Component
struct ReviewContent {
@ObjectLink data: SteamData;
build() {
Scroll() {
Column() {
Row() {
Text('📜 游客留言')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('平均4.5分 · 共12条')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.reviews, (r: ReviewItem, i: number) => {
Column() {
Row() {
Column()
.width(36)
.height(36)
.justifyContent(FlexAlign.Center)
.backgroundColor(getTagColor(r.tag) + '22')
.borderRadius(6)
Text(r.emoji)
.fontSize(15)
.width(26)
.textAlign(TextAlign.Center)
Column() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('⭐'.repeat(Math.round(r.score)) + ' · ' + r.date)
.fontSize(9)
.fontColor(COLORS.warning)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
SteamTag({ text: r.tag, color: getTagColor(r.tag) })
}
.width('100%')
Text(r.content)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.lineHeight(18)
.width('100%')
.margin({ top: 8 })
}
这段代码定义了 ReviewContent 评论列表组件。该组件只通过 @ObjectLink 引用 SteamData,没有回调函数,因为评论页面是纯展示型的,不涉及管理操作。标题行显示"游客留言"标题和"平均4.5分 · 共12条"的统计信息。
评论列表项的布局与前四个列表有所不同。它使用 Column 作为外层容器而非 Row,因为评论项包含头部行和正文内容两个垂直排列的区域。头部行使用 Row 布局,包含图标(36x36,背景色由 getTagColor 函数根据标签着色)、Emoji、评论者信息(昵称+星级评分+日期)和标签四部分。
星级评分的实现使用了 '⭐'.repeat(Math.round(r.score)) 的方式,将评分数值通过 Math.round 取整后,用字符串重复方法生成对应数量的星号 Emoji。这是一种简洁而有效的评分可视化方案,无需图片资源或自定义星形组件。评分和日期组合显示,使用 warning 金色,与星级 Emoji 的视觉风格呼应。评论正文使用 11 号字体和 18px 行高,保证了长文本的可读性。
第四十八段:AddTrainModal 入库弹窗
@Component
struct AddTrainModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🚂')
.fontSize(26)
Column() {
Text('机车入库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('录入新到馆机车')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('机车名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('新绿皮号 · 蒸汽时代')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
这段代码定义了 AddTrainModal 机车入库弹窗组件的开头部分。该组件只接收一个 onClose 回调函数参数,用于关闭弹窗。弹窗的标题区域使用 Row 布局,包含机车 Emoji 图标、标题文字("机车入库"主标题+"录入新到馆机车"副标题)和关闭按钮"✕"三部分。
关闭按钮"✕"的 onClick 事件直接调用 this.onClose() 回调,这种"组件内触发→父组件处理"的事件传递模式与列表项中的回调机制一致。标题区域使用了与页头类似的"图标+标题+操作"三列布局,但操作区域只有一个关闭按钮而非多个交互图标。
弹窗的内容区域使用"标签+值"的行式布局展示预填信息。每行包含一个固定宽度 70px 的标签(如"机车名称")和一个弹性宽度的值文本。这些信息目前是静态的预填内容(如"新绿皮号 · 蒸汽时代"),没有使用输入框组件。在实际应用中,这些行可以替换为 TextInput 组件以支持用户输入。每行使用 cardAlt 背景色和 10px 圆角,行间距 8px,形成了卡片式的信息展示效果。约束高度 maxHeight: '78%' 确保弹窗不会超出屏幕范围,justifyContent(FlexAlign.End) 使弹窗内容在容器底部对齐,模拟从底部弹出的效果。
第四十九段:AddTrainModal——操作按钮与提示
Text('✅ 新车入库前须完成锅炉安检与轨距测量,展牌由馆方统一制作。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
Text('确认入库')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.primary + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
这段代码是 AddTrainModal 弹窗的底部部分,包含安全提示文本和操作按钮组。提示文本"✅ 新车入库前须完成锅炉安检与轨距测量,展牌由馆方统一制作"使用 10 号字体和 textHint 灰色,提供了入库流程的安全须知信息,增强了应用的真实感和业务完整性。
操作按钮组使用 Row 布局,包含"取消"和"确认入库"两个等宽按钮。两个按钮都使用 layoutWeight(1) 实现等分宽度,但视觉风格形成鲜明对比:取消按钮使用 cardAlt 背景色和 textSecondary 文字色,呈现弱化效果;确认按钮使用 primary 主色背景、白色粗体文字,呈现强调效果。这种"弱化取消+强调确认"的按钮设计在弹窗 UI 中是标准的视觉层级处理方式,引导用户优先关注主要操作。
两个按钮的 onClick 事件都调用 this.onClose(),这意味着当前实现中无论点击哪个按钮都会关闭弹窗,没有实际执行入库操作。在实际应用中,"确认入库"按钮应该调用一个 onSubmit 回调来执行数据保存逻辑,然后关闭弹窗。弹窗容器使用 cardBg 背景色、14px 圆角和 primary + '66' 边框色,与标题区域的主色调呼应,形成了统一的视觉主题。
第五十段:EditLineModal 编辑线路弹窗
@Component
struct EditLineModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🛤️')
.fontSize(26)
Column() {
Text('编辑线路信息')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('调整票价与班次')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('票价调整')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('¥68 → ¥78')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
这段代码定义了 EditLineModal 编辑线路弹窗组件的开头部分。该组件的结构与 AddTrainModal 高度一致,只在标题内容、信息行内容和按钮颜色上有所差异。标题区域显示"编辑线路信息"主标题和"调整票价与班次"副标题,使用线路 Emoji “🛤️” 作为图标。
信息行展示了线路编辑的具体内容。与 AddTrainModal 的静态预填信息不同,EditLineModal 的信息行展示了"变化前后"的对比值,如票价调整行显示"¥68 → ¥78",直观地呈现了修改前后的差异。票价调整行使用 warning 金色文字,暗示价格上调的信息;发车间隔行使用 textPrimary 文字色,表示中性的运营调整;调整说明行使用 success 绿色,传达积极的改进信息。
这种"变化对比"的信息展示方式在编辑类弹窗中非常有效,它让用户在确认操作前清楚地了解修改的具体内容。每行的布局结构与 AddTrainModal 完全一致:70px 固定标签+弹性值文本,cardAlt 背景色和 10px 圆角。弹窗容器的边框色使用 COLORS.accent + '66'(辅助色加透明度),与 AddTrainModal 的 primary 边框色形成区分,通过边框颜色暗示了弹窗的功能类型差异。
第五十一段:EditLineModal——操作按钮与提示
Text('⚠️ 票价调整需提前一周公示,已售车票差额由馆方承担。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
Text('保存修改')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.accent + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
这段代码是 EditLineModal 弹窗的底部部分,包含票价调整须知和操作按钮组。提示文本"⚠️ 票价调整需提前一周公示,已售车票差额由馆方承担"使用 warning 语气前缀"⚠️",提供了票价调整的合规须知信息,增强了应用的业务真实感。
操作按钮组的结构与 AddTrainModal 一致,但"保存修改"按钮使用 accent 辅助色(深蓝灰)作为背景色,而非 AddTrainModal 中的 primary 主色。这种按钮颜色的差异化设计使得不同功能弹窗的主操作按钮具有不同的视觉识别度:入库操作用铜橙色(primary),编辑操作用深蓝灰(accent),下架操作用红色(danger),形成了一套基于功能语义的按钮颜色体系。
弹窗容器的约束设置与 AddTrainModal 完全一致:width 100%、justifyContent End、maxHeight 78%。这种统一的容器约束确保了所有弹窗在屏幕上的位置和大小保持一致,提供了统一的弹窗交互体验。两个弹窗在外层 Column 上都没有设置半透明遮罩背景,这意味着底层内容不会被遮罩覆盖。在实际应用中,通常需要在弹窗外层添加一个半透明背景层来遮挡底层内容并提供点击外部关闭的功能。
第五十二段:DeleteGiftModal 下架确认弹窗
@Component
struct DeleteGiftModal {
@Prop title: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Column() {
Text('🗑️')
.fontSize(34)
Text('确认下架纪念品')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 10 })
Text('「' + this.title + '」将从车站商店移除,线上订单已售出不受影响。')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
Text('确认下架')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.danger + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
这段代码定义了 DeleteGiftModal 下架确认弹窗组件。该组件接收一个 @Prop title 参数(纪念品名称)和一个 onClose 回调函数。与前面两个弹窗不同,DeleteGiftModal 使用了 @Prop 而非直接属性声明来接收 title 参数,@Prop 确保了该值在组件内部是只读的,由父组件传递并不可修改。
弹窗的内容布局与前两个弹窗有显著差异。它没有标题行和多个信息行,而是采用居中布局展示一个大的"🗑️"图标(34 号字体)、确认标题"确认下架纪念品"和说明文本。说明文本使用 this.title 动态拼接:「 + this.title + 」将从车站商店移除,线上订单已售出不受影响。,向用户明确了下架操作的影响范围。
"确认下架"按钮使用 danger 红色作为背景色,与下架操作的"危险"语义高度匹配。这种基于操作语义的按钮颜色设计在前面的弹窗中已经建立了体系:入库用 primary 铜橙色、编辑用 accent 深蓝灰、下架用 danger 红色。弹窗容器的边框色也使用了 danger + '66'(40% 透明度红色),在视觉上强化了"危险操作"的警示感。整体来看,DeleteGiftModal 的设计简洁而聚焦,是一个典型的"确认对话框"实现。
第五十三段:DetailCoachModal 车厢详情弹窗
@Component
struct DetailCoachModal {
@Prop name: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🚃')
.fontSize(26)
Column() {
Text('车厢详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('车厢「' + this.name + '」')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('车厢等级')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('一等 · 现役展出')
.fontSize(12)
.fontColor(COLORS.primaryLight)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
这段代码定义了 DetailCoachModal 车厢详情弹窗组件的开头部分。该组件接收一个 @Prop name 参数(车厢名称)和一个 onClose 回调函数。标题区域的结构与 AddTrainModal 和 EditLineModal 一致,使用车厢 Emoji “🚃” 作为图标,副标题通过 this.name 动态拼接显示当前查看的车厢名称。
信息行展示了车厢的详细属性,包括车厢等级、载客数量、开放方式和车厢简介。每行的值文本使用了不同的颜色来传达不同的语义:车厢等级"一等 · 现役展出"使用 primaryLight 浅铜色,暗示其较高的展示价值;载客数量"60座 · 观景大窗"使用 textPrimary 白色,表示中性的数据信息;开放方式"随车体验 · 需预约"使用 warning 金色,提示需要预约的限制条件。
车厢简介行使用了 Column 容器而非 Row,因为简介内容是多行文本,需要独占整行宽度。简介文本描述了车厢的历史背景和乘坐体验,使用 12 号字体和 20px 行高,保证了长文本的可读性。这种"标签行+多行文本"的混合布局在详情类弹窗中非常常见,能够在有限空间内展示结构化数据和描述性文本两种类型的信息。
第五十四段:DetailCoachModal——简介与关闭按钮
Column() {
Text('车厢简介')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('本车厢为上世纪三十年代手工打造,保留了原木内饰与铜制拉手。乘客可坐在藤椅上随蒸汽机车穿行,体验百年前铁路旅行的缓慢与浪漫。')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.lineHeight(20)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
.margin({ top: 8 })
Text('🚂 凭当日车票可免费进入车厢内部参观。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Text('知道了')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.primary)
.borderRadius(10)
.margin({ top: 16 })
.onClick(() => {
this.onClose();
})
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.primaryLight + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
这段代码是 DetailCoachModal 弹窗的底部部分,包含车厢简介、参观提示和关闭按钮。车厢简介区域使用 Column 容器,标题"车厢简介"使用 11 号次级文字色,正文使用 12 号主文字色和 20px 行高,确保了长文本的良好阅读体验。简介文本描绘了车厢的历史价值和乘坐体验,增强了应用的文化沉浸感。
参观提示文本"🚂 凭当日车票可免费进入车厢内部参观"使用 10 号 textHint 灰色,提供了实用的参观指南信息。关闭按钮设计为全宽单按钮"知道了",使用 primary 主色背景和白色粗体文字,与前三个弹窗的双按钮布局形成差异。这种单按钮设计是因为详情弹窗是纯信息展示,没有"确认"或"取消"的操作选择,只需一个关闭按钮即可。
弹窗容器的边框色使用 primaryLight + '66'(浅铜色加透明度),与 AddTrainModal 的 primary + '66' 略有不同,通过更浅的边框颜色暗示了详情弹窗的"温和"性质(与入库弹窗的"操作"性质相对)。至此,四个弹窗组件的边框颜色形成了完整的语义体系:入库用 primary(操作类)、编辑用 accent(调整类)、下架用 danger(危险类)、详情用 primaryLight(信息类),通过颜色编码传达了弹窗的功能属性。
技术对比总结表
| 技术要点 | 实现方式 | 优势 | 适用场景 |
|---|---|---|---|
| 色彩体系管理 | SteamPalette 接口 + COLORS 常量集中管理 | 单一数据源,修改一处全局生效;类型安全,编译时检查 | 中大型应用的多主题色彩管理 |
| 状态管理分层 | @Observed+@ObjectLink 管理业务数据,@State 管理局部状态 | 数据修改自动触发 UI 更新;状态职责清晰分离 | 需要多组件响应数据变化的场景 |
| 标签页路由 | 枚举 + if-else 条件渲染 | 语义化索引,可读性强;切换逻辑简单直接 | 标签页数量较少(5个以内)的应用 |
| 组件通信 | 属性向下传递 + 回调函数向上通信 | 单向数据流,状态修改集中管理;子组件无副作用 | 父子层级明确的组件树结构 |
| 数据可视化 | Row/Column + layoutWeight 原生实现条形图 | 无第三方依赖,减小体积;样式与主题高度一致 | 简单图表展示,无需复杂交互 |
| 颜色语义映射 | 四个映射函数按业务维度返回颜色 | 颜色决策集中管理;业务语义与视觉属性解耦 | 需要按数据类型动态着色的列表展示 |
| 弹窗管理 | 独立布尔状态变量 + 条件渲染 | 弹窗控制灵活,支持多弹窗并存;渲染高效 | 弹窗数量适中的应用 |
| 按钮颜色编码 | primary/accent/danger/primaryLight 四色体系 | 操作类型通过颜色语义化;用户快速感知操作后果 | 需要区分操作类型(增/改/删/查)的界面 |
| 列表斑马纹 | 索引取模交替背景色 | 提升长列表可读性;实现简单无额外开销 | 数据量超过一屏的列表展示 |
| 进度条实现 | 双 Row + layoutWeight 按比例分配 | 精确的按比例填充;无需计算像素值 | 数据占比可视化展示 |
| 标签组件复用 | @Component + @Prop 接收文本和颜色 | 一处定义多处使用;视觉风格统一 | 多种数据类型需要标签化展示 |
| 评分可视化 | Emoji 字符串重复 repeat 方法 | 无需图片资源;跨平台渲染一致 | 1-5 星评分展示场景 |
| 库存预警 | 阈值判断 + 状态色差异化(success/danger) | 管理者快速识别需补货商品;视觉反馈直观 | 电商库存管理界面 |
| 排行榜高亮 | 前三名使用主色,其余使用灰色 | 突出头部排名;视觉层级分明 | Top N 排行榜展示 |
| 动画交互 | animation 属性 + 状态驱动 transform | 声明式动画,无需手动控制帧;缓动曲线可选 | 微交互动画、主题沉浸感增强 |
| 数据预处理 | 排行榜数据定义时即完成排序 | 渲染时无需排序计算;性能优化 | 静态排行榜数据展示 |
| 静态与动态数据分离 | 展示型常量与 @Observed 业务数据分离 | 减少不必要的状态管理开销;职责清晰 | 兼具静态展示和动态管理的应用 |
| 弹窗约束 | constraintSize maxHeight + justifyContent End | 弹窗不超出屏幕;从底部弹出视觉效果 | 底部弹出的模态对话框 |
| 多层背景设计 | bg/cardBg/cardAlt 三层深色叠加 | 深色主题下信息层次清晰;避免大量边框 | 深色主题应用的信息分层 |
| @Builder 复用 | 声明可参数化的 UI 构建方法 | 比 @Component 更轻量;可接受动态参数 | 弹窗遮罩等简单可复用 UI 片段 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
interface SteamPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
steamGold: string;
ironGray: string;
}
const COLORS: SteamPalette = {
primary: '#B0602A',
primaryLight: '#D89A66',
primaryDark: '#5C3A1E',
accent: '#3E5C76',
accentLight: '#9FB6C9',
bg: '#23201D',
cardBg: '#332E29',
cardAlt: '#2B2723',
textPrimary: '#F2E8DC',
textSecondary: '#C4B7A6',
textHint: '#8A7D6E',
border: '#4A423A',
line: '#403A33',
success: '#7FA65A',
warning: '#D9A441',
danger: '#C05B3C',
white: '#FFFFFF',
steamGold: '#D9A441',
ironGray: '#8B8B8B'
};
enum SteamTab {
TRAIN = 0,
COACH = 1,
LINE = 2,
GIFT = 3,
REVIEW = 4
}
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'train', icon: '🚂', label: '机车', color: '#B0602A' },
{ key: 'coach', icon: '🚃', label: '车厢', color: '#3E5C76' },
{ key: 'line', icon: '🛤️', label: '线路', color: '#D9A441' },
{ key: 'gift', icon: '🎁', label: '纪念品', color: '#7FA65A' },
{ key: 'review', icon: '📜', label: '留言', color: '#C05B3C' }
];
const RIVET_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
interface TrainItem {
name: string;
era: string;
year: number;
power: number;
speed: number;
emoji: string;
}
interface CoachItem {
name: string;
type: string;
seats: number;
year: number;
level: string;
emoji: string;
}
interface LineItem {
name: string;
distance: number;
stations: number;
fare: number;
hours: number;
emoji: string;
}
interface GiftItem {
name: string;
type: string;
price: number;
sales: number;
stock: number;
emoji: string;
}
interface ReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
interface WeekMeta {
day: string;
value: number;
}
interface EngineMeta {
name: string;
count: number;
color: string;
}
interface LineHotMeta {
name: string;
riders: number;
color: string;
}
interface GiftTopMeta {
name: string;
sales: number;
color: string;
}
const WEEK_RIDE: WeekMeta[] = [
{ day: '周一', value: 220 },
{ day: '周二', value: 186 },
{ day: '周三', value: 240 },
{ day: '周四', value: 268 },
{ day: '周五', value: 356 },
{ day: '周六', value: 620 },
{ day: '周日', value: 548 }
];
const ENGINE_SHARE: EngineMeta[] = [
{ name: '蒸汽机车', count: 6, color: '#B0602A' },
{ name: '内燃机车', count: 3, color: '#3E5C76' },
{ name: '电力机车', count: 2, color: '#7FA65A' },
{ name: '动车组', count: 1, color: '#D9A441' }
];
const LINE_HOT: LineHotMeta[] = [
{ name: '绿皮慢车线', riders: 4820, color: '#B0602A' },
{ name: '峡谷观光线', riders: 4360, color: '#3E5C76' },
{ name: '滨海蒸汽线', riders: 3980, color: '#7FA65A' },
{ name: '森林小火车', riders: 3520, color: '#D9A441' },
{ name: '古镇怀旧线', riders: 2860, color: '#C05B3C' },
{ name: '雪山观光带', riders: 2140, color: '#9FB6C9' }
];
const GIFT_TOP: GiftTopMeta[] = [
{ name: '蒸汽机车模型', sales: 860, color: '#B0602A' },
{ name: '铁路怀旧明信片', sales: 720, color: '#3E5C76' },
{ name: '铜制怀表', sales: 640, color: '#D9A441' },
{ name: '火车头徽章', sales: 580, color: '#7FA65A' },
{ name: '复古车票夹', sales: 460, color: '#C05B3C' },
{ name: '铁轨镇纸', sales: 380, color: '#9FB6C9' }
];
@Observed
export class SteamData {
trains: TrainItem[] = [
{ name: '黑金刚', era: '蒸汽时代', year: 1920, power: 2100, speed: 80, emoji: '🚂' },
{ name: '巨灵神', era: '蒸汽时代', year: 1932, power: 2600, speed: 90, emoji: '🏭' },
{ name: '太行号', era: '蒸汽时代', year: 1948, power: 2800, speed: 95, emoji: '⛰️' },
{ name: '东风一号', era: '内燃时代', year: 1958, power: 3200, speed: 110, emoji: '🚆' },
{ name: '草原骄子', era: '内燃时代', year: 1964, power: 3500, speed: 120, emoji: '🌾' },
{ name: '韶山一号', era: '电力时代', year: 1972, power: 4200, speed: 130, emoji: '⚡' },
{ name: '北疆之星', era: '电力时代', year: 1980, power: 4600, speed: 140, emoji: '🌟' },
{ name: '复兴先锋', era: '动车时代', year: 1998, power: 5800, speed: 200, emoji: '🚄' },
{ name: '和谐号', era: '动车时代', year: 2008, power: 7200, speed: 250, emoji: '🦅' },
{ name: '黎明号', era: '蒸汽时代', year: 1936, power: 2400, speed: 85, emoji: '🌅' },
{ name: '山鹰', era: '内燃时代', year: 1968, power: 3100, speed: 105, emoji: '🦅' },
{ name: '水晶宫号', era: '客运时代', year: 1986, power: 4000, speed: 125, emoji: '💎' }
];
coaches: CoachItem[] = [
{ name: '绿皮软卧车', type: '卧铺', seats: 36, year: 1958, level: '一等', emoji: '🛏️' },
{ name: '红木餐车', type: '餐车', seats: 48, year: 1932, level: '特等', emoji: '🍽️' },
{ name: '蒸汽观景车', type: '观光', seats: 60, year: 1928, level: '一等', emoji: '🔭' },
{ name: '邮务车厢', type: '货运', seats: 0, year: 1946, level: '普通', emoji: '📮' },
{ name: '硬座怀旧车', type: '硬座', seats: 118, year: 1962, level: '普通', emoji: '🪑' },
{ name: '贵宾沙龙车', type: '沙龙', seats: 24, year: 1938, level: '特等', emoji: '🍷' },
{ name: '行李车厢', type: '货运', seats: 0, year: 1940, level: '普通', emoji: '🧳' },
{ name: '双层观光车', type: '观光', seats: 88, year: 1985, level: '一等', emoji: '🪟' },
{ name: '蒸汽守车', type: '守车', seats: 12, year: 1930, level: '普通', emoji: '🛎️' },
{ name: '卧铺包厢车', type: '卧铺', seats: 28, year: 1974, level: '一等', emoji: '🛋️' },
{ name: '儿童乐园车', type: '娱乐', seats: 42, year: 1990, level: '普通', emoji: '🎠' },
{ name: '历史陈列车', type: '展览', seats: 0, year: 1925, level: '特等', emoji: '🏛️' }
];
lines: LineItem[] = [
{ name: '绿皮慢车线', distance: 86, stations: 12, fare: 28, hours: 4, emoji: '🌳' },
{ name: '峡谷观光线', distance: 120, stations: 8, fare: 68, hours: 5, emoji: '🏞️' },
{ name: '滨海蒸汽线', distance: 96, stations: 6, fare: 88, hours: 3, emoji: '🌊' },
{ name: '森林小火车', distance: 42, stations: 9, fare: 58, hours: 2, emoji: '🌲' },
{ name: '古镇怀旧线', distance: 64, stations: 7, fare: 48, hours: 3, emoji: '🏮' },
{ name: '雪山观光带', distance: 150, stations: 5, fare: 128, hours: 6, emoji: '🏔️' },
{ name: '稻香田园线', distance: 58, stations: 10, fare: 38, hours: 2, emoji: '🌾' },
{ name: '湖畔晨光线', distance: 72, stations: 6, fare: 52, hours: 3, emoji: '🌅' },
{ name: '矿山探险线', distance: 108, stations: 8, fare: 78, hours: 5, emoji: '⛏️' },
{ name: '花海踏春线', distance: 66, stations: 9, fare: 46, hours: 2, emoji: '🌸' },
{ name: '星空夜行线', distance: 132, stations: 7, fare: 98, hours: 5, emoji: '🌌' },
{ name: '茶山品香线', distance: 54, stations: 5, fare: 62, hours: 3, emoji: '🍵' }
];
gifts: GiftItem[] = [
{ name: '蒸汽机车模型', type: '模型', price: 298, sales: 860, stock: 46, emoji: '🚂' },
{ name: '铁路怀旧明信片', type: '文创', price: 18, sales: 720, stock: 200, emoji: '💌' },
{ name: '铜制怀表', type: '复古', price: 388, sales: 640, stock: 32, emoji: '⌚' },
{ name: '火车头徽章', type: '文创', price: 28, sales: 580, stock: 150, emoji: '🪙' },
{ name: '复古车票夹', type: '皮具', price: 68, sales: 460, stock: 88, emoji: '🎫' },
{ name: '铁轨镇纸', type: '摆件', price: 88, sales: 380, stock: 64, emoji: '⚙️' },
{ name: '蒸汽笛声八音盒', type: '摆件', price: 168, sales: 420, stock: 52, emoji: '🎵' },
{ name: '铁路工装外套', type: '服饰', price: 258, sales: 320, stock: 26, emoji: '🧥' },
{ name: '老站长玩偶', type: '玩偶', price: 48, sales: 510, stock: 96, emoji: '🧸' },
{ name: '铁轨图案帆布包', type: '皮具', price: 58, sales: 350, stock: 72, emoji: '👜' },
{ name: '车票磁贴套装', type: '文创', price: 22, sales: 620, stock: 130, emoji: '🧲' },
{ name: '铜哨子', type: '复古', price: 38, sales: 290, stock: 58, emoji: '📯' }
];
reviews: ReviewItem[] = [
{ name: '铁轨迷', score: 5, date: '08-15', content: '绿皮慢车线太有味道了,全程窗外都是田园风光!', tag: '体验极佳', emoji: '🌳' },
{ name: '老站长', score: 5, date: '08-14', content: '博物馆里的蒸汽机车保养得真好,看得出用心。', tag: '馆藏丰富', emoji: '🚂' },
{ name: '小火车迷', score: 4, date: '08-13', content: '儿童乐园车厢娃玩疯了,就是周末排队久。', tag: '排队较长', emoji: '🎠' },
{ name: '摄影爱好者', score: 5, date: '08-12', content: '滨海蒸汽线配落日,随手一拍都是大片。', tag: '风景绝美', emoji: '🌊' },
{ name: '历史控', score: 5, date: '08-11', content: '守车和邮务车厢还原度极高,历史细节拉满。', tag: '馆藏丰富', emoji: '📮' },
{ name: '咖啡客', score: 4, date: '08-10', content: '红木餐车里的咖啡不错,环境复古有格调。', tag: '环境氛围', emoji: '🍽️' },
{ name: '亲子游', score: 4, date: '08-09', content: '蒸汽笛声八音盒买回家孩子天天玩,值。', tag: '商品满意', emoji: '🎵' },
{ name: '背包客', score: 3, date: '08-08', content: '森林小火车票略贵,风景其实不如峡谷线。', tag: '性价比低', emoji: '🌲' },
{ name: '文艺青年', score: 5, date: '08-07', content: '星空夜行线太浪漫了,车顶观景窗绝了。', tag: '氛围满分', emoji: '🌌' },
{ name: '老铁路人', score: 5, date: '08-06', content: '看到韶山一号就想起年轻时候跑车的日子。', tag: '情怀满分', emoji: '⚡' },
{ name: '学生党', score: 4, date: '08-05', content: '学生票很友好,怀旧车票夹做工精致。', tag: '价格实惠', emoji: '🎫' },
{ name: '团建组织', score: 5, date: '08-04', content: '包了贵宾沙龙车做团建,服务很周到。', tag: '服务贴心', emoji: '🍷' }
];
}
function getEraColor(era: string): string {
if (era === '蒸汽时代') {
return '#B0602A';
} else if (era === '内燃时代') {
return '#3E5C76';
} else if (era === '电力时代') {
return '#7FA65A';
}
return '#D9A441';
}
function getLevelColor(level: string): string {
if (level === '特等') {
return '#C05B3C';
} else if (level === '一等') {
return '#D9A441';
}
return '#8B8B8B';
}
function getTagColor(tag: string): string {
if (tag === '体验极佳' || tag === '馆藏丰富' || tag === '风景绝美' || tag === '情怀满分') {
return '#B0602A';
} else if (tag === '环境氛围' || tag === '氛围满分' || tag === '服务贴心') {
return '#3E5C76';
} else if (tag === '商品满意' || tag === '价格实惠') {
return '#7FA65A';
} else if (tag === '排队较长') {
return '#C05B3C';
}
return '#8B8B8B';
}
function getGiftTypeColor(type: string): string {
if (type === '模型' || type === '摆件') {
return '#B0602A';
} else if (type === '文创') {
return '#3E5C76';
} else if (type === '皮具' || type === '服饰') {
return '#7FA65A';
}
return '#D9A441';
}
@Entry
@Component
struct SteamApp {
@State curTab: number = 0;
@State data: SteamData = new SteamData();
@State showAddTrain: boolean = false;
@State showEditLine: boolean = false;
@State showDeleteGift: boolean = false;
@State showDetail: boolean = false;
@State delGiftName: string = '';
@State detailCoachName: string = '';
@State trainRun: boolean = false;
@State gearSpin: boolean = false;
@Builder modalOverlay(onClose: () => void) {
Column() {
Text('')
.width(0)
.height(0)
.opacity(0)
Button('')
.width(1)
.height(1)
.opacity(0)
.onClick(() => {
onClose();
})
}
.width(1)
.height(1)
}
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🚂 蒸汽火车博物馆')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Steam Train Museum · 铁轨上的时光')
.fontSize(10)
.fontColor('#FFFFFFB3')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🚂')
.fontSize(20)
.onClick(() => {
this.trainRun = !this.trainRun;
})
.translate({ x: this.trainRun ? 12 : 0 })
.animation({ duration: 800, curve: Curve.EaseInOut })
Text('⚙️')
.fontSize(18)
.margin({ left: 10 })
.onClick(() => {
this.gearSpin = !this.gearSpin;
})
.rotate({ angle: this.gearSpin ? 360 : 0 })
.animation({ duration: 1500, curve: Curve.Linear })
Text('🛤️')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#FFFFFF26')
.borderRadius(8)
.border({ width: 1, color: '#FFFFFF40' })
}
.width('100%')
Row() {
ForEach(RIVET_COL, (c: number) => {
Column()
.width(4)
.height(4)
.margin({ left: 2, right: 2 })
.backgroundColor(c % 2 === 0 ? COLORS.steamGold : COLORS.ironGray)
.borderRadius(2)
}, (c: number) => 'rivet' + c)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 10 })
Row() {
Column()
.layoutWeight(1)
.height(4)
.backgroundColor(COLORS.steamGold)
.borderRadius(2)
Text('⚙️ 蒸汽动力 · 百年铁轨 ⚙️')
.fontSize(9)
.fontColor('#FFFFFFCC')
.margin({ left: 8, right: 8 })
Column()
.layoutWeight(1)
.height(4)
.backgroundColor(COLORS.steamGold)
.borderRadius(2)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primaryDark)
if (this.curTab === SteamTab.TRAIN) {
TrainContent({ data: this.data, onAdd: () => {
this.showAddTrain = true;
} })
} else if (this.curTab === SteamTab.COACH) {
CoachContent({ data: this.data, onDetail: (n: string) => {
this.detailCoachName = n;
this.showDetail = true;
} })
} else if (this.curTab === SteamTab.LINE) {
LineContent({ data: this.data, onEdit: () => {
this.showEditLine = true;
} })
} else if (this.curTab === SteamTab.GIFT) {
GiftContent({ data: this.data, onDel: (n: string) => {
this.delGiftName = n;
this.showDeleteGift = true;
} })
} else {
ReviewContent({ data: this.data })
}
}
.width('100%')
.height('100%')
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
.fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primaryDark : COLORS.cardBg)
.borderRadius(10)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '88' } : { width: 1, color: COLORS.line })
.onClick(() => {
this.curTab = TAB_LIST.indexOf(t);
})
}, (t: TabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
.border({ width: 1, color: COLORS.line })
if (this.showAddTrain) {
AddTrainModal({
onClose: () => {
this.showAddTrain = false;
}
})
}
if (this.showEditLine) {
EditLineModal({
onClose: () => {
this.showEditLine = false;
}
})
}
if (this.showDeleteGift) {
DeleteGiftModal({
title: this.delGiftName, onClose: () => {
this.showDeleteGift = false;
}
})
}
if (this.showDetail) {
DetailCoachModal({
name: this.detailCoachName, onClose: () => {
this.showDetail = false;
}
})
}
}
}
}
@Component
struct SteamTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(4)
.border({ width: 1, color: this.color + '40' })
}
}
@Component
struct TrainContent {
@ObjectLink data: SteamData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 周乘务量')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六620人次')
.fontSize(9)
.fontColor(COLORS.danger)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_RIDE, (w: WeekMeta) => {
Column() {
Text(Math.round(w.value / 10) + '')
.fontSize(8)
.fontColor(w.value >= 500 ? COLORS.primaryLight : COLORS.textSecondary)
Column()
.width(20)
.height(w.value / 6)
.backgroundColor(w.value >= 500 ? COLORS.primary : COLORS.accent)
.borderRadius(3)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekMeta) => w.day)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.primary + '66' })
Column() {
Row() {
Text('🔥 机车馆藏分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('蒸汽机车最多')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(ENGINE_SHARE, (n: EngineMeta) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(76)
Row() {
Row()
.layoutWeight(n.count / 6)
.height(8)
.backgroundColor(n.color)
.borderRadius(3)
Row()
.layoutWeight(1 - n.count / 6)
.height(8)
.backgroundColor(COLORS.cardAlt)
.borderRadius(3)
}
.layoutWeight(1)
.height(8)
Text(n.count + '台')
.fontSize(9)
.fontColor(n.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (n: EngineMeta) => n.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
Column() {
Row() {
Text('🚂 镇馆机车')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击➕入库')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('➕')
.fontSize(13)
.margin({ left: 8 })
.onClick(() => {
this.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.trains, (t: TrainItem, i: number) => {
Row() {
Column()
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(getEraColor(t.era) + '22')
.borderRadius(6)
.border({ width: 2, color: getEraColor(t.era) + '55' })
Text(t.emoji)
.fontSize(19)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: t.era, color: getEraColor(t.era) })
.margin({ left: 6 })
}
.width('100%')
Text(t.year + '年 · 功率' + t.power + '马力')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(t.speed + 'km/h')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(getEraColor(t.era))
Text('最高时速')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (t: TrainItem, i: number) => t.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.border({ width: 1, color: COLORS.primary + '66' })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct CoachContent {
@ObjectLink data: SteamData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🏆 热门线路榜')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('绿皮慢车线居首')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(LINE_HOT, (h: LineHotMeta, i: number) => {
Row() {
Text((i + 1) + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
.width(22)
Text(h.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(88)
Row() {
Row()
.layoutWeight(h.riders / 5000)
.height(8)
.backgroundColor(h.color)
.borderRadius(3)
Row()
.layoutWeight(1 - h.riders / 5000)
.height(8)
.backgroundColor(COLORS.cardAlt)
.borderRadius(3)
}
.layoutWeight(1)
.height(8)
Text(h.riders + '人')
.fontSize(9)
.fontColor(h.color)
.width(48)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7 })
}, (h: LineHotMeta, i: number) => h.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.accent + '66' })
Column() {
Row() {
Text('🚃 特色车厢')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击👁️查看')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.coaches, (c: CoachItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getLevelColor(c.level) + '22')
.borderRadius(6)
Text(c.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: c.level, color: getLevelColor(c.level) })
.margin({ left: 6 })
}
.width('100%')
Text(c.type + ' · ' + (c.seats === 0 ? '无座位' : c.seats + '座'))
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(c.year + '')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(getLevelColor(c.level))
Text('出厂年份')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('👁️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onDetail(c.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (c: CoachItem, i: number) => c.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct LineContent {
@ObjectLink data: SteamData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🛤️ 运行线路')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击✏️调整')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('运营线路')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.primary + '66' })
Column() {
Text('150km')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
Text('最长线路')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.warning + '66' })
.margin({ left: 10 })
Column() {
Text('¥28')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accentLight)
Text('最低票价')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.accent + '66' })
.margin({ left: 10 })
}
.width('100%')
.margin({ bottom: 12 })
ForEach(this.data.lines, (l: LineItem, i: number) => {
Row() {
Column()
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.steamGold + '22')
.borderRadius(6)
Text(l.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(l.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: l.stations + '站', color: COLORS.accent })
.margin({ left: 6 })
}
.width('100%')
Text(l.distance + '公里 · 全程' + l.hours + '小时')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text('¥' + l.fare)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(l.fare >= 100 ? COLORS.warning : COLORS.primaryLight)
Text('票价')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('✏️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onEdit();
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (l: LineItem, i: number) => l.name + i)
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct GiftContent {
@ObjectLink data: SteamData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🎁 纪念品销量TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('蒸汽机车模型夺冠')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(GIFT_TOP, (p: GiftTopMeta, i: number) => {
Row() {
Text((i + 1) + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(i < 3 ? COLORS.primary : COLORS.textHint)
.width(22)
Text(p.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(110)
Row() {
Row()
.layoutWeight(p.sales / 900)
.height(8)
.backgroundColor(p.color)
.borderRadius(3)
Row()
.layoutWeight(1 - p.sales / 900)
.height(8)
.backgroundColor(COLORS.cardAlt)
.borderRadius(3)
}
.layoutWeight(1)
.height(8)
Text(p.sales + '件')
.fontSize(9)
.fontColor(p.color)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7 })
}, (p: GiftTopMeta, i: number) => p.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.border({ width: 1, color: COLORS.success + '66' })
Column() {
Row() {
Text('🛍️ 车站商店')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击🗑️下架')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.gifts, (g: GiftItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getGiftTypeColor(g.type) + '22')
.borderRadius(6)
Text(g.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SteamTag({ text: g.type, color: getGiftTypeColor(g.type) })
.margin({ left: 6 })
}
.width('100%')
Text('¥' + g.price + ' · 已售' + g.sales + '件')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(g.stock > 50 ? '充足' : '告急')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(g.stock > 50 ? COLORS.success : COLORS.danger)
Text('库存' + g.stock)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('🗑️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onDel(g.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (g: GiftItem, i: number) => g.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct ReviewContent {
@ObjectLink data: SteamData;
build() {
Scroll() {
Column() {
Row() {
Text('📜 游客留言')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('平均4.5分 · 共12条')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.reviews, (r: ReviewItem, i: number) => {
Column() {
Row() {
Column()
.width(36)
.height(36)
.justifyContent(FlexAlign.Center)
.backgroundColor(getTagColor(r.tag) + '22')
.borderRadius(6)
Text(r.emoji)
.fontSize(15)
.width(26)
.textAlign(TextAlign.Center)
Column() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('⭐'.repeat(Math.round(r.score)) + ' · ' + r.date)
.fontSize(9)
.fontColor(COLORS.warning)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
SteamTag({ text: r.tag, color: getTagColor(r.tag) })
}
.width('100%')
Text(r.content)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.lineHeight(18)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(12)
.border({ width: 1, color: COLORS.line })
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 8 })
}, (r: ReviewItem, i: number) => r.name + i)
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct AddTrainModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🚂')
.fontSize(26)
Column() {
Text('机车入库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('录入新到馆机车')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('机车名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('新绿皮号 · 蒸汽时代')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('入馆来源')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('铁路局捐赠 · 已修复')
.fontSize(12)
.fontColor(COLORS.accentLight)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('技术参数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('功率2400 · 时速90')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('开放状态')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('静态展出 · 暂不试乘')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Text('✅ 新车入库前须完成锅炉安检与轨距测量,展牌由馆方统一制作。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
Text('确认入库')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.primary + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct EditLineModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🛤️')
.fontSize(26)
Column() {
Text('编辑线路信息')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('调整票价与班次')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('线路名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('峡谷观光线 · 8站')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('票价调整')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('¥68 → ¥78')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('发车间隔')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('30分钟 → 40分钟')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('调整说明')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('增设观景停车点')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Text('⚠️ 票价调整需提前一周公示,已售车票差额由馆方承担。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
Text('保存修改')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.accent + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct DeleteGiftModal {
@Prop title: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Column() {
Text('🗑️')
.fontSize(34)
Text('确认下架纪念品')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 10 })
Text('「' + this.title + '」将从车站商店移除,线上订单已售出不受影响。')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
Text('确认下架')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(10)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.danger + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct DetailCoachModal {
@Prop name: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🚃')
.fontSize(26)
Column() {
Text('车厢详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('车厢「' + this.name + '」')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('车厢等级')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('一等 · 现役展出')
.fontSize(12)
.fontColor(COLORS.primaryLight)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('载客数量')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('60座 · 观景大窗')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('开放方式')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('随车体验 · 需预约')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Column() {
Text('车厢简介')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('本车厢为上世纪三十年代手工打造,保留了原木内饰与铜制拉手。乘客可坐在藤椅上随蒸汽机车穿行,体验百年前铁路旅行的缓慢与浪漫。')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.lineHeight(20)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
.margin({ top: 8 })
Text('🚂 凭当日车票可免费进入车厢内部参观。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Text('知道了')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.primary)
.borderRadius(10)
.margin({ top: 16 })
.onClick(() => {
this.onClose();
})
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.primaryLight + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
结尾总结

通过对这款蒸汽火车博物馆应用的完整代码分析,我们可以从多个维度提炼出其架构设计的核心要点和工程价值。
从整体架构来看,该应用采用了经典的"单入口+多内容页+多弹窗"的三层组件架构。主入口组件 SteamApp 作为状态管理中心和路由控制器,统一管理所有跨组件共享的状态(当前标签页、弹窗显示状态、弹窗参数、动画状态),并通过属性传递和回调函数与子组件进行通信。五个内容页面组件各自负责一个功能模块的数据展示,四个弹窗组件各自处理一种交互操作。这种架构在中小型应用中是非常合理的选择,它在组件职责清晰划分的同时,避免了过度抽象带来的复杂度。如果应用规模进一步增长(例如每个标签页下需要嵌套子标签),可以考虑引入更细粒度的状态管理方案,如将弹窗状态管理抽取为独立的 Store 或引入局部状态管理。
从状态管理的角度来看,该应用展示了 ArkTS 声明式 UI 框架状态管理体系的完整应用。@Observed + @ObjectLink 用于可观察的业务数据对象(SteamData),确保数据修改能自动触发 UI 更新;@State 用于组件内部的局部状态管理(标签切换、弹窗控制、动画触发);@Prop 用于父到子的只读数据传递(弹窗参数);@Builder 用于可复用的 UI 构建方法(遮罩层)。这种分层状态管理体系使得不同类型的状态有明确的管理归属,避免了状态混乱和不可控的更新链。特别值得关注的是 @Observed 与 @ObjectLink 的配合使用——SteamData 实例在主组件中通过 @State 创建,在子组件中通过 @ObjectLink 引用,实现了数据的单向流动和多组件响应式更新。
从数据架构的角度来看,该应用展示了"业务数据"与"展示数据"分离的设计理念。SteamData 类承载了五类核心业务数据(机车、车厢、线路、纪念品、评论),通过 @Observed 标记为可观察对象,支持运行时修改和 UI 更新。而 WEEK_RIDE、ENGINE_SHARE、LINE_HOT、GIFT_TOP 等图表和排行榜数据则定义为全局静态常量,因为它们是统计汇总值,在应用运行期间不会发生变化。这种区分避免了将不必要的数据纳入可观察体系,减少了状态管理的开销。同时,四种颜色映射函数(getEraColor、getLevelColor、getTagColor、getGiftTypeColor)将业务语义与视觉属性解耦,使得颜色策略的修改只需调整函数实现即可全局生效。
从可视化设计的角度来看,该应用完全使用基础布局组件(Row、Column)和 layoutWeight 属性实现了多种数据可视化形式,包括垂直条形图、水平进度条、排行榜和统计卡片,没有依赖任何第三方图表库。这种"原生图表"的实现方式不仅减小了应用体积,还保证了图表样式与整体 UI 风格的高度一致性。layoutWeight 的按比例分配特性被巧妙地用于实现进度条的填充比例计算,通过"填充条+背景条"的双 Row 组合,精确地模拟了专业图表库的进度条效果。阈值条件判断(如 value >= 500、fare >= 100、stock > 50)被用于实现数据驱动的颜色差异化,使得关键数据在视觉上自然突出。
从视觉设计体系的角度来看,该应用建立了一套完整的"蒸汽朋克"设计令牌系统。SteamPalette 接口定义了 19 个语义化命名的颜色字段,涵盖了主色、辅助色、背景色、卡片色、文字色、边框色、状态色和主题特色色等全链路的设计令牌。三层背景色(bg/cardBg/cardAlt)通过微小的明度差异建立了深色主题下的信息层次,三级文字色(Primary/Secondary/Hint)确保了信息层级的清晰传达。弹窗按钮的颜色编码体系(primary=入库、accent=编辑、danger=下架、primaryLight=详情)通过颜色语义化了操作类型,增强了用户对操作后果的感知。铆钉装饰行、金色分隔线、火车和齿轮的动画交互等"蒸汽朋克"主题元素,为应用赋予了强烈的品牌识别度。
从组件复用策略的角度来看,SteamTag 标签组件是整个应用中复用频率最高的组件,在五个内容页面中被广泛用于展示机车时代、车厢等级、线路站点、商品类型和评论标签等信息。这种高频复用的基础组件是组件化开发的核心价值体现——一次定义,多处使用,保证视觉风格一致性的同时降低了维护成本。四个弹窗组件虽然功能不同,但在布局结构上高度一致(标题行+信息行+操作按钮),体现了"结构复用、数据差异"的组件设计原则。如果项目进一步发展,可以考虑将这些弹窗抽象为一个通用的 ModalShell 组件,通过 slot 或 builder 参数注入具体内容。
从交互体验的角度来看,该应用在页头区域设计了两个微交互动画:火车 Emoji 的位移动画和齿轮 Emoji 的旋转动画。这些动画虽然不承载业务功能,但极大地增强了应用的趣味性和主题沉浸感,是优秀移动应用中常见的"惊喜元素"。动画的实现使用了 ArkTS 的 animation 属性与状态驱动的 transform 属性配合,开发者只需声明目标状态值和动画参数,框架自动处理中间帧的计算和渲染。两个动画使用了不同的缓动曲线(EaseInOut 和 Linear),根据动画的物理特性选择合适的运动模式,体现了对动画细节的考究。
从工程化实践的角度来看,该应用在代码组织中遵循了"常量定义→接口定义→数据模型→工具函数→组件实现"的自底向上组织顺序,使得代码读者可以从基础数据结构开始逐步理解应用的完整架构。TypeScript 的类型系统被充分利用,所有数据结构都有明确的接口定义,所有组件的属性都有类型标注,保证了编译时的类型安全。ForEach 的键值生成函数为每个列表项提供了唯一标识,确保了列表更新时的精确 diff 和高效渲染。这些工程化实践虽然在小项目中看似"过度设计",但在项目规模增长时将体现出显著的维护性优势。
更多推荐




所有评论(0)