阅读视图

发现新文章,点击刷新页面。

做了个 EPUB 阅读器,被「阅读进度同步」折磨了一周,总结 4 个血泪教训

你做过"打开一本书,自动回到上次阅读位置"这个功能吗?

听起来很简单对吧——存个页码,下次打开翻过去就行。我一开始也是这么想的,直到在 Web EPUB 阅读器上被反复打脸。

这篇文章不讲理论框架,直接讲:我在实现 Web/Mobile 阅读进度同步时踩过的每一个坑,以及为什么"存页码"这条路从一开始就是死的。

一句话结论

进度 = 内容位置(Anchor),页面 = 当前设备的渲染结果。

只要你不存页码,Web 双页 / Mobile 单页 / 字体可调 / 阅读器大小可调 / 多端同步,全部迎刃而解。

为什么不能存页码?

同一本 EPUB,79 章,30 万字:

环境 页数
PC 双页 (319px/页) 1165 页
iPad 横屏 (500px/页) 约 750 页
iPhone 竖屏 (350px/页) 约 1400 页
调大字号 (20px) 约 1800 页

用户在 PC 上读到第 142 页,存下来。手机打开,翻到第 142 页——内容完全对不上,可能差了好几章。

页码是渲染结果,不是内容属性。 它取决于字体、字号、行高、容器宽高、双页/单页模式。换任何一个参数,页码就变了。

Anchor 锚点设计

数据结构

interface ReadingAnchor {
  chapterIndex: number;   // 第 11 章
  blockIndex: number;     // 章内第 17 个段落
  charOffset: number;     // 段内第 0 个字符
  textSnippet: string;    // "尤里身体前倾,像是在敦促她"
}

每个字段都是内容属性——和设备无关、和字体无关、和屏幕宽度无关。

textSnippet 是保险:万一书的内容更新导致 blockIndex 偏移,还能用文字片段做模糊匹配(Kindle 也是这么做的)。

存储格式

anchor:11:17:0|snippet:尤里身体前倾,像是在敦促她|char:25000

char:25000 是全局字符偏移,供旧客户端降级。一个字符串,三层 fallback,向前兼容。

多端同步流程

手机端退出 → 保存 anchor → 后端存储
                                    ↓
PC 端打开 → 请求 anchor → 当前设置下重新分页 → 定位到锚点所在页

后端只做一件事:存最新的 anchor。"翻到哪一页"这个问题完全由前端根据当前设备环境实时计算。

前端分页:CSS 多列布局测量

EPUB 分页的核心是 CSS column-width。将章节 HTML 注入一个隐藏容器,浏览器自动按列排布,scrollWidth / columnWidth 就是页数。

// 隐藏测量容器
measureEl.innerHTML = `
  <div class="epub-measure-container" style="
    width: ${pageWidth}px;
    height: ${pageHeight}px;
    column-width: ${pageWidth}px;
    column-gap: 0px;
    column-fill: auto;
    font-size: ${fontSize}px;
    line-height: ${lineHeight};
  ">${chapter.html}</div>
`;

const scrollW = contentEl.scrollWidth;
const pageCount = Math.ceil(scrollW / pageWidth);

同时,遍历每个块级元素,记录它在第几列(第几页),构建 blockMap

// 用 getBoundingClientRect 计算元素所在列
const containerRect = containerEl.getBoundingClientRect();
for (const el of leafElements) {
  const elRect = el.getBoundingClientRect();
  const relativeLeft = elRect.left - containerRect.left;
  const pageInChapter = Math.floor(relativeLeft / columnWidth);
  // 记录:blockIndex → pageInChapter
}

有了 blockMap,锚点 → 页码的转换就是一次查表:range.startPage + block.pageInChapter

四个真实的坑

坑 1:测量 CSS ≠ 渲染 CSS → 定位偏移

这是最隐蔽的 Bug。测量容器和实际渲染的 CSS 差了几条规则:

/* 渲染容器有,测量容器漏了 */
h1, h2, h3 { margin-top: 0.5em; margin-bottom: 0.3em; }
blockquote { text-indent: 0 !important; }
a { color: inherit; text-decoration: underline; }

一个标题的 margin 差了 0.5em(≈ 8px),在 319px 宽的手机屏幕上,就足以让后续段落的列分配偏移一整页。79 章累积下来,锚点可以偏差几十页。

