【观止·诗史汇 HarmonyOS 实战系列 05】诗文详情页:正文、注释、译文、简析与作者信息的组织方式
【观止·诗史汇 HarmonyOS 实战系列 05】诗文详情页正文、注释、译文、简析与作者信息的组织方式前四篇已经把《观止·诗史汇》的主线铺开了第一篇讲本地优先的学习闭环第二篇讲entry / features / commons三层边界第三篇把首页的每日诗文、每日史事和功能入口落到 ArkUI 页面第四篇继续往底层走拆解诗文内容包如何从 Markdown 转成rawfile/poempack中可按需读取的本地诗库。到了第五篇问题从“内容如何进 App”变成“内容如何被用户真正读完”。诗文详情页看起来只是打开一首诗但真实工程里它承接的东西很多正文、译文、注释、简析、作者介绍、分类标签、字号设置、收藏文件夹、笔记草稿、朗读全文、学习统计。任何一个能力都可以单独写难点在于它们不能互相挤压也不能让详情页变成一条失控的长卷。本文就沿着真实源码拆解PoemDetailPage.ets它如何通过poemId poemShard精准读取内容包详情如何用四个 Tab 组织正文、译注、简析和作者信息如何把 Settings、Favorite、Note、Stats 和 SpeechKit 接入同一个阅读页面。上图应来自本机 DevEco 模拟器中打开《观止·诗史汇》的诗文详情页而不是首页或分类页。第五篇的主题是“详情阅读体验”所以封面和正文图都应该展示某首诗的详情页顶部诗名、朝代作者、分类标签、朗读/收藏/笔记动作以及原文、译文及注释、简析、作者介绍四个 Tab。本篇要解决什么问题诗文详情页不是静态文本页。它至少要回答这几个工程问题问题当前实现从哪里拿完整诗文PoemPackRepo.getDetail(poemId, shard)页面如何处理缺参和缺数据EmptyView 展示“缺少诗文参数”或“本诗暂未收录于内容包”正文、译注、简析、作者如何分层四个固定 Tab原文、译文及注释、简析、作者介绍字号如何跟随设置订阅 SettingsStore分别使用 fontScale 与 poemFontScale收藏如何选择文件夹FavoriteStore.listFolders() FolderPicker()笔记如何关联诗文NoteStore.getByTarget(poem, poemId)没有则创建草稿朗读如何组织内容PoemReadInfoBuilder.buildFullText(poem, author)统计如何自动记录页面加载成功后调用 StatsStore.recordPoem(poemId)这也是详情页和第四篇内容包的衔接点内容包提供稳定poemId、详情分片和作者表详情页把它们变成可阅读、可收藏、可记录、可朗读的学习页面。源码对象总览文件职责features/src/main/ets/poem/PoemDetailPage.ets诗文详情页负责读取详情、组织 Tab、接入收藏笔记朗读统计features/src/main/ets/poem/PoemPackRepo.ets内容包仓储按 poemId shard 读取详情分片features/src/main/ets/poem/PoemPackTypes.etsPoemDetail、PoemAuthor、PoemCategory 等类型features/src/main/ets/state/AppStores.etsSettingsStore、FavoriteStore、NoteStore、StatsStorefeatures/src/main/ets/speech/PoemReadInfoBuilder.ets把诗文详情组装为 SpeechKit 可读内容features/src/main/ets/speech/TextReaderService.ets朗读能力封装commons/src/main/ets/router/RouteNames.ets详情页、笔记页、诗文列表页等路由名如果只看 UI详情页像一个普通Tabs页面如果看工程关系它其实是诗文内容包、本地状态、路由参数和朗读服务的汇合点。详情页入口必须依赖 poemId 和 poemShard第四篇讲过列表页的轻量索引项PoemBrief只保存标题、作者、朝代、首句和分片号。点击某首诗时列表页不会把完整正文塞进路由而是传两个关键参数const params: NavigateParams { poemId: it.poemId, poemShard: it.shard }; Navigator.push(AppRoutes.POEM_DETAIL, params);详情页再读取参数const params: NavigateParams Navigator.getParams(); const poemId: string params.poemId ?? ; const shard: number params.poemShard ?? 0;这里的边界很重要。详情页不是从全局变量里猜当前诗也不是依赖列表页残留状态而是通过路由参数拿到稳定 ID。这样有三个好处好处说明可以从多个入口打开分类列表、首页每日一文、收藏页、笔记页都可以打开同一个详情便于持久化关联收藏、笔记、统计都围绕 poemId便于按需加载poemShard 让仓储直接定位详情分片避免全量读取当前源码在缺少poemId时会进入空态if (!poemId) { this.state { loading: false, error: 缺少诗文参数, poem: null, author: null, cats: [], poemId: , favored: false, folders: this.favStore.listFolders(), favoriteFolderId: }; return; }这类防御逻辑很容易被忽略但它决定了页面是否能承受异常入口。真实 App 中深链、收藏回跳、草稿恢复都可能带来不完整参数详情页必须先保证自己不会崩。DetailState详情页状态要覆盖内容和行为PoemDetailPage的核心状态定义如下interface DetailState { loading: boolean; error: string; poem: PoemDetail | null; author: PoemAuthor | null; cats: PoemCategory[]; poemId: string; favored: boolean; folders: FavoriteFolder[]; favoriteFolderId: string; }这组字段可以分成三类类型字段服务区域页面状态loading、error加载态、空态内容状态poem、author、cats、poemId标题、Tab、分类标签行为状态favored、folders、favoriteFolderId收藏按钮、文件夹选择此外还有几个独立StateState activeTab: number 0; State fontScale: number 1.0; State poemFontScale: number 1.0; State choosingFolder: boolean false; State readState: ReadStateCode ReadStateCode.WAITING;它们不放进DetailState也合理因为它们是页面交互状态当前 Tab、字体缩放、是否弹出文件夹选择器、朗读状态。这些状态变化频繁和一次详情加载得到的内容数据不是一类生命周期。aboutToAppear读取详情、作者、分类和收藏状态详情页进入时做的事情比较集中const repo: PoemPackRepo PoemPackRepo.instance(); await repo.ensureReady(); const p: PoemDetail | null await repo.getDetail(poemId, shard);如果详情不存在页面展示“本诗暂未收录于内容包”。如果详情存在就继续补齐作者、分类和收藏状态const a: PoemAuthor | null p.authorId ? repo.getAuthor(p.authorId) : null; const cats: PoemCategory[] p.categories .map((slug: string) repo.getCategory(slug)) .filter((c: PoemCategory | null) c ! null) as PoemCategory[]; const fav: FavoriteItem | null this.favStore.getFav(poem, poemId);这里的处理体现了内容包设计的价值PoemDetail只保存作者 ID 和分类 slug页面通过仓储把它们映射成PoemAuthor和PoemCategory。这样可以避免详情分片里重复塞作者介绍和分类名也方便后续做作者页、分类页和检索页。加载成功后页面还会记录学习行为this.statsStore.recordPoem(poemId);这行代码看起来很小但它是学习闭环的关键。统计页不需要用户手动打卡只要他真正打开并阅读某首诗阅读记录就进入StatsStore。四个 Tab不要把长内容堆成一屏详情页把内容拆成四个固定 Tabprivate tabs: TabDef[] [ { key: t_body, label: 原文 }, { key: t_tran, label: 译文及注释 }, { key: t_brief, label: 简析 }, { key: t_author, label: 作者介绍 } ];页面结构是Tabs({ index: this.activeTab }) { TabContent() { this.BodyTab() }.tabBar(this.TabLabel(0)) TabContent() { this.TranslationTab() }.tabBar(this.TabLabel(1)) TabContent() { this.TextScrollTab(this.state.poem!.brief, 本篇暂无简析) } .tabBar(this.TabLabel(2)) TabContent() { this.AuthorTab() }.tabBar(this.TabLabel(3)) } .barMode(BarMode.Fixed) .barHeight(AppDimens.tabBarHeight) .scrollable(true) .animationDuration(120) .layoutWeight(1) .onChange((i: number) { this.activeTab i; })这比把正文、译文、注释、简析、作者一口气往下排更适合移动端阅读。原因有三点设计作用原文独立用户进入详情后优先读诗不被解释打断译注独立需要理解时再切换注释和译文保持块级结构简析独立避免短诗被长赏析淹没作者独立作者信息是上下文不干扰正文阅读移动端详情页最怕“信息全都有但用户读不动”。四个 Tab 的价值就是把内容层级显性化。原文 Tab按句末标点断行居中原文展示不是简单Text(poem.body)。源码里先把正文拆成行private bodyLines(): string[] { const raw: string this.state.poem!.body; const out: string[] []; raw.split(/\r?\n/).forEach((para: string) { const t: string para.trim(); if (!t) { out.push(); return; } const parts: string[] t.split(/(?[。!?])/); parts.forEach((s: string) { const ss: string s.trim(); if (ss) out.push(ss); }); }); return out; }然后逐行居中展示Text(line) .fontSize(AppText.poemBody * this.poemFontScale) .lineHeight(34 * this.poemFontScale) .fontColor(AppColors.textPrimary) .width(100%) .textAlign(TextAlign.Center)这里有两个细节空行被保留为段落间隔。句末标点被保留在句子末尾避免切出“半截句”。诗文正文和普通段落不同。它需要节奏、留白和视觉重心。按句断行后用户读原文时更接近诗文阅读的节奏而不是读一段普通说明文。译文及注释注释在上译文在下译注 Tab 先判断是否有内容private hasTranslation(): boolean { return !!this.state.poem (this.state.poem.annotation.length 0 || this.state.poem.translation.length 0); }没有内容时显示空态有内容时按“注释、译文”两个区块展示if (this.state.poem!.annotation.length 0) { this.LabeledBlock(注释, this.state.poem!.annotation) } if (this.state.poem!.translation.length 0) { this.LabeledBlock(译文, this.state.poem!.translation) }LabeledBlock统一处理标题和正文Text(label) .fontSize(AppText.subtitle * this.fontScale) .fontWeight(FontWeight.Bold) .fontColor(AppColors.accent) Text(text) .fontSize(AppText.body * this.fontScale) .lineHeight(26 * this.fontScale) .fontColor(AppColors.textPrimary)这里使用的是fontScale不是poemFontScale。当前实现把“诗文正文”和“普通解释文本”分开正文使用诗文字号缩放译注、简析、作者介绍使用普通字号缩放。这是一个很实用的设置边界。作者介绍作者不是字符串而是可复用对象作者 Tab 不是直接展示poem.author而是使用PoemAuthorconst a: PoemAuthor | null p.authorId ? repo.getAuthor(p.authorId) : null;页面展示头像首字、作者名、朝代和简介Text(this.state.author.name.slice(0, 1)) .fontSize(AppText.title) .fontWeight(FontWeight.Bold) .fontColor(#FFFFFF) .width(48) .height(48) .textAlign(TextAlign.Center) .backgroundColor(AppColors.accent) .borderRadius(24)这个设计给后续扩展留下空间作者可以变成独立页面作者下可以列作品朝代页也可以反向关联作者。详情页目前只是消费作者表但数据结构已经不是散落字符串。分类标签MetaBar 用横向滚动承接多分类一首诗可能属于多个分类例如唐诗三百、古诗三百、小学古诗。页面顶部的MetaBar用横向滚动展示分类Scroll() { Row({ space: AppDimens.spaceSm }) { ForEach(this.state.cats, (c: PoemCategory) { Text(c.name) .fontSize(AppText.captionXs) .fontColor(AppColors.accent) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(AppColors.accentSoft) .borderRadius(AppDimens.radiusSm) }, (c: PoemCategory) c.slug) } } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off)这里使用c.slug作为 key比使用分类名更稳定。分类名可能调整显示文案slug 才是内容包里的稳定标识。ActionRow朗读、收藏、笔记是学习动作入口详情页在 Tab 上方放了三个动作动作背后能力朗读全文TextReaderService PoemReadInfoBuilder收藏FavoriteStore 文件夹选择记笔记NoteStore AppRoutes.NOTE_DETAIL源码结构如下Row({ space: AppDimens.spaceMd }) { // 朗读全文 // 收藏 / 已收藏 // 记笔记 } .padding({ left: AppDimens.pagePadding, right: AppDimens.pagePadding, bottom: AppDimens.spaceSm })这些动作没有塞进底部 Tab也没有放到很深的菜单里。原因很简单用户读到一首诗时最自然的行为就是听、收、记。它们属于阅读页面的一等动作。收藏不是简单 toggle而是选择文件夹详情页收藏不是一个布尔状态就结束。未收藏时点击会打开文件夹选择器private onFavoriteTap(): void { if (!this.state.poem || this.state.poemId.length 0) return; if (this.state.favored) { this.favStore.removeFavorite(poem, this.state.poemId); this.choosingFolder false; this.updateFavoriteState(false, ); return; } this.choosingFolder !this.choosingFolder; }用户选择文件夹后再写入private addFavToFolder(folderId: string): void { if (!this.state.poem || this.state.poemId.length 0) return; this.favStore.addToFolder(poem, this.state.poemId, this.favoriteTitle(), folderId); this.choosingFolder false; this.updateFavoriteState(true, folderId); }FavoriteStore.addToFolder()还处理了“已经收藏过但换文件夹”的情况const existedIndex: number this.items.findIndex( (it: FavoriteItem) it.type type it.targetId targetId ); if (existedIndex 0) { const existed: FavoriteItem this.items[existedIndex]; const updated: FavoriteItem { id: existed.id, type: existed.type, targetId: existed.targetId, title, folderId, createdAt: existed.createdAt }; this.items[existedIndex] updated; this.notifyChanged(); return updated; }这个实现比简单toggle更接近真实学习 App。用户收藏诗文不是为了“点亮星星”而是为了后续按主题、人物、课程或自定义文件夹整理。笔记围绕 poemId 查旧笔记没有则建草稿记笔记入口的逻辑也围绕poemIdprivate openNote(): void { if (!this.state.poem || this.state.poemId.length 0) return; const t: FavoriteType poem; let note this.noteStore.getByTarget(t, this.state.poemId); if (!note) { note this.noteStore.newDraft(t, this.state.poemId, this.favoriteTitle()); this.noteStore.upsert(note); } const p: NavigateParams { noteId: note.id }; Navigator.push(AppRoutes.NOTE_DETAIL, p); }这里有两个很好的细节如果已经有同一首诗的笔记优先打开旧笔记。如果没有创建草稿后跳到笔记详情页。这避免了同一首诗反复产生多个散乱笔记。对学习类应用来说笔记的“目标关联”比普通文本编辑更重要。朗读把详情组装成 SpeechKit ReadInfo朗读全文不是把页面上的可见文字读一遍而是通过PoemReadInfoBuilder生成结构化内容await TextReaderService.start( PoemReadInfoBuilder.buildFullText(this.state.poem, this.state.author) );构造逻辑如下static buildFullText(poem: PoemDetail, author: PoemAuthor | null): TextReader.ReadInfo { const parts: string[] []; PoemReadInfoBuilder.push(parts, poem.title); PoemReadInfoBuilder.push(parts, PoemReadInfoBuilder.authorLine(poem)); PoemReadInfoBuilder.push(parts, poem.body); PoemReadInfoBuilder.push(parts, PoemReadInfoBuilder.withLabel(注释, poem.annotation)); PoemReadInfoBuilder.push(parts, PoemReadInfoBuilder.withLabel(译文, poem.translation)); PoemReadInfoBuilder.push(parts, PoemReadInfoBuilder.withLabel(简析, poem.brief)); if (author author.intro.length 0) { PoemReadInfoBuilder.push(parts, PoemReadInfoBuilder.withLabel(作者介绍, author.intro)); } return { id: poem.id, title: { text: poem.title, isClickable: false }, author: { text: PoemReadInfoBuilder.authorLine(poem), isClickable: false }, date: { text: , isClickable: false }, bodyInfo: parts.join(\n\n) }; }这个构造有几个取舍取舍原因朗读内容包含正文、注释、译文、简析和作者介绍用户可以完整听完一首诗的学习材料空字段通过 push() 清理掉避免朗读出现空段落id 使用 poem.id朗读状态能和当前页面 poemId 对上标题和作者单独提供符合 SpeechKit 的结构化输入详情页还监听朗读状态private readerListener: (state: TextReader.ReadState) void (state: TextReader.ReadState) { if (this.state.poemId.length 0 state.id this.state.poemId) { this.readState state.state; } else { this.readState ReadStateCode.WAITING; } };这样只有当前诗的朗读状态才会影响按钮图标避免其他页面或其他诗文的朗读状态误刷新当前详情页。SettingsStore普通字号和诗文字号分开详情页进入时读取设置const s0: AppSettings this.settingsStore.get(); this.fontScale s0.fontScale; this.poemFontScale s0.poemFontScale; this.settingsStore.subscribe(this.settingsListener);监听器如下private settingsListener: () void () { const s: AppSettings this.settingsStore.get(); this.fontScale s.fontScale; this.poemFontScale s.poemFontScale; };离开页面时取消订阅aboutToDisappear(): void { this.settingsStore.unsubscribe(this.settingsListener); TextReaderService.releaseListeners(); }这里有一个很重要的用户体验设计普通解释文本和诗文正文使用不同缩放值。SettingsStore中有两个字段fontScale: 1.0, poemFontScale: 1.0诗文正文可能需要更大字号和更宽行距译注和作者介绍则不一定。把它们拆开后续设置页可以给用户更细的阅读控制。StatsStore阅读行为自动沉淀StatsStore.recordPoem(poemId)的实现很克制recordPoem(poemId: string): void { if (!poemId || poemId.length 0) return; const t: DailyStat this.ensureToday(); if (t.poemIds.indexOf(poemId) 0) { t.poemIds.push(poemId); this.notifyChanged(); } }它做了两件事确保当天有一条DailyStat。同一天同一首诗只记录一次。这避免了用户来回进入同一首诗导致统计膨胀。统计页最终展示的“今日读诗数”“累计诗文数”才是可信的学习行为而不是页面访问次数。响应式边界大屏不是复制一套页面详情页外层根据断点给左右留白.padding({ left: AppBp.isLg(this.curBp) ? 12% : (AppBp.isMdUp(this.curBp) ? 24 : 0), right: AppBp.isLg(this.curBp) ? 12% : (AppBp.isMdUp(this.curBp) ? 24 : 0) })手机上内容贴近全宽中屏左右留 24大屏左右留 12%。这是一种很轻的响应式策略不复制页面不改 Tab 结构只控制阅读宽度。诗文详情这种文本阅读页尤其需要控制行宽否则大屏上每行过长会影响阅读节奏。与第四篇内容包的衔接第四篇讲的是内容包结构第五篇讲的是详情页消费结构。两者之间的关系可以总结为内容包提供详情页消费PoemBrief.poemId路由参数和学习状态目标 IDPoemBrief.shard精准读取详情分片PoemDetail.body原文 TabPoemDetail.translation译文区块PoemDetail.annotation注释区块PoemDetail.brief简析 TabPoemDetail.authorId作者介绍 TabPoemDetail.categories顶部分类标签这说明详情页并不是孤立页面。它是内容工程的展示层也是后续收藏、笔记、统计、朗读的行为入口。本地验收命令先检查源码对象Get-Content -LiteralPath .\features\src\main\ets\poem\PoemDetailPage.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\poem\PoemPackRepo.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\speech\PoemReadInfoBuilder.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\state\AppStores.ets -Encoding UTF8再启动应用并进入诗文详情页截图 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/poem_detail.png -w 1080 -h 2400 -t png人工路径建议首页 → 诗文时空 → 任意分类 → 任意诗文 → 详情页页面验收清单检查项期望缺少 poemId显示空态不崩溃poemId poemShard能加载正确诗文详情顶部标题显示诗名、朝代和作者分类标签显示该诗所属分类横向滚动正常原文 Tab正文按句断行居中显示字号跟随 poemFontScale译文及注释 Tab注释和译文按块展示缺内容时显示空态简析 Tab展示 poem.brief缺内容时显示空态作者介绍 Tab通过 authorId 展示作者信息收藏可选择文件夹已收藏后可取消笔记同一首诗优先打开已有笔记否则创建草稿朗读朗读内容包含标题、作者、正文、译注、简析和作者介绍统计首次打开当天记录一次 poemId离开页面取消设置订阅并释放朗读监听常见问题复盘1. 为什么详情页不把译文、注释、简析全部放在原文下面因为诗文详情页的第一任务是阅读原文。译注和简析是理解辅助如果全部堆在原文下面用户会在移动端一直滚动阅读节奏被打断。四个 Tab 让“读诗”和“理解诗”有清晰切换。2. 为什么需要poemShard只传poemId不行吗只传poemId当然也能查但要么需要全局详情索引要么需要扫描多个分片。当前内容包已经在PoemBrief中保存 shard详情页直接按分片读取路径更短IO 更可控。3. 为什么收藏要选择文件夹学习类 App 的收藏不是临时点赞而是长期知识整理。文件夹能让用户按“唐诗”“考试”“人物”“课堂”等维度组织内容。详情页直接接入文件夹选择后续收藏页才能形成真正的知识库。4. 为什么笔记入口会复用已有笔记同一首诗反复创建多个笔记会让知识沉淀变散。NoteStore.getByTarget(poem, poemId)优先找旧笔记能让同一学习对象对应一份持续更新的笔记。5. 为什么朗读内容不只读原文当前设计的“朗读全文”更像学习朗读不只是吟诵。它会把注释、译文、简析和作者介绍一起组织起来适合用户在通勤或休息时完整听一遍学习材料。后续如果要区分“只读原文”和“全文讲解”可以在PoemReadInfoBuilder上扩展模式参数。本章小结第五篇的核心不是“详情页用了 Tabs”而是一个内容型学习页面如何把数据和行为组织在一起。PoemDetailPage通过poemId poemShard精准读取内容包详情通过四个 Tab 分层展示原文、译注、简析和作者信息通过SettingsStore控制阅读字号通过FavoriteStore做文件夹收藏通过NoteStore做目标关联笔记通过PoemReadInfoBuilder接入朗读通过StatsStore把真实阅读行为沉淀到学习统计。这样诗文详情页就不只是“文本展示终点”而是学习闭环中的关键节点读、听、收、记、统计都从这里发生。下一篇会从诗文阅读切到历史主线拆解时间轴如何把朝代、年份、历史事件和兴衰分析串成一条可理解的路径。[#HarmonyOS](https://so.csdn.net/so/search/s.do?qHarmonyOStallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#ArkTS](https://so.csdn.net/so/search/s.do?qArkTStallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#ArkUI](https://so.csdn.net/so/search/s.do?qArkUItallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#DevEco Studio](https://so.csdn.net/so/search/s.do?qDevEcoStudiotallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#鸿蒙开发](https://so.csdn.net/so/search/s.do?q%E9%B8%BF%E8%92%99%E5%BC%80%E5%8F%91tallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art)