当前位置: 首页 > news >正文

【观止·诗史汇 HarmonyOS 实战系列 08】古今地理:从历史地名到诗文、事件、朝代的空间关联

【观止·诗史汇 HarmonyOS 实战系列 08】古今地理:从历史地名到诗文、事件、朝代的空间关联

第七篇拆了“兴替明鉴”:它把朝代兴衰拆成六类详细分析,并通过dynastyId跳到古今地理和朝代详情。到了第八篇,我们顺着这个入口继续往下看:当用户知道一个朝代的兴衰线索之后,怎样把这个朝代放回真实空间?

诗文和历史里有大量地名:长安、洛阳、汴梁、金陵、黄州、襄州、白帝城、岳阳楼。它们不是孤立名词。长安既是秦汉隋唐的政治中心,也是无数诗文的情感坐标;汴梁既是北宋都城,也是靖康之变的历史伤口;黄州既是苏轼被贬之地,也是《赤壁赋》与东坡词的文学现场。

因此古今地理模块要解决的不是“给地名配一个现代地址”这么简单,而是把一个地名和朝代、历史事件、诗文作品串起来。当前实现的核心对象就是GeoPlace,页面入口是GeoPage,服务层由GeoService负责合并MockDataHistoryGeoData

上图来自本机 DevEco 模拟器中的“古今地理”模块。第八篇的正文和封面截图展示地名列表与朝代筛选,不再复用首页图,也不使用时间轴截图代替地理模块。

本篇要解决什么问题

古今地理模块至少要处理这些工程问题:

问题当前实现
地名数据从哪里来`GeoService.list()` 合并 `MOCK_GEO` 与 `HISTORY_GEO_PLACES`
如何按朝代筛选`selectedDynasty` + 顶部 `FilterChip()`
一个页面如何支持列表和详情`NavigateParams.geoId` 存在时展示详情,否则展示列表
地名如何关联历史`GeoPlace.dynastyIds / eventIds / poemIds`
详情页如何跳转朝代跳 `DYNASTY_INSIGHT`,事件跳 `TIMELINE_EVENT_DETAIL`,诗文跳 `POEM_DETAIL`
地名如何进入学习状态收藏和笔记使用 `FavoriteStore`、`NoteStore`,类型为 `place`

这说明古今地理不是一个地图控件,而是项目里的空间索引层。地图以后可以加,但关系模型必须先稳定。

源码对象总览

文件职责
`features/src/main/ets/geo/GeoPage.ets`古今地理页面,列表、筛选、详情、收藏、笔记和跨模块跳转
`features/src/main/ets/services/GeoService.ets`地名服务,合并基础 mock 与历史地理扩展包
`features/src/main/ets/services/HistoryGeoData.ets`历史地名扩展数据包,来自 `doc/lishi` 资料整理
`features/src/main/ets/services/MockData.ets`初始地名数据,含诗文和事件联动基础记录
`features/src/main/ets/domain/Models.ets``GeoPlace`、`Dynasty`、`HistoryEvent`、`Poem` 等领域模型
`features/src/main/ets/state/AppStores.ets`收藏、笔记等本地状态仓

这篇重点看GeoPage的双态页面设计和GeoService的数据合并逻辑。

GeoPlace:一条地名记录连接四类对象

古今地理的核心不是地图坐标,而是GeoPlace的关系字段。当前页面会使用这些属性:

GeoPlace { id: string; ancientName: string; modernName: string; region: string; history: string; dynastyIds: string[]; eventIds: string[]; poemIds: string[]; }

字段可以分成三类:

类型字段作用
展示字段`ancientName`、`modernName`、`region`、`history`列表和详情页直接展示
关系字段`dynastyIds`、`eventIds`、`poemIds`关联朝代、事件和诗文
稳定标识`id`收藏、笔记、跳转和合并的基础

这个模型很适合当前项目。它没有急着接经纬度,也没有把地图服务放进第一版,而是先把诗史学习中最重要的“关系”建起来。

GeoService:合并两个地理数据源

地名服务的公开接口非常小:

