【观止·诗史汇 HarmonyOS 实战系列 07】兴替明鉴:四维总览与六类分析的朝代洞察模型
【观止·诗史汇 HarmonyOS 实战系列 07】兴替明鉴:四维总览与六类分析的朝代洞察模型
前六篇已经把《观止·诗史汇》的两条主线铺出来了:前四篇解决工程骨架、首页和诗文内容包,第五篇把一首诗的详情页拆成正文、译注、简析、作者、朗读、收藏、笔记和统计,第六篇把历史事件放进时间轴,让用户能顺着朝代顺序理解关键节点。
第七篇继续往“史”的一侧推进。时间轴解决的是“先后顺序”,但只知道一个朝代从哪一年到哪一年还不够。学习者真正需要的是:这个朝代为什么兴起,为什么衰落,政治、经济、文化、科技和对外关系分别扮演什么角色,关键事件又如何把这些判断落到具体节点上。
这就是 兴替明鉴 模块要解决的问题。它不是朝代表的详情页,也不是一段朝代介绍文案,而是一个朝代洞察页:顶部可以切换朝代,中间有基础信息和跨模块入口,主体用六类详细分析解释兴衰,再用关键节点把抽象分析接回事件详情。

上图来自本机 DevEco 模拟器中打开《观止·诗史汇》的“兴替明鉴”模块。第七篇的截图重点不是首页入口,而是 DynastyInsightPage 本体:朝代选择、基础信息、古今地理/朝代详情跳转、六类详细分析和后续关键节点。
本篇要解决什么问题
兴替明鉴至少要回答六个工程问题:
| 问题 | 当前实现 |
|---|---|
| 朝代从哪里来 | `DynastyService.list()` 读取朝代时间线 |
| 默认进入哪个朝代 | `Navigator.getParams()` 读取 `dynastyId`,否则回落到 `d_ancient` |
| 深度分析从哪里来 | `DynastyAnalysisService` 读取 `HistoryAnalysis.EXTRA_DETAILS` |
| 六类分析如何固定结构 | `DynastyDetail` 拆成 `gaikuo / zhengzhi / jingji / wenhua / keji / duiwai` |
| 关键节点如何生成 | 合并 `HistoryEvent.nodeType`、`turningPoints` 和 `keyEvents` |
| 如何连接其他模块 | 跳转 `GEO_DETAIL`、`DYNASTY_OVERVIEW`、`TIMELINE_EVENT_DETAIL` |
这几个点决定了兴替明鉴的定位:它不是孤立页面,而是时间轴、朝代详情、古今地理、事件详情之间的解释层。
源码对象总览
| 文件 | 职责 |
|---|---|
| `features/src/main/ets/dynasty/DynastyInsightPage.ets` | 兴替明鉴页,负责朝代切换、基础卡、六类分析、关键节点和跨模块跳转 |
| `features/src/main/ets/dynasty/DynastyOverviewPage.ets` | 朝代详情页,展示时期概述、重大事件、人物、发明、转折点和收藏笔记 |
| `features/src/main/ets/services/DynastyAnalysisService.ets` | 朝代分析服务,读取四维总览和六类详细分析 |
| `features/src/main/ets/services/HistoryAnalysis.ets` | 朝代兴衰内容包,维护 `EXTRA_DETAILS` |
| `features/src/main/ets/services/HistoryEvents.ets` | 历史事件扩展数据,为关键节点提供 `nodeType`、摘要和详情 |
| `commons/src/main/ets/router/RouteNames.ets` | 路由常量:`DYNASTY_INSIGHT`、`DYNASTY_OVERVIEW`、`GEO_DETAIL`、`TIMELINE_EVENT_DETAIL` |
这篇文章会重点拆 DynastyInsightPage 和 DynastyAnalysisService,再补充它与 DynastyOverviewPage、事件详情和地理详情的关系。
页面不是从文案开始,而是从状态模型开始
DynastyInsightPage 的状态定义比较克制:
interface InsightState {
loading: boolean;
dynasties: Dynasty[];
current: Dynasty | null;
overview: DynastyOverview | null;
detail: DynastyDetail | null;
modules: DynModule[];
nodes: KeyNodeRow[];
}
这组状态可以分成三层:
| 状态 | 说明 |
|---|---|
| `dynasties`、`current` | 当前朝代上下文 |
| `overview`、`detail`、`modules` | 朝代分析内容 |
| `nodes` | 可点击的历史关键节点 |
这种拆法有两个好处。
第一,页面不会把全部内容都塞进一个巨大对象。朝代本体仍然来自 DynastyService,深度分析来自 DynastyAnalysisService,事件节点来自 EventService。
第二,页面可以清晰处理内容缺口。比如 overview 当前为空,页面可以隐藏“四维总览”;detail 不存在,六类分析就显示空态;事件没有摘要,也不影响朝代基础信息展示。
默认朝代与路由参数
兴替明鉴既可以从首页直接打开,也可以从时间轴、文脉、地理等模块带着 dynastyId 打开。因此它不能假设入口唯一。
async aboutToAppear() {
const dyn: Dynasty[] = await this.dynastySvc.list();
const params: NavigateParams = Navigator.getParams();
let target: string = params.dynastyId ?? DEFAULT_DYNASTY;
if (!dyn.some((d: Dynasty) => d.id === target)) {
target = dyn.length > 0 ? dyn[0].id : DEFAULT_DYNASTY;
}
this.state = {
loading: true, dynasties: dyn, current: null,
overview: null, detail: null, modules: [], nodes: []
};
await this.loadDynasty(target);
}
这里有一个小但重要的防御:如果传入的 dynastyId 不存在,页面会回退到列表中的第一个朝代,而不是直接空白或崩溃。
对一个内容型 App 来说,路由参数不可靠是常态。用户可能从收藏回跳,可能从旧版本笔记进入,也可能从未来新增的文脉入口进入。页面先校验参数,再加载内容,是比“入口保证正确”更稳的做法。
loadDynasty:一次装配四类信息
loadDynasty(id) 是这个页面最核心的方法。它不是只加载一个朝代,而是把四类信息装成页面可渲染状态:
- 当前朝代
cur - 四维总览
ov - 六类详细分析
dt - 关键节点
nodes
async loadDynasty(id: string) {
const cur: Dynasty | null = await this.dynastySvc.getById(id);
const ov: DynastyOverview | null = await this.analysisSvc.getOverview(id);
const dt: DynastyDetail | null = await this.analysisSvc.getDetail(id);
const allEvents: HistoryEvent[] = await this.eventSvc.list();
const evs: HistoryEvent[] = allEvents
.filter((e: HistoryEvent) => e.dynastyId === id && e.nodeType && e.nodeType.length > 0)
.sort((a: HistoryEvent, b: HistoryEvent) => a.year - b.year);
}
这一段有两个选择很值得注意。
第一,关键节点不取所有事件,而是只取 nodeType 存在的事件。也就是说,不是每条史事都适合出现在兴衰节点里。nodeType 把事件分成建立、兴盛、转折、危机、衰亡等类型,让页面可以从“事件列表”升级为“兴衰结构”。
第二,事件按 year 排序。兴替分析不是按录入顺序展示,而是回到历史时间顺序。这和第六篇时间轴的设计是同一条原则:历史内容的第一秩序是时间。
六类详细分析:固定字段比自由标题更稳定
当前 DynastyDetail 不是一个自由数组,而是固定六个字段:
const modules: DynModule[] = dt ? [
{ key: `${id}_gaikuo`, title: '兴衰关键', body: dt.gaikuo },
{ key: `${id}_zhengzhi`, title: '政治', body: dt.zhengzhi },
{ key: `${id}_jingji`, title: '经济', body: dt.jingji },
{ key: `${id}_wenhua`, title: '文化', body: dt.wenhua },
{ key: `${id}_keji`, title: '科技', body: dt.keji },
{ key: `${id}_duiwai`, title: '对外交流', body: dt.duiwai }
] : [];
这比“后端给一个标题数组,页面照着渲染”更朴素,但更适合当前项目。
原因是本项目仍处于本地内容包阶段,内容结构需要稳定。六类字段固定后,写文章、写数据、写 UI、做验收都能对齐同一套口径:
| 字段 | 页面标题 | 解决的问题 |
|---|---|---|
| `gaikuo` | 兴衰关键 | 一句话或一段话解释核心兴衰线索 |
| `zhengzhi` | 政治 | 权力结构、制度设计、治理能力 |
| `jingji` | 经济 | 农业、商业、财政、交通、资源 |
| `wenhua` | 文化 | 思想、文学、教育、艺术与价值秩序 |
| `keji` | 科技 | 技术、工程、历法、医学、制造 |
| `duiwai` | 对外交流 | 边疆、战争、贸易、外交和世界联系 |
对读者来说,这种结构也更容易横向比较。看完秦、汉、唐、宋、明、清之后,用户自然会意识到每个朝代都可以从同一组维度观察。
空内容过滤:不要为了完整而展示空壳
HistoryAnalysis.ets 里有些朝代的 gaikuo 可能为空。页面没有强行展示空卡片,而是做了过滤:
const nonEmptyModules: DynModule[] = modules.filter((m: DynModule) => m.body && m.body.length > 0);
这个细节很像内容产品里的“温柔退让”:数据还没有补齐时,页面不要把空白暴露给用户。模块是否出现,由内容决定,而不是由模板强行撑出来。
这也解释了为什么当前截图里直接从“六类详细分析”的政治、经济等模块开始。页面不是漏了内容,而是尊重了当前内容包状态。
DynastyAnalysisService:服务层负责 ID 兼容
朝代分析服务很短,但它承担了一个重要职责:把历史遗留 ID 差异收束在服务层。
export class DynastyAnalysisService {
getOverview(dynastyId: string): Promise<DynastyOverview | null> {
const o: DynastyOverview | undefined =
EXTRA_OVERVIEWS.find((it: DynastyOverview) => it.dynastyId === dynastyId);
return Promise.resolve(o ?? null);
}
getDetail(dynastyId: string): Promise<DynastyDetail | null> {
const normalizedId: string = normalizeDynastyAnalysisId(dynastyId);
const d: DynastyDetail | undefined =
EXTRA_DETAILS.find((it: DynastyDetail) => it.dynastyId === normalizedId);
return Promise.resolve(d ?? null);
}
}
兼容函数如下:
function normalizeDynastyAnalysisId(dynastyId: string): string {
if (dynastyId === 'd_w_han') {
return 'd_west_han';
}
if (dynastyId === 'd_three') {
return 'd_sanguo';
}
return dynastyId;
}
为什么不在页面里写兼容?
因为页面不应该知道 d_w_han 和 d_west_han 是历史包 ID 差异,也不应该到处出现 if dynastyId === ...。页面只关心“我要某个朝代的分析”,服务层负责把不同数据源的 ID 归一。
这是内容工程里非常常见的小问题:一开始几个文档、几个 mock、几个页面各自命名,后来统一时如果没有服务层兜住,就会出现页面分支蔓延。越早把归一逻辑放到 service,后面越省心。
四维总览当前为什么为空
HistoryAnalysis.ets 顶部有一段说明:
/**
* - EXTRA_OVERVIEWS:四维总览(文治/商业/科技/积弱)。当前按产品需求留空,
* 页面在无数据时会自动隐藏 "四维总览" 区块。
* - EXTRA_DETAILS:六模块详细分析,但按最新产品口径将首模块 `gaikuo`
* 改为承载 "兴衰关键" 叙事段,其余五项对应政治 / 经济 / 文化 / 科技 / 对外。
*/
export const EXTRA_OVERVIEWS: DynastyOverview[] = [];
这说明当前产品口径发生过一次调整:四维总览的模型还在,但数据暂时留空;重点先落在六类详细分析上。
这不是坏事。工程上保留 overview 的状态和 UI 分支,意味着未来要恢复四维卡片时,不需要重写页面结构。只要补齐 EXTRA_OVERVIEWS,页面就会自动出现“四维总览”。
关键节点:从事件、转折点和 keyEvents 合成
兴替明鉴的关键节点不是单一来源。页面会先读取事件列表中已经带 nodeType 的事件:
const nodes: KeyNodeRow[] = evs.map((e: HistoryEvent) => {
const r: KeyNodeRow = {
id: e.id,
eventId: e.id,
year: e.year,
title: e.title,
nodeType: e.nodeType ?? '',
summary: this.eventContent(e),
hasContent: this.hasEventContent(e)
};
return r;
});
接着又把 Dynasty.turningPoints 并入:
if (cur && cur.turningPoints) {
for (let i = 0; i < cur.turningPoints.length; i++) {
const tp: DynastyTurningPoint = cur.turningPoints[i];
const related: HistoryEvent | null = this.findRelatedEvent(tp.title, evs);
const r: KeyNodeRow = {
id: `${id}_turning_${i}`,
eventId: related ? related.id : '',
year: related ? related.year : cur.startYear,
title: tp.title,
nodeType: related && related.nodeType ? related.nodeType : this.turningPointType(i, cur.turningPoints.length),
summary: related ? this.eventContent(related) : '',
hasContent: related ? this.hasEventContent(related) : false
};
nodes.push(r);
}
}
最后还会把 keyEvents 兜底合进去。
这种做法的价值在于:兴替明鉴可以尽量利用已有内容包。事件详情足够完整时,节点点击后可以进入 TimelineEventDetailPage;事件详情还没补齐时,转折点仍然可以在页面上作为简要节点出现。
标题匹配:用 normalizeEventTitle 做轻量关联
turningPoints 和 HistoryEvent 之间没有强绑定 ID,页面通过标题做近似匹配:
private findRelatedEvent(title: string, events: HistoryEvent[]): HistoryEvent | null {
const key: string = this.normalizeEventTitle(title);
for (let i = 0; i < events.length; i++) {
const eventKey: string = this.normalizeEventTitle(events[i].title);
if (key === eventKey || key.indexOf(eventKey) >= 0 || eventKey.indexOf(key) >= 0) {
return events[i];
}
}
return null;
}
归一函数会去掉括号、数字、公元年、标点和一些常见差异:
private normalizeEventTitle(title: string): string {
return title
.replace(/([^)]*)/g, '')
.replace(/\([^)]*\)/g, '')
.replace(/[0-9]/g, '')
.replace(/[前后约公元年\s·、,。]/g, '')
.replace(/之变/g, '')
.replace(/之耻/g, '')
.replace(/首下/g, '下')
.replace(/开创科举制/g, '科举')
.replace(/开创科举/g, '科举')
.replace(/开科举设进士/g, '科举')
.replace(/修建大运河/g, '大运河')
.replace(/开凿大运河/g, '大运河');
}
这不是一个完美的语义匹配算法,但在本地内容包阶段足够实用。它解决的是“同一件事在不同文档里写法略不同”的现实问题。
后续如果内容包继续扩大,更稳的方案是给 turningPoints 增加 eventId 字段,让内容编辑阶段就建立显式关联。当前的标题归一可以作为过渡层。
节点类型:让历史事件带上结构意义
nodeType 的渲染逻辑很直接:
private nodeLabel(t: DynastyNodeType): string {
if (t === 'found') return '建立';
if (t === 'prosper') return '兴盛';
if (t === 'turn') return '转折';
if (t === 'crisis') return '危机';
if (t === 'fall') return '衰亡';
return '';
}
配套颜色也按语义分组:
private nodeColor(t: DynastyNodeType): ResourceColor {
if (t === 'found') return AppColors.accent;
if (t === 'prosper') return AppColors.success;
if (t === 'turn') return AppColors.warning;
if (t === 'crisis') return AppColors.warning;
if (t === 'fall') return AppColors.danger;
return AppColors.textTertiary;
}
这让关键节点不只是列表。用户能一眼看出这件事是在“建立”“兴盛”“转折”“危机”还是“衰亡”阶段。对于历史学习来说,这种类型提示很有价值,因为它把事件放回王朝生命线里。
与古今地理的关系
兴替明鉴里有一个“古今地理”入口:
Text(`${this.state.current!.name}·古今地理`)
.onClick(() => {
const p: NavigateParams = { dynastyId: this.state.current!.id };
Navigator.push(AppRoutes.GEO_DETAIL, p);
})
这个入口带的是 dynastyId,不是 geoId。也就是说,它打开的不是某一个地名详情,而是古今地理列表,并按当前朝代筛选。
这和第八篇要讲的 GeoPage 正好衔接:地理模块既可以作为一级入口展示全部地名,也可以从某个朝代进入,只展示该朝代相关的古今地理。
与朝代详情的关系
兴替明鉴也能跳到朝代详情:
Text(`${this.state.current!.name}·朝代详情`)
.onClick(() => {
const p: NavigateParams = { dynastyId: this.state.current!.id };
Navigator.push(AppRoutes.DYNASTY_OVERVIEW, p);
})
DynastyOverviewPage 更像资料页:时期概述、重大事件、著名人物、发明创造、历史转折点,以及收藏和笔记。
兴替明鉴则更像分析页:围绕兴衰逻辑展开六类解释,并用节点把分析接回事件。
两者分工可以这样理解:
| 页面 | 角色 |
|---|---|
| `DynastyOverviewPage` | 一个朝代有什么:概述、事件、人物、发明、转折点 |
| `DynastyInsightPage` | 一个朝代为什么这样兴衰:政治、经济、文化、科技、对外和关键节点 |
这也是系列文章里要特别强调的工程设计:不要把所有朝代信息都堆在一个页面里。资料页和分析页分开,用户路径会更清楚。
当前实现的边界
这个模块已经完成了兴替分析的核心链路,但还有几个边界值得记录。
第一,EXTRA_OVERVIEWS 当前为空。页面保留了四维总览能力,但数据还没有填充。后续如果恢复“文治昌明、商业繁荣、科技发达、积赏积弱”的四维卡片,需要优先补内容包,而不是改页面。
第二,关键节点存在重复风险。因为 HistoryEvent、turningPoints、keyEvents 会合并到同一个 nodes 数组,如果标题匹配没有命中,就可能出现语义相近的节点重复。后续可以用规范化标题做去重。
第三,事件标题匹配是启发式的。它能处理“科举”“大运河”等常见差异,但不能替代显式 ID。长期看,内容包应该把 turningPoints 与 eventId 绑定起来。
第四,六类分析当前是纯文本。未来如果要做高亮、引用、图表、参考出处或折叠展开,DynastyDetail 可能需要从字符串升级为结构化段落数组。
本地验收命令
本篇使用真实模拟器截图,不使用封面加工图作为正文图。基本验收路径如下:
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_07_dynasty_insight.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_07_dynasty_insight.png .\screenshots\07_dynasty_insight_emulator.png
页面人工验收重点:
- 首页点击“兴替明鉴”可以进入
DynastyInsightPage。 - 顶部朝代 chip 可以横向切换。
- 当前朝代基础信息显示正确。
- “古今地理”“朝代详情”能带
dynastyId跳转。 - 六类详细分析不展示空模块。
- 关键节点点击后能进入事件详情。
常见问题复盘
1. 为什么不把兴替明鉴做成时间轴的一部分?
时间轴负责“顺序”,兴替明鉴负责“解释”。如果把六类分析塞进时间轴,时间轴会变成很长的知识百科,失去快速浏览朝代顺序的能力。两个页面分开后,用户可以先看顺序,再进入某个朝代深读。
2. 为什么六类分析不用动态标题?
因为当前阶段更需要统一口径。固定的政治、经济、文化、科技、对外和兴衰关键,方便横向比较,也方便内容包补齐。等内容更丰富后,再考虑让部分朝代出现特有模块。
3. 为什么 ID 归一要放在服务层?
页面不应该知道数据历史包的命名差异。d_w_han 和 d_west_han 是内容层的兼容问题,放在 DynastyAnalysisService 更稳。
4. 为什么关键节点要有 nodeType?
因为历史事件本身不等于兴衰节点。nodeType 让事件具备结构意义:建立、兴盛、转折、危机、衰亡。用户看到的就不是散点,而是一条朝代生命线。
5. 为什么现在四维总览为空也保留代码?
这是一个可扩展位。当前产品口径先落六类详细分析,四维总览暂不展示;但保留 overview 状态和 UI 分支,后续补内容时成本很低。
本章小结
第七篇的核心是:兴替明鉴不是“朝代介绍页”,而是把朝代数据、深度分析、历史节点、地理入口和朝代详情页串起来的解释层。
从工程角度看,这个模块最值得借鉴的地方有三点:
- 页面状态按朝代上下文、分析内容、关键节点拆开。
- 服务层吸收历史 ID 差异,页面只面向稳定接口。
- 六类分析和节点类型让历史内容具备可比较、可跳转、可扩展的结构。
下一篇会顺着兴替明鉴里的“古今地理”入口继续拆解:一个地名如何同时关联朝代、事件和诗文,为什么 GeoPlace 应该先稳定关系模型,再考虑地图展示。
更多推荐


所有评论(0)