HarmonyOS实战《疆域纪行》第07篇|路线时间轴:多日行程、住宿和亮点如何做成可读结构

发布时间:2026/7/15 20:13:42
HarmonyOS实战《疆域纪行》第07篇|路线时间轴:多日行程、住宿和亮点如何做成可读结构 这一篇专门讲路线模块。旅行路线不是普通列表它要表达“第几天去哪、住哪里、吃什么、亮点是什么”。疆域纪行用一套轻量时间轴把多日行程做成了可读结构。项目落点路线模型RouteItem单日模型RouteDayDetail路线页RoutePage()路线详情RouteDetail()时间轴RouteTimeline()、RouteTimelineDay()行程状态itineraryIds读完这一篇你可以复用一套路线展示方案列表展示路线决策信息详情页用时间轴展示每日安排本地行程只保存稳定 id。本文解决什么把多日路线拆成RouteItem和RouteDayDetail避免详情页硬编码行程。用时间轴表达第几天、住哪里、吃什么和当天亮点让路线可扫读。把路线和景点都加入本地行程清单先跑通 MVP 的个人规划闭环。路线模块为什么单独成篇旅行攻略 App 里路线不是普通列表。它承载的是“把多个目的地按时间组织起来”的能力。《疆域纪行》的路线模块包含三层体验路线列表展示多条预设路线。路线详情查看每日安排。行程清单把感兴趣的路线或景点加入本地计划。第一版没有做复杂拖拽编辑但已经把路线规划 MVP 的核心打通了。能力MVP 做法暂不做的原因预设路线内置RouteItem内容可控首版最快闭环每日安排RouteDayDetail时间轴比纯文本更容易扫读本地行程保存 id 列表先验证收藏和规划意图拖拽编辑暂不实现需要更复杂的数据结构和交互验证这个取舍很适合第一版先让用户看懂路线、收藏路线、保存路线再谈复杂编辑。路线数据模型回顾路线模型是RouteItemexport interface RouteItem { id: string; title: string; days: string; region: string; summary: string; tags: string[]; color: string; image: Resource; bestSeason: string; transport: string; dailyPlans: string[]; dayDetails: RouteDayDetail[]; tips: string[]; } export interface RouteDayDetail { title: string; meal: string; stay: string; highlight: string; }dailyPlans适合列表概览dayDetails适合详情时间轴。一个字段用于轻量浏览一个字段用于深度阅读这是路线类内容很实用的设计。路线页结构路线页很简洁Builder RoutePage() { Column({ space: 16 }) { this.PageHeader(路线规划, Route Planner) this.RoutePlannerCard() ForEach(this.routes, (item: RouteItem) { this.RouteCard(item) }, (item: RouteItem) item.id) } }顶部的RoutePlannerCard负责展示当前本地行程概览下面的RouteCard展示官方预设路线。这套结构符合旅行规划流程先看到自己的计划再浏览可加入的路线。路线卡片列表页只展示决策信息路线卡片需要回答几个问题这条路线叫什么玩几天覆盖哪个区域适合什么主题是否值得点进去代码上它通常包含封面图标题天数区域摘要标签收藏入口这里仍然使用稳定 id 作为ForEachkeyForEach(this.routes, (item: RouteItem) { this.RouteCard(item) }, (item: RouteItem) item.id)在 ArkUI 列表中key 很重要。使用稳定 id 可以减少列表重建时的状态错乱也方便后续插入、删除、排序。路线详情页结构路线详情页与景点详情类似但中间主体换成时间轴Builder RouteDetail(item: RouteItem) { Stack({ alignContent: Alignment.TopStart }) { Column() { this.DetailTopBar() Scroll() { Column({ space: 0 }) { this.DetailHero(item.color, item.image) Column({ space: 16 }) { this.RouteDetailHeader(item) Text(item.summary) .fontSize(14) .fontColor(#34424B) .lineHeight(23) this.SectionTitle(行程概览, DAILY PLAN) this.RouteTimeline(item) this.SectionTitle(出行提示, TRAVEL NOTES) ForEach(item.tips, (tip: string) { this.NoteRow(tip) }, (tip: string) tip) } .padding({ left: 18, right: 18, top: 18, bottom: 188 }) } } } } }路线详情页的重点不是图文排版而是如何把“多天安排”做得清楚。时间轴组件时间轴入口Builder RouteTimeline(item: RouteItem) { Column({ space: 0 }) { ForEach(item.dayDetails, (day: RouteDayDetail, index: number) { this.RouteTimelineDay(item, day, index) }, (day: RouteDayDetail) day.title) } .padding({ left: 0, right: 0, top: 4, bottom: 4 }) .width(100%) .backgroundColor(Color.White) .borderRadius(18) }这里把每一天拆成RouteTimelineDay让时间轴主体保持简单。如果后续要加入“每日景点列表”“车程”“导航跳转”也可以只扩展单日组件不影响整体结构。单日节点设计单日时间轴节点代码Builder RouteTimelineDay(item: RouteItem, day: RouteDayDetail, index: number) { Row({ space: 10 }) { Column() { Text(D${index 1}) .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .width(42) .height(42) .textAlign(TextAlign.Center) .backgroundColor(#000000) .borderRadius(21) if (index item.dayDetails.length - 1) { Column() .width(1) .height(118) .backgroundColor(#1B1B1B) } } .width(46) .alignItems(HorizontalAlign.Center) Column({ space: 10 }) { Text(day.title) .fontSize(18) .fontColor(#111111) .fontWeight(FontWeight.Bold) Row({ space: 12 }) { this.RouteMealMeta(day.meal) this.RouteStayMeta(day.stay) } Text(day.highlight) .fontSize(13) .fontColor(#111111) .fontWeight(FontWeight.Medium) .lineHeight(20) .backgroundColor(#FFE347) .borderRadius(7) } .layoutWeight(1) .constraintSize({ minWidth: 0 }) } }这个时间轴有几个细节左侧用D1/D2/D3形成强识别。非最后一天继续画竖线形成连续感。右侧内容使用layoutWeight(1)避免长标题挤压布局。当日亮点用高亮背景强调便于快速扫读。时间轴不需要复杂组件Row Column 条线 圆点就能完成。行程清单路线和景点共用 id 列表行程清单状态State itineraryIds: string[] [];加入或移除private toggleItinerary(id: string): void { if (this.isInItinerary(id)) { this.itineraryIds this.itineraryIds.filter((item: string) item ! id); } else { this.itineraryIds [...this.itineraryIds, id]; } this.persistItinerary(); }景点详情页可以加入行程路线详情页也可以加入行程。因为保存的是 id所以需要分别从景点库和路线库里解析private getItinerarySpots(): AtlasItem[] { return this.itineraryIds .map((id: string) this.spots.find((item: AtlasItem) item.id id)) .filter((item: AtlasItem | undefined) !!item) as AtlasItem[]; } private getItineraryRoutes(): RouteItem[] { return this.itineraryIds .map((id: string) this.routes.find((item: RouteItem) item.id id)) .filter((item: RouteItem | undefined) !!item) as RouteItem[]; }这种写法简单直接。未来如果行程类型更多可以把 id 改成带类型的结构interface ItineraryRef { kind: spot | route; id: string; }但当前项目里景点 id 和路线 id 前缀不同使用字符串数组已经足够。行程摘要项目用一个方法生成本地行程摘要private getItinerarySummary(): string { return ${this.getItineraryRoutes().length}条路线 / ${this.getItinerarySpots().length}个景点本地保存; }这里没有单独保存 count而是每次从itineraryIds推导。对于这种小数据派生计算比手动维护计数更安全。手动维护计数常见问题是添加成功了但计数没更新删除失败了但计数先减了。派生值能避免这类不一致。路线模块后续可以怎么升级当前路线规划是 MVP已经实现预设路线和本地加入。后续可以逐步增强支持按天添加景点。支持拖拽调整每日顺序。支持自定义路线命名。支持路线导出为文本或图片。支持按季节推荐路线。支持景点之间参考车程。但这些功能都应该建立在当前模型之上而不是推翻重来。第一版先把 id、路线详情、时间轴和本地持久化跑通是正确的节奏。升级时建议优先保留RouteItem和RouteDayDetail两层结构只给每日详情增加spotIds、driveTime、note这类字段。这样列表页、详情页和收藏逻辑都能复用真正变化的是行程编辑页而不是整个路线系统。验证清单检查项操作方式通过标准每条路线都有dayDetails对照项目文件或真机/模拟器操作结果符合文章描述时间轴最后一天不继续画竖线对照项目文件或真机/模拟器操作结果符合文章描述长住宿名称不会挤出屏幕对照项目文件或真机/模拟器操作结果符合文章描述路线加入行程后摘要数量更新对照项目文件或真机/模拟器操作结果符合文章描述重启后行程清单仍存在对照项目文件或真机/模拟器操作结果符合文章描述常见问题和处理问题现象优先排查处理方式时间轴高度错乱检查单日节点高度和竖线高度长文案加 maxLines 和省略路线和景点混在一起解析失败检查 id 前缀或类型判断必要时升级为{ kind, id }结构摘要数量不准不要手动维护计数从 id 列表重新派生本篇小结路线模块的实战要点路线模型要区分概览字段和详情字段。列表页展示决策信息详情页展示每日安排。时间轴可以用基础 ArkUI 组件组合实现。行程清单保存稳定 id不复制完整对象。摘要从原始状态派生避免手动计数不同步。下一篇我们看资源和启动体验如何管理本地图片、启动页、图标和离线素材让 App 看起来不像 Demo。