export class GeoService { list(): Promise<GeoPlace[]> { return Promise.resolve(mergeGeoPlaces()); } getById(id: string): Promise<GeoPlace | null> { const p: GeoPlace | undefined = mergeGeoPlaces().find((it: GeoPlace) => it.id === id); return Promise.resolve(p ?? null); } }

真正的关键在mergeGeoPlaces()

function mergeGeoPlaces(): GeoPlace[] { const merged: GeoPlace[] = []; appendUnique(merged, MOCK_GEO); appendUnique(merged, HISTORY_GEO_PLACES); return merged; }

它先放入基础MOCK_GEO,再把HistoryGeoData中的扩展地名合进去。这样做的好处是:

  • 原有 mock 数据不用删除。
  • 历史扩展包可以逐步补充。
  • 相同 ID 的记录可以在服务层合并,而不是在页面层去重。

对内容型项目来说,这是一种很实用的渐进扩容方式。页面只问GeoService.list(),至于数据来自早期 mock 还是后续内容包,页面不需要知道。

appendUnique:按 id 合并,不按名称猜测

合并逻辑没有按ancientNamemodernName去重,而是按id

function appendUnique(target: GeoPlace[], source: GeoPlace[]): void { for (let i = 0; i < source.length; i++) { const next: GeoPlace = source[i]; const existedIndex: number = target.findIndex((it: GeoPlace) => it.id === next.id); if (existedIndex >= 0) { target[existedIndex] = mergeSameIdGeo(target[existedIndex], next); } else { target.push(next); } } }

为什么不用名称?

因为古今地名天然复杂:一个古地名可能对应多个现代区域,一个现代城市也可能承载多个古名。例如“金陵”“建康”“南京”在不同历史时期有不同含义;“北京”也可能是辽南京、金中都、元大都、明清京师、民国北平等多个层次。

所以稳定的id是必须的。页面和服务都不应靠名称猜测同一性。

mergeSameIdGeo:文本和数组都要去重

同一个id出现在两个数据源时,服务层会合并:

function mergeSameIdGeo(oldItem: GeoPlace, newItem: GeoPlace): GeoPlace { return { id: newItem.id, ancientName: newItem.ancientName || oldItem.ancientName, modernName: newItem.modernName || oldItem.modernName, region: newItem.region || oldItem.region, history: mergeText(oldItem.history, newItem.history), dynastyIds: mergeStringList(oldItem.dynastyIds, newItem.dynastyIds), eventIds: mergeStringList(oldItem.eventIds, newItem.eventIds), poemIds: mergeStringList(oldItem.poemIds, newItem.poemIds) }; }

这里有两个小取舍。

第一,标题类字段优先取新数据。HistoryGeoData是后续整理的历史地理扩展包,通常比早期 mock 更完整。

第二,关系数组用mergeStringList()合并。一个地名可能先在 mock 中关联诗文,后来又在历史包里关联朝代或事件,不能互相覆盖。

合并文本时也做了包含关系判断:

function mergeText(a: string, b: string): string { if (!a || a.length === 0) { return b; } if (!b || b.length === 0 || a === b || a.indexOf(b) >= 0) { return a; } if (b.indexOf(a) >= 0) { return b; } return `${a}${b}`; }

这个实现比较朴素,但能避免最常见的重复:如果新旧文本完全相同,或者一段已经包含另一段,就保留更完整的那段。

GeoPage 是一个双态页面

GeoPage既是列表页,也是详情页。它通过路由参数判断当前状态:

async aboutToAppear() { const params: NavigateParams = Navigator.getParams(); const id: string = params.geoId ?? ''; const dynastyId: string = params.dynastyId ?? ''; if (id) { await this.loadDetail(id); } else { const all: GeoPlace[] = await this.geoSvc.list(); if (dynastyId) { this.selectedDynasty = dynastyId; } else { this.selectedDynasty = GEO_ALL_ID; } this.state = { loading: false, list: all, dynastiesForFilter: MOCK_DYNASTIES_TIMELINE.slice(), detail: null, events: [], poems: [], dynasties: [], favored: false, folders: this.favStore.listFolders(), favoriteFolderId: '' }; } }

