Skip to content

一个古籍阅读器不只是排版:ReadTao Reader 的响应式设计与状态模型

约 3051 字大约 10 分钟

ReadTaoNuxtVue响应式设计

2026-07-28

古籍阅读器不只是选一款宋体、把正文放到页面中间。ReadTao Reader 需要同时处理稳定发布数据、语义段落对齐、拼音词元、来源注文、桌面与移动布局、阅读偏好、继续阅读,以及与屏幕状态一致的导出和打印。

前四篇已经把 ReadTao 的内容链走到了公共读取 API:

  1. Studio 与 Reader 的整体架构
  2. 原文件导入、来源分层和语义分段
  3. 机器初稿、人工修订和失败重试
  4. 不可变 Release 和公共读取投影

最后一篇回到读者真正看到的页面。

Reader 的 Nuxt 页面、阅读状态、导出逻辑和样式都可以在 ReadTao GitHub 仓库 中查看。

阅读器面对的不是一段 HTML

如果只显示原文,一个 <article> 已经足够。

ReadTao 的一个已发布语义段落却可能同时包含:

  • 繁体规范原文;
  • 简体原文;
  • 与字符偏移对应的拼音词元;
  • 一条或多条来源注文;
  • 现代汉语;
  • English;
  • 稳定 segment_idanchor
  • 来源、底本和发布版本信息。

这些内容不是几段松散文字,而是围绕同一个语义段落组织的不同层。

Reader 的任务是根据当前偏好选择和排列这些层,同时保持段落对应、地址稳定和移动端可读。

一条阅读路由怎样拿到发布内容

正式阅读地址由作品、底本和内容单元组成:

/read/{workSlug}/{editionSlug}/{divisionSlug}

页面根据路由构造公共章节接口:

const endpoint = computed(() =>
  `${apiBase}/api/public/works/` +
  `${encodeURIComponent(workSlug.value)}/editions/` +
  `${encodeURIComponent(editionSlug.value)}/chapters/` +
  `${encodeURIComponent(divisionSlug.value)}`,
)

返回的 PublishedChapter 不只包含当前章正文,还带有:

  • 当前作品和底本;
  • 发布号;
  • 完整已发布目录;
  • 上一篇和下一篇;
  • 当前内容单元的全部 PublishedSegment

Reader 不读取 Studio 工作区,也不会为了得到译文再发起一组分散请求。一次章节响应已经固定到同一个 Release。

为什么对照必须建立在 Segment 上

桌面阅读支持上下对照和左右对照。

左右对照阅读上下对照阅读
ReadTao Reader 左右对照ReadTao Reader 上下对照

如果原文和译文只是两个独立长数组,一处段落拆分变化就会让左右关系全部错位。

ReadTao 的每个 PublishedSegment 已经固定原文、派生层和来源注文,所以页面按段落循环:

<section
  v-for="segment in chapter.segments"
  :id="segment.anchor"
  :key="segment.id"
  class="text-segment"
>
  <div class="original-block">...</div>
  <div class="translation">...</div>
</section>

左右布局只是让同一个段落内部变成两列,而不是把全章原文和全章译文拆成两个互不相关的面板。

这样即使某段注文很长,也只影响它所在段落的高度,不会让后面的所有原译对应关系逐渐漂移。

阅读状态不能靠按钮互相改写

Reader 当前主要阅读偏好包括:

interface ReadingPreferences {
  scriptMode: 'traditional' | 'simplified'
  translationMode: 'compare' | 'original' | 'translation'
  translationLanguage: 'modern_zh' | 'en'
  layout: 'flow' | 'split'
  theme: 'paper' | 'dark' | 'system'
  lineHeight: 'compact' | 'standard' | 'relaxed'
  fontSize: number
  showPinyin: boolean
}

这些状态彼此独立:

  • 显示模式决定原文和译文是否可见;
  • 繁简只影响原文;
  • 拼音只附属于原文;
  • 界面语言选择现代汉语或英文译文;
  • 布局只在对照模式下生效;
  • 字号和行距不应该重置其他偏好。

