旅行探索项目。同样是 HarmonyOS ArkTS 技术栈同样是首页 多页面的结构但旅行 App 的首页在视觉和数据结构上都有明显的差异——最突出的是那块橙色渐变的 Hero Banner以及嵌套了景点和美食数据的城市卡片。完整效果项目结构概览先看一下这个 App 的全貌。pages 目录下有 7 个页面文件页面职责Index探索首页CityDetail城市详情景点美食CuisinePage美食指南CurrencyPage汇率换算PhraseBook旅行短语TripPlanner行程规划ProfilePage个人中心services 目录下有一个TravelData.ets定义了四个接口和完整的 mock 数据。和运动健康 App 的WorkoutData不同TravelData 的数据结构是嵌套的——每个城市对象里面包含了它的景点数组和美食数组而不是用 ID 引用再查询。颜色体系从运动红到旅行橙constA:string#FF6B35// 主色·旅行橙constBG:string#F8F7FC// 背景灰constCW:string#FFFFFF// 卡片白constT1:string#1E1B2E// 标题黑constT2:string#888888// 副文本constT3:string#BBBBBB// 辅助灰主色从运动健康的#FF4757红换成了#FF6B35橙。橙色在旅行场景里很常见——它代表热情、活力、温暖和探索世界的主题契合。T1/T2/T3 三级文本颜色保持不变这是跨项目复用的视觉基础设施。页面骨架Stack Scroll Column和运动健康首页完全一样的骨架结构Stack({alignContent:Alignment.Bottom}){Scroll(){Column(){// 内容区}}// 底部导航栏固定在 Stack 底部}Stack把内容层和导航层叠在一起Scroll让内容区可以上下滚动导航栏固定在底部不跟着滚。这个模式在两个 App 里已经验证过了是一个可靠的首页布局范式。Header头像区的渐变圆Stack(){Column(){}.width(42).height(42).borderRadius(21).linearGradient({angle:135,colors:[[A,0],[#FF9F43,1]]})Text(✈️).fontSize(18)}一个 42×42 的圆形用橙色到金色的 135° 渐变填充上面叠一个飞机 emoji。这是用户头像的占位——运动健康 App 里用的是跑步 emoji这里换成了飞机保持和 App 主题的一致。Stack在这里的作用是让 emoji 居中显示在渐变圆上方。Stack({ alignContent: Alignment.Center })是默认行为不需要额外指定。Hero Banner视觉重心的设计Hero Banner 是整个首页视觉冲击力最强的元素。它是一块 150vp 高的渐变卡片用橙色三段渐变作为背景Stack({alignContent:Alignment.Center}){Column(){}.width(100%).height(150).borderRadius(20).linearGradient({angle:135,colors:[[A,0],[#FF8C42,0.5],[#FFB07C,1]]})Circle({width:120,height:120}).fill(rgba(255,255,255,0.06)).position({x:60%,y:-20})Row(){Column(){Text().fontSize(44).margin({bottom:8})Text(探索热门目的地).fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)Text(6大城市 · 深度旅行指南).fontSize(12).fontColor(rgba(255,255,255,0.65)).margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:18})Text().fontSize(40).margin({right:18})}.width(100%)}.width(100%).height(150).borderRadius(20).shadow({radius:16,color:A25,offsetY:6})逐层分析三段渐变背景[[A, 0], [#FF8C42, 0.5], [#FFB07C, 1]]定义了三个颜色断点——起点是主色橙#FF6B35中间是深橙#FF8C42终点是浅橙#FFB07C。角度 135° 表示从左上到右下的对角线渐变。比运动健康首页的两段渐变更细腻视觉层次更丰富。装饰性半透明圆Circle({ width: 120, height: 120 }).fill(rgba(255,255,255,0.06))是一个几乎看不见的白色半透明圆放在右上方x: 60%, y: -20。它的作用纯粹是装饰——打破纯色渐变的单调感让背景有一点微妙的纹理。rgba(255,255,255,0.06)的透明度只有 6%在渐变背景上几乎察觉不到但就是这点细节能让 Banner 看起来不那么平。左右分栏内容左侧是标题文字地球 emoji 主标题 副标题右侧是一个旅行箱 emoji。layoutWeight(1)让左侧占据所有剩余空间旅行箱被推到最右边。投影效果shadow({ radius: 16, color: A 25, offsetY: 6 })给 Banner 加了一层橙色系的阴影。A 25是主色加上 15% 透明度#FF6B3525比纯灰色阴影更协调。城市网格数据驱动的卡片Grid(){ForEach(CITIES,(city:City){GridItem(){Column(){Text(city.emoji).fontSize(40).margin({bottom:8})Text(city.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)Text(city.country).fontSize(11).fontColor(T3)Row(){Text(⭐ city.rating.toString()).fontSize(10).fontColor(A)}.margin({top:4})}.width(100%).padding(14).backgroundColor(CW).borderRadius(16).shadow({radius:4,color:rgba(0,0,0,0.03),offsetY:1}).onClick((){router.pushUrl({url:pages/CityDetail,params:{cityId:city.id}asRecordstring,Object})})}})}.columnsTemplate(1fr 1fr).columnsGap(10).rowsGap(10).width(100%).padding({left:16,right:16}).margin({bottom:24})两列网格每个城市卡片包含四层信息emoji 图标、城市名称、国家名称、评分。点击卡片跳转到CityDetail页面通过params传递cityId。这里有一个值得注意的设计——城市 emoji 图标没有用小号的装饰性 emoji如运动健康首页快捷按钮的 24px emoji而是用了fontSize(40)的大号图标。这让每张卡片的视觉重心集中在图标上城市名称和国家是辅助信息。对于旅行类 App 来说这种图标优先的展示方式比文字优先更能激发用户的探索欲。数据传递cityId 的参数化路由.onClick((){router.pushUrl({url:pages/CityDetail,params:{cityId:city.id}asRecordstring,Object})})跳转到城市详情页时不是把整个city对象传过去ArkTS 的路由参数只支持基本类型而是只传cityId。详情页在aboutToAppear里根据 ID 从CITIES数组里查找对应的城市对象aboutToAppear():void{constprouter.getParams()asRecordstring,Objectif(pp[cityId]){constid:numberp[cityId]asnumberfor(leti:number0;iCITIES.length;i){if(CITIES[i].idid){this.cityCITIES[i];break}}}}这种传 ID、查数据的模式比传整个对象更可靠。如果以后数据源从本地 mock 换成服务器 API详情页只需要改数据获取方式不需要改路由参数的格式。旅行工具快捷入口的横向排列Row({space:10}){this.ToolBtn(,行程规划,#5DADE2,(){router.pushUrl({url:pages/TripPlanner})})this.ToolBtn(,美食指南,#E8734A,(){router.pushUrl({url:pages/CuisinePage})})this.ToolBtn(,汇率换算,#00B894,(){router.pushUrl({url:pages/CurrencyPage})})this.ToolBtn(️,旅行短语,#8A5DFA,(){router.pushUrl({url:pages/PhraseBook})})}四个工具按钮用Row({ space: 10 })横向排列每个按钮占据等宽空间layoutWeight(1)。这个布局和运动健康首页的快捷操作按钮完全一样——不同的颜色区分功能相同的结构保证一致性。ToolBtn Builder 的实现和运动健康首页的 QuickBtn 也是一样的模式BuilderToolBtn(icon:string,label:string,color:string,action:()void){Column(){Text(icon).fontSize(28).margin({bottom:6})Text(label).fontSize(11).fontColor(T1)}.layoutWeight(1).padding({top:14,bottom:14}).backgroundColor(color10).borderRadius(14).onClick(action)}color 10是给背景色加上 6% 的透明度16 进制的10 16 的十进制 6.3%让按钮背景是极浅的主题色。这个透明度计算方式在两个 App 里都用到了是一个复用的模式。底部导航栏BuilderNavBtn(idx:number,label:string,active:boolean){Column({space:2}){Text(label探索?:(label行程?:(label美食?:(label工具?:)))).fontSize(20)Text(label).fontSize(9).fontColor(active?A:#C0BFC6)}.onClick((){if(idx1)router.pushUrl({url:pages/TripPlanner})elseif(idx2)router.pushUrl({url:pages/CuisinePage})elseif(idx3)router.pushUrl({url:pages/CurrencyPage})elseif(idx4)router.pushUrl({url:pages/ProfilePage})})}和运动健康首页的底部导航栏结构完全一样——五个 Tab活跃态用主色非活跃态用灰色。区别只在图标和文字内容。有一个细节当idx 0探索 Tab时onClick 里没有任何操作。这是合理的——用户已经在首页了再点探索不需要跳转。但更好的做法是让点击后 Scroll 滚动到顶部这样用户在浏览了城市列表后可以快速回到 Hero Banner。目前这个交互还缺失。数据层嵌套结构 vs ID 引用TravelData 的数据设计和运动健康 App 的 WorkoutData 有本质区别旅行 AppTravelDatainterfaceCity{landmarks:Landmark[]// 直接嵌套景点数组cuisines:Cuisine[]// 直接嵌套美食数组}// 查询时直接 city.landmarks 即可旅行 App 用嵌套是因为城市和景点/美食是明确的一对多关系——浅草寺只属于东京不可能同时属于巴黎。嵌套结构在读取时更方便不需要二次查询而且数据量有限6 个城市每个城市几个景点不存在冗余问题。选择哪种数据结构取决于业务关系不是技术偏好。如果以后这个旅行 App 的景点数量增长到几百个且多个城市可能共享某些景点比如连锁餐厅可能需要改成 ID 引用的方式。但在当前规模下嵌套是最直观的选择。