HarmonyOS7旅行地图清单页实战:已去未去地点切分与愿望管理
文章目录
旅行清单这种页面很容易写成相册墙:上面放几张图,下面列几个城市,看起来热闹,但数据关系其实很薄。这个案例稍微有意思一点,它没有把“去过”和“想去”拆成两份列表,而是把所有目的地都放进
destinations,再通过 visited 字段切出两种视图。
这个选择看起来普通,但对页面后续扩展很关键。因为旅行地点不是两个互不相关的集合,一个心愿地被打卡之后,应该自然进入已去过列表,照片数、评分、游记备注也应该跟着补上。如果一开始就维护两份数组,后面做状态迁移会很容易写乱。
destinations 是这页真正的数据中心
我读这段代码时,最先看的不是布局,而是 DestinationItem 这个模型。它把一个地点从“心愿状态”到“已打卡状态”需要的信息都放在同一个结构里:城市、国家、地区、是否去过、访问时间、评分、照片数、备注和心愿理由。
interface DestinationItem {
id: number
name: string
country: string
region: string
emoji: string
visited: boolean
visitDate: string
rating: number
photos: number
note: string
wishReason: string
}

这里最重要的字段其实是 visited。它不是单纯的布尔开关,而是页面语义的分界线。visited = true 时,用户关心的是照片、评分、游记备注和访问时间;visited = false 时,用户关心的是为什么想去、属于哪个地区、什么时候可以打卡。也就是说,同一条数据会根据状态进入完全不同的展示语境。
这种模型很适合教学,因为它比简单列表更接近真实业务。正式项目里也常见类似情况:订单的待支付和已完成、课程的未学和已学、任务的待办和已归档,本质都是一份数据在不同状态下进入不同视图。
两个列表不是两份数据,而是两个视图投影
这页最干净的地方是 getVisitedList() 和 getWishList()。它们没有保存新状态,只是从 destinations 里过滤出当前页面要看的内容。
getVisitedList(): DestinationItem[] {
return this.destinations.filter((d: DestinationItem) => d.visited)
}
getWishList(): DestinationItem[] {
return this.destinations.filter((d: DestinationItem) => !d.visited)
}
我比较喜欢这种写法。因为“已去过列表”和“心愿清单”本来就是派生结果,不应该再额外存一份。只要 destinations 变了,两个列表自然会重新计算,顶部统计也会跟着变化。
顶部的三个数字也是同一个思路:已探索数量来自 getVisitedList().length,心愿地数量来自 getWishList().length,照片总数来自 getTotalPhotos()。这些值都没有单独维护,避免了一个很常见的坑:列表变了,统计没同步。
getTotalPhotos(): number {
return this.destinations.reduce((s: number, d: DestinationItem) => s + d.photos, 0)
}
如果后面接真实接口,我也会保留这种组织方式。接口只负责返回地点数据,页面内部再根据状态投影出不同视图,不要让后端字段直接绑死前端展示结构。
activeTab 控制的是阅读场景,不只是 tab 样式

activeTab 在代码里只是一个数字,0 表示“已去过”,1 表示“心愿清单”。但它影响的不只是顶部 tab 的颜色,而是整页信息重心。
在“已去过”视图里,卡片展示照片数、评分、游记备注和访问时间。用户看到这些信息时,心理上是在回顾一段经历。这里的 renderStars() 就是为了把数字评分转成更容易扫读的视觉符号。
renderStars(rating: number): string {
return '★'.repeat(rating) + '☆'.repeat(5 - rating)
}
切到“心愿清单”后,页面不再展示评分和照片,因为这些字段此时没有意义。它改成展示 wishReason 和 region,并给出一个“已打卡!”按钮。这个差异挺重要:同一个地点模型,在不同任务场景下应该拿出不同字段,而不是把所有字段一股脑塞进卡片。
很多列表页看起来乱,就是因为没有区分“用户此刻想完成什么任务”。这页虽然代码不复杂,但任务语境切得比较清楚。
markVisited() 是这页的状态迁移入口
心愿地变成已打卡地点,靠的是 markVisited(id)。它不是简单把 visited 改成 true,还顺手补上了访问时间、评分、照片数和备注。
markVisited(id: number) {
this.destinations = this.destinations.map((d: DestinationItem): DestinationItem => {
if (d.id === id) {
d.visited = true
d.visitDate = '2026-06'
d.rating = 4
d.photos = 100
d.note = '刚刚去过!'
}
return d
})
}
这里有一个细节值得留意:它用 map() 生成新的数组,再赋回 this.destinations。对 ArkUI 这种状态驱动界面来说,这比直接在原数组里悄悄改对象更稳,界面刷新路径也更明确。
不过如果放到正式项目,我不会把 visitDate、rating、photos 写死在函数里。更合理的做法是点击“已打卡!”后弹出一个记录面板,让用户填真实日期、评分和照片数量,再统一回写。当前写法适合作为教学示例,因为它把“状态迁移之后页面会发生什么”演示得很直观。