一个常见但危险的写法是:切换“仅译文”时顺手把 showPinyin 设为 false,切回对照以后用户原来的偏好就丢了。

ReadTao 使用派生状态决定哪些控制暂时无效:

export function readingModeState(mode: TranslationMode) {
  return {
    showOriginal: mode !== 'translation',
    showTranslation: mode !== 'original',
    languageEnabled: mode !== 'original',
    originalControlsEnabled: mode !== 'translation',
    layoutEnabled: mode === 'compare',
  }
}

控件可以禁用,偏好不被清空。

偏好怎样跨页面保存

Reader 使用 Nuxt useState 保存当前响应式状态,并在客户端挂载后从 localStorage 恢复:

export const READING_PREFERENCES_KEY =
  'readtao:reading-preferences:v1'

onMounted(() => {
  preferences.value = normalizeReadingPreferences(
    JSON.parse(localStorage.getItem(READING_PREFERENCES_KEY) ?? 'null'),
  )
})

读取本地数据时不能直接相信旧值。normalizeReadingPreferences 会:

  • 过滤不属于当前枚举的值;
  • 给缺失字段补默认值;
  • 把字号限制在 20 到 36;
  • JSON 损坏时恢复默认配置。

这样以后增加字段或升级偏好结构时,旧浏览器数据不会直接让页面崩溃。

全站配色、日夜模式和界面语言的写入口只放在首页;书库、问道、账号和阅读器消费已经保存的状态,不在每个页面重复一套全局控制。阅读器顶栏只保留目录、显示、繁简、导出和阅读设置。

桌面和移动端不是同一布局等比缩小

Reader 把桌面和手机都当成正式体验,但不会要求它们使用完全相同的布局。

桌面宽屏:

  • 64px 固定顶栏;
  • 多内容单元典籍默认展开 272px 目录侧栏;
  • 单列正文最大约 780px;
  • 左右对照最大约 1180px;
  • 显示、导出和设置使用顶栏锚定弹层。

移动端:

  • 保持 64px 单行顶栏;
  • 目录变为覆盖式抽屉;
  • 显示、导出和设置变成底部面板;
  • 对照阅读强制单列;
  • 不显示不可操作的左右布局选项;
  • 所有主要触控区域至少为 40×40px

移动端强制单列只是渲染规则,不会把用户在桌面保存的 layout: split 改成 flow。回到宽屏以后,原来的左右偏好仍然有效。

CSS 中这道边界是明确的:

.reading-pane {
  width: min(780px, calc(100% - 64px));
}

.reading-pane.split-reading {
  width: min(1180px, calc(100% - 64px));
}

@media (max-width: 960px) {
  .segment-split {
    display: block;
  }

  .compare-layout-choice {
    display: none;
  }
}

拼音为什么使用 ruby/rt

拼音不是一串和原文长度碰巧相等的文本。多音节、标点、生僻字和人工覆盖都会影响对应关系。

Backend 发布的是带字符偏移的 PronunciationToken,Reader 再把它映射成阅读单元:

export function buildReadingPieces(displayText, tokens) {
  const tokensByOffset = new Map(
    tokens.map(token => [token.start_offset, token]),
  )

  return Array.from(displayText).map((character, offset) => {
    const token = tokensByOffset.get(offset)
    return token
      ? { text: character, reading: token.pinyin }
      : { text: character }
  })
}

模板使用语义化 ruby/rt

<ruby v-if="piece.reading">
  <span class="ruby-base">{{ piece.text }}</span>
  <rt>{{ piece.reading }}</rt>
</ruby>

CSS 使用内容驱动宽度,让一个单元至少容纳完整音节和汉字中较宽的一方,而不是把拼音绝对定位到字的上方。

.ruby-line ruby {
  display: inline-grid;
  grid-template-areas: "reading" "base";
  grid-template-columns: max-content;
  min-width: 1em;
}

