
城市详情页是旅行 App 里信息密度最高的页面——一个页面里塞了 Hero 横幅、货币语言标签、景点列表、美食列表四个区块。从数据结构看它是整个 App 里唯一一个从外部接收 ID → 查找完整对象 → 分区展示嵌套数据的页面也是路由参数 ID 查询模式的唯一使用者。完整效果页面结构从上到下四个区块Hero 横幅城市 emoji 名称 国家 描述 评分 最佳季节信息标签货币和语言两个 Chip必游景点景点列表每项有 emoji、名称、描述、分类标签、费用当地美食美食列表每项有 emoji、名称、描述、价格整体用Scroll Column实现内容超出屏幕时上下滚动。参数接收与数据查找Statecity:City|undefinedundefinedaboutToAppear():void{constprouter.getParams()asRecordstring,Objectif(pp[cityId]){constid:numberp[cityId]asnumberfor(leti:number0;iCITIES.length;i){if(CITIES[i].idid){this.cityCITIES[i];break}}}}和美食详情页的全字段传递不同城市详情页只接收一个cityId然后在aboutToAppear里遍历CITIES数组查找对应城市。找到后赋值给State cityUI 自动渲染。city的类型是City | undefined——初始值是undefined找到数据后变成City对象。这意味着build()里需要处理city为空的情况。条件渲染city 为空时if(this.city){Scroll(){Column(){// Hero、标签、景点、美食}}}if (this.city)保证了只有在找到城市数据时才渲染内容区。如果cityId无效或CITIES数组为空页面只显示导航栏内容区是空白。这是一个基本的空状态处理。更好的做法是在找不到数据时显示一个提示比如城市不存在并自动返回但目前这个版本没有处理。Hero 横幅Stack({alignContent:Alignment.BottomStart}){Column(){}.width(100%).height(180).borderRadius(20).linearGradient({angle:135,colors:[[A,0],[#FF9F43,1]]})Column(){Text(this.city.emoji).fontSize(64)Text(this.city.name · this.city.country).fontSize(22).fontWeight(FontWeight.Bold).fontColor(Color.White).margin({top:8})Text(this.city.desc).fontSize(13).fontColor(rgba(255,255,255,0.7)).margin({top:2})Row(){Text(⭐ this.city.rating.toString()).fontSize(12).fontColor(Color.White).fontWeight(FontWeight.Medium).padding({left:10,right:10,top:4,bottom:4}).backgroundColor(rgba(255,255,255,0.25)).borderRadius(8)Text( this.city.bestSeason).fontSize(12).fontColor(rgba(255,255,255,0.8))}.margin({top:8})}.padding(20)}Stack({ alignContent: Alignment.BottomStart })把内容对齐到左下角——渐变背景铺满整个 180vp 区域文字信息贴着底部排列。这和首页的 HeroAlignment.Center居中对齐不同详情页的 Hero 信息量更大左下角对齐更符合阅读习惯。评分标签用了半透明白色背景Text(⭐ this.city.rating.toString()).backgroundColor(rgba(255,255,255,0.25)).borderRadius(8)rgba(255,255,255,0.25)是 25% 不透明度的白色在橙色渐变背景上形成一个磨砂玻璃效果。这种半透明标签在深色背景上很常见——既保证了文字可读性又不会完全遮挡背景。信息标签ChipRow({space:8}){this.Chip(,this.city.currency)this.Chip(,this.city.language)}.width(100%).padding({left:16,right:16}).margin({bottom:16})BuilderChip(icon:string,text:string){Row(){Text(icon).fontSize(12)Text( text).fontSize(11).fontColor(T2)}.padding({left:10,right:10,top:5,bottom:5}).backgroundColor(#FFFFFF).borderRadius(10)}两个 Chip 分别显示货币和语言信息。Chip 的实现很简单——一个 Row 包裹 emoji 文字白底圆角。这个 Chip Builder 在整个项目里只被城市详情页使用。和 ToolCard、Menu 等高频复用的 Builder 不同Chip 的使用场景很窄所以没有被其他页面引用。但把它抽成 Builder 仍然有价值——如果以后要在其他页面显示类似的信息标签比如天气页面的温度湿度标签可以直接复用。景点列表Text(必游景点).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1).width(100%).padding({left:16}).margin({bottom:10})Column({space:10}){ForEach(this.city.landmarks,(lm:Landmark){Row(){Text(lm.emoji).fontSize(32).width(56).height(56).borderRadius(14).backgroundColor(#F5F5F7).textAlign(TextAlign.Center)Column(){Text(lm.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)Text(lm.desc).fontSize(11).fontColor(T3).maxLines(2).margin({top:2})Row(){Text(lm.category).fontSize(10).fontColor(A).padding({left:6,right:6,top:1,bottom:1}).backgroundColor(#FFF5F0).borderRadius(4)Text( lm.fee).fontSize(10).fontColor(T3)}.margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)}.width(100%).padding(12).backgroundColor(#FFFFFF).borderRadius(14)})}每个景点卡片分左右两部分左侧是 56×56 的 emoji 容器和美食指南的 FoodItem 用的是同一个尺寸右侧是三层信息——景点名称、描述最多两行、分类标签 费用。maxLines 的作用Text(lm.desc).fontSize(11).fontColor(T3).maxLines(2)maxLines(2)限制描述文字最多显示两行。如果描述超过两行会在末尾显示省略号ArkTS 的默认行为。这防止了某个景点的超长描述把整个卡片撑得很高影响其他卡片的视觉一致性。分类标签Text(lm.category).fontSize(10).fontColor(A).padding({left:6,right:6,top:1,bottom:1}).backgroundColor(#FFF5F0).borderRadius(4)分类标签如历史文化、“城市地标”用 10px 字号 浅橙色背景和美食指南的城市标签是同一套视觉语言。标签的圆角只有 4px比城市标签的 3px 稍大因为分类标签的文字通常更长四个字太小的圆角会让长文字显得拥挤。美食列表Text(当地美食).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1).width(100%).padding({left:16}).margin({bottom:10})Column({space:10}){ForEach(this.city.cuisines,(cu:Cuisine){Row(){Text(cu.emoji).fontSize(32)Column(){Text(cu.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)Text(cu.desc).fontSize(11).fontColor(T3).margin({top:2})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)Text(cu.price).fontSize(12).fontColor(A).fontWeight(FontWeight.Medium)}.width(100%).padding(14).backgroundColor(#FFFFFF).borderRadius(14)})}美食列表比景点列表简单——没有 emoji 底色容器直接显示 emoji没有分类标签右侧只有价格。这是因为美食的信息层级比景点少景点需要分类和费用美食只需要价格。ForEach 的数据源景点和美食的ForEach都直接遍历this.city.landmarks和this.city.cuisines——这些数据是City对象自带的嵌套数组不需要额外查询。这正是嵌套数据结构的优势读取时直接访问不需要二次查找。如果用的是运动健康 App 那样的 ID 引用模式这里就需要先取出 ID 数组再逐个查询动作详情代码会复杂很多。页面间的数据关系城市详情页的数据来源可以用一张图概括首页 (Index) │ │ params: cityId ↓ CityDetail │ ├→ CITIES[cityId] (城市基本信息) │ ├→ city.landmarks (景点数组直接遍历) │ └→ city.cuisines (美食数组直接遍历) │ └→ 跳转到美食详情页 │ params: name, emoji, city, desc, price ↓ FoodDetail城市详情页是整个数据流的中转站——从首页接收 ID查找完整数据展示嵌套内容再把部分字段传递给美食详情页。这个中转站的设计让数据流变成了树状结构首页是根节点城市详情页是分支节点美食详情页是叶子节点。每个节点只关心自己需要的数据不需要了解整棵树的全貌。和其他详情页的对比维度CityDetailFoodDetailExerciseDetail运动健康参数传递ID 查询全字段ID 查询数据嵌套有landmarks, cuisines无有exercisesHero 高度180vp200vp200vp内容区块4 个2 个3 个条件渲染有city 存在检查无有exercise 存在检查三个详情页的共同点是都有 Hero 横幅和条件渲染区别在于数据复杂度和参数传递方式。城市详情页是三者中信息密度最高的美食详情页是最简单的运动详情页介于两者之间。