这里的两个参数语义不同:

参数页面行为
`geoId`展示某个地名详情
`dynastyId`展示列表,并按朝代筛选

所以从首页点“古今地理”时,没有参数,展示全部地名;从兴替明鉴点“上古·古今地理”时,传dynastyId,页面展示该朝代相关地名;从列表点某个地名时,传geoId,进入地名详情。

这种“一页双态”的做法很适合轻量模块,避免过早拆成GeoListPageGeoDetailPage两套文件。但它也要求状态设计清楚,不能让列表状态和详情状态互相污染。

GeoState:列表字段和详情字段并存

GeoState直接体现了双态页面的结构:

interface GeoState { loading: boolean; list: GeoPlace[]; dynastiesForFilter: Dynasty[]; detail: GeoPlace | null; events: RelEvent[]; poems: RelPoem[]; dynasties: RelDyn[]; favored: boolean; folders: FavoriteFolder[]; favoriteFolderId: string; }

列表态使用:

  • list
  • dynastiesForFilter
  • selectedDynasty

详情态使用:

  • detail
  • events
  • poems
  • dynasties
  • favored
  • folders
  • favoriteFolderId

这不是最“纯”的建模方式,但在 ArkUI 页面中很直观。只要 build 阶段根据detail是否为空切换渲染,状态就不会乱。

if (!this.state.detail) { this.DynastyFilter() } if (this.state.loading) { LoadingView({ message: '加载…' }).layoutWeight(1) } else if (this.state.detail) { this.DetailScroll() } else if (this.filteredPlaces().length === 0) { EmptyView({ message: '暂无地名数据' }).layoutWeight(1) } else { this.ListView() }

注意:筛选条只在列表态出现。详情页不再展示朝代筛选,避免用户以为筛选会影响当前详情。

朝代筛选:从时间线复用朝代集合

古今地理列表顶部的筛选条来自MOCK_DYNASTIES_TIMELINE

dynastiesForFilter: MOCK_DYNASTIES_TIMELINE.slice()

页面构造筛选 chip:

@Builder DynastyFilter() { Scroll() { Row({ space: AppDimens.spaceSm }) { this.FilterChip(GEO_ALL_ID, '全部') ForEach(this.state.dynastiesForFilter, (d: Dynasty) => { this.FilterChip(d.id, d.name) }, (d: Dynasty) => d.id) } .padding({ left: AppDimens.pagePadding, right: AppDimens.pagePadding }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .height(48) .width('100%') }

筛选逻辑很简单:

private filteredPlaces(): GeoPlace[] { if (this.selectedDynasty === GEO_ALL_ID) { return this.state.list; } return this.state.list.filter((p: GeoPlace) => { return p.dynastyIds.indexOf(this.selectedDynasty) >= 0; }); }

这种筛选是基于关系字段完成的。只要GeoPlace.dynastyIds稳定,页面不需要知道“长安属于哪些朝代”,也不需要写一堆 if 判断。

列表卡片:地名先给古今对照,再给历史说明

列表项GeoCard的信息层级很清楚:

@Builder GeoCard(p: GeoPlace) { Column({ space: 6 }) { Row({ space: AppDimens.spaceSm }) { Text(p.ancientName) Text(`今 · ${p.modernName}`) } Text(p.region) Text(p.history) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .onClick(() => { const params: NavigateParams = { geoId: p.id }; Navigator.push(AppRoutes.GEO_DETAIL, params); }) }

古名是主标题,今名是副信息,地区用于快速定位,历史沿革用两行摘要。点击后用geoId打开同一个页面的详情态。

这个列表体验比较适合学习应用。用户先看到“长安 今·西安”,再通过历史摘要判断是否继续深入。

详情页:按关系反查朝代、事件、诗文

进入详情时,页面不只是展示GeoPlace本身,还会把关系 ID 转成可读对象:

private async loadDetail(id: string) { const p: GeoPlace | null = await this.geoSvc.getById(id); if (!p) { this.state = { loading: false, list: [], dynastiesForFilter: [], detail: null, events: [], poems: [], dynasties: [], favored: false, folders: this.favStore.listFolders(), favoriteFolderId: '' }; return; } const events: RelEvent[] = []; for (let i = 0; i < p.eventIds.length; i++) { const e: HistoryEvent | null = await this.eventSvc.getById(p.eventIds[i]); if (e) events.push({ id: e.id, title: e.title, year: e.year }); } }

诗文和朝代也用同样方式加载:

const poems: RelPoem[] = []; for (let i = 0; i < p.poemIds.length; i++) { const po: Poem | null = await this.poemSvc.getById(p.poemIds[i]); if (po) poems.push({ id: po.id, title: po.title }); } const dynasties: RelDyn[] = []; for (let i = 0; i < p.dynastyIds.length; i++) { const d: Dynasty | null = await this.dynastySvc.getById(p.dynastyIds[i]); if (d) dynasties.push({ id: d.id, name: d.name }); }

这里的设计思路是:GeoPlace只保存关系 ID,页面进入详情后再通过 service 转成展示模型。这避免了地理数据重复保存朝代名、事件标题和诗文标题。

详情页跳转:地名变成诗史交通枢纽

详情页有三组关系。

相关朝代跳兴替明鉴:

Text(d.name) .onClick(() => { const p: NavigateParams = { dynastyId: d.id }; Navigator.push(AppRoutes.DYNASTY_INSIGHT, p); })

相关事件跳事件详情:

Row() { Text(`${e.year}`) Text(e.title) Text('→') } .onClick(() => { const p: NavigateParams = { eventId: e.id }; Navigator.push(AppRoutes.TIMELINE_EVENT_DETAIL, p); })

相关诗文跳诗文详情:

Row() { Text(po.title) Text('→') } .onClick(() => { const pp: NavigateParams = { poemId: po.id }; Navigator.push(AppRoutes.POEM_DETAIL, pp); })

注意这里有一个后续优化点:诗文详情页在第五篇中依赖poemId + poemShard更稳,而这里目前只传了poemId。如果PoemService返回的是 mock 诗文,这样没问题;但如果要跳转到内容包中的完整诗文详情,后续最好补充poemShard或通过仓储按poemId查出 shard。

收藏和笔记:地名也进入本地学习状态

古今地理详情页也接入了收藏和笔记:

private onFavoriteTap(): void { if (!this.state.detail) return; if (this.state.favored) { this.favStore.removeFavorite('place', this.state.detail.id); this.choosingFolder = false; this.updateFavoriteState(false, ''); return; } this.choosingFolder = !this.choosingFolder; }

添加收藏时使用类型place

private addFavToFolder(folderId: string): void { if (!this.state.detail) return; this.favStore.addToFolder('place', this.state.detail.id, this.state.detail.ancientName, folderId); this.choosingFolder = false; this.updateFavoriteState(true, folderId); }

笔记也围绕place建草稿:

private openNote(): void { if (!this.state.detail) return; const t: FavoriteType = 'place'; let note = this.noteStore.getByTarget(t, this.state.detail.id); if (!note) { note = this.noteStore.newDraft(t, this.state.detail.id, this.state.detail.ancientName); this.noteStore.upsert(note); } const p: NavigateParams = { noteId: note.id }; Navigator.push(AppRoutes.NOTE_DETAIL, p); }

这和第五篇诗文详情页、第七篇朝代详情页的模式一致:不同内容类型共享同一套本地学习状态,只是FavoriteType不同。

内容类型收藏类型
诗文`poem`
朝代`dynasty`
地名`place`
事件可继续扩展为 `event`

统一收藏和笔记模型的价值在第十一篇会继续展开。

HistoryGeoData:地理内容包的特点

HistoryGeoData.ets不是简单的城市列表,而是按朝代扩展地理名词。例如:

{ id: 'g_bianzhou', ancientName: '汴州', modernName: '河南开封', region: '中原', history: '五代后梁、后晋等政权都城所在地,也是北宋东京的前身,依托汴河漕运成为中原政治中心。', dynastyIds: ['d_wudai', 'd_n_song'], eventIds: ['e_chenqiao'], poemIds: [] }

这条记录就把三个模块连在一起:

  • 地名:汴州/开封
  • 朝代:五代、北宋
  • 事件:陈桥兵变

后续如果再补诗文 ID,它还可以连接到宋诗宋词和城市书写。

再比如:

{ id: 'g_baidicheng', ancientName: '白帝城', modernName: '重庆奉节白帝山', region: '川东', history: '三峡瞿塘峡口名城,刘备托孤故事使其成为三国记忆的重要地标,后世亦为唐宋诗文名胜。', dynastyIds: ['d_sanguo', 'd_tang', 'd_n_song'], eventIds: [], poemIds: [] }

白帝城横跨三国记忆和唐宋诗文,这类地名最能体现“诗史汇”的价值:同一个空间,在不同朝代被不断重写。

为什么先做关系模型,而不是先上地图

很多人看到“古今地理”会第一时间想到地图,但当前项目没有先接地图组件,这是合理的。

原因有三点。

第一,诗史学习的核心不是导航,而是理解。用户不是要去长安怎么走,而是要知道“长安”在不同朝代和诗文中的意义。

第二,地名存在区域型表达。比如“九边重镇”“岭北行省”“通商口岸”“租界”都不是单点坐标,强行上地图容易产生误导。

第三,关系数据先稳定,地图只是展示升级。只要GeoPlacedynastyIds / eventIds / poemIds稳定,未来可以新增lat/lngpolygonmapLevel,但不会破坏现有页面和文章体系。

当前实现的边界

第八篇也需要诚实记录当前实现边界。

第一,详情页跳诗文时只传poemId,没有传poemShard。如果目标诗文来自内容包,后续需要补 shard 查询或在GeoPlace.poemIds中使用更完整的引用结构。

第二,mergeText()直接拼接文本时没有自动加标点或换行。如果两个文本都不包含对方,当前结果可能显得紧。后续可以改成段落数组或用分隔符合并。

第三,列表筛选只支持朝代,没有支持地区、关键词、诗文/事件关联状态。随着地名达到 119 处,后续需要增加搜索。

第四,地名详情目前没有统计记录。用户阅读地点也应进入学习统计,这可以和第十二篇统计闭环一起做。

第五,区域型地名没有区分展示类型。后续可以给GeoPlace增加placeType: 'point' | 'region' | 'route' | 'system',让页面文案更准确。

本地验收命令

本篇同样使用真实模拟器截图:

git status --short & "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets & "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell aa start -a EntryAbility -b com.example.app_project02 & "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell snapshot_display -i 0 -f /data/local/tmp/guanzhi_08_geo.png -w 1080 -h 2400 -t png & "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" file recv /data/local/tmp/guanzhi_08_geo.png .\screenshots\08_geo_emulator.png

页面验收清单:

  • 首页“古今地理”入口可以进入GeoPage
  • 默认展示全部地名,并显示总数。
  • 顶部朝代 chip 可以筛选地名。
  • 列表卡片展示古名、今名、地区和历史摘要。
  • 点击地名能进入详情页。
  • 详情页能展示历史沿革、相关朝代、事件、诗文。
  • 收藏和记笔记可以围绕place类型工作。

常见问题复盘

1. 为什么地名列表里有“通商口岸”“九边重镇”这种非单点?

因为本项目不是地图导航,而是诗史学习。很多历史地理概念本来就是区域、制度或路线,不能强行压成一个点。先保留区域型地名,有助于后续更准确地表达历史空间。

2. 为什么同一个地名要关联多个朝代?

地名的意义会随朝代变化。长安在西周、秦汉、隋唐意义不同,汴梁在五代和北宋意义不同,金陵在六朝、明初和民国意义也不同。dynastyIds让一个地名可以承载多层历史。

3. 为什么不直接在 GeoPlace 里保存朝代名和诗文标题?

因为那会导致数据重复。朝代名、事件标题、诗文标题应该由对应 service 提供,GeoPlace保存 ID 就够了。这样改一个朝代名或诗文标题,不需要同步改地理数据。

4. 为什么从兴替明鉴进入古今地理时用 dynastyId 而不是 geoId?

因为用户此时关心的是“这个朝代有哪些地名”,不是某一个地点。传dynastyId可以直接打开筛选后的地名列表。

5. 为什么地理模块也要收藏和笔记?

因为地名是学习对象。用户可能想收藏“黄州”“汴梁”“白帝城”,也可能为某个地名记录诗文和事件关联。统一进入收藏笔记体系,才能形成长期学习闭环。

本章小结

第八篇的核心是:古今地理不是地图优先,而是关系优先。GeoPlace用一条记录同时连接古名、今名、历史沿革、朝代、事件和诗文;GeoService把早期 mock 与历史地理扩展包合并;GeoPage通过同一个页面处理列表和详情两种状态。

这个模块和第七篇兴替明鉴互相补位:兴替明鉴解释一个朝代为什么兴衰,古今地理解释这个朝代发生在什么空间。

下一篇会继续往“诗”和“史”的中间层走:文脉纵览如何按朝代聚合作者和作品,当前实现为什么还只是最小可用,以及“体裁源流、流派演变、思潮脉络”应该如何从设计文档走向结构化内容包。

http://www.jsqmd.com/news/1103417/

相关文章:

  • 魔兽争霸III终极优化指南:如何在现代系统上完美运行经典游戏
  • 2026企业GEO选型指南:三大主流排名监测平台实战对比
  • 2026年Ozon ERP软件实测:爆单AI、妙手ERP、上品帮到底谁好用?个人卖家真实对比
  • 第二届通信网络与智能系统工程国际会议(ICCNSE 2026)成功在线举办
  • STM32与DS28EC20 EEPROM的嵌入式存储方案实践
  • 3分钟让你的网易云音乐在任何设备自由播放:ncmdumpGUI轻松解锁NCM格式
  • 【毕业设计】桂林旅游景点导游平台 SpringBoot+Vue 完整源码(含论文+数据库,可运行)
  • IntelliJ IDEA SQL控制台导出不生效?3分钟定位是IDE缓存、驱动版本还是JVM参数问题(含诊断树图)
  • 【自编工具】文件整理工具:自动解压压缩包 + 全局去重
  • 最后一次刷卡——替不会说话的东西办退休
  • 国网项目验收必看:功能、非功能、安全、渗透测试一站式办理指南!
  • 5分钟拯救B站收藏:如何用开源工具实现m4s视频永久备份?
  • 第一章Netty,ByteBuffer大小分配问题
  • 哪有什么免费的午餐?阿贝云免费主机入坑指南
  • ICM-42688-P与STM32L021K4在运动控制与工业监测中的应用
  • Smithbox免费开源游戏修改工具:魂系游戏Mod制作的终极指南
  • 如何快速搭建网易云音乐API服务:终极配置与开发指南
  • AMD Ryzen处理器免费调试神器:5分钟学会SMU Debug Tool完整指南
  • ncmdumpGUI:免费解锁网易云音乐加密NCM文件的终极Windows图形界面解决方案
  • DouyinLiveRecorder:一站式多平台直播录制解决方案,支持40+平台自动录制
  • Windows 10 环境下 Docker 部署 Sub2API 完整教程(避坑版)
  • 解决一个操作系统两个Java版本的问题
  • GPT 应用场景全解析:从代码编写到技术文档,AI 到底能帮你做什么?
  • 终极指南:如何轻松实现Switch与WiiU《塞尔达传说》存档自由转换
  • 三步掌握pywencai:Python高效获取同花顺问财数据的实战指南
  • BurpSuite API发现插件实战:自动化侦察与越权漏洞挖掘
  • GNSS定位与LTE Cat 1的嵌入式硬件实现方案
  • 2026 程序员 AI 兵器谱:Cursor vs GitHub Copilot vs 通义灵码 vs CodeBuddy 深度横评
  • ScratchJr桌面版:儿童编程启蒙的终极完整指南
  • iOS 混编提交苹果 Appstore 流程详解