这能减少长音节覆盖相邻字符的问题,也让字号变化时拼音跟随正文一起缩放。

来源注文怎样进入阅读层

Reader 只渲染 Release 固定的已批准来源注文。

桌面左右对照时,注文属于原文侧;移动单列时,顺序是:

原文 → 来源注文 → 译文

无注文典籍不会显示空标题、空卡片或“暂无注释”。这是内容类型本身的差异,不是数据不完整。

注文和原文使用相同的古籍字体体系,但通过字号、颜色和间距区分。右侧译文只与正文对应,不为来源注文生成一份看似完整、实际不存在的翻译。

稳定锚点和继续阅读分别解决什么

每个段落容器直接使用发布数据中的稳定锚点:

<section :id="segment.anchor" class="text-segment">

这样搜索结果、问道引用、导出文件和分享链接可以指向同一个段落身份。

继续阅读则是另一种状态。当前版本先把最近阅读位置保存在浏览器:

readtao:last-reading:v1

它记录作品、底本、内容单元、路由和滚动位置。重新打开同一路由且 URL 没有显式锚点时,页面恢复上次滚动位置;如果链接已经带有 #anchor,则优先尊重分享目标。

一个是可以公开引用的内容身份,一个是用户本地的临时进度,两者不能混成同一个字段。

导出应该复用阅读语义,而不是截图页面

Reader 支持:

  • 当前章或整部;
  • Markdown 或 TXT;
  • 浏览器打印和保存 PDF。

当前章导出直接复用已经加载的 PublishedChapter。只有选择整书时,才请求 PublishedBook,避免每次进入阅读页都下载整部内容。

Markdown/TXT 使用线性语义顺序,不会把屏幕上的左右两列硬编码进文件。导出内容会带有:

  • 作品和底本;
  • 发布号;
  • 来源、提交和许可;
  • 当前阅读模式;
  • 稳定段落锚点。

打印预览才会应用当前上下或左右布局,并通过 @media print 隐藏工具栏。

阅读界面的可靠性也需要测试

Reader 的验收不能只看我自己的桌面浏览器。

当前约定至少覆盖:

  • 手机:360×800390×844430×932
  • 平板:768×1024
  • 桌面:1280×8001440×9001920×1080

检查重点不是每个像素完全相同,而是:

  • 有没有横向滚动和内容遮挡;
  • 顶栏能否保持单行;
  • 目录、弹层和底部面板能否关闭并返回焦点;
  • 左右对照是否按语义段落对应;
  • 显示模式切换是否丢失其他偏好;
  • 稳定锚点、全书导出和打印是否工作;
  • 接口失败、无发布版本和缺少内容层是否有明确状态。

仓库里保留了 Vitest、Backend pytest、OpenAPI 检查和 Playwright 浏览器测试。发布前的完整本地检查入口包括:

pnpm typecheck
pnpm test
pnpm build
pnpm api:check
pnpm test:e2e

自动测试之外,我仍然需要在真实桌面和手机尺寸检查字体、拼音、长文本和滚动。这些是 DOM 正确却仍可能阅读不舒服的地方。

这一组文章最后回到了“读”

写完这五篇再回头看,ReadTao 的链路并不复杂到需要大量微服务,但它有几条我不想放松的边界:

  • 原始来源不能在清洗中消失;
  • 正文、来源注文和 AI 派生内容不能混淆;
  • 语义段落必须能够回到来源坐标;
  • 模型只能产生初稿,任务失败必须可见、可重试;
  • 发布快照不可原地覆盖;
  • Reader 只读取已发布内容;
  • 桌面和手机都要真正可读;
  • AI 服务退出以后,古籍仍然存在。

技术最终还是要回到产品目标:让我可以用很长的时间慢慢整理内容,也让读者在任何时候打开页面,都能读到一份来源和版本说得清楚的文本。

完整实现、演示数据、运行命令和验证记录都在 ReadTao GitHub 仓库