这页暂时还没用上的 searchText
代码里有一个 searchText,但当前页面并没有真正接搜索框。这不是大问题,反而说明这个案例有一个很自然的扩展方向。
如果继续往下做,我会把搜索接在两个派生列表之后:先按 visited 分组,再按城市名、国家、地区做过滤。不要直接改 destinations,否则搜索一清空,还得重新恢复原始数据。更稳的思路是保留原始数组,只在渲染前做二次投影。
也就是说,后续可以变成这样的链路:destinations 保存原始地点,visited 决定当前分类,searchText 决定当前可见范围。每一层只做一件事,页面就不会越写越乱。
真正能复用的是这套状态组织方式
这篇示例最值得拿走的不是旅行主题本身,而是一种很常见的业务页写法:用一个主数组保存完整数据,用状态字段表达阶段,用计算函数切出不同视图,再用一个明确的事件函数完成状态迁移。
你以后写收藏夹、课程进度、任务看板、订单状态页,都可以套这个结构。只要先把“主数据是什么”“哪些是派生视图”“哪一个动作会改变状态阶段”这三件事想清楚,页面自然会比单纯堆布局稳定很多。
完整代码
下面保留案例完整代码,方便你直接对照学习、复制和重构。
// 旅行地图清单页: 旅行地图列表 - 已去/未去地点管理、心愿清单、游记记录
interface DestinationItem {
id: number
name: string
country: string
region: string
emoji: string
visited: boolean
visitDate: string
rating: number
photos: number
note: string
wishReason: string
}
@Entry
@Component
struct TravelMapWishlist {
@State destinations: DestinationItem[] = [
{ id: 1, name: '巴黎', country: '法国', region: '欧洲', emoji: '🗼', visited: true, visitDate: '2024-03', rating: 5, photos: 428, note: '浪漫之都,埃菲尔铁塔夜景太美了!', wishReason: '' },
{ id: 2, name: '东京', country: '日本', region: '亚洲', emoji: '⛩️', visited: true, visitDate: '2023-10', rating: 5, photos: 631, note: '秋天的红叶绝了,拉面也超好吃', wishReason: '' },
{ id: 3, name: '纽约', country: '美国', region: '北美', emoji: '🗽', visited: true, visitDate: '2023-05', rating: 4, photos: 312, note: '百老汇音乐剧震撼,Central Park散步很惬意', wishReason: '' },
{ id: 4, name: '圣地亚哥', country: '智利', region: '南美', emoji: '🏔️', visited: false, visitDate: '', rating: 0, photos: 0, note: '', wishReason: '想看南半球星空和安第斯山脉' },
{ id: 5, name: '伊斯坦布尔', country: '土耳其', region: '欧亚', emoji: '🕌', visited: false, visitDate: '', rating: 0, photos: 0, note: '', wishReason: '东西方文化交汇,历史建筑太迷人' },
{ id: 6, name: '开普敦', country: '南非', region: '非洲', emoji: '🏄', visited: false, visitDate: '', rating: 0, photos: 0, note: '', wishReason: '桌山和企鹅海滩,非洲最美城市' },
{ id: 7, name: '京都', country: '日本', region: '亚洲', emoji: '🍁', visited: true, visitDate: '2023-10', rating: 5, photos: 289, note: '岚山竹林和金阁寺,比东京更治愈', wishReason: '' },
{ id: 8, name: '马尔代夫', country: '马尔代夫', region: '亚洲', emoji: '🏝️', visited: false, visitDate: '', rating: 0, photos: 0, note: '', wishReason: '蜜月旅行目的地,水上屋梦想' },
{ id: 9, name: '罗马', country: '意大利', region: '欧洲', emoji: '🏛️', visited: true, visitDate: '2024-06', rating: 4, photos: 195, note: '斗兽场和梵蒂冈令人震撼,冰淇淋好吃', wishReason: '' },
{ id: 10, name: '悉尼', country: '澳大利亚', region: '大洋洲', emoji: '🦘', visited: false, visitDate: '', rating: 0, photos: 0, note: '', wishReason: '歌剧院和大堡礁,以及袋鼠!' },
]
@State activeTab: number = 0
@State searchText: string = ''
getVisitedList(): DestinationItem[] {
return this.destinations.filter((d: DestinationItem) => d.visited)
}
getWishList(): DestinationItem[] {
return this.destinations.filter((d: DestinationItem) => !d.visited)
}
getTotalPhotos(): number {
return this.destinations.reduce((s: number, d: DestinationItem) => s + d.photos, 0)
}
renderStars(rating: number): string {
return '★'.repeat(rating) + '☆'.repeat(5 - rating)
}
markVisited(id: number) {
this.destinations = this.destinations.map((d: DestinationItem): DestinationItem => {
if (d.id === id) {
d.visited = true
d.visitDate = '2026-06'
d.rating = 4
d.photos = 100
d.note = '刚刚去过!'
}
return d
})
}
build() {
Column() {
// 顶部
Column() {
Text('我的旅行').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row({ space: 32 }) {
Column() {
Text(String(this.getVisitedList().length)).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFD700')
Text('已探索').fontSize(11).fontColor('#FFFFFF80')
}
Column() {
Text(String(this.getWishList().length)).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('心愿地').fontSize(11).fontColor('#FFFFFF80')
}
Column() {
Text(String(this.getTotalPhotos())).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('张照片').fontSize(11).fontColor('#FFFFFF80')
}
}
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 24, bottom: 20 })
.linearGradient({ angle: 135, colors: [['#1C7ED6', 0], ['#0B4F8C', 1]] })
.alignItems(HorizontalAlign.Start)
// Tab
Row() {
ForEach(['已去过', '心愿清单'], (tab: string, idx: number) => {
Text(tab)
.fontSize(14).fontColor(this.activeTab === idx ? '#1C7ED6' : '#888888')
.fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.border({ width: { bottom: this.activeTab === idx ? 2 : 0 }, color: { bottom: '#1C7ED6' } })
.onClick(() => { this.activeTab = idx })
})
}
.backgroundColor('#FFFFFF')
.margin({ bottom: 8 })
if (this.activeTab === 0) {
// 已去过
List({ space: 10 }) {
ForEach(this.getVisitedList(), (dest: DestinationItem) => {
ListItem() {
Row() {
Text(dest.emoji)
.fontSize(40).width(60).height(60)
.textAlign(TextAlign.Center).lineHeight(60)
.backgroundColor('#F0F8FF').borderRadius(12)
Column() {
Row() {
Text(dest.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Text(\` · \${dest.country}\`).fontSize(12).fontColor('#888888')
Blank()
Text(\`📷 \${dest.photos}\`).fontSize(11).fontColor('#888888')
}
.width('100%')
Text(this.renderStars(dest.rating))
.fontSize(13).fontColor('#FFD700').margin({ top: 4 })
Text(dest.note)
.fontSize(12).fontColor('#666666').margin({ top: 4 })
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(\`✈️ \${dest.visitDate}\`).fontSize(11).fontColor('#AAAAAA').margin({ top: 4 })
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 4, color: '#00000008', offsetX: 0, offsetY: 2 })
}
})
}
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.scrollBar(BarState.Off)
} else {
// 心愿清单
List({ space: 10 }) {
ForEach(this.getWishList(), (dest: DestinationItem) => {
ListItem() {
Row() {
Text(dest.emoji)
.fontSize(40).width(60).height(60)
.textAlign(TextAlign.Center).lineHeight(60)
.backgroundColor('#FFF8EE').borderRadius(12)
Column() {
Row() {
Text(dest.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Text(\` · \${dest.country}\`).fontSize(12).fontColor('#888888')
Blank()
Text(dest.region).fontSize(10).fontColor('#1C7ED6')
.backgroundColor('#E8F4FF').borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
Text(\`💭 \${dest.wishReason}\`)
.fontSize(12).fontColor('#666666').margin({ top: 6 })
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Blank()
Button('已打卡!')
.fontSize(12).fontColor('#FFFFFF').backgroundColor('#34C759')
.borderRadius(14).height(30).padding({ left: 12, right: 12 })
.onClick(() => this.markVisited(dest.id))
}
.width('100%')
.margin({ top: 8 })
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 4, color: '#00000008', offsetX: 0, offsetY: 2 })
}
})
}
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.scrollBar(BarState.Off)
}
}
.width('100%').height('100%')
.backgroundColor('#F0F4F8')
}
}
最后总结
旅行地图清单页 这个案例并不靠复杂技巧取胜,它更像一个结构扎实、逻辑完整的业务页面样板。把这篇文章里的状态设计、函数组织和页面分层吃透之后,你再去写同类型功能,速度会明显快很多。
更多推荐

所有评论(0)