结论:测量 CSS 和渲染 CSS 必须完全一致,差一个属性就可能出错。

坑 2:offsetLeft 在多列布局中不可靠

最初用 el.offsetLeft / columnWidth 判断元素在哪一列。但 offsetLeft 的语义是"相对于 offsetParent",在 CSS 多列布局中,不同浏览器的实现有差异。

换成 getBoundingClientRect() 后解决。它返回元素的实际视觉位置,跨浏览器一致:

// ❌ 不可靠
const page = Math.floor(el.offsetLeft / columnWidth);

// ✅ 可靠
const rect = el.getBoundingClientRect();
const page = Math.floor((rect.left - containerRect.left) / columnWidth);

坑 3:字体设置变更 → 用旧数据算出错误页码

用户调整字号 → settingsFingerprint 变化 → 触发重新分页。但 React 中多个 Hook 的状态更新有时差:

Effect 看到:新的 settingsFingerprint ✓
             旧的 blockMaps ✗ (分页 Hook 还没完成重新测量)

用旧的 blockMaps + 新设置去算 anchorToPage,结果必然是错的。

解决方案:两阶段导航。

// 第一阶段:检测到设置变更,标记等待,不导航
if (isSettingsChange) {
  pendingSettingsNavRef.current = true;
  return; // 等分页重新测量
}

// 第二阶段:分页完成后,用新 blockMaps 安全导航
if (pendingSettingsNavRef.current) {
  pendingSettingsNavRef.current = false;
  const newPage = anchorToPage(anchor, newRanges, newBlockMaps);
  navigateTo(newPage);
}

坑 4:渐进加载 + 翻页库事件竞态

79 章的书不会一次加载完。第一次分页只有 17 章精确测量 + 62 章估算。当更多章节加载后,avgCharsPerPage 从 135 变成 129,所有估算章节的 startPage 集体偏移,锚点对应的全局页码从 132 变成 142。

但阅读器还停在 132 页,因为初始化后没有"自动修正"逻辑。

更麻烦的是,尝试用 setSettingsKey 重新挂载 flipbook 来修正时,翻页库在 mount 时会发射一个 onFlip({page: 0}) 的伪事件。这个事件把 currentPageRef 污染成 0,导致后续自动修正全部失效。

解决方案:两个机制配合。

门控机制:flipbook 初始化阶段忽略 onFlip 事件。

const flipReadyRef = useRef(false);

// mount 后 300ms 才标记就绪
setTimeout(() => { flipReadyRef.current = true; }, 300);

// handleFlip 中门控
if (!flipReadyRef.current) return; // 忽略伪事件

直接导航:渐进加载修正时用 turnToPage 而不是重新挂载,从根本上避免竞态。

if (!userHasFlippedRef.current && startPage !== currentPageRef.current) {
  flipBookRef.current?.pageFlip()?.turnToPage(startPage);
}

最终架构

┌───────────────────────────────────┐
│ 后端:只存 anchor 字符串          │  POST /api/library/progress
├───────────────────────────────────┤
│ 前端解析层:anchor ↔ 页码转换     │  anchorToPage / pageToAnchor
├───────────────────────────────────┤
│ 前端测量层:CSS columns 精确测量   │  buildBlockMap → blockMaps
├───────────────────────────────────┤
│ 前端渲染层:flipbook 翻页 UI      │  react-pageflip
└───────────────────────────────────┘

核心原则:

  • 后端不分页,只存内容位置
  • 页码纯前端算,根据当前设备环境实时计算
  • 锚点与设备无关,同一个锚点在任何设备上都能定位
  • 转换方向:永远是 anchor → page(打开时),page → anchor(保存时)

写在最后

实现这个功能的过程让我深刻理解了一件事:看似简单的需求,难点往往不在算法设计,而在工程细节的一致性

CSS 差一条规则、React Effect 的执行时序差一帧、第三方库的一个初始化事件——这些"微小"的不一致累积起来,就是"打开书发现位置完全不对"的用户体验灾难。

如果你也在做类似的阅读器产品,记住这个原则:

永远不要存页码。存内容位置,让前端去算页码。

这一个决策,能帮你避开 80% 的坑。

当 AI 学会了写博客:Cursor AI Skill 如何让你的开发效率翻倍

前言

你有没有想过,写完代码之后,AI 自动帮你把技术博客也写了?

