跨设备实时同步的极简 Markdown 笔记应用
TinySyncNote 是一款轻量级的笔记应用,支持实时多端同步、版本历史和冲突解决。
功能特性 • 技术栈 • 快速开始 • 同步架构 • API • 项目结构
| 特性 | 说明 |
|---|---|
| 🔄 实时多端同步 | 基于 SignalR WebSocket,一处修改处处可见 |
| 📝 Markdown 编辑 | Vditor 编辑器,支持所见即所得、即时渲染、分屏预览 |
| 🤝 冲突检测与解决 | 乐观锁版本控制,检测到冲突时提供可视化解决界面 |
| 📋 版本历史 | 自动/手动快照,可随时回滚到任意历史版本 |
| 📂 笔记本 + 分类 | 双层目录结构,支持无限层级子分类 |
| 🔐 用户认证 | JWT + Refresh Token 认证体系 |
| 📤 导入/导出 | 导出为 Markdown/ZIP,批量导入笔记 |
| 🔗 笔记分享 | 站内分享给其他用户、生成公开链接(支持过期) |
| 🗄️ 多数据库 | 开发环境 SQLite,生产环境 PostgreSQL / MySQL |
| 层级 | 技术 |
|---|---|
| 后端框架 | ASP.NET Core 10 (.NET 10) |
| ORM | Entity Framework Core 10 |
| 实时通信 | SignalR (WebSocket + LongPolling 回退) |
| 认证 | JWT Bearer + Refresh Token |
| 密码 | BCrypt |
| 前端框架 | Vue 3 (Composition API) |
| 状态管理 | Pinia |
| Markdown 编辑器 | Vditor |
| UI 组件库 | Element Plus |
| 构建工具 | Vite |
| 数据库 | SQLite / PostgreSQL / MySQL |
- .NET 10.0 SDK
- Node.js 20+
- (可选)Docker — 用于 PostgreSQL/MySQL 生产环境
# 克隆项目
git clone <repo-url>
cd TinySyncNote
# 恢复依赖
dotnet restore
# 启动 API 服务(默认 SQLite,监听 http://localhost:5000)
cd TinySyncNote.Api
dotnet run首次启动会自动创建 SQLite 数据库文件 App_Data/TinySyncNote.db。
cd TinySyncNote.Web
# 安装依赖
npm install
# 开发模式(热重载,代理 API 到 :5000)
npm run dev打开浏览器访问 http://localhost:5173。
# 启动 PostgreSQL 或 MySQL
docker-compose up -d
# 切换到 PostgreSQL
bash scripts/switch-to-postgresql.sh
# 或者切换到 MySQL
bash scripts/switch-to-mysql.sh在 TinySyncNote.Api/appsettings.json 中配置连接字符串。
┌─────────────┐ PUT /api/notes/:id ┌──────────────┐
│ 设备 A │ ──── (携带版本号 N) ────────→ │ │
│ (HTTP) │ │ ASP.NET │
│ │ ←── HTTP 200 (Version N+1) ─── │ Core API │
│ │ │ │
│ SignalR │ ←── NoteUpdated(N+1) ───────── │ SignalR │
│ (WebSocket)│ (广播给用户组) │ Hub │
└─────────────┘ └──────────────┘
│
│ NoteUpdated(N+1)
▼
┌──────────────┐
│ 设备 B │
│ SignalR │
│ (WebSocket) │
└──────────────┘
- 乐观锁并发控制 — 每篇笔记维护一个递增的
Version整数 - 保存时版本检查 — 提交的版本号必须与数据库当前版本一致,否则判定为冲突
- SignalR 实时广播 — 保存成功后,向同用户的所有连接推送更新事件
- 无轮询 — 纯推送架构,省流量、响应快
- 自动重连 — 指数退避 [0, 2s, 5s, 10s, 30s],WebSocket 失败自动回退到 LongPolling
检测到版本不一致
│
▼
记录冲突 (NoteConflict):
- LocalVersion / RemoteVersion
- LocalContent / RemoteContent
│
▼
返回 HTTP 409 给客户端
│
▼
用户选择解决策略:
┌─────────────────────┐
│ 保留我的版本 │ → 强制版本号 +1
│ 采用服务端版本 │ → 覆盖为服务端内容
│ 手动合并 │ → 用户编辑合并内容
└─────────────────────┘
由于 SignalR WebSocket 消息可能比 HTTP 响应先到达,同一设备会收到自己的保存通知。 前端通过以下机制避免误报:
- 版本号匹配:加载笔记时将
lastSavedVersion与服务端返回的版本同步 - 在途保存追踪:发送保存请求时记录
pendingSaveVersion,收到 SignalR 通知时,若newVersion === pendingSaveVersion + 1则判定为自己的保存
详见 NoteEditorView.vue 中的
onNoteUpdated处理逻辑。
| 端点 | 方法 | 说明 |
|---|---|---|
/api/auth/register |
POST | 注册 |
/api/auth/login |
POST | 登录 |
/api/auth/refresh |
POST | 刷新令牌 |
/api/auth/profile |
GET | 获取当前用户信息 |
/api/notebooks |
GET/POST | 笔记本列表/创建 |
/api/notebooks/{id} |
PUT/DELETE | 更新/删除笔记本 |
/api/categories/tree/{notebookId} |
GET | 分类树 |
/api/categories |
POST | 创建分类 |
/api/notes/by-category/{categoryId} |
GET | 获取分类下笔记列表 |
/api/notes/{id} |
GET/PUT/DELETE | 笔记详情/更新/删除 |
/api/notes |
POST | 创建笔记 |
/api/conflicts |
GET | 未解决冲突列表 |
/api/conflicts/{id} |
GET | 冲突详情 |
/api/conflicts/{id}/resolve |
POST | 解决冲突 |
/api/notes/{noteId}/snapshots |
GET/POST | 快照列表/创建 |
/api/notes/{noteId}/snapshots/{id}/restore |
POST | 恢复快照 |
/api/export/note/{id}/markdown |
GET | 导出笔记为纯 MD(无 YAML 头部) |
/api/export/note/{id}/html |
GET | 导出笔记为渲染 HTML(支持 ?theme=dark) |
/api/export/notebook/{id} |
GET | 导出笔记本为 ZIP |
/api/import/markdown |
POST | 导入 MD 文件 |
/api/import/zip |
POST | 导入 ZIP 压缩包 |
/api/users/search?q= |
GET | 搜索用户 |
/api/share/note/{id} |
POST | 分享笔记给其他用户 |
/api/share/note/{id}/public |
POST/GET | 创建/查询公开分享链接 |
/api/share/public/{shareId} |
DELETE | 撤销公开链接 |
/api/share/{token} |
GET | 查看公开分享的笔记(无需认证) |
/hubs/sync |
WebSocket | SignalR Sync Hub |
TinySyncNote/
├── TinySyncNote.Core/ # 领域层 + 数据层
│ ├── Data/AppDbContext.cs # EF Core DbContext
│ ├── Models/
│ │ ├── Entities/ # User, Note, Notebook, Category ...
│ │ ├── DTOs/ # 请求/响应模型
│ │ └── Enums/ # SnapshotType, ConflictResolution
│ └── Services/ # 业务逻辑
│ ├── AuthService.cs # 认证
│ ├── NoteService.cs # 笔记 CRUD + 乐观锁冲突检测
│ ├── ConflictService.cs # 冲突记录与解决
│ ├── SnapshotService.cs # 快照管理
│ ├── NotebookService.cs # 笔记本 CRUD
│ ├── CategoryService.cs # 分类 CRUD + 树形结构
│ ├── ImportExportService.cs # 导入导出(MD + HTML)
│ ├── ShareService.cs # 站内分享
│ ├── PublicShareService.cs # 公开链接
│ └── UserService.cs # 用户搜索
├── TinySyncNote.Api/ # Web API 层
│ ├── Controllers/ # REST 控制器
│ ├── Hubs/SyncHub.cs # SignalR Hub
│ ├── Program.cs # 应用入口
│ └── appsettings.json # 配置
├── TinySyncNote.Web/ # Vue 3 前端
│ └── src/
│ ├── composables/useSync.ts # SignalR 客户端封装
│ ├── stores/ # Pinia 状态
│ ├── views/ # 页面组件
│ │ ├── NoteEditorView.vue # 编辑器(含同步提示)
│ │ ├── ConflictResolverView.vue# 冲突解决界面
│ │ └── ...
│ ├── utils/http.ts # Axios + JWT 拦截器
│ └── types/index.ts # TypeScript 类型定义
└── TinySyncNote.Tests/ # 单元测试
└── ServicesTests.cs
TinySyncNote 对 Vditor 进行了深度定制,所有编辑器相关代码集中在 src/editor/ 目录下:
| 文件 | 说明 |
|---|---|
useTableEnhancer.ts |
表格操作增强:Tab/Shift+Tab 单元格导航、右键菜单(插入/删除行列、对齐、删除表格)、Ctrl+A 递进选择(单元格→行→表→全选)、选中单元格高亮、Backspace 删除行/表 |
useClipboardEnhancer.ts |
剪贴板增强:复制时同时输出 text/html(Word 等富文本编辑器用)和 text/plain(Markdown 源码);表格复制自动添加边框样式 |
editor.css |
全局样式:暗色模式 Vditor 主题覆盖、右键菜单样式、选中单元格高亮样式 |
| 操作 | 说明 |
|---|---|
| Tab | 在单元格间跳转,最后一个单元格自动新增行 |
| Shift+Tab | 反向跳转 |
| Ctrl+A × 1 | 选中当前单元格 |
| Ctrl+A × 2 | 选中整行 |
| Ctrl+A × 3 | 选中整个表格 |
| Ctrl+A × 4 | 全选编辑器内容 |
| Backspace(行选中) | 删除当前行 |
| Backspace(表选中) | 删除整个表格 |
| 右键 | 插入行/列、删除行/列/表格、左/中/右对齐 |
| 工具栏「表格」按钮 | 自定义行数列数弹窗,焦点在表格内时自动禁用 |
| 目标 | 剪贴板格式 |
|---|---|
| Word / 富文本编辑器 | text/html(带格式、表格含边框) |
| 纯文本编辑器 / 代码工具 | text/plain(Markdown 源码) |
项目默认使用 SQLite。通过运行切换脚本转移到 PostgreSQL 或 MySQL:
# 启动容器
docker compose up -d postgresql
# 切换到 PostgreSQL(会修改 .csproj 启用 ENABLE_PGSQL 编译符号)
bash scripts/switch-to-postgresql.sh
# 或 PowerShell
.\scripts\switch-to-postgresql.ps1# 启动 MySQL
docker compose up -d mysql
# 切换到 MySQL
bash scripts/switch-to-mysql.sh
.\scripts\switch-to-mysql.ps1脚本会完成:
- 修改
appsettings.json中的DatabaseProvider - 取消注释
Directory.Packages.props中对应的包版本 - 取消注释
.csproj中的包引用 - 添加
ENABLE_PGSQL或ENABLE_MYSQL编译符号
切回 SQLite 只需手动将 DatabaseProvider 改回 "Sqlite"。
在 appsettings.json 中配置 JWT 密钥和过期时间:
{
"Jwt": {
"Key": "TinySyncNote_SuperSecretKey_2024_MustBeAtLeast32Characters!",
"Issuer": "TinySyncNote",
"Audience": "TinySyncNoteApp",
"AccessTokenExpirationMinutes": "60",
"RefreshTokenExpirationDays": "30"
}
}dotnet test TinySyncNote.Tests --nologo测试覆盖:用户注册/登录、笔记 CRUD、乐观锁冲突检测。
MIT