项目3 · Next.js 个人知识站
用 App Router 和本地 SQLite 做一个能长期维护的个人知识站:检索、编辑、版本、归档、导出都要真实可用。
- 用 App Router 的目录结构组织列表、详情、新建、编辑、版本历史和归档恢复等页面,并处理动态参数与 not-found
- 把数据访问收在服务端数据层:Server Component 直接读 SQLite,浏览器构建里不出现 node:sqlite
- 用 node:sqlite 建 articles / categories / article_versions 三张表,写可重复执行的迁移函数和 seed 脚本
- 在 Server Action 写入成功后调用 revalidatePath,理解数据库、文件系统与路由缓存各自的职责
- 每次编辑保存一条版本记录,支持查看历史与回滚,并用 Route Handler 导出 JSON / Markdown
- 01文章列表页:分类筛选、关键词搜索、结果计数与空状态
- 02文章详情页:标题、分类、标签、正文、更新时间与操作入口
- 03新建文章页:Server Action 提交、字段校验、slug 唯一性检查
- 04编辑文章页:回填原值、保存后生成新版本并刷新详情
- 05版本历史页:版本号、变更说明、时间与正文预览,支持回滚
- 06归档页:归档列表、恢复操作,归档文章不出现在默认列表
- 07导出页:下载全部数据 JSON 和文章 Markdown
- 08Route Handlers:/api/export 与 /api/export/markdown 两个下载接口
列表 /、详情 /articles/[slug]、新建 /articles/new、编辑 /articles/[slug]/edit、版本 /articles/[slug]/versions、归档 /archive、导出 /export 均可访问;不存在的文章进入 not-found,导航链接可用。
lib/db.ts 使用 node:sqlite 打开 data/knowledge.db,通过 PRAGMA user_version 顺序执行迁移,建出 categories、articles、article_versions 三张表;npm run seed 写入 3 篇示例文章且可重复执行不重复插入。
首页默认只显示 active 文章并按更新时间倒序;分类下拉与关键词可组合生效,URL 上保留 q 和 category 参数;无结果时显示空状态和清除筛选入口。
详情页完整展示标题、分类、标签、正文和更新时间;新建表单校验标题、slug 格式、摘要、正文和分类,重复 slug 有明确错误提示,成功后跳转详情。
编辑页回填原值;每次保存更新 articles 并插入一条 article_versions,version_no 递增;版本历史页按版本号倒序列出变更说明、时间和正文预览。
任意历史版本可回滚;回滚后详情页内容恢复为该版本,同时新增一条「回滚到 vN」的版本记录,旧版本不被删除。
详情页可归档,归档后首页和搜索结果不再出现;归档页列出全部 archived 文章并可恢复,恢复后重新出现在首页,状态在详情、列表、导出三处一致。
所有写操作在返回前调用 revalidatePath 覆盖列表、详情、归档等受影响路径;/api/export 下载包含三表的 JSON,/api/export/markdown 下载 Markdown;solution 与 starter 均 npm run build 通过,node tests/check.mjs 判定 solution 通过、starter 存在缺口。
# Next.js 个人知识站(p3-knowledge-site)
用 Next.js 16 App Router + React 19 + `node:sqlite` 做一个真正能用的个人知识站:文章列表、分类筛选、关键词搜索、详情、新建、编辑、版本历史、回滚、归档恢复、JSON / Markdown 导出。
这个项目的重点不是「页面能打开」,而是把服务端数据访问、写入校验、版本快照和缓存失效连成一条完整的链路。
## 学习目标
- 用 App Router 的目录组织页面:静态段、动态段 `[slug]`、`searchParams`、`notFound()`。
- 在 Server Component 里直接读本地 SQLite,理解为什么数据库代码不能进入浏览器构建。
- 用 `node:sqlite` 建 `categories`、`articles`、`article_versions` 三张表,写基于 `PRAGMA user_version` 的迁移和可重复执行的 seed。
- 用 Server Actions + `useActionState` 完成表单校验、错误回显、防重复提交。
- 每次编辑保存一条完整版本快照,支持查看历史与回滚;归档只改状态,不伪造版本。
- 写入后调用 `revalidatePath`,让列表、详情、归档页立刻反映数据库变化。
- 用 Route Handler 输出 JSON 与 Markdown 下载,完成数据导出闭环。
## 环境要求
- Node.js **24+**(`node:sqlite` 在此版本稳定;不需要安装数据库服务)
- npm 10+
- 无网络字体依赖,构建不需要访问 Google Fonts
## 快速开始
### 运行起点(starter)
```bash
cd starter
npm install --no-audit --no-fund
npm run seed
npm run dev
```
打开 `http://localhost:3000`。起点只有布局、静态列表外观、最小 `articles` 表和 seed,缺口写在 `starter/TODO.md` 与代码里的 `TODO m1`–`TODO m8`。
### 运行参考实现(solution)
```bash
cd solution
npm install --no-audit --no-fund
npm run seed
npm run dev
```
一条命令版本:
```bash
cd solution && npm install --no-audit --no-fund && npm run seed && npm run dev
```
常用命令:
| 命令 | 作用 |
| --- | --- |
| `npm run dev` | 启动开发服务器 |
| `npm run seed` | 建库、迁移并导入 `../fixtures/articles.json`,已存在的文章跳过 |
| `npm run seed:reset` | 清空三张表后重新导入 |
| `npm run build` | 生产构建(starter 与 solution 都必须通过) |
| `npm run start` | 启动生产服务器 |
| `npm run typecheck` | TypeScript 检查 |
### 自动检查
```bash
# 在 p3-knowledge-site 根目录
node tests/check.mjs
```
脚本检查 solution 的页面、数据层、Server Actions、导出接口等 11 项特征,并用临时 SQLite 文件真实执行迁移和 seed;同时确认 starter 仍有缺口。详细走查步骤见 `tests/README.md`。
## 目录结构
```text
p3-knowledge-site/
├── README.md
├── starter/ # 可启动起点
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # 静态列表骨架 + TODO
│ │ └── globals.css
│ ├── lib/db.ts # 最小建表骨架
│ ├── scripts/seed.ts
│ ├── next.config.ts # cacheComponents: false
│ ├── package.json
│ ├── TODO.md
│ └── tsconfig.json
├── solution/ # 完整参考实现
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # 列表 + 分类筛选 + 关键词搜索
│ │ ├── not-found.tsx
│ │ ├── articles/
│ │ │ ├── new/page.tsx # 新建
│ │ │ └── [slug]/
│ │ │ ├── page.tsx # 详情
│ │ │ ├── edit/page.tsx # 编辑
│ │ │ └── versions/page.tsx# 版本历史与回滚
│ │ ├── archive/page.tsx # 归档列表与恢复
│ │ ├── export/page.tsx # 导出入口
│ │ └── api/export/
│ │ ├── route.ts # JSON 下载
│ │ └── markdown/route.ts # Markdown 下载(可单篇)
│ ├── components/
│ │ └── article-form.tsx # useActionState 表单
│ ├── lib/
│ │ ├── db.ts # 连接、迁移、seed
│ │ ├── articles.ts # 查询、写入、版本、导出
│ │ ├── validation.ts # 表单校验
│ │ ├── actions.ts # Server Actions
│ │ ├── form-state.ts # 表单状态类型
│ │ └── format.ts # 北京时间展示
│ ├── scripts/seed.ts
│ ├── next.config.ts
│ └── package.json
├── tests/
│ ├── check.mjs # 结构 / API 特征 + 临时数据库检查
│ └── README.md # 本机走查步骤
├── docs/
│ ├── data-model.md # 三表结构、迁移、版本规则、职责
│ └── routes.md # 路由、Server Actions、缓存失效
└── fixtures/
└── articles.json # 3 个分类 + 3 篇示例文章
```
`node_modules/`、`.next/`、`data/*.db` 是运行产物,不提交。
## 实现要点
### 数据层
- `lib/db.ts`:`openDatabase()` 建目录、开 WAL、执行迁移;`migrate()` 用 `PRAGMA user_version` 记账;`seedDatabase()` 在事务里 upsert 分类、按 slug 幂等插入文章与 v1。
- `lib/articles.ts`:所有 SQL 集中在这里。列表查询用 `LIKE` 同时匹配标题、摘要、正文、标签;版本号用 `MAX(version_no) + 1`;回滚生成新版本而不是删除历史。
- 数据页统一 `export const dynamic = "force-dynamic"`,避免 `next build` 时把某一次数据库结果静态化。
### 写入与缓存
- `lib/actions.ts` 顶部 `"use server"`,表单通过 `useActionState` 接收 `{ status, message, errors, values }`。
- 每个写操作在 `redirect` 前调用 `revalidatePath`,覆盖 `/`、`/archive`、`/articles` layout、详情与版本页;slug 变化时补旧路径。
- 数据库负责持久化,路由缓存只负责复用渲染结果。不要把查询结果写回 JSON 文件当缓存。
### 版本与归档
- 新建:`articles` 一行 + `article_versions` v1。
- 编辑:更新当前行 + 追加 v2/v3/…,`change_note` 来自表单。
- 回滚:用旧快照覆盖当前行 + 追加「回滚到 vN」,历史只增不减。
- 归档:只改 `status`,首页与搜索隐藏,归档页可恢复;不产生版本。
## 验收清单(m1–m8)
| 里程碑 | 验收 |
| --- | --- |
| m1 路由骨架 | 列表、详情、新建、编辑、版本、归档、导出均可访问;不存在的文章进入 404 |
| m2 数据层 | 三张表 + `user_version` 迁移;`npm run seed` 写入 3 篇,重复执行跳过 |
| m3 列表检索 | 分类与关键词可组合,URL 保留 `q` / `category`,空状态可用 |
| m4 详情与新建 | 详情字段完整;表单校验与 slug 唯一性错误可见,成功后跳详情 |
| m5 编辑与版本 | 每次保存版本号 +1,版本页按倒序展示变更说明与正文预览 |
| m6 回滚 | 回滚后正文恢复,历史新增一条「回滚到 vN」,旧版本仍在 |
| m7 归档恢复 | 归档后首页/搜索消失、归档页出现;恢复后回到首页 |
| m8 缓存与导出 | 写后 `revalidatePath` 生效;JSON / Markdown 可下载;两个工程 build 通过 |
逐项手动走查命令与预期结果见 `tests/README.md`。
## 建议实现顺序
1. 先读 `docs/data-model.md`,把三张表和版本规则画出来。
2. 补 `lib/db.ts` 的迁移与 seed,用 `node tests/check.mjs` 看数据层是否达标。
3. 做列表与详情,让只读链路先跑通。
4. 接 Server Action 完成新建、编辑,再补版本历史与回滚。
5. 最后做归档、导出和 `revalidatePath`,跑 build 与完整走查。
categories(id, slug, name, description) 保存分类;articles(id, slug, title, summary, content, category_id, tags, status, created_at, updated_at) 保存当前文章,status 取 active / archived;article_versions(id, article_id, version_no, title, summary, content, category_id, tags, change_note, created_at) 保存每次编辑的完整快照。articles 与 categories 是多对一,article_versions 与 articles 是多对一,同一文章内 version_no 唯一。新建文章写入 v1;每次编辑先更新 articles,再插入下一条版本;回滚不删除历史,而是用旧版本内容生成一条新版本。