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

📅 2026/7/1 16:07:43
【观止·诗史汇 HarmonyOS 实战系列 08】古今地理:从历史地名到诗文、事件、朝代的空间关联
【观止·诗史汇 HarmonyOS 实战系列 08】古今地理从历史地名到诗文、事件、朝代的空间关联第七篇拆了“兴替明鉴”它把朝代兴衰拆成六类详细分析并通过dynastyId跳到古今地理和朝代详情。到了第八篇我们顺着这个入口继续往下看当用户知道一个朝代的兴衰线索之后怎样把这个朝代放回真实空间诗文和历史里有大量地名长安、洛阳、汴梁、金陵、黄州、襄州、白帝城、岳阳楼。它们不是孤立名词。长安既是秦汉隋唐的政治中心也是无数诗文的情感坐标汴梁既是北宋都城也是靖康之变的历史伤口黄州既是苏轼被贬之地也是《赤壁赋》与东坡词的文学现场。因此古今地理模块要解决的不是“给地名配一个现代地址”这么简单而是把一个地名和朝代、历史事件、诗文作品串起来。当前实现的核心对象就是GeoPlace页面入口是GeoPage服务层由GeoService负责合并MockData和HistoryGeoData。上图来自本机 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.etsGeoPlace、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(): PromiseGeoPlace[] { return Promise.resolve(mergeGeoPlaces()); } getById(id: string): PromiseGeoPlace | 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 合并不按名称猜测合并逻辑没有按ancientName或modernName去重而是按idfunction 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进入地名详情。这种“一页双态”的做法很适合轻量模块避免过早拆成GeoListPage和GeoDetailPage两套文件。但它也要求状态设计清楚不能让列表状态和详情状态互相污染。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; }列表态使用listdynastiesForFilterselectedDynasty详情态使用detaileventspoemsdynastiesfavoredfoldersfavoriteFolderId这不是最“纯”的建模方式但在 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_TIMELINEdynastiesForFilter: MOCK_DYNASTIES_TIMELINE.slice()页面构造筛选 chipBuilder 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; }添加收藏时使用类型placeprivate 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: [] }白帝城横跨三国记忆和唐宋诗文这类地名最能体现“诗史汇”的价值同一个空间在不同朝代被不断重写。为什么先做关系模型而不是先上地图很多人看到“古今地理”会第一时间想到地图但当前项目没有先接地图组件这是合理的。原因有三点。第一诗史学习的核心不是导航而是理解。用户不是要去长安怎么走而是要知道“长安”在不同朝代和诗文中的意义。第二地名存在区域型表达。比如“九边重镇”“岭北行省”“通商口岸”“租界”都不是单点坐标强行上地图容易产生误导。第三关系数据先稳定地图只是展示升级。只要GeoPlace的dynastyIds / eventIds / poemIds稳定未来可以新增lat/lng、polygon、mapLevel但不会破坏现有页面和文章体系。当前实现的边界第八篇也需要诚实记录当前实现边界。第一详情页跳诗文时只传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通过同一个页面处理列表和详情两种状态。这个模块和第七篇兴替明鉴互相补位兴替明鉴解释一个朝代为什么兴衰古今地理解释这个朝代发生在什么空间。下一篇会继续往“诗”和“史”的中间层走文脉纵览如何按朝代聚合作者和作品当前实现为什么还只是最小可用以及“体裁源流、流派演变、思潮脉络”应该如何从设计文档走向结构化内容包。