这不是幻想。我在自己的博客系统 Ink & Codeptclove.com)上实现了这个能力——通过 Cursor 的 AI Skill 机制,只需一句话,AI 就能分析代码、撰写文章、一键发布。

本文会深入介绍 AI Skill 的实际应用,以及支撑这一切的技术架构:Next.js 16 + Prisma + Tailwind CSS 4 + Tiptap

什么是 Cursor AI Skill

AI Skill 是 Cursor IDE 提供的一种扩展机制,本质上是一份 Markdown 格式的指令文件(SKILL.md),告诉 AI 在特定场景下该做什么、怎么做。

与普通的 Prompt 不同,Skill 有几个核心优势:

  1. 场景触发:当用户提到"写博客"、"发布文章"时自动激活
  2. 结构化流程:定义清晰的多步骤工作流,而不是一次性提问
  3. 工具集成:可以调用 Shell 脚本、API 接口,实现端到端的自动化

实战:一个自动写博客的 Skill

我在项目中创建了 .cursor/skills/generate-blog/SKILL.md,定义了博客生成的完整流程:

---
name: generate-blog
description: 生成技术博客并发布到 Ink & Code
---

# 生成博客文章

## 工作流程

### 1. 确定生成模式
- commit 模式:根据 Git 提交改动生成
- topic 模式:根据特定主题生成
- repo 模式:介绍整个项目

### 2. 收集上下文
根据模式收集相关代码上下文...

### 3. 生成博客内容
撰写高质量技术博客,包含背景、方案、实现、总结...

### 4. 发布文章
使用脚本发布到博客系统:

关键在于第 4 步——Skill 不仅能生成内容,还能通过 Shell 脚本直接发布:

# publish.sh - 一键发布到博客
TITLE="$1"
TAGS="$2"

# 读取内容(支持文件、stdin、剪贴板)
if [ -n "$CONTENT_FILE" ] && [ -f "$CONTENT_FILE" ]; then
    CONTENT=$(cat "$CONTENT_FILE")
elif [ ! -t 0 ]; then
    CONTENT=$(cat)
else
    CONTENT=$(pbpaste 2>/dev/null || echo "")
fi

# 构建 JSON 并调用 API
jq -n --arg title "$TITLE" --arg content "$CONTENT" \
  '{title: $title, content: $content, published: false}' \
  > /tmp/blog_payload.json

curl -X POST "${INK_AND_CODE_URL}/api/article/create-from-commit" \
  -H "Authorization: Bearer $INK_AND_CODE_TOKEN" \
  -d @/tmp/blog_payload.json

这意味着从代码变更到文章发布,整个链路都是自动化的。

更进一步:GitHub Actions + AI 自动发文

除了本地 Skill,我还配置了 GitHub Actions 实现 CI/CD 级别的博客自动化:

# .github/workflows/auto-blog.yml
on:
  push:
    branches: [main]

jobs:
  generate-blog:
    # 只有提交信息包含 [blog] 时才触发
    if: contains(github.event.head_commit.message, '[blog]')
    steps:
      - name: Gather project context
        run: |
          git diff HEAD~1 HEAD > /tmp/commit_diff.txt
          # 收集项目结构、改动文件、配置文件...

      - name: Generate and publish
        run: |
          # 将上下文发送给 AI,生成博客
          # 解析标题、内容、标签
          # 调用 API 发布

工作原理:当我提交代码时,在 commit message 中加上 [blog] 标记,GitHub Actions 会自动收集项目上下文(diff、文件结构、配置文件),调用 DeepSeek/Claude/GPT 生成技术博客,最后通过 API 发布到网站。

整个过程无需人工干预,写完代码,博客就自动出现在网站上了。

支撑一切的技术架构

这套自动化能跑通,离不开底层的技术架构。Ink & Code 采用的是 Next.js 16 + Prisma + Tailwind CSS 4 + Tiptap 的组合。

Next.js 16 App Router

项目使用 Next.js 16 的 App Router 架构,目录结构清晰:

app/
├── api/          # API 路由
│   ├── article/  # 文章 CRUD
│   ├── chat/     # AI 聊天
│   └── auth/     # 认证
├── admin/        # 后台管理
├── blog/         # 博客页面
├── components/   # 组件
└── u/[username]/ # 用户主页

API 路由提供了完整的 RESTful 接口,支持两种认证方式:

