HarmonyOS实战《疆域纪行》第07篇|路线时间轴:多日行程、住宿和亮点如何做成可读结构
这一篇专门讲路线模块。旅行路线不是普通列表,它要表达“第几天去哪、住哪里、吃什么、亮点是什么”。疆域纪行用一套轻量时间轴把多日行程做成了可读结构。
项目落点:
- 路线模型:
RouteItem - 单日模型:
RouteDayDetail - 路线页:
RoutePage() - 路线详情:
RouteDetail() - 时间轴:
RouteTimeline()、RouteTimelineDay() - 行程状态:
itineraryIds
读完这一篇,你可以复用一套路线展示方案:列表展示路线决策信息,详情页用时间轴展示每日安排,本地行程只保存稳定 id。
本文解决什么
- 把多日路线拆成
RouteItem和RouteDayDetail,避免详情页硬编码行程。 - 用时间轴表达第几天、住哪里、吃什么和当天亮点,让路线可扫读。
- 把路线和景点都加入本地行程清单,先跑通 MVP 的个人规划闭环。
路线模块为什么单独成篇
旅行攻略 App 里,路线不是普通列表。它承载的是“把多个目的地按时间组织起来”的能力。
《疆域纪行》的路线模块包含三层体验:
- 路线列表:展示多条预设路线。
- 路线详情:查看每日安排。
- 行程清单:把感兴趣的路线或景点加入本地计划。
第一版没有做复杂拖拽编辑,但已经把路线规划 MVP 的核心打通了。
| 能力 | MVP 做法 | 暂不做的原因 |
|---|---|---|
| 预设路线 | 内置 RouteItem |
内容可控,首版最快闭环 |
| 每日安排 | RouteDayDetail 时间轴 |
比纯文本更容易扫读 |
| 本地行程 | 保存 id 列表 | 先验证收藏和规划意图 |
| 拖拽编辑 | 暂不实现 | 需要更复杂的数据结构和交互验证 |
这个取舍很适合第一版:先让用户看懂路线、收藏路线、保存路线,再谈复杂编辑。
路线数据模型回顾
路线模型是 RouteItem:
export 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 作为 ForEach key:
ForEach(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。
更多推荐


所有评论(0)