【观止·诗史汇 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 通过同一个页面处理列表和详情两种状态。

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

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