// Session 认证(用户登录)
const session = await auth();

// Token 认证(外部调用,如 GitHub Actions)
const token = request.headers
  .get('Authorization')?.replace('Bearer ', '');
const hashedToken = hashToken(token);
const apiToken = await prisma.apiToken.findUnique({
  where: { tokenHash: hashedToken }
});

这种双认证机制让博客系统既支持浏览器登录,又支持脚本和 CI/CD 的外部调用。

Prisma 数据建模

数据层使用 Prisma ORM,核心模型设计:

model Post {
  id         String    @id @default(cuid())
  title      String
  slug       String
  content    String    @db.Text  // TipTap JSON
  excerpt    String?
  coverImage String?
  published  Boolean   @default(false)
  tags       String[]
  sortOrder  Int       @default(0)

  user       User      @relation(fields: [userId])
  category   Category? @relation(fields: [categoryId])

  @@unique([userId, slug])
}

model Category {
  id       String     @id @default(cuid())
  name     String
  slug     String
  icon     String?
  parentId String?    // 树形结构
  parent   Category?  @relation("children", fields: [parentId])
  children Category[] @relation("children")

  @@unique([userId, slug])
}

文章内容以 TipTap JSON 格式存储,而非原始 Markdown。这意味着从外部(如 GitHub Actions)提交的 Markdown 内容需要经过转换:

// lib/markdown-to-tiptap.ts
export function markdownToTiptap(markdown: string): string {
  const doc: TiptapDoc = { type: 'doc', content: [] };
  const lines = markdown.split('\n');

  while (i < lines.length) {
    const line = lines[i];
    if (line.startsWith('```')) {
      // 代码块 → codeBlock 节点
    } else if (line.match(/^#{1,6}\s/)) {
      // 标题 → heading 节点
    } else if (line.match(/^\d+.\s/)) {
      // 有序列表 → orderedList 节点
    }
    // ...更多格式处理
  }
  return JSON.stringify(doc);
}

Tiptap 富文本编辑器

后台管理使用 Tiptap 作为编辑器,支持丰富的内容格式:

const editor = useEditor({
  extensions: [
    StarterKit,
    CodeBlockLowlight.configure({
      lowlight  // 代码高亮
    }),
    Image,           // 图片
    Link,            // 链接
    Table,           // 表格
    Placeholder.configure({
      placeholder: '开始写作...'
    }),
  ],
});

编辑器还实现了自动保存、快捷键(Cmd+S)、图片上传(阿里云 OSS)等实用功能。

Tailwind CSS 4

样式层使用最新的 Tailwind CSS 4,通过 CSS 变量实现主题切换:

:root {
  --color-primary: #b8860b;
  --color-background: #faf8f5;
  --color-foreground: #1a1a1a;
}

[data-theme='dark'] {
  --color-primary: #d4a537;
  --color-background: #1a1a1a;
  --color-foreground: #e8e8e8;
}

配合 ThemeProvider 组件,实现了丝滑的明暗主题切换。

Ink & Code 核心功能一览

除了上面提到的技术架构,ptclove.com 还有这些核心功能:

  • AI 助手:内置 AI 聊天组件,基于 DeepSeek 实时流式响应,随时提问
  • 智能目录:自动从文章标题生成目录树,支持大标题折叠子标题、滚动高亮、点击跳转
  • 分类体系:支持树形分类结构,多层级管理文章
  • 文档树管理:后台采用树形文档管理,拖拽排序,所见即所得
  • 多用户支持:每个用户有独立主页(/u/username),独立分类和文章
  • 深色模式:跟随系统或手动切换,全站适配
  • 响应式设计:从手机到桌面端完美适配
  • 一键部署:GitHub Actions 自动构建、SSH 部署到服务器,PM2 进程管理

总结

Cursor AI Skill 的价值不在于它是一个多高深的技术,而在于它提供了一种思路:让 AI 不只是回答问题,而是融入你的工作流

在 Ink & Code 这个项目中,AI Skill 串联了从代码提交到博客发布的完整链路。而 Next.js + Prisma + Tiptap + Tailwind 这套技术栈,则提供了足够灵活的底层能力来支撑这种自动化。

如果你也想体验 AI 驱动的博客写作,欢迎访问 ptclove.com ,也欢迎在评论区交流你的 AI Skill 实践。

❌